Merge commit 'stable-3.1' into stable-3.2

* stable-3.1:
  PG: Remove legacy permission 'publishDrafts'
  Add searching duplicate "external" externalIds only differing in case
  find-duplicate-usernames.sh: add example output of git grep
  Fix empty TopMenu drop down list
  Script to find duplicate usernames only differing in case
  AccountPatchReview mariadb: fix key length
  RepositorySizeQuotaIT: change try/catch to assertThrows
  Enforce repository size on pack rather than on object

Change-Id: Ida990f451a4b2bcaf4da33536771aa880fa2eb8d
diff --git a/.bazelignore b/.bazelignore
index 30f1613..69c04b1 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1 +1,2 @@
 eclipse-out
+node_modules
diff --git a/.bazelproject b/.bazelproject
index e14c108..a7f5450 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -22,3 +22,12 @@
 
 build_flags:
   --javacopt=-g
+
+additional_languages:
+  javascript
+  typescript
+
+ts_config_rules:
+  //tools/node_tools/node_modules_licenses:tsconfig_editor
+  //tools/node_tools/polygerrit_app_preprocessor:preprocessor_tsconfig.json
+  //polygerrit-ui/app/node_modules_licenses:tsconfig_editor
diff --git a/.gitignore b/.gitignore
index fb53fc6..1e91c8f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # Keep following lines sorted according to `LC_COLLATE=C sort`
 *.eml
 *.iml
+*.log
 *.pyc
 *.sublime-*
 *.swp
diff --git a/.gitmodules b/.gitmodules
index 9f67e77..e5eef1e 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -56,3 +56,8 @@
 	path = plugins/webhooks
 	url = ../plugins/webhooks
 	branch = .
+
+[submodule "polymer-bridges"]
+	path = polymer-bridges
+	url = ../polymer-bridges
+	branch = .
diff --git a/.gitreview b/.gitreview
index 9df7aae..dc05242 100644
--- a/.gitreview
+++ b/.gitreview
@@ -2,4 +2,4 @@
 host=gerrit-review.googlesource.com
 scheme=https
 project=gerrit.git
-defaultbranch=stable-3.1
+defaultbranch=master
diff --git a/.zuul.yaml b/.zuul.yaml
index fe9dc80..d6dbc34 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -30,6 +30,7 @@
       - plugins/reviewnotes
       - plugins/singleusergroup
       - plugins/webhooks
+      - polymer-bridges
 
 - project:
     check:
diff --git a/Documentation/BUILD b/Documentation/BUILD
index 52ab7a8..11d3efa 100644
--- a/Documentation/BUILD
+++ b/Documentation/BUILD
@@ -42,6 +42,9 @@
 
 license_map(
     name = "licenses",
+    json_maps = [
+        "//polygerrit-ui/app/node_modules_licenses:polygerrit-licenses.json",
+    ],
     opts = ["--asciidoctor"],
     targets = [
         "//polygerrit-ui/app:polygerrit_ui",
@@ -51,6 +54,9 @@
 
 license_map(
     name = "js_licenses",
+    json_maps = [
+        "//polygerrit-ui/app/node_modules_licenses:polygerrit-licenses.json",
+    ],
     targets = [
         "//polygerrit-ui/app:polygerrit_ui",
     ],
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 7961b7e..71c9330 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
@@ -779,6 +780,14 @@
 can still do the rebase locally and upload the rebased commit as a new
 patch set.
 
+[[category_revert]]
+=== Revert
+
+This category permits users to revert changes via the web UI by pushing
+the `Revert Change` button.
+
+Users without this access right who are able to upload changes can
+still do the revert locally and upload the revert commit as a new change.
 
 [[category_remove_reviewer]]
 === Remove Reviewer
@@ -987,7 +996,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
@@ -1318,7 +1327,9 @@
 
 Allow group creation.  Groups are used to grant users access to different
 actions in projects.  This capability allows the granted group members to
-either link:cmd-create-group.html[create new groups via ssh] or via the web UI.
+either link:cmd-create-group.html[create new groups via ssh] or via the web UI
+by navigating at the top of the page to BROWSE -> Groups, and then pushing the
+"CREATE NEW" button.
 
 
 [[capability_createProject]]
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..8a970c5 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -94,6 +94,9 @@
 link:cmd-set-reviewers.html[gerrit set-reviewers]::
 	Add or remove reviewers on a change.
 
+link:cmd-set-topic.html[gerrit set-topic]::
+	Set the topic for a change.
+
 link:cmd-stream-events.html[gerrit stream-events]::
 	Monitor events occurring in real time.
 
@@ -178,6 +181,12 @@
 link:cmd-set-account.html[gerrit set-account]::
 	Change an account's settings.
 
+link:cmd-sequence-set.html[gerrit sequence set]::
+        Set new sequence value.
+
+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/cmd-set-topic.txt b/Documentation/cmd-set-topic.txt
new file mode 100644
index 0000000..6ed1a49
--- /dev/null
+++ b/Documentation/cmd-set-topic.txt
@@ -0,0 +1,43 @@
+= gerrit set-topic
+
+== NAME
+gerrit set-topic - Set the topic for one or more changes.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit set-topic_
+  <CHANGE>
+  [ --topic <TOPIC> | -t <TOPIC> ]
+--
+
+== DESCRIPTION
+Sets the topic for the specified changes.
+
+== ACCESS
+Caller must have the rights to modify the topic of the specified changes.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== OPTIONS
+<CHANGE>::
+	Required; change id.
+
+--topic::
+-topic::
+	Valid topic name to apply to the specified changes.
+
+== EXAMPLES
+Set the topic of the change "I6686e64a788365bd252df69ae5b3ec9d65aaf068" in "MyProject" on branch "master" to "MyTopic".
+
+----
+$ ssh -p 29418 user@review.example.com gerrit set-topic MyProject~master~I6686e64a788365bd252df69ae5b3ec9d65aaf068 --topic MyTopic
+----
+
+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..b4a5cef 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Accounts
 
 == Overview
@@ -6,10 +7,10 @@
 link:note-db.html[NoteDb].
 
 The account data consists of a sequence number (account ID), account
-properties (full name, preferred email, registration date, status,
-inactive flag), preferences (general, diff and edit preferences),
-project watches, SSH keys, external IDs, starred changes and reviewed
-flags.
+properties (full name, display name, preferred email, registration
+date, status, inactive flag), preferences (general, diff and edit
+preferences), project watches, SSH keys, external IDs, starred changes
+and reviewed flags.
 
 Most account data is stored in a special link:#all-users[All-Users]
 repository, which has one branch per user. Within the user branch there
@@ -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
@@ -154,6 +155,7 @@
 ----
 [account]
   fullName = John Doe
+  displayName = John
   preferredEmail = john.doe@example.com
   status = OOO
   active = false
@@ -256,7 +258,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 e63e0be..e9d5e55 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Configuration
 
 == File `etc/gerrit.config`
@@ -120,6 +121,27 @@
 +
 Default is `ALL`.
 
+[[accounts.defaultDisplayName]]accounts.defaultDisplayName::
++
+If a user account does not have a display name set, which is the normal
+case, then this configuration value chooses the strategy how to choose
+the display name. Note that this strategy is not applied by the backend.
+If the AccountInfo has the display name unset, then the client has to
+apply this strategy.
++
+If `FULL_NAME`, then the (full) name of the user is chosen from
+link:rest-api-accounts.html#account-info[AccountInfo].
++
+If `FIRST_NAME`, then the first word (i.e. everything until first
+whitespace character) of the (full) name of the user is chosen from
+link:rest-api-accounts.html#account-info[AccountInfo].
++
+If `USERNAME`, then the username of the user is chosen from
+link:rest-api-accounts.html#account-info[AccountInfo]. If that is not
+set, then the (full) name will be used.
++
+Default is `FULL_NAME`.
+
 [[addreviewer]]
 === Section addreviewer
 
@@ -174,7 +196,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 +308,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 +326,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://`,
@@ -702,7 +724,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.
 +
@@ -715,7 +737,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.
@@ -825,6 +847,12 @@
 requires two HTTP requests, and this cache tries to carry state from
 the first request into the second to ensure it can complete.
 
+cache `"default_preferences"`::
++
+Caches the server's default general, edit and diff preferences.
++
+Default value is 1 to hold only the most current version in-memory.
+
 cache `"changes"`::
 +
 The size of `memoryLimit` determines the number of projects for which
@@ -951,6 +979,19 @@
 Caches the parent groups of a subgroup.  If direct updates are made
 to the `account_group_includes` table, this cache should be flushed.
 
+cache `"groups_external"`::
++
+Caches all the external groups available to Gerrit. The cache holds a
+single entry which maps to the latest available of all external groups'
+UUIDs. This cache uses "groups_external_persisted" to load its value.
+
+cache `"groups_external_persisted"`::
++
+Caches all external groups available to Gerrit at some point in history.
+The cache key is representation of a specific groups state in NoteDb and
+the value is the list of all external groups.
+The cache is persisted to enhance performance.
+
 cache `"ldap_groups"`::
 +
 Caches the LDAP groups that a user belongs to, if LDAP has been
@@ -1165,15 +1206,6 @@
 +
 Default is true.
 
-[[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 merge commits, the left-hand side shows the output of the
@@ -1189,12 +1221,43 @@
 +
 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 16kiB.
+
+[[change.cumulativeCommentSizeLimit]]change.cumulativeCommentSizeLimit::
++
+Maximum allowed size in characters of all comments (including robot comments)
+and change messages. Size computation is approximate and may be off by roughly
+1%. Common unit suffixes of 'k', 'm', or 'g' are supported.
++
+The default limit is 3MiB.
+
 [[change.disablePrivateChanges]]change.disablePrivateChanges::
 +
 If set to true, users are not allowed to create private changes.
 +
 The default is false.
 
+[[change.enableAttentionSet]]change.enableAttentionSet::
++
+If set to true, then all UI features for using and interacting with the
+attention set are enabled.
++
+The default is false for now, but will be changed to true in Q2 2020.
+
+[[change.enableAssignee]]change.enableAssignee::
++
+If set to true, then all UI features for using and interacting with the
+assignee are enabled.
++
+The default is true for now, but will be changed to false in Q2 2020.
+
 [[change.largeChange]]change.largeChange::
 +
 Number of changed lines from which on a change is considered as a large
@@ -1206,6 +1269,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
@@ -1217,14 +1287,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
@@ -1259,13 +1358,12 @@
 
 [[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.
+The default limit is 1MiB.
 
 [[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
 +
@@ -1387,9 +1485,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::
 +
@@ -1720,7 +1825,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.
@@ -2139,6 +2244,12 @@
 Path for PolyGerrit's favicon after link:#gerrit.canonicalWebUrl[default URL],
 including icon name and extension (.ico should be used).
 
+[[gerrit.instanceId]]gerrit.instanceId::
++
+Optional identifier for this Gerrit instance.
+Used to identify a specific instance within a group of Gerrit instances with the
+same `serverId` (i.e.: a Gerrit cluster).
+Unlike `instanceName` this value is not available in the email templates.
 
 [[gerrit.instanceName]]gerrit.instanceName::
 +
@@ -2302,9 +2413,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`.
 +
@@ -2356,6 +2467,40 @@
 when this parameter is removed and the system group uses the default
 name again.
 
+[[has-operand-alias]]
+=== Section has operand alias
+
+'has' operand aliasing allows global aliases to be defined for query
+'has' operands. Currently only change queries are supported. The alias
+name is the git config key name, and the 'has' operand being aliased
+is the git config value.
+
+For example:
+
+----
+[has-operand-alias "change"]
+  oldtopic = topic
+----
+
+This section is particularly useful to alias 'has' operands (which may
+be long and clunky as they include a plugin name in them) to shorter
+operands without the plugin name. Admins should take care to choose
+shorter operands that are unique and unlikely to conflict in the future.
+
+Aliases are resolved dynamically at invocation time to the currently
+loaded version of plugins. If a referenced plugin is not loaded, or
+does not define the command, "unsupported operand" is returned to the
+user.
+
+Aliases will override existing 'has' operands. In case of multiple
+aliases with same name, the last one defined will be used.
+
+When the target of an alias does not exist, the 'has' operand with the
+name of the alias will be used (if present). This enables an admin to
+configure the system to override a core 'has' operand with an operand
+provided by a plugin when present and otherwise fall back to the 'has'
+operand provided by core.
+
 [[http]]
 === Section http
 
@@ -2454,7 +2599,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]
 --
@@ -2730,6 +2875,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
@@ -2784,7 +2951,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.
 
@@ -2855,19 +3022,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
@@ -2945,7 +3099,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.
 
@@ -2955,7 +3109,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.
@@ -2988,7 +3142,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).
 
@@ -2998,13 +3152,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).
 
@@ -3015,7 +3169,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).
 
@@ -3045,7 +3199,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].
 
 Note that when Gerrit is configured to use Elasticsearch, the Elasticsearch
 server(s) must be reachable during the site initialization.
@@ -3074,7 +3228,7 @@
 +
 Sets the number of shards to use per index. Refer to the
 link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#_static_index_settings[
-Elasticsearch documentation] for details.
+Elasticsearch documentation,role=external,window=_blank] for details.
 +
 Defaults to 1.
 
@@ -3082,7 +3236,7 @@
 +
 Sets the number of replicas to use per index. Refer to the
 link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation] for details.
+Elasticsearch documentation,role=external,window=_blank] for details.
 +
 Defaults to 1.
 
@@ -3090,7 +3244,7 @@
 +
 Sets the maximum value of `from + size` for searches to use per index. Refer to the
 link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation] for details.
+Elasticsearch documentation,role=external,window=_blank] for details.
 +
 Defaults to 10000.
 
@@ -3100,7 +3254,7 @@
 Note that the same username and password are used for all servers.
 
 For further information about Elasticsearch security, please refer to
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/security-getting-started.html[the documentation].
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/security-getting-started.html[the documentation,role=external,window=_blank].
 This is the current documentation link. Select another Elasticsearch version
 from the dropdown menu available on that page if need be.
 
@@ -3129,7 +3283,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
@@ -3153,8 +3307,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]
@@ -3508,8 +3662,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.
 
@@ -3528,7 +3682,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]:
@@ -3546,7 +3700,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.
 +
@@ -3669,6 +3823,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
 
@@ -3823,6 +4010,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.
@@ -3924,7 +4124,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.
@@ -4390,7 +4590,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.
 
@@ -4808,6 +5008,17 @@
 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`.
++
+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.
++
+By default 50.
 
 [[tracing]]
 === Section tracing
@@ -4916,7 +5127,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.
@@ -5212,7 +5423,13 @@
 login as the 'Gerrit Code Review' user, required for the link:cmd-suexec.html[suexec]
 command.
 
-The format is one Base-64 encoded public key per line.
+The format is one Base-64 encoded public key per line with optional comment, e.g.:
+----
+# Comments allowed at start of line
+AAAAC3...51R== john@example.net
+# Another comment
+AAAAB5...21S== jane@example.net
+----
 
 === Configurable Parameters
 
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-labels.txt b/Documentation/config-labels.txt
index 193a96f..a3b9d0b 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -344,6 +344,13 @@
 
 Defaults to true.
 
+[[label_copyValue]]
+=== `label.Label-Name.copyValue`
+
+Value that should be copied forward when a new patch set is uploaded.
+This can be used to enable sticky votes. Can be specified multiple
+times. By default not set.
+
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
 
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 fb53937..272c4eb 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
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 22d8a0d..a01df50 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -316,16 +316,19 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-[[submit.mergeContent]]submit.mergeContent::
+[[content_merge]]submit.mergeContent::
 +
-Defines whether to automatically merge changes.  Valid values are 'true', 'false', or 'INHERIT'.
-Default is 'INHERIT'.
+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`.
 
 [[submit.action]]submit.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'.
+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'.
 
 [[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
 +
@@ -343,6 +346,99 @@
 is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
 initial commit on a branch.
 
+[[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]]
 === Access section
 
@@ -479,100 +575,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 782aba0..02ae43c 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] -launched with
-link:https://github.com/bazelbuild/bazelisk[Bazelisk]
+* link:https://docs.bazel.build/versions/master/install.html[Bazel,role=external,window=_blank] -launched with
+link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank]
 * Maven
 * zip, unzip
 * curl
@@ -20,8 +32,8 @@
 [[bazel]]
 === Bazel
 
-link:https://github.com/bazelbuild/bazelisk[Bazelisk] includes a
-link:https://bazel.build/[Bazel] version check and downloads the correct
+link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank] includes a
+link:https://bazel.build/[Bazel,role=external,window=_blank] version check and downloads the correct
 `bazel` version for the git project/repository. Bazelisk is the recommended
 `bazel` launcher for Gerrit. Once Bazelisk is installed locally, a `bazel`
 symlink can be created towards it. This is so that every `bazel` command
@@ -43,17 +55,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 \
@@ -66,7 +78,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 \
@@ -79,7 +91,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
@@ -107,7 +119,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
@@ -223,7 +235,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
@@ -243,7 +255,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
@@ -354,17 +366,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].
 
 [[logging]]
 === Controlling logging level
@@ -496,7 +508,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.
 
@@ -507,7 +519,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.
@@ -523,10 +535,11 @@
   package=some-npm-package
   version=1.2.3
 
-  npm install -g license-checker && \
+  # Note - yarn must be installed before running the following commands
+  yarn global add license-checker && \
   rm -rf /tmp/$package-$version && mkdir -p /tmp/$package-$version && \
   cd /tmp/$package-$version && \
-  npm install $package@$version && \
+  yarn add $package@$version && \
   license-checker | grep licenses: | sort -u
 ----
 
@@ -535,7 +548,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.
 
@@ -549,41 +562,44 @@
 If you see anything that looks like a native library or binary, then we can't
 use the bundle.
 
-If everything looks good, create the bundle, and note the SHA-1:
-[source,bash]
+If everything looks good, install the package with the following command:
+[source, bash]
 ----
-  $gerrit_repo/tools/js/npm_pack.py $package $version && \
-  sha1sum $package-$version.tgz
+# Add to ui_npm. Other packages.json can be updated in the same way
+cd $gerrit_repo/polygerrit-ui/app
+bazel run @nodejs//:yarn add $package
 ----
 
-This creates a file named `$package-$version.tgz` in your working directory.
+Update the `polygerrit-ui/app/node_modules_licenses/licenses.ts` file. You should add licenses
+for the package itself and for all transitive depndencies. If you forgot to add a license, the
+`Documentation:check_licenses` test will fail.
 
-Any project maintainer can upload this file to the
-link:https://console.cloud.google.com/storage/browser/gerrit-maven/npm-packages[storage
-bucket].
+After the update, commit all changes to the repository (including `yarn.lock`).
 
-Finally, add the new binary to the build process:
-----
-  # WORKSPACE
-  npm_binary(
-      name = "some-npm-package",
-      repository = GERRIT,
-  )
+[NOTE]
+====
+If a npm package has transitive dependencies (or just several files) with a not allowed
+license and you can't avoid use it in release, then you can add this package.
+For example some packages contain demo-code with a different license. Another example - optional
+dependencies, which are not needed to build polygerrit, but they are installed together with
+the package anyway.
 
-  # lib/js/npm.bzl
-  NPM_VERSIONS = {
-    ...
-    "some-npm-package": "1.2.3",
-  }
+In this case you should exclude all files and/or transitive dependencies with a not allowed license.
+Adding such package requires additional updates:
 
-  NPM_SHA1S = {
-    ...
-    "some-npm-package": "<sha1>",
-  }
-----
+- Add dependencies (or files) to the license.ts with an appropriate license marked with
+`allowed: false`.
 
-To use the binary from the Bazel build, you need to use the `run_npm_binary.py`
-wrapper script. For an example, see the use of `crisper` in `tools/bzl/js.bzl`.
+- update package.json postinstall script to remove all non-allowed files (if you don't
+update postinstall script, `Documentation:check_licenses` test will fail.)
+====
+
+=== Update NPM Binaries
+To update a NPM binary the same actions as for a new one must be done (check licenses,
+update `licenses.ts` file, etc...). The only difference is a command to install a package: instead
+of `bazel run @nodejs//:yarn add $package` you should run the `bazel run @nodejs//:yarn upgrade ...`
+command with correct arguments. You can find the list of arguments in the
+link:https://classic.yarnpkg.com/en/docs/cli/upgrade/[yarn upgrade doc,role=external,window=_blank].
 
 
 [[RBE]]
@@ -607,7 +623,7 @@
     --project=${PROJECT} \
     --instance=default_instance \
     --worker-count=50 \
-    --machine-type=n1-highcpu-4 \
+    --machine-type=e2-standard-4 \
     --disk-size=200
 ```
 
diff --git a/Documentation/dev-cla.txt b/Documentation/dev-cla.txt
index 5bc34a7..2aa459c 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 4fb025f..53829c9 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 49f8fcf..23ecd67 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -1,10 +1,12 @@
+:linkattrs:
 = Gerrit Code Review - Contributing
 
 [[cla]]
 == Contributor License Agreement
 
 In order to contribute to Gerrit a link:dev-cla.html[Contributor
-License Agreement] must be completed before contributions are accepted.
+License Agreement,role=external,window=_blank] must be completed before
+contributions are accepted.
 
 [[contribution-processes]]
 == Contribution Processes
@@ -19,26 +21,28 @@
 The lightweight contribution process has little overhead and is best
 suited for small contributions (documentation updates, bug fixes, small
 features). Contributions are pushed as changes and
-link:dev-roles.html#maintainer[maintainers] review them adhoc.
+link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
+review them adhoc.
 
 For large/complex features, it is required to follow the
 link:#design-driven-contribution-process[design-driven contribution
 process] and specify the feature in a link:dev-design-docs.html[design
-doc] before starting with the implementation.
+doc,role=external,window=_blank] before starting with the implementation.
 
-If link:dev-roles.html#contributor[contributors] choose the lightweight
-contribution process and during the review it turns out that the feature
-is too large or complex, link:dev-roles.html#maintainer[maintainers] can
+If link:dev-roles.html#contributor[contributors,role=external,window=_blank]
+choose the lightweight contribution process and during the review it turns out
+that the feature is too large or complex,
+link:dev-roles.html#maintainer[maintainers,role=external,window=_blank] can
 require to follow the 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
-the Gerrit project, including link:dev-roles.html#maintainer[maintainers].
-When reading this document, keep in mind that maintainers are also
-contributors when they contribute code.
+the Gerrit project, including link:dev-roles.html#maintainer[
+maintainers,role=external,window=_blank]. When reading this document, keep in
+mind that maintainers are also contributors when they contribute code.
 
 If a new feature is large or complex, it is often difficult to find a
 maintainer who can take the time that is needed for a thorough review,
@@ -55,7 +59,8 @@
 |======================
 |        |Lightweight Contribution Process|Design-Driven Contribution Process
 |Overhead|low (write good commit message, address review comments)|
-high (write link:dev-design-docs.html[design doc] and get it approved)
+high (write link:dev-design-docs.html[design doc,role=external,window=_blank]
+and get it approved)
 |Technical Guidance|by reviewer|during the design review and by
 reviewer/mentor
 |Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
@@ -82,16 +87,16 @@
 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
-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].
+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,role=external,window=_blank] 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,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
-link:dev-roles.html#maintainer[maintainers], that can approve changes,
-than link:dev-roles.html#contributor[contributors];
+link:dev-roles.html#maintainer[maintainers,role=external,window=_blank], that
+can approve changes, than link:dev-roles.html#contributor[contributors,role=external,window=_blank];
 so anything that you can do to ensure that your contribution will undergo fewer
 revisions will speed up the contribution process.  This includes
 helping out reviewing other people's changes to relieve the load from
@@ -123,26 +128,28 @@
 * think about possibilities how the feature could be evolved over time
 
 This is why for large/complex features it is required to describe the
-feature in a link:dev-design-docs.html[design doc] and get it approved
-by the link:dev-processes.html#steering-committee[steering committee],
+feature in a link:dev-design-docs.html[design doc,role=external,window=_blank]
+and get it approved by the
+link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
 before starting the implementation.
 
 The design-driven contribution process has the following steps:
 
-* A link:dev-roles.html#contributor[contributor]
-  link:dev-design-docs.html#propose[proposes] a new feature by uploading
-  a change with a link:dev-design-docs.html[design doc].
-* The design doc is link:dev-design-docs.html#review[reviewed]
+* A link:dev-roles.html#contributor[contributor,role=external,window=_blank]
+  link:dev-design-docs.html#propose[proposes,role=external,window=_blank] a new
+  feature by uploading a change with a
+  link:dev-design-docs.html[design doc,role=external,window=_blank].
+* The design doc is link:dev-design-docs.html#review[reviewed,role=external,window=_blank]
   by interested parties from the community. The design review is public
   and everyone can comment and raise concerns.
 * Design docs should stay open for a minimum of 10 calendar days so
   that everyone has a fair chance to join the review.
 * Within 14 calendar days the contributor should hear back from the
-  link:dev-processes.html#steering-committee[steering committee]
+  link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
   whether the proposed feature is in scope of the project and if it can
   be accepted.
 * To be submitted, the design doc needs to be approved by the
-  link:dev-processes.html#steering-committee[steering committee].
+  link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank].
 * After the design was approved, the implementation is done by pushing
   changes for review, see link:#lightweight-contribution-process[
   lightweight contribution process]. Changes that are associated with
@@ -168,7 +175,7 @@
 
 * Accepting the feature when it is implemented.
 * Supporting the feature by assigning a link:dev-roles.html#mentor[
-  mentor] (if requested, see link:#mentorship[mentorship]).
+  mentor,role=external,window=_blank] (if requested, see link:#mentorship[mentorship]).
 
 If the implementation of a feature gets stuck and it's unclear whether
 the feature gets fully done, it should be discussed with the steering
@@ -184,7 +191,7 @@
   design review, feedback from various sides can be collected, which
   likely leads to improvements of the feature.
 * Once a design was approved by the
-  link:dev-processes.html#steering-committee[steering committee],
+  link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
   the contributor can be almost certain that the feature will be accepted.
   Hence, there is only a low risk to invest into implementing a feature
   and see it being rejected later during the code review, as it can
@@ -196,12 +203,12 @@
 [[mentorship]]
 == Mentorship
 
-For features for which a link:dev-design-docs.html[design]
+For features for which a link:dev-design-docs.html[design,role=external,window=_blank]
 has been approved (see link:#design-driven-contribution-process[design-driven
 contribution process]), contributors can gain the support of a mentor
 if they are committed to implement the feature.
 
-A link:dev-roles.html#mentor[mentor] helps with:
+A link:dev-roles.html#mentor[mentor,role=external,window=_blank] helps with:
 
 * doing timely reviews
 * providing technical guidance during code reviews
@@ -211,12 +218,12 @@
 
 A feature can have more than one mentor. To be able to deliver the
 promised support, at least one of the mentors must be a
-link:dev-roles.html#maintainer[maintainer].
+link:dev-roles.html#maintainer[maintainer,role=external,window=_blank].
 
 Mentors are assigned by the link:dev-processes.html#steering-committee[
-steering committee]. To gain a mentor, ask for a
+steering committee,role=external,window=_blank]. To gain a mentor, ask for a
 mentor in the link:dev-design-doc-template.html#implementation-plan[Implementation
-Plan] section of the design doc or ask the steering
+Plan,role=external,window=_blank] section of the design doc or ask the steering
 committee after the design has been approved.
 
 Mentors may not be available immediately. In this case, the steering
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
new file mode 100644
index 0000000..04e2420
--- /dev/null
+++ b/Documentation/dev-core-plugins.txt
@@ -0,0 +1,163 @@
+: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.
+
+[[list]]
+== Which core plugins exist?
+
+See link:config-plugins.html#core-plugins[here].
+
+[[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.
+
+[[process-to-make-a-plugin-a-core-plugin]]
+== Process to make a plugin a core plugin
+
+Anyone can propose to make a plugin a core plugin, but to be accepted as core
+plugin the plugin must fulfill certain link:#criteria[criteria].
+
+1. Propose a plugin as core plugin:
++
+File a link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Core+Plugin+Request[
+Core Plugin Request] in the issue tracker and provide the information that is
+being asked for in the request template.
+
+2. Community Feedback:
++
+Anyone can comment on the issue to raise concerns or to support the request.
+The issue should stay open for at least 10 calendar days so that everyone in
+the community has a chance to comment.
+
+3. ESC Decision:
++
+The ESC should discuss the request and reject/approve it or ask for further
+information that the reporter or commenters should provide.
++
+Any decision must be based on the link:#criteria[criteria] that core plugins
+are expected to fulfill and should take the feedback from the community into
+account.
++
+Accepting the request is only possible if the issue was open for at least 10
+calendar days (see 2., this gives the community time to comment).
++
+When the ESC approves/rejects the request a summary of the reasons for the
+decision should be added to the issue.
++
+If a request is rejected, it's possible to ask for a revalidation if the
+concerns that led to the rejection have been addressed (e.g. the plugin was
+rejected due to missing tests, but tests have been added afterwards).
+
+4. Add plugin as core plugin:
++
+If the request was accepted, a Gerrit maintainer should add the plugin as
+core plugin:
++
+--
+** Host the plugin repo on link:https://gerrit-review.googlesource.com/[
+   gerrit-review].
+** Ensure that the plugin repo inherits from the
+   link:https://gerrit-review.googlesource.com/admin/repos/Public-Plugins[
+   Public-Plugins] repo.
+** Remove all permissions on the plugin repo (the inherited permissions from
+   `Public-Plugins` should be enough). Especially make sure that there are no
+   link:access-control.html#category_owner[Owner],
+   link:access-control.html#category_push_direct[Direct Push],
+   link:access-control.html#category_submit[Submit] or
+   link:access-control.html#category_review_labels[Code-Review+2]
+   permissions for non-Gerrit maintainers.
+** Create a component for the plugin in
+   link:https://bugs.chromium.org/p/gerrit/adminComponents[Monorail] and assign
+   all issues that already exist for the plugin to this component.
+** Add the plugin as
+   link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/.gitmodules[Git
+   submodule].
+** Register the plugin as core plugin in
+   link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/tools/bzl/plugins.bzl[
+   plugins.bzl].
+** Announce the new core plugin in the
+   link:https://www.gerritcodereview.com/news.html[project news].
+--
+
+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 2bbbc45..7935f30 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
@@ -117,10 +118,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.
 
@@ -140,14 +141,14 @@
 
 Gerrit 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 4.0.0). Unused dependencies are found and removed using the
-link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`]
+link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`,role=external,window=_blank]
 build tool, a sibling of `buildifier`.
 
 These tools automatically apply format according to the style guides; this
@@ -260,6 +261,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 a339da3..7e48eea 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Design Docs
 
 For the link:dev-contributing.html#design-driven-contribution-process[
@@ -73,7 +74,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).
 
@@ -88,7 +89,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.
@@ -99,7 +100,14 @@
 Everyone in the link:dev-roles.html[Gerrit community] is welcome to
 take part in the design review and comment on the design. As such, every
 design reviewer is expected to respect the community
-link:https://www.gerritcodereview.com/codeofconduct.html[Code of Conduct].
+link:https://www.gerritcodereview.com/codeofconduct.html[Code of Conduct,role=external,window=_blank].
+
+Positive `Code-Review` votes on changes that add/modify design docs are
+sticky. This means any `Code-Review+1` and `Code-Review+2` vote is
+preserved when a new patch set is uploaded. If a new patch set makes
+significant changes, the uploader of the new patch set must start a new
+review round by removing all positive `Code-Review` votes from the
+change manually.
 
 Ideas for alternative solutions should be uploaded as a change that
 describes the solution (see link:#collaboration[above]). This should be
@@ -134,14 +142,15 @@
 too much or becomes overly complex, one can use a meeting to refocus it. From
 that review thread, the organizer can volunteer oneself, or be proposed (even
 requested) by a reviewer. link:https://www.gerritcodereview.com/members.html#community-managers[
-Community managers] may help facilitate that if ultimately necessary.
+Community managers,role=external,window=_blank] may help facilitate that if
+ultimately necessary.
 
 [[watch-designs]]
 == How to get notified for new design docs?
 
 . 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 bf35c32..c50a293 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -1,14 +1,15 @@
+:linkattrs:
 = Gerrit Code Review - End to end tests
 
 This document provides descriptions of Gerrit end-to-end (`e2e`) test scenarios implemented using
-the link:https://gatling.io/[Gatling] framework.
+the link:https://gatling.io/[Gatling,role=external,window=_blank] framework.
 
 Similar scenarios have been successfully used to compare performance of different Gerrit versions
 or study the Gerrit response under different load profiles. Although mostly for load, scenarios can
-either be for link:https://gatling.io/load-testing-continuous-integration/[load or functional]
+either be for link:https://gatling.io/load-testing-continuous-integration/[load or functional,role=external,window=_blank]
 (e2e) testing purposes. Functional scenarios may then reuse this framework and Gatling's usability
 features such as its protocols (more below) and
-link:https://en.wikipedia.org/wiki/Domain-specific_language[DSL].
+link:https://en.wikipedia.org/wiki/Domain-specific_language[DSL,role=external,window=_blank].
 
 That cross test-scope reusability applies to both Gerrit core scenarios and non-core ones, such as
 for Gerrit plugins or other potential extensions. End-to-end testing may then include scopes like
@@ -20,26 +21,27 @@
 
 Gatling is mostly 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]. However, in the scenarios that were
-initially proposed, the link:https://github.com/GerritForge/gatling-git[Gatling Git extension] was
+link:https://gatling.io/docs/current/http/http_protocol/[here,role=external,window=_blank].
+However, in the scenarios that were initially proposed, the
+link:https://github.com/GerritForge/gatling-git[Gatling Git extension,role=external,window=_blank] was
 leveraged to run tests at the 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. The online `End-to-end tests`
-link:https://www.gerritcodereview.com/presentations.html#list-of-presentations[presentation] links
-posted on the homepage have more introductory information.
+link:https://www.gerritcodereview.com/presentations.html#list-of-presentations[presentation,role=external,window=_blank]
+links posted on the homepage have more introductory information.
 
 == IDE: IntelliJ
 
 Examples of scenarios can be found in the `e2e-tests` directory. The files in that directory should
 be formatted using the mainstream
-link:https://plugins.jetbrains.com/plugin/1347-scala[Scala plugin for IntelliJ]. The latter is not
-mandatory but preferred for `sbt` and Scala IDE purposes in this project. So, Eclipse can also be
-used alongside as a development IDE; this is described below.
+link:https://plugins.jetbrains.com/plugin/1347-scala[Scala plugin for IntelliJ,role=external,window=_blank].
+The latter is not mandatory but preferred for `sbt` and Scala IDE purposes in this project.
+So, Eclipse can also be used alongside as a development IDE; this is described below.
 
 === Eclipse
 
-1. Install the link:http://scala-ide.org/docs/user/gettingstarted.html[Scala plugin for Eclipse].
+1. Install the link:http://scala-ide.org/docs/user/gettingstarted.html[Scala plugin for Eclipse,role=external,window=_blank].
 2. Run `sbt eclipse` from the `e2e-tests` root directory.
 3. Import the resulting `e2e-tests` eclipse file inside the Gerrit project, in Eclipse.
 4. You should see errors in Eclipse telling you there are missing packages.
@@ -55,8 +57,8 @@
 
 == How to build the tests
 
-An link:https://www.scala-sbt.org/download.html[sbt-based installation] of
-link:https://www.scala-lang.org/download/[Scala] is required.
+An link:https://www.scala-sbt.org/download.html[sbt-based installation,role=external,window=_blank]
+of link:https://www.scala-lang.org/download/[Scala,role=external,window=_blank] is required.
 
 The `scalaVersion` used by `sbt` once installed is defined in the `build.sbt` file. That specific
 version of Scala is automatically used by `sbt` while building:
@@ -66,7 +68,7 @@
 ----
 
 The following warning, if present when executing `sbt` commands, can be removed by creating the
-link:https://www.scala-sbt.org/1.x/docs/Using-Sonatype.html#step+3%3A+Credentials[related credentials file]
+link:https://www.scala-sbt.org/1.x/docs/Using-Sonatype.html#step+3%3A+Credentials[related credentials file,role=external,window=_blank]
 locally. Dummy values for `user` and `password` in that file can be used initially.
 
 ----
@@ -82,7 +84,7 @@
 ----
 
 Every `sbt` command can include an optional log level
-link:https://www.scala-sbt.org/1.x/docs/Howto-Logging.html#Change+the+logging+level+globally[argument].
+link:https://www.scala-sbt.org/1.x/docs/Howto-Logging.html#Change+the+logging+level+globally[argument,role=external,window=_blank].
 Below, `[info]` logs are no longer shown:
 
 ----
@@ -101,7 +103,7 @@
 
 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
-link:https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch[otherwise]):
+link:https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch[otherwise,role=external,window=_blank]):
 
 ----
 mkdir /tmp/ssh-keys
@@ -125,7 +127,7 @@
 below. This scenario serves as a simple example with no actual load in it. It can be used to test
 or validate the local setup. More complex scenarios can be further developed, under the
 `com.google.gerrit.scenarios` package. The uppercase keywords are set through
-link:#_environment_properties[environment properties].
+link:#_environment_properties[environment properties,role=external,window=_blank].
 
 ----
 [
@@ -164,9 +166,9 @@
 === Environment properties
 
 The `JAVA_OPTS` environment variable
-link:https://gatling.io/docs/current/cookbook/passing_parameters[can optionally be used] to define
-non-default values for keys found in scenario `json` data files. That variable can currently be set
-with either one or many of these supported properties, from the core framework:
+link:https://gatling.io/docs/current/cookbook/passing_parameters[can optionally be used,role=external,window=_blank]
+to define non-default values for keys found in scenario `json` data files. That variable can
+currently be set with either one or many of these supported properties, from the core framework:
 
 * `-Dcom.google.gerrit.scenarios.hostname=localhost`
 * `-Dcom.google.gerrit.scenarios.ssh_port=29418`
@@ -175,7 +177,8 @@
 
 Above, the properties can be set with values matching specific deployment topologies under test.
 The name of the property corresponds to the uppercase keyword found in the json file. For example,
-`hostname` above will set the value of `HOSTNAME` in the link:#_input_file[aforementioned example].
+`hostname` above will set the value of `HOSTNAME` in the
+link:#_input_file[aforementioned example,role=external,window=_blank].
 
 The example values shown above are the currently coded default ones. For example, the `http` scheme
 above could be replaced with `https`. The framework may support differing or more properties over time.
@@ -194,10 +197,11 @@
 
 ==== Automatic properties
 
-The link:#_input_file[example keywords] also include `_PROJECT`, prefixed with an underscore, which
-means that its value gets automatically generated by the scenario. Any property setting for it is
-therefore not applicable. Its usage differs from the non-prefixed `PROJECT` keyword, in that sense.
-Using the latter instead in json files requires setting this `JAVA_OPTS` property:
+The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
+prefixed with an underscore, which means that its value gets automatically generated by the
+scenario. Any property setting for it is therefore not applicable. Its usage differs from the
+non-prefixed `PROJECT` keyword, in that sense. Using the latter instead in json files requires
+setting this `JAVA_OPTS` property:
 
 * `-Dcom.google.gerrit.scenarios.project=myOwnTestRepoProjectName`
 
@@ -210,10 +214,10 @@
 those scenario classes are. Such extending scenarios can also add extension-specific properties.
 Examples of this can be found in these Gerrit plugins test code:
 
-* `link:https://gerrit.googlesource.com/plugins/gc-conductor[gc-conductor]`
-* `link:https://gerrit.googlesource.com/plugins/high-availability[high-availability]`
-* `link:https://gerrit.googlesource.com/plugins/multi-site[multi-site]`
-* `link:https://gerrit.googlesource.com/plugins/rename-project[rename-project]`
+* `link:https://gerrit.googlesource.com/plugins/gc-conductor[gc-conductor,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/high-availability[high-availability,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/multi-site[multi-site,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/rename-project[rename-project,role=external,window=_blank]`
 
 ==== Power factor
 
@@ -257,9 +261,10 @@
 ----
 
 The `src/test/resources/logback.xml` file
-link:http://logback.qos.ch/manual/configuration.html[configures] Gatling's logging level. To quickly
-enable link:https://gatling.io/docs/current/general/debugging#logback[detailed logging] of `http`
-requests and responses, the `root level` can be set to `trace` in that file.
+link:http://logback.qos.ch/manual/configuration.html[configures,role=external,window=_blank]
+Gatling's logging level. To quickly enable
+link:https://gatling.io/docs/current/general/debugging#logback[detailed logging,role=external,window=_blank]
+of `http` requests and responses, the `root level` can be set to `trace` in that file.
 
 === How to run using Docker
 
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index bdd6360..742cf42 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:
 
 ----
@@ -91,13 +92,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.
 
@@ -111,6 +112,13 @@
 
 == Testing
 
+=== PolyGerrit UI is served by `server.go` process. To launch it,
+run this command:
+
+----
+  $ bazel run polygerrit-ui:devserver
+----
+
 === Running the Daemon
 
 Duplicate the existing launch configuration:
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 4408d93..c3df396 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.
 
@@ -1284,6 +1285,39 @@
 }
 ----
 
+== Trace Event origin
+
+When plugins are installed in a multi-master setups it can be useful to know
+the Gerrit `instanceId` of the server that has generated an Event.
+
+E.g. A plugin that sends an instance message for every comment on a change may
+want to react only if the event is generated on the local Gerrit master, for
+avoiding duplicating the notifications.
+
+If link:config-gerrit.html[instanceId] is set, each Event will contain its
+origin in the `instanceId` field.
+
+Here and example of ref-updated JSON event payload with `instanceId`:
+
+[source,json]
+---
+{
+  "submitter": {
+    "name": "Administrator",
+    "email": "admin@example.com",
+    "username": "admin"
+  },
+  "refUpdate": {
+    "oldRev": "a69fc95c7aad5ad41c618d31548b8af835d2959a",
+    "newRev": "31da6556d638a74e5370b62f83e8007f94abb7c6",
+    "refName": "refs/changes/01/1/meta",
+    "project": "test"
+  },
+  "type": "ref-updated",
+  "eventCreatedOn": 1588849085,
+  "instanceId": "instance1"
+}
+---
 
 [[capabilities]]
 == Plugin Owned Capabilities
@@ -2201,7 +2235,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
@@ -2259,16 +2293,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
 
@@ -2305,7 +2339,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
@@ -2351,7 +2385,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`.
 
@@ -2765,6 +2799,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 09dcbee..0fe2372 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.
@@ -53,18 +55,35 @@
 The election of the non-Google steering committee members happens once
 a year in May. Non-Google link:dev-roles.html#maintainer[maintainers]
 can nominate themselves by posting an informal application on the
-non-public maintainers mailing list by end of April (deadline for 2019
-is Mon 13th of May). By applying to be steering committee member, the
-candidate confirms to be able to dedicate the time that is needed to
-fulfill this role (also see
-link:dev-roles.html#steering-committee-member[steering committee
+non-public mailto:gerritcodereview-community-managers@googlegroups.com[
+community manager mailing list] by end of April (deadline for 2020
+is Thu 30th of April EOD).
+
+The list with all candidates will be published at the beginning of the
+voting period (for 2020 the start of the voting is planned for Mon 4th
+of May).
+
+Keeping the candidates private during the nomination phase and
+publishing all candidates at once only at the start of the voting
+period ensures that:
+
+* people do not start voting before all candidates are known and the
+  voting period has started
+* candidates that announce their candidacy early do not have an
+  advantage
+* people are not discouraged to candidate when there are already other
+  candidates
+
+By applying to be steering committee member, the candidate confirms to
+be able to dedicate the time that is needed to fulfill this role (also
+see link:dev-roles.html#steering-committee-member[steering committee
 member]).
 
 Each non-Google maintainer can vote for 2 candidates. The voting
-happens by posting on the maintainer mailing list. The voting period is
-14 calendar days from the nomination deadline (except for 2019, where
-the initial steering committee should be confirmed during the Munich
-hackathon, the voting period goes from 14th May to 16th May).
+happens by posting on the
+mailto:gerritcodereview-maintainers@googlegroups.com[maintainer mailing
+list]. The voting period is 14 calendar days from the start of the
+voting (for 2020 the voting period ends on Mon 18th May EOD).
 
 Google maintainers do not take part in this vote, because Google
 already has dedicated seats in the steering committee (see section
@@ -83,7 +102,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 +200,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 +240,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 +271,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 +302,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 +311,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 ad25147..2748413 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
-compile the code, preferably launched with link:https://github.com/bazelbuild/bazelisk[Bazelisk].
+To build a developer instance, you'll need link:https://bazel.build/[Bazel,role=external,window=_blank] to
+compile the code, preferably launched with link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank].
 
 == 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:
 
@@ -64,7 +66,7 @@
 === End-to-end tests
 
 <<dev-e2e-tests#,This document>> describes how `e2e` (load or functional) test
-scenarios are implemented using link:https://gatling.io/[`Gatling`].
+scenarios are implemented using link:https://gatling.io/[`Gatling`,role=external,window=_blank].
 
 
 == Local server
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-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 2131d00..a7240e2 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Making a Gerrit Release
 
 [NOTE]
@@ -89,7 +90,8 @@
 === 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
@@ -139,9 +141,10 @@
 ----
 
 If unable to tag, make sure that git is locally
-link:https://medium.com/@rwbutler/signing-commits-using-gpg-on-macos-7210362d15[configured with your user's key].
-These are the macOS instructions but such commands should be portable enough.
-Setting `GPG_TTY` this way or similar might also be necessary:
+link:https://medium.com/@rwbutler/signing-commits-using-gpg-on-macos-7210362d15[
+configured with your user's key,role=external,window=_blank]. These are the
+macOS instructions but such commands should be portable enough. Setting
+`GPG_TTY` this way or similar might also be necessary:
 
 ----
   export GPG_TTY=$(tty)
@@ -210,14 +213,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].
+link:https://oss.sonatype.org/[Sonatype Nexus Server,role=external,window=_blank].
 
 * 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
@@ -240,7 +243,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
@@ -259,27 +262,27 @@
 +
 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!
 
 ** Find the closed staging repository in the
-link:https://oss.sonatype.org/[Sonatype Nexus Server], select it and
-click on `Release`.
+link:https://oss.sonatype.org/[Sonatype Nexus Server,role=external,window=_blank],
+select it and 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:
 +
-https://repo1.maven.org/maven2/com/google/gerrit/
+https://repo1.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'.
@@ -290,7 +293,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.
 
@@ -301,7 +304,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.
@@ -333,7 +336,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
@@ -397,7 +400,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 9dbc450..2ca7f22 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 45586c3..6434b2f 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
@@ -39,12 +40,12 @@
   link:user-upload.html#base[exception].
 
 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]
+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], or see link:user-upload.html#base[exception]. 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.
+link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase,role=external,window=_blank],
+or see link:user-upload.html#base[exception,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.
 
 If you are pushing the new change to the same destination branch as
 the old commit (case 1 above), you also need to replace it with a new
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 4de55a7..8f36ecc 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review for Git
 
 == Quickstarts
@@ -9,6 +10,7 @@
 . 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]
 
 == Contributor Guides
 . link:dev-community.html[Gerrit Community]
@@ -17,7 +19,7 @@
 == User 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
@@ -44,7 +46,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]
@@ -86,11 +88,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 f7aed5e..7f932da 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,7 +588,7 @@
 the change screen.
 
 - 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
@@ -671,14 +672,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
@@ -692,7 +693,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):
 
@@ -734,7 +735,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
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 a54774b..eb2025c 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]]
@@ -120,7 +121,7 @@
 A change ref has the format `refs/changes/X/Y/Z` where `X` is the last
 two digits of the change number, `Y` is the entire change number, and `Z`
 is the patch set. For example, if the change number is
-link:https://gerrit-review.googlesource.com/c/gerrit/+/263270[263270],
+link:https://gerrit-review.googlesource.com/c/gerrit/+/263270[263270,role=external,window=_blank],
 the ref would be `refs/changes/70/263270/2` for the second patch set.
 
 [[fetch-change]]
@@ -212,7 +213,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]]
@@ -472,6 +473,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
 
@@ -565,7 +575,19 @@
 ----
   $ git push origin HEAD:refs/for/master%ready
 ----
-Alternatively, click *Start Review* from the Change screen.
+There are two options for marking the change ready for review from the Change
+screen:
+
+1. Click *Start Review* (the primary action *Reply* is renamed when in WIP
+state).
++
+This will open the reply-modal and allow you to add reviewers and/or CC
+before you start review.
+
+2. Click button *Mark As Active*.
++
+This will only change the state from WIP to ready, without opening the
+reply-modal.
 
 Change owners, project owners, site administrators and members of a group that
 was granted link:access-control.html#category_toggle_work_in_progress_state[
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/js_licenses.txt b/Documentation/js_licenses.txt
index c2bdfbb3..0342b8c 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -3,7 +3,6 @@
 Apache2.0
 
 * fonts:robotofonts
-* polymer_externs:polymer_closure
 
 [[Apache2_0_license]]
 ----
@@ -213,105 +212,10 @@
 ----
 
 
-[[ba-linkify]]
-ba-linkify
-
-* js:ba-linkify
-
-[[ba-linkify_license]]
-----
-Copyright (c) 2009 "Cowboy" Ben Alman
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
-[[es6-promise]]
-es6-promise
-
-* js:es6-promise
-
-[[es6-promise_license]]
-----
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
-[[fetch]]
-fetch
-
-* js:fetch
-
-[[fetch_license]]
-----
-Copyright (c) 2014-2016 GitHub, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[highlightjs]]
 highlightjs
 
 * js:highlightjs
-* js:highlightjs_files
 
 [[highlightjs_license]]
 ----
@@ -339,113 +243,88 @@
 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-----
-
-
-[[moment]]
-moment
-
-* js:moment
-
-[[moment_license]]
-----
-Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
 
 ----
 
 
-[[page_js]]
-page.js
+[[isarray]]
+isarray
 
-* js:page
+* isarray
 
-[[page_js_license]]
+[[isarray_license]]
 ----
-(The MIT License)
+(MIT)
 
-Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
 
 Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the 'Software'), to deal in
+this software and associated documentation files (the "Software"), to deal in
 the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
 
 The above copyright notice and this permission notice shall be included in all
 copies or substantial portions of the Software.
 
-THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
 
 ----
 
 
-[[polymer]]
-polymer
+[[es6-promise]]
+es6-promise
 
-* js:font-roboto-local
-* js:iron-a11y-announcer
-* js:iron-a11y-keys-behavior
-* js:iron-autogrow-textarea
-* js:iron-behaviors
-* js:iron-checked-element-behavior
-* js:iron-dropdown
-* js:iron-fit-behavior
-* js:iron-flex-layout
-* js:iron-form-element-behavior
-* js:iron-icon
-* js:iron-iconset-svg
-* js:iron-input
-* js:iron-menu-behavior
-* js:iron-meta
-* js:iron-overlay-behavior
-* js:iron-resizable-behavior
-* js:iron-selector
-* js:iron-validatable-behavior
-* js:neon-animation
-* js:paper-behaviors
-* js:paper-button
-* js:paper-icon-button
-* js:paper-input
-* js:paper-item
-* js:paper-listbox
-* js:paper-ripple
-* js:paper-styles
-* js:paper-tabs
-* js:paper-toggle-button
-* js:polymer
-* js:polymer-resin
-* js:webcomponentsjs
+* es6-promise
 
-[[polymer_license]]
+[[es6-promise_license]]
 ----
-Copyright (c) 2014 The Polymer Authors. All rights reserved.
+Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
+[[Polymer-2014]]
+Polymer-2014
+
+* @polymer/paper-ripple
+* @polymer/paper-styles
+
+[[Polymer-2014_license]]
+----
+Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
 
 Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are
@@ -476,33 +355,1010 @@
 ----
 
 
-[[shadycss]]
-shadycss
+[[Polymer-2018]]
+Polymer-2018
 
-* js:shadycss
+* @webcomponents/webcomponentsjs
+* polymer-bridges
+* polymer-resin
 
-[[shadycss_license]]
+[[Polymer-2018_license]]
 ----
-# License
+Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
 
-Everything in this repo is BSD style license unless otherwise specified.
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
 
-Copyright (c) 2015 The Polymer Authors. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
 
-Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright
+   * Redistributions of source code must retain the above copyright
 notice, this list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above
+   * Redistributions in binary form must reproduce the above
 copyright notice, this list of conditions and the following disclaimer
 in the documentation and/or other materials provided with the
 distribution.
-* Neither the name of Google Inc. nor the names of its
+   * Neither the name of Google Inc. nor the names of its
 contributors may be used to endorse or promote products derived from
 this software without specific prior written permission.
 
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+----
+
+
+[[font-roboto-local-fonts-robotomono]]
+font-roboto-local-fonts-robotomono
+
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/robotomono/LICENSE.txt
+** fonts/robotomono/METADATA.json
+** fonts/robotomono/RobotoMono-Bold.ttf
+** fonts/robotomono/RobotoMono-BoldItalic.ttf
+** fonts/robotomono/RobotoMono-Italic.ttf
+** fonts/robotomono/RobotoMono-Light.ttf
+** fonts/robotomono/RobotoMono-LightItalic.ttf
+** fonts/robotomono/RobotoMono-Medium.ttf
+** fonts/robotomono/RobotoMono-MediumItalic.ttf
+** fonts/robotomono/RobotoMono-Regular.ttf
+** fonts/robotomono/RobotoMono-Thin.ttf
+** fonts/robotomono/RobotoMono-ThinItalic.ttf
+
+[[font-roboto-local-fonts-robotomono_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[shadow-selection-polyfill]]
+shadow-selection-polyfill
+
+* shadow-selection-polyfill
+
+[[shadow-selection-polyfill_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[Polymer-2015]]
+Polymer-2015
+
+* @polymer/font-roboto
+* @polymer/font-roboto-local - only the following file(s):
+** README.md
+** bower.json
+** demo/index.d.ts
+** demo/index.html
+** fonts/roboto/DESCRIPTION.en_us.html
+** fonts/robotomono/DESCRIPTION.en_us.html
+** generate-style.js
+** manifest.json
+** package.json
+** roboto.js
+** update-fonts.sh
+* @polymer/iron-a11y-announcer
+* @polymer/iron-a11y-keys-behavior
+* @polymer/iron-autogrow-textarea
+* @polymer/iron-behaviors
+* @polymer/iron-checked-element-behavior
+* @polymer/iron-dropdown
+* @polymer/iron-fit-behavior
+* @polymer/iron-flex-layout
+* @polymer/iron-form-element-behavior
+* @polymer/iron-icon
+* @polymer/iron-iconset-svg
+* @polymer/iron-input
+* @polymer/iron-menu-behavior
+* @polymer/iron-meta
+* @polymer/iron-overlay-behavior
+* @polymer/iron-resizable-behavior
+* @polymer/iron-selector
+* @polymer/iron-validatable-behavior
+* @polymer/neon-animation
+* @polymer/paper-behaviors
+* @polymer/paper-button
+* @polymer/paper-dialog
+* @polymer/paper-dialog-behavior
+* @polymer/paper-dialog-scrollable
+* @polymer/paper-icon-button
+* @polymer/paper-input
+* @polymer/paper-item
+* @polymer/paper-listbox
+* @polymer/paper-tabs
+* @polymer/paper-toggle-button
+
+[[Polymer-2015_license]]
+----
+Copyright (c) 2015 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[ba-linkify]]
+ba-linkify
+
+* ba-linkify
+
+[[ba-linkify_license]]
+----
+Copyright (c) 2009 "Cowboy" Ben Alman
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
+[[whatwg-fetch]]
+whatwg-fetch
+
+* whatwg-fetch
+
+[[whatwg-fetch_license]]
+----
+Copyright (c) 2014-2016 GitHub, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
+[[moment]]
+moment
+
+* moment
+
+[[moment_license]]
+----
+Copyright (c) JS Foundation and other contributors
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
+[[font-roboto-local-fonts-roboto]]
+font-roboto-local-fonts-roboto
+
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/roboto/COPYRIGHT.txt
+** fonts/roboto/LICENSE.txt
+** fonts/roboto/METADATA.json
+** fonts/roboto/Roboto-Black.ttf
+** fonts/roboto/Roboto-BlackItalic.ttf
+** fonts/roboto/Roboto-Bold.ttf
+** fonts/roboto/Roboto-BoldItalic.ttf
+** fonts/roboto/Roboto-Italic.ttf
+** fonts/roboto/Roboto-Light.ttf
+** fonts/roboto/Roboto-LightItalic.ttf
+** fonts/roboto/Roboto-Medium.ttf
+** fonts/roboto/Roboto-MediumItalic.ttf
+** fonts/roboto/Roboto-Regular.ttf
+** fonts/roboto/Roboto-Thin.ttf
+** fonts/roboto/Roboto-ThinItalic.ttf
+
+[[font-roboto-local-fonts-roboto_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/polymer
+* @webcomponents/shadycss
+
+[[Polymer-2017_license]]
+----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[path-to-regexp]]
+path-to-regexp
+
+* path-to-regexp
+
+[[path-to-regexp_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+----
+
+
+[[page]]
+page
+
+* page
+
+[[page_license]]
+----
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
 ----
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 9f7bd99..3c7dbb1 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
@@ -74,8 +73,6 @@
 * jetty:servlet
 * jetty:util
 * jetty:util-ajax
-* log:json-smart
-* log:jsonevent-layout
 * log:log4j
 * lucene:lucene-analyzers-common
 * lucene:lucene-core-and-backward-codecs-merged
@@ -88,7 +85,6 @@
 * openid:consumer
 * openid:nekohtml
 * openid:xerces
-* polymer_externs:polymer_closure
 * blame-cache
 * caffeine
 * caffeine-guava
@@ -1069,39 +1065,6 @@
 ----
 
 
-[[ba-linkify]]
-ba-linkify
-
-* js:ba-linkify
-
-[[ba-linkify_license]]
-----
-Copyright (c) 2009 "Cowboy" Ben Alman
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[bouncycastle]]
 bouncycastle
 
@@ -1152,67 +1115,6 @@
 ----
 
 
-[[es6-promise]]
-es6-promise
-
-* js:es6-promise
-
-[[es6-promise_license]]
-----
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
-[[fetch]]
-fetch
-
-* js:fetch
-
-[[fetch_license]]
-----
-Copyright (c) 2014-2016 GitHub, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[flexmark]]
 flexmark
 
@@ -1999,7 +1901,6 @@
 highlightjs
 
 * js:highlightjs
-* js:highlightjs_files
 
 [[highlightjs_license]]
 ----
@@ -2027,6 +1928,7 @@
 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
 ----
 
 
@@ -2545,39 +2447,6 @@
 ----
 
 
-[[moment]]
-moment
-
-* js:moment
-
-[[moment_license]]
-----
-Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[ow2]]
 ow2
 
@@ -2622,107 +2491,6 @@
 ----
 
 
-[[page_js]]
-page.js
-
-* js:page
-
-[[page_js_license]]
-----
-(The MIT License)
-
-Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the 'Software'), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
-[[polymer]]
-polymer
-
-* js:font-roboto-local
-* js:iron-a11y-announcer
-* js:iron-a11y-keys-behavior
-* js:iron-autogrow-textarea
-* js:iron-behaviors
-* js:iron-checked-element-behavior
-* js:iron-dropdown
-* js:iron-fit-behavior
-* js:iron-flex-layout
-* js:iron-form-element-behavior
-* js:iron-icon
-* js:iron-iconset-svg
-* js:iron-input
-* js:iron-menu-behavior
-* js:iron-meta
-* js:iron-overlay-behavior
-* js:iron-resizable-behavior
-* js:iron-selector
-* js:iron-validatable-behavior
-* js:neon-animation
-* js:paper-behaviors
-* js:paper-button
-* js:paper-icon-button
-* js:paper-input
-* js:paper-item
-* js:paper-listbox
-* js:paper-ripple
-* js:paper-styles
-* js:paper-tabs
-* js:paper-toggle-button
-* js:polymer
-* js:polymer-resin
-* js:webcomponentsjs
-
-[[polymer_license]]
-----
-Copyright (c) 2014 The Polymer Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
 [[prologcafe]]
 prologcafe
 
@@ -3374,37 +3142,6 @@
 ----
 
 
-[[shadycss]]
-shadycss
-
-* js:shadycss
-
-[[shadycss_license]]
-----
-# License
-
-Everything in this repo is BSD style license unless otherwise specified.
-
-Copyright (c) 2015 The Polymer Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-* Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-
-----
-
-
 [[slf4j]]
 slf4j
 
@@ -3453,6 +3190,1122 @@
 ----
 
 
+[[isarray]]
+isarray
+
+* isarray
+
+[[isarray_license]]
+----
+(MIT)
+
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
+[[es6-promise]]
+es6-promise
+
+* es6-promise
+
+[[es6-promise_license]]
+----
+Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
+[[Polymer-2014]]
+Polymer-2014
+
+* @polymer/paper-ripple
+* @polymer/paper-styles
+
+[[Polymer-2014_license]]
+----
+Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[Polymer-2018]]
+Polymer-2018
+
+* @webcomponents/webcomponentsjs
+* polymer-bridges
+* polymer-resin
+
+[[Polymer-2018_license]]
+----
+Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[font-roboto-local-fonts-robotomono]]
+font-roboto-local-fonts-robotomono
+
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/robotomono/LICENSE.txt
+** fonts/robotomono/METADATA.json
+** fonts/robotomono/RobotoMono-Bold.ttf
+** fonts/robotomono/RobotoMono-BoldItalic.ttf
+** fonts/robotomono/RobotoMono-Italic.ttf
+** fonts/robotomono/RobotoMono-Light.ttf
+** fonts/robotomono/RobotoMono-LightItalic.ttf
+** fonts/robotomono/RobotoMono-Medium.ttf
+** fonts/robotomono/RobotoMono-MediumItalic.ttf
+** fonts/robotomono/RobotoMono-Regular.ttf
+** fonts/robotomono/RobotoMono-Thin.ttf
+** fonts/robotomono/RobotoMono-ThinItalic.ttf
+
+[[font-roboto-local-fonts-robotomono_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[shadow-selection-polyfill]]
+shadow-selection-polyfill
+
+* shadow-selection-polyfill
+
+[[shadow-selection-polyfill_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[Polymer-2015]]
+Polymer-2015
+
+* @polymer/font-roboto
+* @polymer/font-roboto-local - only the following file(s):
+** README.md
+** bower.json
+** demo/index.d.ts
+** demo/index.html
+** fonts/roboto/DESCRIPTION.en_us.html
+** fonts/robotomono/DESCRIPTION.en_us.html
+** generate-style.js
+** manifest.json
+** package.json
+** roboto.js
+** update-fonts.sh
+* @polymer/iron-a11y-announcer
+* @polymer/iron-a11y-keys-behavior
+* @polymer/iron-autogrow-textarea
+* @polymer/iron-behaviors
+* @polymer/iron-checked-element-behavior
+* @polymer/iron-dropdown
+* @polymer/iron-fit-behavior
+* @polymer/iron-flex-layout
+* @polymer/iron-form-element-behavior
+* @polymer/iron-icon
+* @polymer/iron-iconset-svg
+* @polymer/iron-input
+* @polymer/iron-menu-behavior
+* @polymer/iron-meta
+* @polymer/iron-overlay-behavior
+* @polymer/iron-resizable-behavior
+* @polymer/iron-selector
+* @polymer/iron-validatable-behavior
+* @polymer/neon-animation
+* @polymer/paper-behaviors
+* @polymer/paper-button
+* @polymer/paper-dialog
+* @polymer/paper-dialog-behavior
+* @polymer/paper-dialog-scrollable
+* @polymer/paper-icon-button
+* @polymer/paper-input
+* @polymer/paper-item
+* @polymer/paper-listbox
+* @polymer/paper-tabs
+* @polymer/paper-toggle-button
+
+[[Polymer-2015_license]]
+----
+Copyright (c) 2015 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[ba-linkify]]
+ba-linkify
+
+* ba-linkify
+
+[[ba-linkify_license]]
+----
+Copyright (c) 2009 "Cowboy" Ben Alman
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
+[[whatwg-fetch]]
+whatwg-fetch
+
+* whatwg-fetch
+
+[[whatwg-fetch_license]]
+----
+Copyright (c) 2014-2016 GitHub, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
+[[moment]]
+moment
+
+* moment
+
+[[moment_license]]
+----
+Copyright (c) JS Foundation and other contributors
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
+[[font-roboto-local-fonts-roboto]]
+font-roboto-local-fonts-roboto
+
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/roboto/COPYRIGHT.txt
+** fonts/roboto/LICENSE.txt
+** fonts/roboto/METADATA.json
+** fonts/roboto/Roboto-Black.ttf
+** fonts/roboto/Roboto-BlackItalic.ttf
+** fonts/roboto/Roboto-Bold.ttf
+** fonts/roboto/Roboto-BoldItalic.ttf
+** fonts/roboto/Roboto-Italic.ttf
+** fonts/roboto/Roboto-Light.ttf
+** fonts/roboto/Roboto-LightItalic.ttf
+** fonts/roboto/Roboto-Medium.ttf
+** fonts/roboto/Roboto-MediumItalic.ttf
+** fonts/roboto/Roboto-Regular.ttf
+** fonts/roboto/Roboto-Thin.ttf
+** fonts/roboto/Roboto-ThinItalic.ttf
+
+[[font-roboto-local-fonts-roboto_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/polymer
+* @webcomponents/shadycss
+
+[[Polymer-2017_license]]
+----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[path-to-regexp]]
+path-to-regexp
+
+* path-to-regexp
+
+[[path-to-regexp_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+----
+
+
+[[page]]
+page
+
+* page
+
+[[page_license]]
+----
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 643bde0..29bb409 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.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
 
 ....
 wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 713fbff..4724445 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -187,10 +187,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
@@ -198,11 +198,13 @@
 * `notedb/external_id_update_count`: Total number of external ID updates.
 * `notedb/read_all_external_ids_latency`: Latency for reading all
 external ID's from NoteDb.
+* `notedb/read_single_account_config_latency`: Latency for reading a single
+account config from NoteDb.
+* `notedb/read_single_external_id_latency`: Latency for reading a single
+external ID from NoteDb.
 
 === Permissions
 
-* `permissions/project_state/computation_latency`: Latency to compute current access
-sections on a project by traversing it's parents.
 * `permissions/permission_collection/filter_latency`: Latency to filter access sections
 by user and ref.
 * `permissions/ref_filter/full_filter_count`: Rate of full ref filter operations
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 0bacca4..a13cbfb 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - NoteDb Backend
 
 NoteDb is the storage backend for code review metadata. It is based on
@@ -191,5 +192,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..1ce1d61 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
@@ -210,11 +211,6 @@
 
 Note: TODO
 
-=== changeView
-`plugin.changeView()`
-
-Note: TODO
-
 === delete
 `plugin.delete(url, opt_callback)`
 
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 6f0d828..6fbedb0 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.
@@ -58,8 +59,8 @@
 
 [[details]]
 --
-* `DETAILS`: Includes full name, preferred email, username, avatars,
-status and state for each account.
+* `DETAILS`: Includes full name, preferred email, username, display
+name, avatars, status and state for each account.
 --
 
 [[all-emails]]
@@ -99,12 +100,14 @@
       "name": "John Doe",
       "email": "john.doe@example.com",
       "username": "john"
+      "display_name": "John D"
     },
     {
       "_account_id": 1001439,
       "name": "John Smith",
       "email": "john.smith@example.com",
       "username": "jsmith"
+      "display_name": "Johnny"
     },
   ]
 ----
@@ -134,6 +137,7 @@
     "name": "John Doe",
     "email": "john.doe@example.com",
     "username": "john"
+    "display_name": "Super John"
   }
 ----
 
@@ -155,6 +159,7 @@
 
   {
     "name": "John Doe",
+    "display_name": "Super John",
     "email": "john.doe@example.com",
     "ssh_key": "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA0T...YImydZAw==",
     "http_password": "19D9aIn7zePb",
@@ -208,6 +213,7 @@
     "name": "John Doe",
     "email": "john.doe@example.com",
     "username": "john"
+    "display_name": "Super John"
   }
 ----
 
@@ -401,6 +407,27 @@
 
 As response the new username is returned.
 
+[[set-display-name]]
+=== Set Display Name
+--
+'PUT /accounts/link:#account-id[\{account-id\}]/displayname'
+--
+
+The new display name must be provided in the request body inside
+a link:#display-name-input[DisplayNameInput] entity.
+
+.Request
+----
+  PUT /accounts/self/displayname HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "display_name": "John"
+  }
+----
+
+As response the new display name is returned.
+
 [[get-active]]
 === Get Active
 --
@@ -569,6 +596,9 @@
 
 Returns the email addresses that are configured for the specified user.
 
+link:access-control.html#capability_modifyAccount[ModifyAccount]
+capability is required to view emails of other users.
+
 .Request
 ----
   GET /accounts/self/emails HTTP/1.0
@@ -1705,6 +1735,10 @@
 
 Retrieves the external ids of a user account.
 
+Only external ids belonging to the caller may be requested. Users that have
+link:access-control.html#capability_modifyAccount[Modify Account] can request
+external ids that belong to other accounts.
+
 .Request
 ----
   GET /a/accounts/self/external.ids HTTP/1.0
@@ -1738,7 +1772,9 @@
 Delete a list of external ids for a user account. The target external ids must
 be provided as a list in the request body.
 
-Only external ids belonging to the caller may be deleted.
+Only external ids belonging to the caller may be deleted. Users that have
+link:access-control.html#capability_modifyAccount[Modify Account] can delete
+external ids that belong to other accounts.
 
 .Request
 ----
@@ -2231,6 +2267,11 @@
 See option link:rest-api-changes.html#detailed-accounts[
 DETAILED_ACCOUNTS] for change queries +
 and option link:#details[DETAILS] for account queries.
+|`display_name`    |optional|The display name of the user. +
+Only set if detailed account information is requested. +
+See option link:rest-api-changes.html#detailed-accounts[
+DETAILED_ACCOUNTS] for change queries +
+and option link:#details[DETAILS] for account queries.
 |`email`           |optional|
 The email address the user prefers to be contacted through. +
 Only set if detailed account information is requested. +
@@ -2271,6 +2312,7 @@
 |`username`     |optional|
 The user name. If provided, must match the user name from the URL.
 |`name`         |optional|The full name of the user.
+|`display_name` |optional|The display name of the user.
 |`email`        |optional|The email address of the user.
 |`ssh_key`      |optional|The public SSH key of the user.
 |`http_password`|optional|The HTTP password of the user.
@@ -2383,13 +2425,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
@@ -2426,8 +2473,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.
@@ -2644,7 +2691,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|
@@ -2868,6 +2915,17 @@
 |`username` |The new username of the account.
 |=======================
 
+[[display-name-input]]
+=== DisplayNameInput
+The `DisplayNameInput` entity contains information for setting the
+display name for an account.
+
+[options="header",cols="1,6"]
+|=======================
+|Field Name     |Description
+|`display_name` |The new display name of the account.
+|=======================
+
 [[project-watch-info]]
 === ProjectWatchInfo
 The `WatchedProjectsInfo` entity contains information about a project watch
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index f67670b..cebf45c 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
@@ -309,21 +312,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].
@@ -369,11 +357,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
@@ -1455,6 +1438,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.
 
@@ -1492,9 +1479,126 @@
   }
 ----
 
+If the user doesn't have revert permission on the change or upload permission on
+the destination branch, the response is "`403 Forbidden`", and the error message is
+contained in the response body.
+
 If the change 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.
+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
+----
+
+[[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 the user doesn't have revert permission on the change or upload permission on
+the destination, the response is "`403 Forbidden`", and the error message is
+contained in the response body.
+
+If the change 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
 ----
@@ -2271,7 +2375,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
@@ -2632,6 +2736,20 @@
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
 ----
 
+To upload a file as binary data in the request body:
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
+  }
+----
+
+Note that it must be base-64 encoded data uri.
+
 When change edit doesn't exist for this change yet it is created. When file
 content isn't provided, it is wiped out for that file. As response
 "`204 No Content`" is returned.
@@ -3253,7 +3371,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 +3451,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 +3915,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`.
 
@@ -4705,7 +4827,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
@@ -5265,6 +5387,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
 --
@@ -5390,8 +5523,8 @@
   }
 ----
 
-As response a link:#cherry-pick-change-info[CherryPickChangeInfo]
-entity is returned that describes the resulting cherry-pick change.
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the resulting cherry-pick change.
 
 .Response
 ----
@@ -5525,7 +5658,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
@@ -5827,10 +5960,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].
@@ -5919,6 +6051,27 @@
 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.
+|`cherry_pick_of_change`   |optional|
+The numeric Change-Id of the change that this change was cherry-picked from.
+|`cherry_pick_of_patch_set`|optional|
+The patchset number of the change that this change was cherry-picked from.
+|`contains_git_conflicts`  |optional, not set if `false`|
+Whether the change contains conflicts. +
+If `true`, some of the file contents of the change contain git conflict
+markers to indicate the conflicts. +
+Only set if this change info is returned in response to a request that
+creates a new change or patch set and conflicts are allowed. In
+particular this field is only populated if the change info is returned
+by one of the following REST endpoints: link:#create-change[Create
+Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
+For Change], link:#cherry-pick[Cherry Pick Revision],
+link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit]
 |==================================
 
 [[change-input]]
@@ -5933,9 +6086,16 @@
 The name of the target branch. +
 The `refs/heads/` prefix is omitted.
 |`subject`            ||
-The commit message of the change. Comment lines (beginning with `#`) will
-be removed.
+The commit message of the change. Comment lines (beginning with `#`)
+will be removed. If the commit message contains a Change-Id (as a
+"Change-Id: I..." footer) that Change-Id will be used for the newly
+created changed. If a change with this Change-Id already exists for
+same repository and branch, the request is rejected since Change-Ids
+must be unique per repository and branch. If the commit message doesn't
+contain a Change-Id, a newly generated Change-Id is automatically
+inserted into the commit message.
 |`topic`              |optional|The topic to which this change belongs.
+Topic can't contain quotation marks.
 |`status`             |optional, default to `NEW`|
 The status of the change (only `NEW` accepted here).
 |`is_private`         |optional, default to `false`|
@@ -5957,6 +6117,13 @@
 If set, the target branch (see  `branch` field) must exist (it is not
 possible to create it automatically by setting the `new_branch` field
 to `true`.
+|`author`             |optional|
+An link:rest-api-accounts.html#account-input[AccountInput] entity
+that will set the author of the commit to create. The author must be
+specified as name/email combination.
+The caller needs "Forge Author" permission when using this field.
+This field does not affect the owner of the change, which will
+continue to use the identity of the caller.
 |`notify`             |optional|
 Notify handling that defines to whom email notifications should be sent
 after the change is created. +
@@ -5997,23 +6164,6 @@
 Which patchset (if any) generated this message.
 |==================================
 
-[[cherry-pick-change-info]]
-=== CherryPickChangeInfo
-The `CherryPickChangeInfo` entity contains information about a
-cherry-pick change.
-
-`CherryPickChangeInfo` has the same fields as link:#change-info[
-ChangeInfo]. In addition `CherryPickChangeInfo` has the following
-fields:
-
-[options="header",cols="1,^1,5"]
-|======================================
-|Field Name               ||Description
-|`contains_git_conflicts` |optional, not set if `false`|
-Whether any file in the change contains Git conflict markers.
-|======================================
-
-
 [[cherrypick-input]]
 === CherryPickInput
 The `CherryPickInput` entity contains information for cherry-picking a change to a new branch.
@@ -6045,10 +6195,21 @@
 there are conflicts. If there are conflicts the file contents of the
 created change contain git conflict markers to indicate the conflicts.
 Callers can find out if there were conflicts by checking the
-`contains_git_conflicts` field in the link:#cherry-pick-change-info[
-CherryPickChangeInfo] that is returned by the cherry-pick REST
-endpoints. If there are conflicts the cherry-pick change is marked as
+`contains_git_conflicts` field in the link:#change-info[ChangeInfo]. If
+there are conflicts the cherry-pick change is marked as
 work-in-progress.
+|`topic`            |optional|
+The topic of the created cherry-picked change. If not set, the default depends
+on the source. If the source is a change with a topic, the resulting topic
+of the cherry-picked change will be {source_change_topic}-{destination_branch}.
+Otherwise, if the source change has no topic, or the source is a commit,
+the created change will have no topic.
+If the change already exists, the topic will not change if not set. If set, the
+topic will be overridden.
+|`allow_empty`      |optional, defaults to false|
+If `true`, the cherry-pick succeeds also if the created commit will be empty.
+If `false`, a cherry-pick that would create an empty commit fails without creating
+the commit.
 |===========================
 
 [[comment-info]]
@@ -6098,6 +6259,10 @@
 Whether or not the comment must be addressed by the user. The state of
 resolution of a comment thread is stored in the last comment in that thread
 chronologically.
+|`change_message_id` |optional|
+Available with published comments. Contains the
+link:rest-api-changes.html#change-message-info[id] of the change message
+that this comment is linked to.
 |===========================
 
 [[comment-input]]
@@ -6139,7 +6304,7 @@
 |`tag`         |optional, drafts only|
 Value of the `tag` field. Only allowed on link:#create-draft[draft comment] +
 inputs; for published comments, use the `tag` field in +
-link#review-input[ReviewInput]. Votes/comments that contain `tag` with
+link:#review-input[ReviewInput]. Votes/comments that contain `tag` with
 'autogenerated:' prefix can be filtered out in the web UI.
 |`unresolved`        |optional|
 Whether or not the comment must be addressed by the user. This value will
@@ -6657,16 +6822,16 @@
 Submit type used for this change, can be `MERGE_IF_NECESSARY`,
 `FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `REBASE_ALWAYS`, `MERGE_ALWAYS` or
 `CHERRY_PICK`.
-|`strategy`     |optional|
+|`strategy`      |optional|
 The strategy of the merge, can be `recursive`, `resolve`,
 `simple-two-way-in-core`, `ours` or `theirs`.
 |`mergeable`     ||
 `true` if this change is cleanly mergeable, `false` otherwise
-|`commit_merged`     |optional|
+|`commit_merged` |optional|
 `true` if this change is already merged, `false` otherwise
-|`content_merged`     |optional|
+|`content_merged`|optional|
 `true` if the content of this change is already merged, `false` otherwise
-|`conflicts`|optional|
+|`conflicts`     |optional|
 A list of paths with conflicts
 |`mergeable_into`|optional|
 A list of other branch names where this change could merge cleanly
@@ -6677,16 +6842,31 @@
 The `MergeInput` entity contains information about the merge
 
 [options="header",cols="1,^1,5"]
-|============================
-|Field Name      ||Description
-|`source`   ||
+|==============================
+|Field Name       ||Description
+|`source`         ||
 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.
-|`strategy`     |optional|
+|`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.
-|============================
+|`allow_conflicts`|optional, defaults to false|
+If `true`, creating the merge succeeds also if there are conflicts. +
+If there are conflicts the file contents of the created change contain
+git conflict markers to indicate the conflicts. +
+Callers can find out whether there were conflicts by checking the
+`contains_git_conflicts` field in the link:#change-info[ChangeInfo]. +
+If there are conflicts the change is marked as work-in-progress. +
+This option is not supported for all merge strategies (e.g. it's
+supported for `recursive` and `resolve`, but not for
+`simple-two-way-in-core`).
+|==============================
 
 [[merge-patch-set-input]]
 === MergePatchSetInput
@@ -6888,8 +7068,6 @@
 Alphanumerical (plus hyphens or underscores) string to identify what the requirement is and why it
 was triggered. Can be seen as a class: requirements sharing the same type were created for a similar
 reason, and the data structure will follow one set of rules.
-|`data`          |optional|
-Holds custom key-value strings, used in templates to render richer status messages
 |===========================
 
 
@@ -6924,10 +7102,25 @@
 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}`.
+Topic can't contain quotation marks.
 |=============================
 
+[[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.
@@ -7072,9 +7265,12 @@
 |`reviewer`      ||
 The link:rest-api-accounts.html#account-id[ID] of one account that
 should be added as reviewer or the link:rest-api-groups.html#group-id[
-ID] of one group for which all members should be added as reviewers. +
+ID] of one internal group for which all members should be added as reviewers. +
 If an ID identifies both an account and a group, only the account is
 added as reviewer to the change.
+External groups, such as LDAP groups, will be silently omitted from a
+link:#set-review[set-review] or
+link:rest-api-changes.html#add-reviewer[add-reviewer] call.
 |`state`         |optional|
 Add reviewer in this state. Possible reviewer states are `REVIEWER`
 and `CC`. If not given, defaults to `REVIEWER`.
@@ -7332,6 +7528,7 @@
 |Field Name    ||Description
 |`topic`       |optional|The topic. +
 The topic will be deleted if not set.
+Topic can't contain quotation marks.
 |===========================
 
 [[tracking-id-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 063e54d..f76e0b8 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1419,10 +1419,13 @@
 
 [options="header",cols="1,6"]
 |=============================
-|Field Name           |Description
-|`visibility`         |
+|Field Name            |Description
+|`visibility`          |
 link:config-gerrit.html#accounts.visibility[Visibility setting for
 accounts].
+|`default_display_name`|The default strategy for choosing the display
+name in the UI, see also
+link:config-gerrit.html#accounts.defaultDisplayName[gerrit.config].
 |=============================
 
 [[auth-info]]
@@ -1566,10 +1569,30 @@
 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.
+|`enable_attention_set` |defaults to `false`|
+Returns true if attention set UI features are enabled.
+|`enable_assignee` |defaults to `true`|
+Returns true if assignee related UI features are enabled.
+|=============================
+
+[[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 +1844,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 +1985,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 c4ba973..72974e2 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..a4e27b3 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
@@ -2575,9 +2576,8 @@
   }
 ----
 
-As response a link:rest-api-changes.html#cherry-pick-change-info[
-CherryPickChangeInfo] entity is returned that describes the resulting
-cherry-picked change.
+As response a link:rest-api-changes.html#change-info[ChangeInfo] entity
+is returned that describes the resulting cherry-picked change.
 
 .Response
 ----
@@ -2925,6 +2925,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 +3317,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 +3494,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 +3663,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 +3705,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 +3771,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 +3866,132 @@
 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.
+|`copy_values`   |optional|
+List of values that should be copied forward when a new patch set is uploaded.
+|`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.
+|`copy_values`   |optional|
+List of values that should be copied forward when a new patch set is uploaded.
+|`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 +4028,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 05765ee..cd26792 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 0845956..0c1ec2d 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Searching Changes
 
 == Default Searches
@@ -72,6 +73,11 @@
 +
 Changes assigned to the given user.
 
+[[attention]]
+attention:'USER'::
++
+Changes whose attention set includes the given user.
+
 [[before_until]]
 before:'TIME'/until:'TIME'::
 +
@@ -140,6 +146,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 +167,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 +186,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 +209,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 +219,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 +234,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 +254,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 +289,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
@@ -332,7 +353,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'::
@@ -450,6 +471,10 @@
 +
 Mergeability of abandoned changes is not computed. This operator will
 not find any abandoned but mergeable changes.
++
+This operator only works if Gerrit indexes 'mergeable'. See
+link:config-gerrit.html#index.change.indexMergeable[indexMergeable]
+for details.
 
 [[ignored]]
 is:ignored::
@@ -467,6 +492,11 @@
 +
 True if the change is Work In Progress.
 
+[[merge]]
+is:merge::
++
+True if the change is a merge commit.
+
 [[status]]
 status:open, status:pending, status:new::
 +
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 cb58ad9..cdaf155 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:
@@ -579,9 +580,10 @@
 === Selecting Merge Base
 
 By default new changes are opened only for new unique commits
-that have never before been seen by the Gerrit server. Clients
-may override that behavior and force new changes to be created
-by setting the merge base SHA-1 using the '%base' argument:
+that are not part of any branch in refs/heads or the target
+branch. Clients may override that behavior and force new
+changes to be created by setting the merge base SHA-1 using
+the '%base' argument:
 
 ----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%base=$(git rev-parse origin/master)
@@ -618,7 +620,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 54c0f8f..3906453 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,4 +1,27 @@
-workspace(name = "gerrit")
+# npm packages are split into different node_modules directories based on their usage.
+# 1. /node_modules (referenced as @npm) - contains packages to run tests, check code, etc...
+#    It is expected that @npm is used ONLY to run tools. No packages from @npm are used by
+#    other code in gerrit.
+# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder contains self-written tools
+#    which are run for building and/or testing. The @tools_npm directory contains all the packages needed to
+#    run this tools.
+# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which are necessary to run polygerrit
+#    and to bundle it. Only code from these packages can be included in the final bundle for polygerrit.
+#    @ui_npm folder must not have devDependencies. All dev dependencies must be placed in @ui_dev_npm
+# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit. The packages from these
+#    folder can be used for testing, but must not be included in the final bundle.
+# Note: separation between @ui_npm and @ui_dev_npm is necessary because with bazel we can't generate
+#    two managed directories from the same package.json. At the same time we want to avoid accidental
+#    usages of code from devDependencies in polygerrit bundle.
+workspace(
+    name = "gerrit",
+    managed_directories = {
+        "@npm": ["node_modules"],
+        "@ui_npm": ["polygerrit-ui/app/node_modules"],
+        "@ui_dev_npm": ["polygerrit-ui/node_modules"],
+        "@tools_npm": ["tools/node_tools/node_modules"],
+    },
+)
 
 load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
@@ -35,6 +58,14 @@
     ],
 )
 
+http_archive(
+    name = "build_bazel_rules_nodejs",
+    patch_args = ["-p1"],
+    patches = ["//:rules_nodejs-1.5.patch"],
+    sha256 = "d0c4bb8b902c1658f42eb5563809c70a06e46015d64057d25560b0eb4bdc9007",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/1.5.0/rules_nodejs-1.5.0.tar.gz"],
+)
+
 # File is specific to Polymer and copied from the Closure Github -- should be
 # synced any time there are major changes to Polymer.
 # https://github.com/google/closure-compiler/blob/master/contrib/externs/polymer-1.0.js
@@ -62,10 +93,10 @@
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "f04d2373bcaf8aa09bccb08a98a57e721306c8f6043a2a0ee610fd6853dcde3d",
+    sha256 = "b34cbe1a7514f5f5487c3bfee7340a4496713ddf4f119f7a225583d6cafd793a",
     urls = [
-        "https://storage.googleapis.com/bazel-mirror/github.com/bazelbuild/rules_go/releases/download/0.18.6/rules_go-0.18.6.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/0.18.6/rules_go-0.18.6.tar.gz",
+        "https://storage.googleapis.com/bazel-mirror/github.com/bazelbuild/rules_go/releases/download/v0.21.1/rules_go-v0.21.1.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.21.1/rules_go-v0.21.1.tar.gz",
     ],
 )
 
@@ -286,12 +317,6 @@
 )
 
 maven_jar(
-    name = "jsonevent-layout",
-    artifact = "net.logstash.log4j:jsonevent-layout:1.7",
-    sha1 = "507713504f0ddb75ba512f62763519c43cf46fde",
-)
-
-maven_jar(
     name = "json-smart",
     artifact = "net.minidev:json-smart:1.1.1",
     sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
@@ -620,18 +645,18 @@
     sha1 = "a3ae34e57fa8a4040e28247291d0cc3d6b8c7bcf",
 )
 
-AUTO_VALUE_VERSION = "1.7"
+AUTO_VALUE_VERSION = "1.7.4"
 
 maven_jar(
     name = "auto-value",
     artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "fe8387764ed19460eda4f106849c664f51c07121",
+    sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
 )
 
 maven_jar(
     name = "auto-value-annotations",
     artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    sha1 = "5be124948ebdc7807df68207f35a0f23ce427f29",
+    sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
 )
 
 declare_nongoogle_deps()
@@ -723,7 +748,7 @@
     sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
 )
 
-GITILES_VERS = "0.3-7"
+GITILES_VERS = "0.4"
 
 GITILES_REPO = GERRIT
 
@@ -732,14 +757,14 @@
     artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
     attach_source = False,
     repository = GITILES_REPO,
-    sha1 = "af6212a62363906c63d367f8276ae1645f83bf93",
+    sha1 = "567198123898aa86bd854d3fcb044dc7a1845741",
 )
 
 maven_jar(
     name = "gitiles-servlet",
     artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
     repository = GITILES_REPO,
-    sha1 = "6a53f722f8572a2f1bcb7d86e5692168844bab76",
+    sha1 = "0dd832a6df108af0c75ae29b752fda64ccbd6886",
 )
 
 # prettify must match the version used in Gitiles
@@ -841,30 +866,30 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-TRUTH_VERS = "1.0"
+TRUTH_VERS = "1.1"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "998e5fb3fa31df716574b4c9e8d374855e800451",
+    sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "d85fbc1daf0510821f552f2aa71d9605e97aa438",
+    sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
 )
 
 maven_jar(
     name = "truth-liteproto-extension",
     artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "7a279c50a0f93da15533cef4993b45606cf67d72",
+    sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
 )
 
 maven_jar(
     name = "truth-proto-extension",
     artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "8c0c2ea61750f02d0d5ce9c653106b6a5dc82d12",
+    sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
 )
 
 maven_jar(
@@ -900,12 +925,6 @@
 )
 
 maven_jar(
-    name = "jetty-continuation",
-    artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERS,
-    sha1 = "09f021e5895471f622ec8f95e28f5815ea7ee192",
-)
-
-maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
     sha1 = "45d35131a35a1e76991682174421e8cdf765fb9f",
@@ -1144,8 +1163,8 @@
 bower_archive(
     name = "codemirror-minified",
     package = "Dominator008/codemirror-minified",
-    sha1 = "e6bda82afc7cf3493f4282c6f17265d40e1485e5",
-    version = "5.43.0",
+    sha1 = "d00f3b97345772d5a7790f206cb1e3c22e96caf6",
+    version = "5.50.2",
 )
 
 # bower test stuff
@@ -1171,9 +1190,40 @@
     version = "6.5.1",
 )
 
-# Bower component transitive dependencies.
-load("//lib/js:bower_archives.bzl", "load_bower_archives")
+load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
 
-load_bower_archives()
+yarn_install(
+    name = "npm",
+    package_json = "//:package.json",
+    yarn_lock = "//:yarn.lock",
+)
+
+yarn_install(
+    name = "ui_npm",
+    args = ["--prod"],
+    package_json = "//:polygerrit-ui/app/package.json",
+    yarn_lock = "//:polygerrit-ui/app/yarn.lock",
+)
+
+yarn_install(
+    name = "ui_dev_npm",
+    package_json = "//:polygerrit-ui/package.json",
+    yarn_lock = "//:polygerrit-ui/yarn.lock",
+)
+
+yarn_install(
+    name = "tools_npm",
+    package_json = "//:tools/node_tools/package.json",
+    yarn_lock = "//:tools/node_tools/yarn.lock",
+)
+
+# Install all Bazel dependencies needed for npm packages that supply Bazel rules
+load("@npm//:install_bazel_dependencies.bzl", "install_bazel_dependencies")
+
+install_bazel_dependencies()
+
+load("@npm_bazel_typescript//:index.bzl", "ts_setup_workspace")
+
+ts_setup_workspace()
 
 external_plugin_deps()
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/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 8b7f9a0..f29cdb2 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -27,6 +27,7 @@
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -43,6 +44,7 @@
 import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -65,6 +67,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
@@ -105,6 +108,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritInstanceId;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
@@ -198,7 +202,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();
@@ -236,6 +246,7 @@
   @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
   @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
   @Inject @GerritServerConfig protected Config cfg;
+  @Inject @GerritInstanceId @Nullable protected String instanceId;
   @Inject protected AcceptanceTestRequestScope atrScope;
   @Inject protected AccountCache accountCache;
   @Inject protected AccountCreator accountCreator;
@@ -386,8 +397,7 @@
     initSsh();
   }
 
-  protected void evictAndReindexAccount(Account.Id accountId) {
-    accountCache.evict(accountId);
+  protected void reindexAccount(Account.Id accountId) {
     accountIndexer.index(accountId);
   }
 
@@ -448,8 +458,8 @@
     user = accountCreator.user();
 
     // Evict and reindex accounts in case tests modify them.
-    evictAndReindexAccount(admin.id());
-    evictAndReindexAccount(user.id());
+    reindexAccount(admin.id());
+    reindexAccount(user.id());
 
     adminRestSession = new RestSession(server, admin);
     userRestSession = new RestSession(server, user);
@@ -704,6 +714,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");
   }
@@ -847,6 +865,10 @@
     return gApi.changes().id(id).info();
   }
 
+  protected ChangeApi change(Result r) throws RestApiException {
+    return gApi.changes().id(r.getChange().getId().get());
+  }
+
   protected Optional<EditInfo> getEdit(String id) throws RestApiException {
     return gApi.changes().id(id).edit().get();
   }
@@ -1207,9 +1229,8 @@
       GroupReference groupReference,
       String ref,
       boolean exclusive,
-      String... permissionNames)
-      throws IOException {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+      String... permissionNames) {
+    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
     AccessSection accessSection = cfg.getAccessSection(ref);
     assertThat(accessSection).isNotNull();
     for (String permissionName : permissionNames) {
@@ -1294,7 +1315,7 @@
   }
 
   protected void assertNotifyTo(String expectedEmail, String expectedFullname) {
-    Address expectedAddress = new Address(expectedFullname, expectedEmail);
+    Address expectedAddress = Address.create(expectedFullname, expectedEmail);
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
     assertThat(m.rcpt()).containsExactly(expectedAddress);
@@ -1304,11 +1325,11 @@
   }
 
   protected void assertNotifyCc(TestAccount expected) {
-    assertNotifyCc(expected.getEmailAddress());
+    assertNotifyCc(expected.getNameEmail());
   }
 
   protected void assertNotifyCc(String expectedEmail, String expectedFullname) {
-    Address expectedAddress = new Address(expectedFullname, expectedEmail);
+    Address expectedAddress = Address.create(expectedFullname, expectedEmail);
     assertNotifyCc(expectedAddress);
   }
 
@@ -1324,7 +1345,7 @@
   protected void assertNotifyBcc(TestAccount expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(expected.getNameEmail());
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
@@ -1332,7 +1353,7 @@
   protected void assertNotifyBcc(String expectedEmail, String expectedFullName) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(new Address(expectedFullName, expectedEmail));
+    assertThat(m.rcpt()).containsExactly(Address.create(expectedFullName, expectedEmail));
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index a372089..bb3901e 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -126,7 +126,7 @@
       recipients.put(
           BCC,
           message.rcpt().stream()
-              .map(Address::getEmail)
+              .map(Address::email)
               .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
               .collect(toList()));
       this.users = users;
@@ -174,7 +174,7 @@
       }
       Truth.assertThat(header).isInstanceOf(AddressList.class);
       AddressList addrList = (AddressList) header;
-      return addrList.getAddressList().stream().map(Address::getEmail).collect(toList());
+      return addrList.getAddressList().stream().map(Address::email).collect(toList());
     }
 
     public FakeEmailSenderSubject to(String... emails) {
@@ -339,8 +339,8 @@
       return description.getClassName();
     }
 
-    private TestAccount evictAndCopy(TestAccount account) {
-      evictAndReindexAccount(account.id());
+    private TestAccount reindexAndCopy(TestAccount account) {
+      reindexAccount(account.id());
       return account;
     }
 
@@ -348,14 +348,14 @@
       synchronized (stagedUsers) {
         if (stagedUsers.containsKey(usersCacheKey())) {
           StagedUsers existing = stagedUsers.get(usersCacheKey());
-          owner = evictAndCopy(existing.owner);
-          author = evictAndCopy(existing.author);
-          uploader = evictAndCopy(existing.uploader);
-          reviewer = evictAndCopy(existing.reviewer);
-          ccer = evictAndCopy(existing.ccer);
-          starrer = evictAndCopy(existing.starrer);
-          assignee = evictAndCopy(existing.assignee);
-          watchingProjectOwner = evictAndCopy(existing.watchingProjectOwner);
+          owner = reindexAndCopy(existing.owner);
+          author = reindexAndCopy(existing.author);
+          uploader = reindexAndCopy(existing.uploader);
+          reviewer = reindexAndCopy(existing.reviewer);
+          ccer = reindexAndCopy(existing.ccer);
+          starrer = reindexAndCopy(existing.starrer);
+          assignee = reindexAndCopy(existing.assignee);
+          watchingProjectOwner = reindexAndCopy(existing.watchingProjectOwner);
           watchers.putAll(existing.watchers);
           return;
         }
@@ -407,14 +407,14 @@
 
     public TestAccount testAccount(String name) throws Exception {
       String username = name(name);
-      TestAccount account = accountCreator.create(username, email(username), name);
+      TestAccount account = accountCreator.create(username, email(username), name, null);
       accountsByEmail.put(account.email(), account);
       return account;
     }
 
     public TestAccount testAccount(String name, String groupName) throws Exception {
       String username = name(name);
-      TestAccount account = accountCreator.create(username, email(username), name, groupName);
+      TestAccount account = accountCreator.create(username, email(username), name, null, groupName);
       accountsByEmail.put(account.email(), account);
       return account;
     }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 75d0d2f..44e2d2d 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -69,6 +69,7 @@
       @Nullable String username,
       @Nullable String email,
       @Nullable String fullName,
+      @Nullable String displayName,
       String... groupNames)
       throws Exception {
 
@@ -94,7 +95,11 @@
         .insert(
             "Create Test Account",
             id,
-            u -> u.setFullName(fullName).setPreferredEmail(email).addExternalIds(extIds));
+            u ->
+                u.setFullName(fullName)
+                    .setDisplayName(displayName)
+                    .setPreferredEmail(email)
+                    .addExternalIds(extIds));
 
     if (groupNames != null) {
       for (String n : groupNames) {
@@ -107,7 +112,7 @@
       }
     }
 
-    account = TestAccount.create(id, username, email, fullName, httpPass);
+    account = TestAccount.create(id, username, email, fullName, displayName, httpPass);
     if (username != null) {
       accounts.put(username, account);
     }
@@ -115,7 +120,7 @@
   }
 
   public TestAccount create(@Nullable String username, String group) throws Exception {
-    return create(username, null, username, group);
+    return create(username, null, username, null, group);
   }
 
   public TestAccount create() throws Exception {
@@ -123,23 +128,23 @@
   }
 
   public TestAccount create(@Nullable String username) throws Exception {
-    return create(username, null, username, (String[]) null);
+    return create(username, null, username, null, (String[]) null);
   }
 
   public TestAccount admin() throws Exception {
-    return create("admin", "admin@example.com", "Administrator", "Administrators");
+    return create("admin", "admin@example.com", "Administrator", "Adminny", "Administrators");
   }
 
   public TestAccount admin2() throws Exception {
-    return create("admin2", "admin2@example.com", "Administrator2", "Administrators");
+    return create("admin2", "admin2@example.com", "Administrator2", null, "Administrators");
   }
 
   public TestAccount user() throws Exception {
-    return create("user", "user@example.com", "User");
+    return create("user", "user@example.com", "User", null);
   }
 
   public TestAccount user2() throws Exception {
-    return create("user2", "user2@example.com", "User2");
+    return create("user2", "user2@example.com", "User2", null);
   }
 
   public TestAccount get(String username) {
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 646d8f0..9d8bc57 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 = [
@@ -40,6 +39,7 @@
     "//lib:gson",
     "//lib:guava-retrying",
     "//lib:jgit",
+    "//lib:jgit-ssh-jsch",
     "//lib:jsch",
     "//lib/commons:compress",
     "//lib/commons:lang",
@@ -61,7 +61,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",
@@ -105,28 +107,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/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
deleted file mode 100644
index 0a1d765..0000000
--- a/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import com.google.auto.value.AutoAnnotation;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-
-class ConfigAnnotationParser {
-  private static Splitter splitter = Splitter.on(".").trimResults();
-
-  static Config parse(Config base, GerritConfigs annotation) {
-    if (annotation == null) {
-      return null;
-    }
-
-    Config cfg = new Config(base);
-    for (GerritConfig c : annotation.value()) {
-      parseAnnotation(cfg, c);
-    }
-    return cfg;
-  }
-
-  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) {
-    if (annotation == null || annotation.value().length < 1) {
-      return null;
-    }
-
-    HashMap<String, Config> result = new HashMap<>();
-
-    for (GlobalPluginConfig c : annotation.value()) {
-      String pluginName = c.pluginName();
-      Config config;
-      if (result.containsKey(pluginName)) {
-        config = result.get(pluginName);
-      } else {
-        config = new Config();
-        result.put(pluginName, config);
-      }
-      parseAnnotation(config, toGerritConfig(c));
-    }
-
-    return result;
-  }
-
-  private static void parseAnnotation(Config cfg, GerritConfig c) {
-    ArrayList<String> l = Lists.newArrayList(splitter.split(c.name()));
-    if (l.size() == 2) {
-      if (!Strings.isNullOrEmpty(c.value())) {
-        cfg.setString(l.get(0), null, l.get(1), c.value());
-      } else {
-        String[] values = c.values();
-        cfg.setStringList(l.get(0), null, l.get(1), Arrays.asList(values));
-      }
-    } else if (l.size() == 3) {
-      if (!Strings.isNullOrEmpty(c.value())) {
-        cfg.setString(l.get(0), l.get(1), l.get(2), c.value());
-      } else {
-        cfg.setStringList(l.get(0), l.get(1), l.get(2), Arrays.asList(c.values()));
-      }
-    } else {
-      throw new IllegalArgumentException(
-          "GerritConfig.name must be of the format section.subsection.name or section.name");
-    }
-  }
-}
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
index cab6b58..1618573 100644
--- a/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -40,7 +40,7 @@
 
 public class EventRecorder {
   private final RegistrationHandle eventListenerRegistration;
-  private final ListMultimap<String, RefEvent> recordedEvents;
+  private final ListMultimap<String, Event> recordedEvents;
 
   @Singleton
   public static class Factory {
@@ -70,15 +70,17 @@
               @Override
               public void onEvent(Event e) {
                 if (e instanceof ReviewerDeletedEvent) {
-                  recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+                  recordedEvents.put(ReviewerDeletedEvent.TYPE, e);
                 } else if (e instanceof ChangeDeletedEvent) {
-                  recordedEvents.put(ChangeDeletedEvent.TYPE, (ChangeDeletedEvent) e);
+                  recordedEvents.put(ChangeDeletedEvent.TYPE, e);
                 } else if (e instanceof RefEvent) {
                   RefEvent event = (RefEvent) e;
                   String key =
                       refEventKey(
                           event.getType(), event.getProjectNameKey().get(), event.getRefName());
                   recordedEvents.put(key, event);
+                } else {
+                  recordedEvents.put(e.type, e);
                 }
               }
 
@@ -158,6 +160,17 @@
     return events;
   }
 
+  public ImmutableList<Event> getGenericEvents(String type, int expectedSize) {
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(type);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(type);
+    ImmutableList<Event> events = FluentIterable.from(recordedEvents.get(type)).toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
   public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
     getRefUpdatedEvents(project, branch, 0);
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index eaf03b3..cfe7964 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance;
 
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
@@ -24,6 +26,7 @@
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
@@ -47,6 +50,8 @@
 import java.util.List;
 
 public class ExtensionRegistry {
+  public static final String PLUGIN_NAME = "myPlugin";
+
   private final DynamicSet<AccountIndexedListener> accountIndexedListeners;
   private final DynamicSet<ChangeIndexedListener> changeIndexedListeners;
   private final DynamicSet<GroupIndexedListener> groupIndexedListeners;
@@ -71,6 +76,9 @@
       accountActivationValidationListeners;
   private final DynamicSet<AccountActivationListener> accountActivationListeners;
   private final DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
+  private final DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners;
+  private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
+  private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
 
   @Inject
   ExtensionRegistry(
@@ -96,7 +104,10 @@
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
       DynamicSet<AccountActivationListener> accountActivationListeners,
-      DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners) {
+      DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners,
+      DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
+      DynamicMap<CapabilityDefinition> capabilityDefinitions,
+      DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -120,6 +131,9 @@
     this.accountActivationValidationListeners = accountActivationValidationListeners;
     this.accountActivationListeners = accountActivationListeners;
     this.onSubmitValidationListeners = onSubmitValidationListeners;
+    this.workInProgressStateChangedListeners = workInProgressStateChangedListeners;
+    this.capabilityDefinitions = capabilityDefinitions;
+    this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
   }
 
   public Registration newRegistration() {
@@ -227,6 +241,19 @@
       return add(onSubmitValidationListeners, onSubmitValidationListener);
     }
 
+    public Registration add(WorkInProgressStateChangedListener workInProgressStateChangedListener) {
+      return add(workInProgressStateChangedListeners, workInProgressStateChangedListener);
+    }
+
+    public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
+      return add(capabilityDefinitions, capabilityDefinition, exportName);
+    }
+
+    public Registration add(
+        PluginProjectPermissionDefinition pluginProjectPermissionDefinition, String exportName) {
+      return add(pluginProjectPermissionDefinitions, pluginProjectPermissionDefinition, exportName);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
@@ -240,7 +267,7 @@
     private <T> Registration add(DynamicMap<T> dynamicMap, T extension, String exportName) {
       RegistrationHandle registrationHandle =
           ((PrivateInternals_DynamicMapImpl<T>) dynamicMap)
-              .put("myPlugin", exportName, Providers.of(extension));
+              .put(PLUGIN_NAME, exportName, Providers.of(extension));
       registrationHandles.add(registrationHandle);
       return this;
     }
diff --git a/java/com/google/gerrit/acceptance/GerritConfig.java b/java/com/google/gerrit/acceptance/GerritConfig.java
deleted file mode 100644
index fe0c628..0000000
--- a/java/com/google/gerrit/acceptance/GerritConfig.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static java.lang.annotation.ElementType.METHOD;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import java.lang.annotation.Repeatable;
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-
-@Target({METHOD})
-@Retention(RUNTIME)
-@Repeatable(GerritConfigs.class)
-public @interface GerritConfig {
-  /**
-   * Setting name in the form {@code "section.name"} or {@code "section.subsection.name"} where
-   * {@code section}, {@code subsection} and {@code name} correspond to the parameters of the same
-   * names in JGit's {@code Config#getString} method.
-   *
-   * @see org.eclipse.jgit.lib.Config#getString(String, String, String)
-   */
-  String name();
-
-  /** Single value. Takes precedence over values specified in {@code values}. */
-  String value() default "";
-
-  /** Multiple values (list). Ignored if {@code value} is specified. */
-  String[] values() default "";
-}
diff --git a/java/com/google/gerrit/acceptance/GerritConfigs.java b/java/com/google/gerrit/acceptance/GerritConfigs.java
deleted file mode 100644
index e0f9d4a..0000000
--- a/java/com/google/gerrit/acceptance/GerritConfigs.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static java.lang.annotation.ElementType.METHOD;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-
-@Target({METHOD})
-@Retention(RUNTIME)
-public @interface GerritConfigs {
-  GerritConfig[] value();
-}
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 4066a10..986f549 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -23,6 +23,11 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+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;
@@ -492,8 +497,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/GlobalPluginConfig.java b/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
deleted file mode 100644
index 43477ae..0000000
--- a/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
+++ /dev/null
@@ -1,39 +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;
-
-import static java.lang.annotation.ElementType.METHOD;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import java.lang.annotation.Repeatable;
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-
-@Target({METHOD})
-@Retention(RUNTIME)
-@Repeatable(GlobalPluginConfigs.class)
-public @interface GlobalPluginConfig {
-  /** Name of the plugin, corresponding to {@code $site/etc/@pluginName.config}. */
-  String pluginName();
-
-  /** @see GerritConfig#name() */
-  String name();
-
-  /** @see GerritConfig#value() */
-  String value() default "";
-
-  /** @see GerritConfig#values() */
-  String[] values() default "";
-}
diff --git a/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java b/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
deleted file mode 100644
index dfcf955..0000000
--- a/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
+++ /dev/null
@@ -1,27 +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;
-
-import static java.lang.annotation.ElementType.METHOD;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-
-@Target({METHOD})
-@Retention(RUNTIME)
-public @interface GlobalPluginConfigs {
-  GlobalPluginConfig[] value();
-}
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 75e5a2e..83c63f9 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.gerrit.server.git.receive.LazyPostReceiveHookChain.affectsSize;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
 
 import com.google.common.collect.ImmutableList;
@@ -241,15 +242,8 @@
         throw new RuntimeException(e);
       }
 
-      ProjectState projectState;
-      try {
-        projectState = projectCache.checkedGet(req.project);
-      } catch (IOException e) {
-        throw new RuntimeException(e);
-      }
-      if (projectState == null) {
-        throw new RuntimeException("can't load project state for " + req.project.get());
-      }
+      ProjectState projectState =
+          projectCache.get(req.project).orElseThrow(illegalState(req.project));
       Repository permissionAwareRepository = PermissionAwareRepositoryManager.wrap(repo, perm);
       UploadPack up = new UploadPack(permissionAwareRepository);
       up.setPackConfig(transferConfig.getPackConfig());
@@ -320,10 +314,11 @@
       }
       try {
         IdentifiedUser identifiedUser = userProvider.get().asIdentifiedUser();
-        ProjectState projectState = projectCache.checkedGet(req.project);
-        if (projectState == null) {
-          throw new RuntimeException(String.format("project %s not found", req.project));
-        }
+        ProjectState projectState =
+            projectCache
+                .get(req.project)
+                .orElseThrow(
+                    () -> new RuntimeException(String.format("project %s not found", req.project)));
 
         AsyncReceiveCommits arc = factory.create(projectState, identifiedUser, db, null);
         if (arc.canUpload() != Capable.OK) {
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index a528974..d885303 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -335,18 +335,18 @@
         // Make sure all accounts are evicted and reindexed.
         try (Repository repo = repoManager.openRepository(allUsersName)) {
           for (Account.Id id : accountIds(repo)) {
-            evictAndReindexAccount(id);
+            reindexAccount(id);
           }
         }
 
         // Remove deleted accounts from the cache and index.
         for (Account.Id id : deletedAccounts) {
-          evictAndReindexAccount(id);
+          reindexAccount(id);
         }
       } else {
         // Evict and reindex all modified and deleted accounts.
         for (Account.Id id : Sets.union(modifiedAccounts, deletedAccounts)) {
-          evictAndReindexAccount(id);
+          reindexAccount(id);
         }
       }
     }
@@ -367,10 +367,7 @@
     }
   }
 
-  private void evictAndReindexAccount(Account.Id accountId) {
-    if (accountCache != null) {
-      accountCache.evict(accountId);
-    }
+  private void reindexAccount(Account.Id accountId) {
     if (groupIncludeCache != null) {
       groupIncludeCache.evictGroupsWithMember(accountId);
     }
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 0e7ad4b..7ee1b26 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.net.HttpHeaders.ACCEPT;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.gerrit.json.OutputFormat.JSON_COMPACT;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.net.HttpHeaders;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
-import com.google.gerrit.json.OutputFormat;
 import java.io.IOException;
 import org.apache.http.Header;
 import org.apache.http.client.fluent.Request;
@@ -40,7 +41,7 @@
   }
 
   public RestResponse getJsonAccept(String endPoint) throws IOException {
-    return getWithHeader(endPoint, new BasicHeader(HttpHeaders.ACCEPT, "application/json"));
+    return getWithHeader(endPoint, new BasicHeader(ACCEPT, "application/json"));
   }
 
   public RestResponse getWithHeader(String endPoint, Header header) throws IOException {
@@ -74,8 +75,7 @@
       put.addHeader(header);
     }
     if (content != null) {
-      put.addHeader(new BasicHeader("Content-Type", "application/json"));
-      put.body(new StringEntity(OutputFormat.JSON_COMPACT.newGson().toJson(content), UTF_8));
+      addContentToRequest(put, content);
     }
     return execute(put);
   }
@@ -83,7 +83,7 @@
   public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
     requireNonNull(stream);
     Request put = Request.Put(getUrl(endPoint));
-    put.addHeader(new BasicHeader("Content-Type", stream.getContentType()));
+    put.addHeader(new BasicHeader(CONTENT_TYPE, stream.getContentType()));
     put.body(
         new BufferedHttpEntity(
             new InputStreamEntity(stream.getInputStream(), stream.getContentLength())));
@@ -105,12 +105,16 @@
       post.addHeader(header);
     }
     if (content != null) {
-      post.addHeader(new BasicHeader("Content-Type", "application/json"));
-      post.body(new StringEntity(OutputFormat.JSON_COMPACT.newGson().toJson(content), UTF_8));
+      addContentToRequest(post, content);
     }
     return execute(post);
   }
 
+  private static void addContentToRequest(Request request, Object content) {
+    request.addHeader(new BasicHeader(CONTENT_TYPE, "application/json"));
+    request.body(new StringEntity(JSON_COMPACT.newGson().toJson(content), UTF_8));
+  }
+
   public RestResponse delete(String endPoint) throws IOException {
     return execute(Request.Delete(getUrl(endPoint)));
   }
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index fd60d16..6698657 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -47,11 +47,10 @@
   }
 
   @SuppressWarnings("resource")
-  public String exec(String command, InputStream opt) throws Exception {
+  public String exec(String command) throws Exception {
     ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
     try {
       channel.setCommand(command);
-      channel.setInputStream(opt);
       InputStream in = channel.getInputStream();
       InputStream err = channel.getErrStream();
       channel.connect();
@@ -82,19 +81,6 @@
     }
   }
 
-  public InputStream exec2(String command, InputStream opt) throws Exception {
-    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
-    channel.setCommand(command);
-    channel.setInputStream(opt);
-    InputStream in = channel.getInputStream();
-    channel.connect();
-    return in;
-  }
-
-  public String exec(String command) throws Exception {
-    return exec(command, null);
-  }
-
   private boolean hasError() {
     return error != null;
   }
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index 07bb739..a7a4a89 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -47,8 +47,9 @@
       @Nullable String username,
       @Nullable String email,
       @Nullable String fullName,
+      @Nullable String displayName,
       @Nullable String httpPassword) {
-    return new AutoValue_TestAccount(id, username, email, fullName, httpPassword);
+    return new AutoValue_TestAccount(id, username, email, fullName, displayName, httpPassword);
   }
 
   public abstract Account.Id id();
@@ -63,6 +64,9 @@
   public abstract String fullName();
 
   @Nullable
+  public abstract String displayName();
+
+  @Nullable
   public abstract String httpPassword();
 
   public PersonIdent newIdent() {
@@ -79,7 +83,7 @@
         .toString();
   }
 
-  public Address getEmailAddress() {
+  public Address getNameEmail() {
     // Address is weird enough that it's safer and clearer to create a new instance in a
     // non-abstract method rather than, say, having an abstract emailAddress() as part of this
     // AutoValue class. Specifically:
@@ -88,6 +92,6 @@
     //    emailAddress().
     //  * Address#equals only considers email, not name, whereas TestAccount#equals should include
     //    name.
-    return new Address(fullName(), email());
+    return Address.create(fullName(), email());
   }
 }
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/config/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
new file mode 100644
index 0000000..24a2117
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.config;
+
+import com.google.auto.value.AutoAnnotation;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class ConfigAnnotationParser {
+  private static Splitter splitter = Splitter.on(".").trimResults();
+
+  public static Config parse(Config base, GerritConfigs annotation) {
+    if (annotation == null) {
+      return null;
+    }
+
+    Config cfg = new Config(base);
+    for (GerritConfig c : annotation.value()) {
+      parseAnnotation(cfg, c);
+    }
+    return cfg;
+  }
+
+  public static Config parse(Config base, GerritConfig annotation) {
+    Config cfg = new Config(base);
+    parseAnnotation(cfg, annotation);
+    return cfg;
+  }
+
+  public static Map<String, Config> parse(GlobalPluginConfigs annotation) {
+    if (annotation == null || annotation.value().length < 1) {
+      return null;
+    }
+
+    HashMap<String, Config> result = new HashMap<>();
+
+    for (GlobalPluginConfig c : annotation.value()) {
+      String pluginName = c.pluginName();
+      Config config;
+      if (result.containsKey(pluginName)) {
+        config = result.get(pluginName);
+      } else {
+        config = new Config();
+        result.put(pluginName, config);
+      }
+      parseAnnotation(config, toGerritConfig(c));
+    }
+
+    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) {
+      if (!Strings.isNullOrEmpty(c.value())) {
+        cfg.setString(l.get(0), null, l.get(1), c.value());
+      } else {
+        String[] values = c.values();
+        cfg.setStringList(l.get(0), null, l.get(1), Arrays.asList(values));
+      }
+    } else if (l.size() == 3) {
+      if (!Strings.isNullOrEmpty(c.value())) {
+        cfg.setString(l.get(0), l.get(1), l.get(2), c.value());
+      } else {
+        cfg.setStringList(l.get(0), l.get(1), l.get(2), Arrays.asList(c.values()));
+      }
+    } else {
+      throw new IllegalArgumentException(
+          "GerritConfig.name must be of the format section.subsection.name or section.name");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/config/GerritConfig.java b/java/com/google/gerrit/acceptance/config/GerritConfig.java
new file mode 100644
index 0000000..26be2d4
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/config/GerritConfig.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.config;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({METHOD})
+@Retention(RUNTIME)
+@Repeatable(GerritConfigs.class)
+public @interface GerritConfig {
+  /**
+   * Setting name in the form {@code "section.name"} or {@code "section.subsection.name"} where
+   * {@code section}, {@code subsection} and {@code name} correspond to the parameters of the same
+   * names in JGit's {@code Config#getString} method.
+   *
+   * @see org.eclipse.jgit.lib.Config#getString(String, String, String)
+   */
+  String name();
+
+  /** Single value. Takes precedence over values specified in {@code values}. */
+  String value() default "";
+
+  /** Multiple values (list). Ignored if {@code value} is specified. */
+  String[] values() default "";
+}
diff --git a/java/com/google/gerrit/acceptance/config/GerritConfigs.java b/java/com/google/gerrit/acceptance/config/GerritConfigs.java
new file mode 100644
index 0000000..0127ace
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/config/GerritConfigs.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.config;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({METHOD})
+@Retention(RUNTIME)
+public @interface GerritConfigs {
+  GerritConfig[] value();
+}
diff --git a/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java b/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
new file mode 100644
index 0000000..ae88e37
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
@@ -0,0 +1,39 @@
+// 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.config;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({METHOD})
+@Retention(RUNTIME)
+@Repeatable(GlobalPluginConfigs.class)
+public @interface GlobalPluginConfig {
+  /** Name of the plugin, corresponding to {@code $site/etc/@pluginName.config}. */
+  String pluginName();
+
+  /** @see GerritConfig#name() */
+  String name();
+
+  /** @see GerritConfig#value() */
+  String value() default "";
+
+  /** @see GerritConfig#values() */
+  String[] values() default "";
+}
diff --git a/java/com/google/gerrit/acceptance/config/GlobalPluginConfigs.java b/java/com/google/gerrit/acceptance/config/GlobalPluginConfigs.java
new file mode 100644
index 0000000..e53e5ba
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/config/GlobalPluginConfigs.java
@@ -0,0 +1,27 @@
+// 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.config;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({METHOD})
+@Retention(RUNTIME)
+public @interface GlobalPluginConfigs {
+  GlobalPluginConfig[] value();
+}
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/account/AccountOperations.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
index efae223..cca0a45 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperations.java
@@ -95,5 +95,25 @@
      * @return a builder to update the account
      */
     TestAccountUpdate.Builder forUpdate();
+
+    /**
+     * Starts the fluent chain to invalidate an account. The returned builder can be used to specify
+     * how the account should be made invalid. To invalidate the account for real, {@link
+     * TestAccountInvalidation.Builder#invalidate()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * accountOperations.forInvalidation()
+     *     .preferredEmailWithoutExternalId("foo.bar@example.com")
+     *     .invalidate();
+     * </pre>
+     *
+     * <p><strong>Note:</strong> The invalidation will fail with an exception if the account to
+     * invalidate doesn't exist.
+     *
+     * @return a builder to invalidate the account
+     */
+    TestAccountInvalidation.Builder forInvalidation();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index f1b840a..f64d7a2 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -168,5 +168,23 @@
       accountUpdate.status().ifPresent(builder::setStatus);
       accountUpdate.active().ifPresent(builder::setActive);
     }
+
+    @Override
+    public TestAccountInvalidation.Builder forInvalidation() {
+      return TestAccountInvalidation.builder(this::invalidateAccount);
+    }
+
+    private void invalidateAccount(TestAccountInvalidation testAccountInvalidation)
+        throws Exception {
+      Optional<AccountState> accountState = getAccountState(accountId);
+      checkState(accountState.isPresent(), "Tried to invalidate a non-existing test account");
+
+      if (testAccountInvalidation.preferredEmailWithoutExternalId().isPresent()) {
+        updateAccount(
+            (account, updateBuilder) ->
+                updateBuilder.setPreferredEmail(
+                    testAccountInvalidation.preferredEmailWithoutExternalId().get()));
+      }
+    }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountInvalidation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountInvalidation.java
new file mode 100644
index 0000000..6816f3b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountInvalidation.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.testsuite.account;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import java.util.Optional;
+
+/**
+ * API to invalidate accounts in tests.
+ *
+ * <p>This allows to test Gerrit behavior when there is invalid account data in NoteDb (e.g.
+ * accounts with duplicate emails).
+ */
+@AutoValue
+public abstract class TestAccountInvalidation {
+  /**
+   * Sets a preferred email for the account for which the account doesn't have an external ID.
+   *
+   * <p>This allows to set the same preferred email for multiple accounts so that the email becomes
+   * ambiguous.
+   */
+  public abstract Optional<String> preferredEmailWithoutExternalId();
+
+  abstract ThrowingConsumer<TestAccountInvalidation> accountInvalidator();
+
+  public static Builder builder(ThrowingConsumer<TestAccountInvalidation> accountInvalidator) {
+    return new AutoValue_TestAccountInvalidation.Builder().accountInvalidator(accountInvalidator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder preferredEmailWithoutExternalId(String preferredEmail);
+
+    abstract Builder accountInvalidator(
+        ThrowingConsumer<TestAccountInvalidation> accountInvalidator);
+
+    abstract TestAccountInvalidation autoBuild();
+
+    /** Executes the account invalidation as specified. */
+    public void invalidate() {
+      TestAccountInvalidation accountInvalidation = autoBuild();
+      accountInvalidation.accountInvalidator().acceptAndThrowSilently(accountInvalidation);
+    }
+  }
+}
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/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index 8a3a23a..f9e2fb5 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -14,6 +14,7 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:lang",
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
index 2db611b..738be4d 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
@@ -73,5 +73,25 @@
      * @return a builder to update the check.
      */
     TestProjectUpdate.Builder forUpdate();
+
+    /**
+     * Starts the fluent chain to invalidate a project. The returned builder can be used to specify
+     * how the project should be made invalid. To invalidate the project for real, {@link
+     * TestProjectInvalidation.Builder#invalidate()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * projectOperations.forInvalidation()
+     *     .addProjectConfigUpdater(cfg -> cfg.setString("invalidSection", null, "foo", "bar"))
+     *     .invalidate();
+     * </pre>
+     *
+     * <p><strong>Note:</strong> The invalidation will fail with an exception if the project to
+     * invalidate doesn't exist.
+     *
+     * @return a builder to invalidate the project
+     */
+    TestProjectInvalidation.Builder forInvalidation();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 7797fe0..8cb20bb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectCreation.Builder;
@@ -45,6 +46,7 @@
 import java.util.Collections;
 import org.apache.commons.lang.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectLoader;
@@ -89,11 +91,12 @@
 
     CreateProjectArgs args = new CreateProjectArgs();
     args.setProjectName(name);
+    args.permissionsOnly = projectCreation.permissionOnly().orElse(false);
     args.branch = Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
     args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
     projectCreation.parent().ifPresent(p -> args.newParent = p);
     // ProjectCreator wants non-null owner IDs.
-    args.ownerIds = new ArrayList<>();
+    args.ownerIds = new ArrayList<>(projectCreation.owners());
     projectCreation.submitType().ifPresent(st -> args.submitType = st);
     projectCreator.createProject(args);
     return Project.nameKey(name);
@@ -255,6 +258,71 @@
         throw new IllegalStateException(e);
       }
     }
+
+    private void setConfig(Config projectConfig) {
+      try (TestRepository<Repository> repo =
+          new TestRepository<>(repoManager.openRepository(nameKey))) {
+        repo.update(
+            RefNames.REFS_CONFIG,
+            repo.commit()
+                .message("Update project.config from test")
+                .parent(getHead(RefNames.REFS_CONFIG))
+                .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+      } catch (Exception e) {
+        throw new IllegalStateException(
+            "updating project.config of project " + nameKey + " failed", e);
+      }
+    }
+
+    @Override
+    public TestProjectInvalidation.Builder forInvalidation() {
+      return TestProjectInvalidation.builder(this::invalidateProject);
+    }
+
+    private void invalidateProject(TestProjectInvalidation testProjectInvalidation)
+        throws Exception {
+      if (testProjectInvalidation.makeProjectConfigInvalid()) {
+        Config projectConfig = new Config();
+        projectConfig.fromText(getConfig().toText());
+
+        // Make the project config invalid by adding a permission entry with an invalid permission
+        // name.
+        projectConfig.setString(
+            "access", "refs/*", "Invalid Permission Name", "group Administrators");
+
+        setConfig(projectConfig);
+        try {
+          projectCache.evict(nameKey);
+        } catch (Exception e) {
+          // Evicting the project from the cache, also triggers a reindex of the project.
+          // The reindex step fails if the project config is invalid. That's fine, since it was our
+          // intention to make the project config invalid. Hence we ignore exceptions that are cause
+          // by an invalid project config here.
+          if (!Throwables.getCausalChain(e).stream()
+              .anyMatch(ConfigInvalidException.class::isInstance)) {
+            throw e;
+          }
+        }
+      }
+      if (!testProjectInvalidation.projectConfigUpdater().isEmpty()) {
+        Config projectConfig = new Config();
+        projectConfig.fromText(getConfig().toText());
+        testProjectInvalidation.projectConfigUpdater().forEach(c -> c.accept(projectConfig));
+        setConfig(projectConfig);
+        try {
+          projectCache.evict(nameKey);
+        } catch (Exception e) {
+          // Evicting the project from the cache, also triggers a reindex of the project.
+          // The reindex step fails if the project config is invalid. That's fine, since it was our
+          // intention to make the project config invalid. Hence we ignore exceptions that are cause
+          // by an invalid project config here.
+          if (!Throwables.getCausalChain(e).stream()
+              .anyMatch(ConfigInvalidException.class::isInstance)) {
+            throw e;
+          }
+        }
+      }
+    }
   }
 
   private static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 99e045c..3bbb8db 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.util.Optional;
@@ -29,8 +33,12 @@
 
   public abstract Optional<Boolean> createEmptyCommit();
 
+  public abstract Optional<Boolean> permissionOnly();
+
   public abstract Optional<SubmitType> submitType();
 
+  public abstract ImmutableSet<AccountGroup.UUID> owners();
+
   abstract ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator();
 
   public static Builder builder(
@@ -48,11 +56,20 @@
 
     public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
 
+    public abstract TestProjectCreation.Builder permissionOnly(boolean value);
+
     /** Skips the empty commit on creation. This means that project's branches will not exist. */
     public TestProjectCreation.Builder noEmptyCommit() {
       return createEmptyCommit(false);
     }
 
+    public TestProjectCreation.Builder addOwner(AccountGroup.UUID owner) {
+      ownersBuilder().add(requireNonNull(owner, "owner"));
+      return this;
+    }
+
+    abstract ImmutableSet.Builder<AccountGroup.UUID> ownersBuilder();
+
     abstract TestProjectCreation.Builder projectCreator(
         ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator);
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectInvalidation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectInvalidation.java
new file mode 100644
index 0000000..d4bd912
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectInvalidation.java
@@ -0,0 +1,79 @@
+// 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.testsuite.project;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * API to invalidate projects in tests.
+ *
+ * <p>This allows to test Gerrit behavior when there is invalid project data in NoteDb (e.g. an
+ * invalid {@code project.config} file).
+ */
+@AutoValue
+public abstract class TestProjectInvalidation {
+  public abstract boolean makeProjectConfigInvalid();
+
+  public abstract ImmutableList<Consumer<Config>> projectConfigUpdater();
+
+  abstract ThrowingConsumer<TestProjectInvalidation> projectInvalidator();
+
+  public static Builder builder(ThrowingConsumer<TestProjectInvalidation> projectInvalidator) {
+    return new AutoValue_TestProjectInvalidation.Builder()
+        .projectInvalidator(projectInvalidator)
+        .makeProjectConfigInvalid(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /**
+     * Updates the project.config file so that it becomes invalid and loading it within Gerrit fails
+     * with {@link org.eclipse.jgit.errors.ConfigInvalidException}.
+     */
+    public Builder makeProjectConfigInvalid() {
+      makeProjectConfigInvalid(true);
+      return this;
+    }
+
+    protected abstract Builder makeProjectConfigInvalid(boolean makeProjectConfigInvalid);
+
+    /**
+     * Adds a consumer that can update the project config.
+     *
+     * <p>This allows tests to set arbitrary values in the project config.
+     */
+    public Builder addProjectConfigUpdater(Consumer<Config> projectConfigUpdater) {
+      projectConfigUpdaterBuilder().add(projectConfigUpdater);
+      return this;
+    }
+
+    protected abstract ImmutableList.Builder<Consumer<Config>> projectConfigUpdaterBuilder();
+
+    abstract Builder projectInvalidator(
+        ThrowingConsumer<TestProjectInvalidation> projectInvalidator);
+
+    abstract TestProjectInvalidation autoBuild();
+
+    /** Executes the project invalidation as specified. */
+    public void invalidate() {
+      TestProjectInvalidation projectInvalidation = autoBuild();
+      projectInvalidation.projectInvalidator().acceptAndThrowSilently(projectInvalidation);
+    }
+  }
+}
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 90b0930..3a68414 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -14,14 +14,17 @@
 
 package com.google.gerrit.common.data;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.collectingAndThen;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -37,6 +40,7 @@
   public static final boolean DEF_COPY_ANY_SCORE = false;
   public static final boolean DEF_COPY_MAX_SCORE = false;
   public static final boolean DEF_COPY_MIN_SCORE = false;
+  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
   public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
@@ -104,6 +108,7 @@
   protected boolean copyAllScoresOnTrivialRebase;
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
+  protected ImmutableList<Short> copyValues;
   protected boolean allowPostSubmit;
   protected boolean ignoreSelfApproval;
   protected short defaultValue;
@@ -144,6 +149,7 @@
     setCopyAnyScore(DEF_COPY_ANY_SCORE);
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
+    setCopyValues(DEF_COPY_VALUES);
     setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
     setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
 
@@ -157,6 +163,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 +183,7 @@
     return canOverride;
   }
 
+  @Nullable
   public List<String> getRefPatterns() {
     return refPatterns;
   }
@@ -198,7 +209,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 +221,10 @@
     return values;
   }
 
+  public void setValues(List<LabelValue> values) {
+    this.values = sortValues(values);
+  }
+
   public LabelValue getMin() {
     if (values.isEmpty()) {
       return null;
@@ -289,6 +304,14 @@
     this.copyAllScoresIfNoChange = copyAllScoresIfNoChange;
   }
 
+  public ImmutableList<Short> getCopyValues() {
+    return copyValues;
+  }
+
+  public void setCopyValues(Collection<Short> copyValues) {
+    this.copyValues = copyValues.stream().sorted().collect(toImmutableList());
+  }
+
   public boolean isMaxNegative(PatchSetApproval ca) {
     return maxNegative == ca.value();
   }
diff --git a/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
index c177e35..e3c0ba6 100644
--- a/java/com/google/gerrit/common/data/PatchScript.java
+++ b/java/com/google/gerrit/common/data/PatchScript.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Patch;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -37,102 +37,73 @@
     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 ChangeType changeType;
+  private final ImmutableList<String> header;
+  private final DiffPreferencesInfo diffPrefs;
+  private final ImmutableList<Edit> edits;
+  private final ImmutableSet<Edit> editsDueToRebase;
+  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,
       ChangeType ct,
       String on,
       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,
       boolean idf,
       boolean idt,
       boolean bin,
       String cma,
       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() {}
-
-  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;
+    fileInfoA = new PatchScriptFileInfo(on, om, ca, ma, mta, cma);
+    fileInfoB = new PatchScriptFileInfo(nn, nm, cb, mb, mtb, cmb);
   }
 
   public List<String> getPatchHeader() {
@@ -144,33 +115,17 @@
   }
 
   public String getOldName() {
-    return oldName;
+    return fileInfoA.name;
   }
 
   public String getNewName() {
-    return newName;
-  }
-
-  public CommentDetail getCommentDetail() {
-    return comments;
-  }
-
-  public List<Patch> getHistory() {
-    return history;
+    return fileInfoB.name;
   }
 
   public DiffPreferencesInfo getDiffPrefs() {
     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 +138,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 +158,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/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index 3ba0ba7..9b86b7e 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -44,6 +44,7 @@
   public static final String READ = "read";
   public static final String REBASE = "rebase";
   public static final String REMOVE_REVIEWER = "removeReviewer";
+  public static final String REVERT = "revert";
   public static final String SUBMIT = "submit";
   public static final String SUBMIT_AS = "submitAs";
   public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
@@ -77,6 +78,7 @@
     NAMES_LC.add(READ.toLowerCase());
     NAMES_LC.add(REBASE.toLowerCase());
     NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
+    NAMES_LC.add(REVERT.toLowerCase());
     NAMES_LC.add(SUBMIT.toLowerCase());
     NAMES_LC.add(SUBMIT_AS.toLowerCase());
     NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
diff --git a/java/com/google/gerrit/common/data/SubmitRequirement.java b/java/com/google/gerrit/common/data/SubmitRequirement.java
index 66e647d..2c341bf 100644
--- a/java/com/google/gerrit/common/data/SubmitRequirement.java
+++ b/java/com/google/gerrit/common/data/SubmitRequirement.java
@@ -18,8 +18,6 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
-import com.google.common.collect.ImmutableMap;
-import java.util.Map;
 
 /** Describes a requirement to submit a change. */
 @AutoValue
@@ -37,15 +35,6 @@
 
     public abstract Builder setFallbackText(String value);
 
-    public Builder setData(Map<String, String> value) {
-      return setData(ImmutableMap.copyOf(value));
-    }
-
-    public Builder addCustomValue(String key, String value) {
-      dataBuilder().put(key, value);
-      return this;
-    }
-
     public SubmitRequirement build() {
       SubmitRequirement requirement = autoBuild();
       checkState(
@@ -54,10 +43,6 @@
       return requirement;
     }
 
-    abstract Builder setData(ImmutableMap<String, String> value);
-
-    abstract ImmutableMap.Builder<String, String> dataBuilder();
-
     abstract SubmitRequirement autoBuild();
   }
 
@@ -65,8 +50,6 @@
 
   public abstract String type();
 
-  public abstract ImmutableMap<String, String> data();
-
   public static Builder builder() {
     return new AutoValue_SubmitRequirement.Builder();
   }
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 ed32ce5..e56f470 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(
@@ -248,7 +248,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 bde3ad5..8967789 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;
@@ -72,7 +73,9 @@
 
   @Override
   public void replace(AccountState as) {
-    BulkRequest bulk = new IndexRequest(getId(as), indexName).add(new UpdateRequest<>(schema, as));
+    BulkRequest bulk =
+        new IndexRequest(getId(as), indexName)
+            .add(new UpdateRequest<>(schema, as, ImmutableSet.of()));
 
     String uri = getURI(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 2be1585..625a598 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -43,6 +44,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;
@@ -58,6 +61,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. */
@@ -82,6 +86,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final Schema<ChangeData> schema;
   private final FieldDef<ChangeData, ?> idField;
+  private final ImmutableSet<String> skipFields;
 
   @Inject
   ElasticChangeIndex(
@@ -89,6 +94,7 @@
       ChangeData.Factory changeDataFactory,
       SitePaths sitePaths,
       ElasticRestClientProvider clientBuilder,
+      @GerritServerConfig Config gerritConfig,
       @Assisted Schema<ChangeData> schema) {
     super(cfg, sitePaths, schema, clientBuilder, CHANGES);
     this.changeDataFactory = changeDataFactory;
@@ -96,11 +102,16 @@
     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
   public void replace(ChangeData cd) {
-    BulkRequest bulk = new IndexRequest(getId(cd), indexName).add(new UpdateRequest<>(schema, cd));
+    BulkRequest bulk =
+        new IndexRequest(getId(cd), indexName).add(new UpdateRequest<>(schema, cd, skipFields));
 
     String uri = getURI(BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
@@ -209,7 +220,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);
@@ -341,6 +352,15 @@
     // Unresolved-comment-count.
     decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
 
+    // Attention set.
+    if (fields.contains(ChangeField.ATTENTION_SET_FULL.getName())) {
+      ChangeField.parseAttentionSet(
+          FluentIterable.from(source.getAsJsonArray(ChangeField.ATTENTION_SET_FULL.getName()))
+              .transform(ElasticChangeIndex::decodeBase64JsonElement)
+              .toSet(),
+          cd);
+    }
+
     return cd;
   }
 
@@ -359,12 +379,16 @@
     }
     ChangeField.parseSubmitRecords(
         FluentIterable.from(records)
-            .transform(i -> new String(decodeBase64(i.getAsString()), UTF_8))
+            .transform(ElasticChangeIndex::decodeBase64JsonElement)
             .toList(),
         opts,
         out);
   }
 
+  private static String decodeBase64JsonElement(JsonElement input) {
+    return new String(decodeBase64(input.getAsString()), UTF_8);
+  }
+
   private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
     JsonElement count = doc.get(fieldName);
     if (count == null) {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index 241e7fd..f8c2ec5 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;
@@ -73,7 +74,8 @@
   @Override
   public void replace(InternalGroup group) {
     BulkRequest bulk =
-        new IndexRequest(getId(group), indexName).add(new UpdateRequest<>(schema, group));
+        new IndexRequest(getId(group), indexName)
+            .add(new UpdateRequest<>(schema, group, ImmutableSet.of()));
 
     String uri = getURI(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 36eeca1..b8bfc38 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;
@@ -31,12 +32,14 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.http.HttpStatus;
 import org.elasticsearch.client.Response;
@@ -74,7 +77,7 @@
   public void replace(ProjectData projectState) {
     BulkRequest bulk =
         new IndexRequest(projectState.getProject().getName(), indexName)
-            .add(new UpdateRequest<>(schema, projectState));
+            .add(new UpdateRequest<>(schema, projectState, ImmutableSet.of()));
 
     String uri = getURI(BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
@@ -118,6 +121,10 @@
 
     Project.NameKey nameKey =
         Project.nameKey(source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
-    return projectCache.get().get(nameKey).toProjectData();
+    Optional<ProjectState> state = projectCache.get().get(nameKey);
+    if (!state.isPresent()) {
+      return null;
+    }
+    return state.get().toProjectData();
   }
 }
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/Account.java b/java/com/google/gerrit/entities/Account.java
index 809db1e..cd3b27a 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -129,6 +129,10 @@
   @Nullable
   public abstract String fullName();
 
+  /** Optional display name of the user to be shown in the UI. */
+  @Nullable
+  public abstract String displayName();
+
   /** Email address the user prefers to be contacted through. */
   @Nullable
   public abstract String preferredEmail();
@@ -236,6 +240,11 @@
     public abstract Builder setFullName(String fullName);
 
     @Nullable
+    public abstract String displayName();
+
+    public abstract Builder setDisplayName(String displayName);
+
+    @Nullable
     public abstract String preferredEmail();
 
     public abstract Builder setPreferredEmail(String preferredEmail);
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/AttentionSetUpdate.java b/java/com/google/gerrit/entities/AttentionSetUpdate.java
new file mode 100644
index 0000000..45588722
--- /dev/null
+++ b/java/com/google/gerrit/entities/AttentionSetUpdate.java
@@ -0,0 +1,72 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.time.Instant;
+
+/**
+ * A single update to the attention set. To reconstruct the attention set these instances are parsed
+ * in reverse chronological order. Since each update contains all required information and
+ * invalidates all previous state, only the most recent record is relevant for each user.
+ *
+ * <p>See {@link com.google.gerrit.extensions.api.changes.AddToAttentionSetInput} and {@link
+ * com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput} for the representation in
+ * the API.
+ */
+@AutoValue
+public abstract class AttentionSetUpdate {
+
+  /** Users can be added to or removed from the attention set. */
+  public enum Operation {
+    ADD,
+    REMOVE
+  }
+
+  /**
+   * The time at which this status was set. This is null for instances to be written because the
+   * timestamp in the commit message will be used.
+   */
+  @Nullable
+  public abstract Instant timestamp();
+
+  /** The user included in or excluded from the attention set. */
+  public abstract Account.Id account();
+
+  /** Indicates whether the user is added to or removed from the attention set. */
+  public abstract Operation operation();
+
+  /** A short human readable reason that explains this status (e.g. "manual"). */
+  public abstract String reason();
+
+  /**
+   * Create an instance from data read from NoteDB. This includes the timestamp taken from the
+   * commit.
+   */
+  public static AttentionSetUpdate createFromRead(
+      Instant timestamp, Account.Id account, Operation operation, String reason) {
+    return new AutoValue_AttentionSetUpdate(timestamp, account, operation, reason);
+  }
+
+  /**
+   * Create an instance to be written to NoteDB. This has no timestamp because the timestamp of the
+   * commit will be used.
+   */
+  public static AttentionSetUpdate createForWrite(
+      Account.Id account, Operation operation, String reason) {
+    return new AutoValue_AttentionSetUpdate(null, account, operation, reason);
+  }
+}
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 8784bd8..26265ae 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -16,6 +16,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/errorprone:annotations",
+        "//proto:cache_java_proto",
         "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 739bd38..b36b5f9 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -16,19 +16,14 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
 import java.sql.Timestamp;
 import java.util.Arrays;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
+import java.util.Optional;
 
 /**
  * A change proposed to be merged into a branch.
@@ -100,15 +95,6 @@
  * notice of a replacement patch set is sent, or when notice of the change submission occurs.
  */
 public final class Change {
-  private static final SecureRandom rng;
-
-  static {
-    try {
-      rng = SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("Cannot create RNG for Change-Id generator", e);
-    }
-  }
 
   public static Id id(int id) {
     return new AutoValue_Change_Id(id);
@@ -116,13 +102,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);
@@ -264,20 +267,6 @@
     }
   }
 
-  public static ObjectId generateChangeId() {
-    byte[] rand = new byte[Constants.OBJECT_ID_STRING_LENGTH];
-    rng.nextBytes(rand);
-    String randomString = new String(rand, UTF_8);
-
-    try (ObjectInserter f = new ObjectInserter.Formatter()) {
-      return f.idFor(Constants.OBJ_COMMIT, Constants.encode(randomString));
-    }
-  }
-
-  public static Key generateKey() {
-    return key("I" + generateChangeId().name());
-  }
-
   public static Key key(String key) {
     return new AutoValue_Change_Key(key);
   }
@@ -513,6 +502,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 +541,7 @@
     workInProgress = other.workInProgress;
     reviewStarted = other.reviewStarted;
     revertOf = other.revertOf;
+    cherryPickOf = other.cherryPickOf;
   }
 
   /** Legacy 32 bit integer identity for a change. */
@@ -742,6 +735,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..892e324 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().mapToInt(FixReplacement::getApproximateSize).sum();
+  }
 }
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index cc38bda..e47d197 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -19,9 +19,15 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.UsedAt;
 import java.util.List;
 
-/** A single modified file in a {@link PatchSet}. */
+/**
+ * Wrapper class for patch related aspects. Originally, this class represented a single modified
+ * file in a {@link PatchSet}. It's only kept in its current form as {@link ChangeType} and {@link
+ * PatchType} are used in diff cache entries for which we would break the serialization if we moved
+ * the enums somewhere else.
+ */
 public final class Patch {
   /** Magical file name which represents the commit message. */
   public static final String COMMIT_MSG = "/COMMIT_MSG";
@@ -77,10 +83,10 @@
     /** Path existed, but is being removed by this patch. */
     DELETED('D'),
 
-    /** Path existed at {@link Patch#getSourceFileName()} but was moved. */
+    /** Path existed at the source but was moved. */
     RENAMED('R'),
 
-    /** Path was copied from {@link Patch#getSourceFileName()}. */
+    /** Path was copied from the source. */
     COPIED('C'),
 
     /** Sufficient amount of content changed to claim the file was rewritten. */
@@ -97,10 +103,7 @@
       return code;
     }
 
-    public boolean matches(String s) {
-      return s != null && s.length() == 1 && s.charAt(0) == code;
-    }
-
+    @UsedAt(UsedAt.Project.COLLABNET)
     public static ChangeType forCode(char c) {
       for (ChangeType s : ChangeType.values()) {
         if (s.code == c) {
@@ -149,125 +152,7 @@
     public char getCode() {
       return code;
     }
-
-    public static PatchType forCode(char c) {
-      for (PatchType s : PatchType.values()) {
-        if (s.code == c) {
-          return s;
-        }
-      }
-      return null;
-    }
   }
 
-  protected Key key;
-
-  /** What sort of change is this to the path; see {@link ChangeType}. */
-  protected char changeType;
-
-  /** What type of patch is this; see {@link PatchType}. */
-  protected char patchType;
-
-  /** Number of published comments on this patch. */
-  protected int nbrComments;
-
-  /** Number of drafts by the current user; not persisted in the datastore. */
-  protected int nbrDrafts;
-
-  /** Number of lines added to the file. */
-  protected int insertions;
-
-  /** Number of lines deleted from the file. */
-  protected int deletions;
-
-  /** Original if {@link #changeType} is {@link ChangeType#COPIED} or {@link ChangeType#RENAMED}. */
-  protected String sourceFileName;
-
-  /** True if this patch has been reviewed by the current logged in user */
-  private boolean reviewedByCurrentUser;
-
-  protected Patch() {}
-
-  public Patch(Patch.Key newId) {
-    key = newId;
-    setChangeType(ChangeType.MODIFIED);
-    setPatchType(PatchType.UNIFIED);
-  }
-
-  public Patch.Key getKey() {
-    return key;
-  }
-
-  public int getCommentCount() {
-    return nbrComments;
-  }
-
-  public void setCommentCount(int n) {
-    nbrComments = n;
-  }
-
-  public int getDraftCount() {
-    return nbrDrafts;
-  }
-
-  public void setDraftCount(int n) {
-    nbrDrafts = n;
-  }
-
-  public int getInsertions() {
-    return insertions;
-  }
-
-  public void setInsertions(int n) {
-    insertions = n;
-  }
-
-  public int getDeletions() {
-    return deletions;
-  }
-
-  public void setDeletions(int n) {
-    deletions = n;
-  }
-
-  public ChangeType getChangeType() {
-    return ChangeType.forCode(changeType);
-  }
-
-  public void setChangeType(ChangeType type) {
-    changeType = type.getCode();
-  }
-
-  public PatchType getPatchType() {
-    return PatchType.forCode(patchType);
-  }
-
-  public void setPatchType(PatchType type) {
-    patchType = type.getCode();
-  }
-
-  public String getFileName() {
-    return key.fileName();
-  }
-
-  public String getSourceFileName() {
-    return sourceFileName;
-  }
-
-  public void setSourceFileName(String n) {
-    sourceFileName = n;
-  }
-
-  public boolean isReviewedByCurrentUser() {
-    return reviewedByCurrentUser;
-  }
-
-  public void setReviewedByCurrentUser(boolean r) {
-    reviewedByCurrentUser = r;
-  }
-
-  @Override
-  public String toString() {
-    return "[Patch " + getKey().toString() + "]";
-  }
+  private Patch() {}
 }
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..9256e79 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()
+                .mapToInt(entry -> nullableLength(entry.getKey(), entry.getValue()))
+                .sum()
+            : 0;
+    approximateSize +=
+        fixSuggestions != null
+            ? fixSuggestions.stream().mapToInt(FixSuggestion::getApproximateSize).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/exceptions/BUILD b/java/com/google/gerrit/exceptions/BUILD
index ef59be1..873b659 100644
--- a/java/com/google/gerrit/exceptions/BUILD
+++ b/java/com/google/gerrit/exceptions/BUILD
@@ -4,5 +4,8 @@
     name = "exceptions",
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//java/com/google/gerrit/entities"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//lib:jgit",
+    ],
 )
diff --git a/java/com/google/gerrit/exceptions/MergeWithConflictsNotSupportedException.java b/java/com/google/gerrit/exceptions/MergeWithConflictsNotSupportedException.java
new file mode 100644
index 0000000..cab1d22
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/MergeWithConflictsNotSupportedException.java
@@ -0,0 +1,25 @@
+// 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.exceptions;
+
+import org.eclipse.jgit.merge.MergeStrategy;
+
+public class MergeWithConflictsNotSupportedException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public MergeWithConflictsNotSupportedException(MergeStrategy strategy) {
+    super("merge with conflicts is not supported with merge strategy: " + strategy.getName());
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 67d6fd2..6df9889 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -87,6 +87,8 @@
 
   void setStatus(String status) throws RestApiException;
 
+  void setDisplayName(String displayName) throws RestApiException;
+
   List<SshKeyInfo> listSshKeys() throws RestApiException;
 
   SshKeyInfo addSshKey(String key) throws RestApiException;
@@ -269,6 +271,11 @@
     }
 
     @Override
+    public void setDisplayName(String displayName) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<SshKeyInfo> listSshKeys() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountInput.java b/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
index 259838b..2bcce30 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
@@ -20,6 +20,7 @@
 public class AccountInput {
   @DefaultInput public String username;
   public String name;
+  public String displayName;
   public String email;
   public String sshKey;
   public String httpPassword;
diff --git a/java/com/google/gerrit/extensions/api/accounts/DisplayNameInput.java b/java/com/google/gerrit/extensions/api/accounts/DisplayNameInput.java
new file mode 100644
index 0000000..16e7afc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/accounts/DisplayNameInput.java
@@ -0,0 +1,27 @@
+// 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.api.accounts;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DisplayNameInput {
+  public @DefaultInput String displayName;
+
+  public DisplayNameInput(String displayName) {
+    this.displayName = displayName;
+  }
+
+  public DisplayNameInput() {}
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
new file mode 100644
index 0000000..39efc64
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
@@ -0,0 +1,33 @@
+// 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.api.changes;
+
+/**
+ * Input at API level to add a user to the attention set.
+ *
+ * @see RemoveFromAttentionSetInput
+ * @see com.google.gerrit.extensions.common.AttentionSetEntry
+ */
+public class AddToAttentionSetInput {
+  public String user;
+  public String reason;
+
+  public AddToAttentionSetInput(String user, String reason) {
+    this.user = user;
+    this.reason = reason;
+  }
+
+  public AddToAttentionSetInput() {}
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
new file mode 100644
index 0000000..5086cd8
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
@@ -0,0 +1,35 @@
+// 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.api.changes;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+/** API for managing the attention set of a change. */
+public interface AttentionSetApi {
+
+  void remove(RemoveFromAttentionSetInput input) throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements AttentionSetApi {
+    @Override
+    public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index f0462c6..8df5343 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 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;
@@ -160,6 +161,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;
 
@@ -245,17 +252,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. */
@@ -292,6 +294,16 @@
    */
   Set<String> getHashtags() throws RestApiException;
 
+  /**
+   * Manage the attention set.
+   *
+   * @param id The account identifier.
+   */
+  AttentionSetApi attention(String id) throws RestApiException;
+
+  /** Adds a user to the attention set. */
+  AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException;
+
   /** Set the assignee of a change. */
   AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
 
@@ -484,6 +496,11 @@
     }
 
     @Override
+    public RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void rebase(RebaseInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -556,6 +573,16 @@
     }
 
     @Override
+    public AttentionSetApi attention(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public AccountInfo setAssignee(AssigneeInput input) 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 8b1ae3c..a26068a 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -151,7 +151,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
@@ -236,7 +251,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/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index 9b9a8a4..d8741f5 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -70,6 +70,8 @@
 
   ChangeApi create(ChangeInput in) throws RestApiException;
 
+  ChangeInfo createAsInfo(ChangeInput in) throws RestApiException;
+
   QueryRequest query();
 
   QueryRequest query(String query);
@@ -209,6 +211,11 @@
     }
 
     @Override
+    public ChangeInfo createAsInfo(ChangeInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public QueryRequest query() {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 0aace7b..fb03bc5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -29,4 +29,6 @@
 
   public boolean keepReviewers;
   public boolean allowConflicts;
+  public String topic;
+  public boolean allowEmpty;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
new file mode 100644
index 0000000..0cfe908
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -0,0 +1,24 @@
+// 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.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.RawInput;
+
+/** Content to be added to a file (new or existing) via change edit. */
+public class FileContentInput {
+  @DefaultInput public RawInput content;
+  public String binary_content;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
new file mode 100644
index 0000000..9212788
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
@@ -0,0 +1,33 @@
+// 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.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+/**
+ * Input at API level to remove a user from the attention set.
+ *
+ * @see AddToAttentionSetInput
+ * @see com.google.gerrit.extensions.common.AttentionSetEntry
+ */
+public class RemoveFromAttentionSetInput {
+  @DefaultInput public String reason;
+
+  public RemoveFromAttentionSetInput(String reason) {
+    this.reason = reason;
+  }
+
+  public RemoveFromAttentionSetInput() {}
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index f854d4a..ff9fb3c 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -16,12 +16,14 @@
 
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.CherryPickChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 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;
@@ -57,7 +59,7 @@
 
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
-  CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
+  ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
 
   default ChangeApi rebase() throws RestApiException {
     RebaseInput in = new RebaseInput();
@@ -115,6 +117,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;
@@ -145,6 +149,15 @@
   /** Returns votes on the revision. */
   ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
 
+  /**
+   * Retrieves the revision as an archive.
+   *
+   * @param format the format of the archive
+   * @return the archive as {@link BinaryResult}
+   * @throws RestApiException
+   */
+  BinaryResult getArchive(ArchiveFormat format) throws RestApiException;
+
   abstract class MergeListRequest {
     private boolean addLinks;
     private int uninterestingParent = 1;
@@ -191,7 +204,7 @@
     }
 
     @Override
-    public CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
+    public ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -286,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();
     }
@@ -374,5 +392,10 @@
     public String etag() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public BinaryResult getArchive(ArchiveFormat format) 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/ArchiveFormat.java b/java/com/google/gerrit/extensions/client/ArchiveFormat.java
new file mode 100644
index 0000000..4ec59cb
--- /dev/null
+++ b/java/com/google/gerrit/extensions/client/ArchiveFormat.java
@@ -0,0 +1,27 @@
+// 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.client;
+
+/**
+ * The {@link com.google.gerrit.server.restapi.change.GetArchive} REST endpoint allows to download
+ * revisions as archive. This enum defines the supported archive formats.
+ */
+public enum ArchiveFormat {
+  TGZ,
+  TAR,
+  TBZ2,
+  TXZ,
+  ZIP;
+}
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/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index e694c0e..4dea42f 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -16,6 +16,7 @@
 
 import java.lang.reflect.InvocationTargetException;
 import java.util.EnumSet;
+import java.util.Set;
 
 /** Enum that can be expressed as a bitset in query parameters. */
 public interface ListOption {
@@ -46,4 +47,13 @@
     }
     return r;
   }
+
+  static String toHex(Set<ListChangesOption> options) {
+    int v = 0;
+    for (ListChangesOption option : options) {
+      v |= 1 << option.getValue();
+    }
+
+    return Integer.toHexString(v);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AccountDefaultDisplayName.java b/java/com/google/gerrit/extensions/common/AccountDefaultDisplayName.java
new file mode 100644
index 0000000..44d6c00
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AccountDefaultDisplayName.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.common;
+
+/**
+ * Fallback rule for choosing a display name, if it is not explicitly set. This rule will not be
+ * applied by the backend, but should be applied by the user interface.
+ */
+public enum AccountDefaultDisplayName {
+
+  /**
+   * If the display name for an account is not set, then the (full) name will be used as the display
+   * name in the user interface.
+   */
+  FULL_NAME,
+
+  /**
+   * If the display name for an account is not set, then the first name (i.e. full name until first
+   * whitespace character) will be used as the display name in the user interface.
+   */
+  FIRST_NAME,
+
+  /**
+   * If the display name for an account is not set, then the username will be used as the display
+   * name in the user interface. If the username is also not set, then the (full) name will be used.
+   */
+  USERNAME
+}
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..2a3d260 100644
--- a/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -18,15 +18,53 @@
 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 display name of the user. This allows users to control how their name is displayed in the
+   * UI. It will likely be unset for most users. This account property is just a way to opt out of
+   * the host wide default strategy of choosing the display name, see
+   * accounts.accountDefaultDisplayName in the server config. The default strategy is not applied by
+   * the backend. The display name will just be left unset, and the client has to load and apply the
+   * default strategy.
+   */
+  public String displayName;
+
+  /** 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) {
@@ -45,6 +83,7 @@
       AccountInfo accountInfo = (AccountInfo) o;
       return Objects.equals(_accountId, accountInfo._accountId)
           && Objects.equals(name, accountInfo.name)
+          && Objects.equals(displayName, accountInfo.displayName)
           && Objects.equals(email, accountInfo.email)
           && Objects.equals(secondaryEmails, accountInfo.secondaryEmails)
           && Objects.equals(username, accountInfo.username)
@@ -60,6 +99,7 @@
     return MoreObjects.toStringHelper(this)
         .add("id", _accountId)
         .add("name", name)
+        .add("displayname", displayName)
         .add("email", email)
         .add("username", username)
         .toString();
@@ -68,7 +108,15 @@
   @Override
   public int hashCode() {
     return Objects.hash(
-        _accountId, name, email, secondaryEmails, username, avatars, _moreAccounts, status);
+        _accountId,
+        name,
+        displayName,
+        email,
+        secondaryEmails,
+        username,
+        avatars,
+        _moreAccounts,
+        status);
   }
 
   protected AccountInfo() {}
diff --git a/java/com/google/gerrit/extensions/common/AccountsInfo.java b/java/com/google/gerrit/extensions/common/AccountsInfo.java
index e1c2825..d669578 100644
--- a/java/com/google/gerrit/extensions/common/AccountsInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountsInfo.java
@@ -14,6 +14,15 @@
 
 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;
+
+  /** The value of the {@code accounts.visibility} parameter in {@code gerrit.config}. */
+  public AccountDefaultDisplayName defaultDisplayName;
 }
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/AttentionSetEntry.java b/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
new file mode 100644
index 0000000..356b38a
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
@@ -0,0 +1,39 @@
+// 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.common;
+
+import java.sql.Timestamp;
+
+/**
+ * Represents a single user included in the attention set. Used in the API. See {@link
+ * com.google.gerrit.entities.AttentionSetUpdate} for the internal representation.
+ *
+ * <p>See <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">here</a> for
+ * background.
+ */
+public class AttentionSetEntry {
+  /** The user included in the attention set. */
+  public AccountInfo accountInfo;
+  /** The timestamp of the last update. */
+  public Timestamp lastUpdate;
+  /** The human readable reason why the user was added. */
+  public String reason;
+
+  public AttentionSetEntry(AccountInfo accountInfo, Timestamp lastUpdate, String reason) {
+    this.accountInfo = accountInfo;
+    this.lastUpdate = lastUpdate;
+    this.reason = reason;
+  }
+}
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..a441bfd 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,7 @@
   public String replyTooltip;
   public int updateDelay;
   public Boolean submitWholeTopic;
-  public Boolean excludeMergeableInChangeInfo;
+  public String mergeabilityComputationBehavior;
+  public Boolean enableAttentionSet;
+  public Boolean enableAssignee;
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 9a739ef..dce6fd1 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -22,13 +22,28 @@
 import java.util.List;
 import java.util.Map;
 
+/**
+ * Representation of a change used in the API. Internally {@link
+ * com.google.gerrit.server.query.change.ChangeData} and {@link com.google.gerrit.entities.Change}
+ * are used.
+ *
+ * <p>Many fields are actually nullable.
+ */
 public class ChangeInfo {
   // ActionJson#copy(List, ChangeInfo) must be adapted if new fields are added that are not
   // protected by any ListChangesOption.
+
   public String id;
   public String project;
   public String branch;
   public String topic;
+  /**
+   * The <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">attention set</a>
+   * for this change. Keyed by account ID. We don't use {@link
+   * com.google.gerrit.entities.Account.Id} to avoid a circular dependency.
+   */
+  public Map<Integer, AttentionSetEntry> attentionSet;
+
   public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
@@ -52,6 +67,25 @@
   public Boolean workInProgress;
   public Boolean hasReviewStarted;
   public Integer revertOf;
+  public String submissionId;
+  public Integer cherryPickOfChange;
+  public Integer cherryPickOfPatchSet;
+
+  /**
+   * Whether the change contains conflicts.
+   *
+   * <p>If {@code true}, some of the file contents of the change contain git conflict markers to
+   * indicate the conflicts.
+   *
+   * <p>Only set if this change info is returned in response to a request that creates a new change
+   * or patch set and conflicts are allowed. In particular this field is only populated if the
+   * change info is returned by one of the following REST endpoints: {@link
+   * com.google.gerrit.server.restapi.change.CreateChange}, {@link
+   * com.google.gerrit.server.restapi.change.CreateMergePatchSet}, {@link
+   * com.google.gerrit.server.restapi.change.CherryPick}, {@link
+   * com.google.gerrit.server.restapi.change.CherryPickCommit}
+   */
+  public Boolean containsGitConflicts;
 
   public int _number;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 36dd8f2..1949ff4 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
@@ -34,6 +35,8 @@
   public Boolean newBranch;
   public MergeInput merge;
 
+  public AccountInput author;
+
   public ChangeInput() {}
 
   /**
diff --git a/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java b/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java
deleted file mode 100644
index 5e2b902..0000000
--- a/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java
+++ /dev/null
@@ -1,19 +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.extensions.common;
-
-public class CherryPickChangeInfo extends ChangeInfo {
-  public Boolean containsGitConflicts;
-}
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index 02a2133..19e002a 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -20,6 +20,7 @@
 public class CommentInfo extends Comment {
   public AccountInfo author;
   public String tag;
+  public String changeMessageId;
 
   @Override
   public boolean equals(Object o) {
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..f552566
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -0,0 +1,38 @@
+// 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 List<Short> copyValues;
+  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..23d5df1
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.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 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 List<Short> copyValues;
+  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..7de5b38 100644
--- a/java/com/google/gerrit/extensions/common/MergeInput.java
+++ b/java/com/google/gerrit/extensions/common/MergeInput.java
@@ -24,9 +24,23 @@
   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
    */
   public String strategy;
+
+  /**
+   * Whether the creation of the merge should succeed if there are conflicts.
+   *
+   * <p>If there are conflicts the file contents of the created change contain git conflict markers
+   * to indicate the conflicts.
+   */
+  public boolean allowConflicts;
 }
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/extensions/common/RevertSubmissionInfo.java b/java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java
new file mode 100644
index 0000000..dabd035
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java
@@ -0,0 +1,21 @@
+// 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;
+
+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/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
index 53f0375..3483de5 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -15,21 +15,17 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.common.base.MoreObjects;
-import java.util.Map;
 import java.util.Objects;
 
 public class SubmitRequirementInfo {
   public final String status;
   public final String fallbackText;
   public final String type;
-  public final Map<String, String> data;
 
-  public SubmitRequirementInfo(
-      String status, String fallbackText, String type, Map<String, String> data) {
+  public SubmitRequirementInfo(String status, String fallbackText, String type) {
     this.status = status;
     this.fallbackText = fallbackText;
     this.type = type;
-    this.data = data;
   }
 
   @Override
@@ -43,13 +39,12 @@
     SubmitRequirementInfo that = (SubmitRequirementInfo) o;
     return Objects.equals(status, that.status)
         && Objects.equals(fallbackText, that.fallbackText)
-        && Objects.equals(type, that.type)
-        && Objects.equals(data, that.data);
+        && Objects.equals(type, that.type);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(status, fallbackText, type, data);
+    return Objects.hash(status, fallbackText, type);
   }
 
   @Override
@@ -58,7 +53,6 @@
         .add("status", status)
         .add("fallbackText", fallbackText)
         .add("type", type)
-        .add("data", data)
         .toString();
   }
 }
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/EditInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
index b5622e0..0d1e82a 100644
--- a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
@@ -16,11 +16,13 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.commits;
+import static com.google.gerrit.truth.MapSubject.mapEntries;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.truth.MapSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import java.util.Optional;
 
@@ -55,4 +57,9 @@
     isNotNull();
     return check("baseRevision").that(editInfo.baseRevision);
   }
+
+  public MapSubject files() {
+    isNotNull();
+    return check("files").about(mapEntries()).that(editInfo.files);
+  }
 }
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..a8ffc26 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, int 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 int 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..ee7c8a5 100644
--- a/java/com/google/gerrit/extensions/validators/CommentValidator.java
+++ b/java/com/google/gerrit/extensions/validators/CommentValidator.java
@@ -25,10 +25,16 @@
 public interface CommentValidator {
 
   /**
-   * Validate the specified comments.
+   * Validate the specified comments. This method will be called once with the {@code comments}
+   * argument containing all new comments that need to be validated and (if applicable) the new
+   * change message. This allows validators to statelessly count the new comments. Note that after
+   * this one call the method may be called again one or more times for texts that are not comments,
+   * but similar in nature.
+   *
+   * <p>NOTE: Autogenerated change messages are not subject to validation.
    *
    * @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/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
index c11c4e3..f4fb9f9 100644
--- a/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.gpg;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -87,7 +89,7 @@
 
     @Override
     public void init(Project.NameKey project, ReceivePack rp) {
-      ProjectState ps = projectCache.get(project);
+      ProjectState ps = projectCache.get(project).orElseThrow(illegalState(project));
       if (!ps.is(BooleanProjectConfig.ENABLE_SIGNED_PUSH)) {
         rp.setSignedPushConfig(null);
         return;
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/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 520ce4e..6e22704 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -285,8 +285,11 @@
 
       try {
         Project.NameKey nameKey = Project.nameKey(projectName);
-        ProjectState state = projectCache.checkedGet(nameKey);
-        if (state == null || !state.statePermitsRead()) {
+        ProjectState state =
+            projectCache
+                .get(nameKey)
+                .orElseThrow(() -> new RepositoryNotFoundException(nameKey.get()));
+        if (!state.statePermitsRead()) {
           throw new RepositoryNotFoundException(nameKey.get());
         }
         req.setAttribute(ATT_STATE, state);
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 e75d8fe..111cc34 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -21,6 +21,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;
@@ -50,7 +51,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.
@@ -112,7 +112,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 fcaef5e..ea0c148 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 37250b4..b987c68 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/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 4fabb18..897d96f 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -413,15 +413,15 @@
     }
 
     Project.NameKey nameKey = Project.nameKey(name);
-    ProjectState projectState;
+    Optional<ProjectState> projectState;
     try {
-      projectState = projectCache.checkedGet(nameKey);
-      if (projectState == null) {
+      projectState = projectCache.get(nameKey);
+      if (!projectState.isPresent()) {
         sendErrorOrRedirect(req, rsp, HttpServletResponse.SC_NOT_FOUND);
         return;
       }
 
-      projectState.checkStatePermitsRead();
+      projectState.get().checkStatePermitsRead();
       permissionBackend.user(userProvider.get()).project(nameKey).check(ProjectPermission.READ);
     } catch (AuthException e) {
       sendErrorOrRedirect(req, rsp, HttpServletResponse.SC_NOT_FOUND);
@@ -437,7 +437,7 @@
 
     try (Repository repo = repoManager.openRepository(nameKey)) {
       CacheHeaders.setNotCacheable(rsp);
-      exec(req, rsp, projectState);
+      exec(req, rsp, projectState.get());
     } catch (RepositoryNotFoundException e) {
       getServletContext().log("Cannot open repository", e);
       rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 0befbd3..05992d4 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;
@@ -337,7 +338,7 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(false, false, false));
+            bind(GerritOptions.class).toInstance(new GerritOptions(false, false, ""));
             bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
           }
         });
@@ -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/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
index e8f173c..d92da18 100644
--- a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -43,6 +43,7 @@
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
     CacheHeaders.setNotCacheable(res);
+    res.setContentLength(0);
     if (user.get().isIdentifiedUser()) {
       res.setStatus(HttpServletResponse.SC_NO_CONTENT);
     } else {
diff --git a/java/com/google/gerrit/httpd/raw/BazelBuild.java b/java/com/google/gerrit/httpd/raw/BazelBuild.java
deleted file mode 100644
index 7677e97..0000000
--- a/java/com/google/gerrit/httpd/raw/BazelBuild.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Joiner;
-import com.google.common.escape.Escaper;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.html.HtmlEscapers;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.launcher.GerritLauncher;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.util.http.CacheHeaders;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InterruptedIOException;
-import java.io.PrintWriter;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Properties;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.util.RawParseUtils;
-
-public class BazelBuild {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final Path sourceRoot;
-
-  public BazelBuild(Path sourceRoot) {
-    this.sourceRoot = sourceRoot;
-  }
-
-  // builds the given label.
-  public void build(Label label) throws IOException, BuildFailureException {
-    ProcessBuilder proc = newBuildProcess(label);
-    proc.directory(sourceRoot.toFile()).redirectErrorStream(true);
-    logger.atInfo().log("building %s", label.fullName());
-    long start = TimeUtil.nowMs();
-    Process rebuild = proc.start();
-    byte[] out;
-    try (InputStream in = rebuild.getInputStream()) {
-      out = ByteStreams.toByteArray(in);
-    } finally {
-      rebuild.getOutputStream().close();
-    }
-
-    int status;
-    try {
-      status = rebuild.waitFor();
-    } catch (InterruptedException e) {
-      String msg = "interrupted waiting for: " + Joiner.on(' ').join(proc.command());
-      logger.atSevere().withCause(e).log(msg);
-      throw new InterruptedIOException(msg);
-    }
-    if (status != 0) {
-      logger.atWarning().log("build failed: %s", new String(out, UTF_8));
-      throw new BuildFailureException(out);
-    }
-
-    long time = TimeUtil.nowMs() - start;
-    logger.atInfo().log("UPDATED    %s in %.3fs", label.fullName(), time / 1000.0);
-  }
-
-  // Represents a label in bazel.
-  static class Label {
-    protected final String pkg;
-    protected final String name;
-
-    public String fullName() {
-      return "//" + pkg + ":" + name;
-    }
-
-    @Override
-    public String toString() {
-      return fullName();
-    }
-
-    // Label in Bazel style.
-    Label(String pkg, String name) {
-      this.name = name;
-      this.pkg = pkg;
-    }
-  }
-
-  static class BuildFailureException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    final byte[] why;
-
-    BuildFailureException(byte[] why) {
-      this.why = why;
-    }
-
-    public void display(String rule, HttpServletResponse res) throws IOException {
-      res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      res.setContentType("text/html");
-      res.setCharacterEncoding(UTF_8.name());
-      CacheHeaders.setNotCacheable(res);
-
-      Escaper html = HtmlEscapers.htmlEscaper();
-      try (PrintWriter w = res.getWriter()) {
-        w.write("<html><title>BUILD FAILED</title><body>");
-        w.format("<h1>%s FAILED</h1>", html.escape(rule));
-        w.write("<pre>");
-        w.write(html.escape(RawParseUtils.decode(why)));
-        w.write("</pre>");
-        w.write("</body></html>");
-      }
-    }
-  }
-
-  private ProcessBuilder newBuildProcess(Label label) throws IOException {
-    Properties properties = GerritLauncher.loadBuildProperties(sourceRoot.resolve(".bazel_path"));
-    String bazel = firstNonNull(properties.getProperty("bazel"), "bazel");
-    List<String> cmd = new ArrayList<>();
-    cmd.add(bazel);
-    cmd.add("build");
-    if (GerritLauncher.isJdk9OrLater()) {
-      String v = GerritLauncher.getJdkVersionPostJdk8();
-      cmd.add("--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java" + v);
-      cmd.add("--java_toolchain=@bazel_tools//tools/jdk:toolchain_java" + v);
-    }
-    cmd.add(label.fullName());
-    ProcessBuilder proc = new ProcessBuilder(cmd);
-    if (properties.containsKey("PATH")) {
-      proc.environment().put("PATH", properties.getProperty("PATH"));
-    }
-    return proc;
-  }
-
-  /** returns the root relative path to the artifact for the given label */
-  public Path targetPath(Label l) {
-    return sourceRoot.resolve("bazel-bin").resolve(l.pkg).resolve(l.name);
-  }
-
-  /** Label for the polygerrit component zip. */
-  public Label polygerritComponents() {
-    return new Label("polygerrit-ui", "polygerrit_components.bower_components.zip");
-  }
-
-  /** Label for the fonts zip file. */
-  public Label fontZipLabel() {
-    return new Label("polygerrit-ui", "fonts.zip");
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java b/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
deleted file mode 100644
index 1be3045..0000000
--- a/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.launcher.GerritLauncher;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.Objects;
-
-/* Bower component servlet only used in development mode */
-class BowerComponentsDevServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Path bowerComponents;
-  private final Path zip;
-
-  BowerComponentsDevServlet(Cache<Path, Resource> cache, BazelBuild builder) throws IOException {
-    super(cache, true);
-
-    Objects.requireNonNull(builder);
-    BazelBuild.Label label = builder.polygerritComponents();
-    try {
-      builder.build(label);
-    } catch (BazelBuild.BuildFailureException e) {
-      throw new IOException(e);
-    }
-
-    zip = builder.targetPath(label);
-    bowerComponents = GerritLauncher.newZipFileSystem(zip).getPath("/");
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) throws IOException {
-    return bowerComponents.resolve(pathInfo);
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
index a295213..7a4f4e6 100644
--- a/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -123,7 +125,10 @@
     try {
       ChangeNotes notes = changeNotesFactory.createChecked(changeId);
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-      projectCache.checkedGet(notes.getProjectName()).checkStatePermitsRead();
+      projectCache
+          .get(notes.getProjectName())
+          .orElseThrow(illegalState(notes.getProjectName()))
+          .checkStatePermitsRead();
       if (patchKey.patchSetId().get() == 0) {
         // change edit
         Optional<ChangeEdit> edit = changeEditUtil.byChange(notes);
diff --git a/java/com/google/gerrit/httpd/raw/FontsDevServlet.java b/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
deleted file mode 100644
index 68b0d8c..0000000
--- a/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.launcher.GerritLauncher;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.Objects;
-
-/* Font servlet only used in development mode */
-class FontsDevServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Path fonts;
-
-  FontsDevServlet(Cache<Path, Resource> cache, BazelBuild builder) throws IOException {
-    super(cache, true);
-    Objects.requireNonNull(builder);
-
-    BazelBuild.Label zipLabel = builder.fontZipLabel();
-    try {
-      builder.build(zipLabel);
-    } catch (BazelBuild.BuildFailureException e) {
-      throw new IOException(e);
-    }
-
-    Path zip = builder.targetPath(zipLabel);
-    Objects.requireNonNull(zip);
-
-    fonts = GerritLauncher.newZipFileSystem(zip).getPath("/");
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) throws IOException {
-    return fonts.resolve(pathInfo);
-  }
-}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 8d81d62..1680457 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -15,31 +15,90 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.template.soy.data.ordainers.GsonOrdainer.serializeObject;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gson.Gson;
 import com.google.template.soy.data.SanitizedContent;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /** Helper for generating parts of {@code index.html}. */
+@UsedAt(Project.GOOGLE)
 public class IndexHtmlUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  public static final String CHANGE_CANONICAL_URL = ".*/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
+  public static final String BASE_PATCH_NUM_URL_PART = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
+  public static final Pattern CHANGE_URL_PATTERN =
+      Pattern.compile(CHANGE_CANONICAL_URL + BASE_PATCH_NUM_URL_PART + "?" + "/?$");
+  public static final Pattern DIFF_URL_PATTERN =
+      Pattern.compile(CHANGE_CANONICAL_URL + BASE_PATCH_NUM_URL_PART + "(/(.+))" + "/?$");
+
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+
+  public static String getDefaultChangeDetailHex() {
+    Set<ListChangesOption> options =
+        ImmutableSet.of(
+            ListChangesOption.ALL_COMMITS,
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.CHANGE_ACTIONS,
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.DOWNLOAD_COMMANDS,
+            ListChangesOption.MESSAGES,
+            ListChangesOption.SUBMITTABLE,
+            ListChangesOption.WEB_LINKS,
+            ListChangesOption.SKIP_DIFFSTAT);
+
+    return ListOption.toHex(options);
+  }
+
+  public static String getDefaultDiffDetailHex() {
+    Set<ListChangesOption> options =
+        ImmutableSet.of(
+            ListChangesOption.ALL_COMMITS,
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.SKIP_DIFFSTAT);
+
+    return ListOption.toHex(options);
+  }
+
+  public static String computeChangeRequestsPath(String requestedURL, Pattern pattern) {
+    Matcher matcher = pattern.matcher(requestedURL);
+    if (matcher.matches()) {
+      Integer changeId = Ints.tryParse(matcher.group("changeNum"));
+      if (changeId != null) {
+        return "changes/" + Url.encode(matcher.group("project")) + "~" + changeId;
+      }
+    }
+
+    return null;
+  }
+
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
    * rendering the soy template.
@@ -50,45 +109,68 @@
       String cdnPath,
       String faviconPath,
       Map<String, String[]> urlParameterMap,
-      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      Function<String, SanitizedContent> urlInScriptTagOrdainer,
+      String requestedURL)
       throws URISyntaxException, RestApiException {
-    return ImmutableMap.<String, Object>builder()
-        .putAll(
+    ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
+    data.putAll(
             staticTemplateData(
-                canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
-        .putAll(dynamicTemplateData(gerritApi))
-        .build();
+                canonicalURL,
+                cdnPath,
+                faviconPath,
+                urlParameterMap,
+                urlInScriptTagOrdainer,
+                requestedURL))
+        .putAll(dynamicTemplateData(gerritApi));
+
+    Set<String> enabledExperiments = experimentData(urlParameterMap);
+    if (!enabledExperiments.isEmpty()) {
+      data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
+    }
+    return data.build();
   }
 
   /** Returns dynamic parameters of {@code index.html}. */
-  @UsedAt(Project.GOOGLE)
-  public static Map<String, Map<String, SanitizedContent>> dynamicTemplateData(GerritApi gerritApi)
+  public static ImmutableMap<String, Object> dynamicTemplateData(GerritApi gerritApi)
       throws RestApiException {
-    Gson gson = OutputFormat.JSON_COMPACT.newGson();
+    ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
     Map<String, SanitizedContent> initialData = new HashMap<>();
     Server serverApi = gerritApi.config().server();
-    initialData.put("\"/config/server/info\"", serializeObject(gson, serverApi.getInfo()));
-    initialData.put("\"/config/server/version\"", serializeObject(gson, serverApi.getVersion()));
-    initialData.put("\"/config/server/top-menus\"", serializeObject(gson, serverApi.topMenus()));
+    initialData.put("\"/config/server/info\"", serializeObject(GSON, serverApi.getInfo()));
+    initialData.put("\"/config/server/version\"", serializeObject(GSON, serverApi.getVersion()));
+    initialData.put("\"/config/server/top-menus\"", serializeObject(GSON, serverApi.topMenus()));
 
     try {
       AccountApi accountApi = gerritApi.accounts().self();
-      initialData.put("\"/accounts/self/detail\"", serializeObject(gson, accountApi.get()));
+      initialData.put("\"/accounts/self/detail\"", serializeObject(GSON, accountApi.get()));
       initialData.put(
-          "\"/accounts/self/preferences\"", serializeObject(gson, accountApi.getPreferences()));
+          "\"/accounts/self/preferences\"", serializeObject(GSON, accountApi.getPreferences()));
       initialData.put(
           "\"/accounts/self/preferences.diff\"",
-          serializeObject(gson, accountApi.getDiffPreferences()));
+          serializeObject(GSON, accountApi.getDiffPreferences()));
       initialData.put(
           "\"/accounts/self/preferences.edit\"",
-          serializeObject(gson, accountApi.getEditPreferences()));
+          serializeObject(GSON, accountApi.getEditPreferences()));
+      data.put("userIsAuthenticated", true);
     } catch (AuthException e) {
       logger.atFine().log("Can't inline account-related data because user is unauthenticated");
       // Don't render data
-      // TODO(hiesel): Tell the client that the user is not authenticated so that it doesn't have to
-      // fetch anyway. This requires more client side modifications.
     }
-    return ImmutableMap.of("gerritInitialData", initialData);
+
+    data.put("gerritInitialData", initialData);
+    return data.build();
+  }
+
+  /** Returns experimentData to be used in {@code index.html}. */
+  public static Set<String> experimentData(Map<String, String[]> urlParameterMap) {
+    // Allow enable experiments with url
+    // ?experiment=a&experiment=b should result in:
+    // "experiment" => [a,b]
+    if (urlParameterMap.containsKey("experiment")) {
+      return Arrays.asList(urlParameterMap.get("experiment")).stream().collect(toSet());
+    }
+
+    return Collections.emptySet();
   }
 
   /** Returns all static parameters of {@code index.html}. */
@@ -97,7 +179,8 @@
       String cdnPath,
       String faviconPath,
       Map<String, String[]> urlParameterMap,
-      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      Function<String, SanitizedContent> urlInScriptTagOrdainer,
+      String requestedURL)
       throws URISyntaxException {
     String canonicalPath = computeCanonicalPath(canonicalURL);
 
@@ -120,15 +203,30 @@
     if (faviconPath != null) {
       data.put("faviconPath", faviconPath);
     }
+    if (requestedURL != null) {
+      data.put("defaultChangeDetailHex", getDefaultChangeDetailHex());
+      data.put("defaultDiffDetailHex", getDefaultDiffDetailHex());
+
+      String changeRequestsPath = computeChangeRequestsPath(requestedURL, CHANGE_URL_PATTERN);
+      if (changeRequestsPath != null) {
+        data.put("preloadChangePage", "true");
+      } else {
+        changeRequestsPath = computeChangeRequestsPath(requestedURL, DIFF_URL_PATTERN);
+        data.put("preloadDiffPage", "true");
+      }
+
+      if (changeRequestsPath != null) {
+        data.put("changeRequestsPath", changeRequestsPath);
+      }
+    }
+
     if (urlParameterMap.containsKey("ce")) {
       data.put("polyfillCE", "true");
     }
-    if (urlParameterMap.containsKey("sd")) {
-      data.put("polyfillSD", "true");
+    if (urlParameterMap.containsKey("gf")) {
+      data.put("useGoogleFonts", "true");
     }
-    if (urlParameterMap.containsKey("sc")) {
-      data.put("polyfillSC", "true");
-    }
+
     return data.build();
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index a0b41b21..97d2270 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -70,10 +70,11 @@
     SoySauce.Renderer renderer;
     try {
       Map<String, String[]> parameterMap = req.getParameterMap();
+      String requestUrl = req.getRequestURL() == null ? null : req.getRequestURL().toString();
       // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
-              gerritApi, canonicalUrl, cdnPath, faviconPath, parameterMap, urlOrdainer);
+              gerritApi, canonicalUrl, cdnPath, faviconPath, parameterMap, urlOrdainer, requestUrl);
       renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData);
     } catch (URISyntaxException | RestApiException e) {
       throw new IOException(e);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 0d4c67e..414a120 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.raw;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
@@ -222,7 +221,8 @@
         @CanonicalWebUrl @Nullable String canonicalUrl,
         @GerritServerConfig Config cfg,
         GerritApi gerritApi) {
-      String cdnPath = cfg.getString("gerrit", null, "cdnPath");
+      String cdnPath =
+          options.useDevCdn() ? options.devCdn() : cfg.getString("gerrit", null, "cdnPath");
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
       return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi);
     }
@@ -233,29 +233,8 @@
       return new PolyGerritUiServlet(cache, polyGerritBasePath());
     }
 
-    @Provides
-    @Singleton
-    BowerComponentsDevServlet getBowerComponentsServlet(@Named(CACHE) Cache<Path, Resource> cache)
-        throws IOException {
-      return getPaths().isDev() ? new BowerComponentsDevServlet(cache, getPaths().builder) : null;
-    }
-
-    @Provides
-    @Singleton
-    FontsDevServlet getFontsServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      return getPaths().isDev() ? new FontsDevServlet(cache, getPaths().builder) : null;
-    }
-
     private Path polyGerritBasePath() {
       Paths p = getPaths();
-      if (options.forcePolyGerritDev()) {
-        checkArgument(
-            p.sourceRoot != null, "no source root directory found for PolyGerrit developer mode");
-      }
-
-      if (p.isDev()) {
-        return p.sourceRoot.resolve("polygerrit-ui").resolve("app");
-      }
 
       return p.warFs != null
           ? p.warFs.getPath("/polygerrit_ui")
@@ -265,7 +244,6 @@
 
   private static class Paths {
     private final FileSystem warFs;
-    private final BazelBuild builder;
     private final Path sourceRoot;
     private final Path unpackedWar;
     private final boolean development;
@@ -285,21 +263,19 @@
                   launcherLoadedFrom.getParentFile().getParentFile().getParentFile().toURI());
           sourceRoot = null;
           development = false;
-          builder = null;
           return;
         }
         warFs = getDistributionArchive(launcherLoadedFrom);
         if (warFs == null) {
           unpackedWar = makeWarTempDir();
           development = true;
-        } else if (options.forcePolyGerritDev()) {
+        } else if (options.useDevCdn()) {
           unpackedWar = null;
           development = true;
         } else {
           unpackedWar = null;
           development = false;
           sourceRoot = null;
-          builder = null;
           return;
         }
       } catch (IOException e) {
@@ -307,7 +283,6 @@
       }
 
       sourceRoot = getSourceRootOrNull();
-      builder = new BazelBuild(sourceRoot);
     }
 
     private static Path getSourceRootOrNull() {
@@ -373,24 +348,15 @@
 
   @Singleton
   private static class PolyGerritFilter implements Filter {
-    private final Paths paths;
     private final HttpServlet polyGerritIndex;
     private final PolyGerritUiServlet polygerritUI;
-    private final BowerComponentsDevServlet bowerComponentServlet;
-    private final FontsDevServlet fontServlet;
 
     @Inject
     PolyGerritFilter(
-        Paths paths,
         @Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
-        PolyGerritUiServlet polygerritUI,
-        @Nullable BowerComponentsDevServlet bowerComponentServlet,
-        @Nullable FontsDevServlet fontServlet) {
-      this.paths = paths;
+        PolyGerritUiServlet polygerritUI) {
       this.polyGerritIndex = polyGerritIndex;
       this.polygerritUI = polygerritUI;
-      this.bowerComponentServlet = bowerComponentServlet;
-      this.fontServlet = fontServlet;
     }
 
     @Override
@@ -408,22 +374,6 @@
       GuiceFilterRequestWrapper reqWrapper = new GuiceFilterRequestWrapper(req);
       String path = pathInfo(req);
 
-      // Special case assets during development that are built by Bazel and not
-      // served out of the source tree.
-      //
-      // In the war case, these are either inlined, or live under
-      // /polygerrit_ui in the war file, so we can just treat them as normal
-      // assets.
-      if (paths.isDev()) {
-        if (path.startsWith("/bower_components/")) {
-          bowerComponentServlet.service(reqWrapper, res);
-          return;
-        } else if (path.startsWith("/fonts/")) {
-          fontServlet.service(reqWrapper, res);
-          return;
-        }
-      }
-
       if (isPolyGerritIndex(path)) {
         polyGerritIndex.service(reqWrapper, res);
         return;
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 fe3621e..ce5de3c 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,12 +45,12 @@
 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.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;
@@ -95,13 +96,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;
@@ -110,10 +111,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;
@@ -122,7 +125,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;
@@ -168,6 +174,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;
@@ -239,6 +246,8 @@
     final Config config;
     final DynamicSet<PerformanceLogger> performanceLoggers;
     final ChangeFinder changeFinder;
+    final RetryHelper retryHelper;
+    final PluginSetContext<ExceptionHook> exceptionHooks;
 
     @Inject
     Globals(
@@ -252,7 +261,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;
@@ -264,6 +275,8 @@
       this.config = config;
       this.performanceLoggers = performanceLoggers;
       this.changeFinder = changeFinder;
+      this.retryHelper = retryHelper;
+      this.exceptionHooks = exceptionHooks;
       allowOrigin = makeAllowOrigin(config);
     }
 
@@ -278,7 +291,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) {
@@ -302,8 +314,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;
@@ -313,10 +326,10 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       List<IdString> path = splitPath(req);
 
-      RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
-      globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
-
       try (PerThreadCache ignored = PerThreadCache.create()) {
+        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
+        globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
+
         // It's important that the PerformanceLogContext is closed before the response is sent to
         // the client. Only this way it is ensured that the invocation of the PerformanceLogger
         // plugins happens before the client sees the response. This is needed for being able to
@@ -324,13 +337,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",
-              lazy(() -> globals.currentUser.get().getEffectiveGroups().getKnownGroups()));
+          traceRequestData(req);
 
           if (isCorsPreflight(req)) {
             doCorsPreflight(req, res);
@@ -375,7 +382,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);
@@ -448,7 +455,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) {
@@ -483,7 +490,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;
@@ -494,7 +501,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 =
@@ -502,7 +511,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);
@@ -515,7 +527,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);
@@ -528,7 +542,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);
@@ -541,7 +557,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);
@@ -551,9 +569,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();
@@ -566,18 +581,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()) {
@@ -589,83 +598,114 @@
           }
         }
       } catch (MalformedJsonException | JsonParseException e) {
+        cause = Optional.of(e);
         logger.atFine().withCause(e).log("REST call failed on JSON parsing");
         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));
+          } else {
+            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)
@@ -1436,14 +1694,23 @@
     if (rootCollection instanceof ProjectsCollection) {
       requestInfo.project(Project.nameKey(resourceId));
     } else if (rootCollection instanceof ChangesCollection) {
-      ChangeNotes changeNotes = globals.changeFinder.findOne(resourceId);
-      if (changeNotes != null) {
-        requestInfo.project(changeNotes.getProjectName());
+      Optional<ChangeNotes> changeNotes = globals.changeFinder.findOne(resourceId);
+      if (changeNotes.isPresent()) {
+        requestInfo.project(changeNotes.get().getProjectName());
       }
     }
     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", lazy(() -> globals.currentUser.get().getEffectiveGroups().getKnownGroups()));
+  }
+
   private boolean isDelete(HttpServletRequest req) {
     return "DELETE".equals(req.getMethod());
   }
@@ -1486,15 +1753,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) {
@@ -1520,13 +1834,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/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index fb48104..63f6887 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -91,7 +91,9 @@
 
   private final String name;
   private final FieldType<?> type;
+  /** Allow reading the actual data from the index. */
   private final boolean stored;
+
   private final boolean repeatable;
   private final Getter<I, T> getter;
 
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 bab2990..3aa9de0 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -20,6 +20,7 @@
 import com.google.common.base.MoreObjects;
 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 com.google.gerrit.exceptions.StorageException;
 import java.util.ArrayList;
@@ -176,7 +177,11 @@
     return true;
   }
 
-  private Values<T> fieldValues(T obj, FieldDef<T, ?> f) {
+  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+    if (skipFields.contains(f.getName())) {
+      return null;
+    }
+
     Object v;
     try {
       v = f.get(obj);
@@ -205,12 +210,13 @@
    * <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) {
+  public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
     try {
       return fields.values().stream()
-          .map(f -> fieldValues(obj, f))
+          .map(f -> fieldValues(obj, f, skipFields))
           .filter(Objects::nonNull)
           .collect(toImmutableList());
     } catch (StorageException e) {
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index f209f24..32b4b21 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 e576d73..bf1a166 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
@@ -29,6 +30,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 +56,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;
@@ -136,6 +139,7 @@
   private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName();
   private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
       ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
+  private static final String ATTENTION_SET_FULL_FIELD = ChangeField.ATTENTION_SET_FULL.getName();
 
   @FunctionalInterface
   static interface IdTerm {
@@ -167,6 +171,7 @@
   private final String idSortFieldName;
   private final IdTerm idTerm;
   private final ChangeIdExtractor extractor;
+  private final ImmutableSet<String> skipFields;
 
   @Inject
   LuceneChangeIndex(
@@ -179,6 +184,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 +198,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;
@@ -519,6 +550,9 @@
     if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
       decodePendingReviewersByEmail(doc, cd);
     }
+    if (fields.contains(ATTENTION_SET_FULL_FIELD)) {
+      decodeAttentionSet(doc, cd);
+    }
     decodeSubmitRecords(
         doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
     decodeSubmitRecords(
@@ -565,7 +599,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);
@@ -643,6 +677,14 @@
                 .transform(IndexableField::stringValue)));
   }
 
+  private void decodeAttentionSet(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    ChangeField.parseAttentionSet(
+        doc.get(ATTENTION_SET_FULL_FIELD).stream()
+            .map(field -> field.binaryValue().utf8ToString())
+            .collect(toImmutableSet()),
+        cd);
+  }
+
   private void decodeSubmitRecords(
       ListMultimap<String, IndexableField> doc,
       String field,
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..2a418ca 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());
@@ -140,7 +142,6 @@
   @Override
   protected ProjectData fromDocument(Document doc) {
     Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
-    ProjectState projectState = projectCache.get().get(nameKey);
-    return projectState == null ? null : projectState.toProjectData();
+    return projectCache.get().get(nameKey).map(ProjectState::toProjectData).orElse(null);
   }
 }
diff --git a/java/com/google/gerrit/mail/Address.java b/java/com/google/gerrit/mail/Address.java
index 24ab353..520a4c8 100644
--- a/java/com/google/gerrit/mail/Address.java
+++ b/java/com/google/gerrit/mail/Address.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.mail;
 
+import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
 
-public class Address {
+/** Represents an address (name + email) in an email message. */
+@AutoValue
+public abstract class Address {
   public static Address parse(String in) {
     final int lt = in.indexOf('<');
     final int gt = in.indexOf('>');
@@ -32,11 +35,11 @@
       if (name.endsWith("\"")) {
         nameEnd--;
       }
-      return new Address(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
+      return Address.create(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
     }
 
     if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
-      return new Address(in);
+      return Address.create(in);
     }
 
     throw new IllegalArgumentException("Invalid email address: " + in);
@@ -50,60 +53,52 @@
     }
   }
 
-  @Nullable private final String name;
-  private final String email;
-
-  public Address(String email) {
-    this(null, email);
+  public static Address create(String email) {
+    return create(null, email);
   }
 
-  public Address(String name, String email) {
-    this.name = name;
-    this.email = email;
+  public static Address create(String name, String email) {
+    return new AutoValue_Address(name, email);
   }
 
   @Nullable
-  public String getName() {
-    return name;
-  }
+  public abstract String name();
 
-  public String getEmail() {
-    return email;
+  public abstract String email();
+
+  @Override
+  public final int hashCode() {
+    return email().hashCode();
   }
 
   @Override
-  public int hashCode() {
-    return email.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
+  public final boolean equals(Object other) {
     if (other instanceof Address) {
-      return email.equals(((Address) other).email);
+      return email().equals(((Address) other).email());
     }
     return false;
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     return toHeaderString();
   }
 
   public String toHeaderString() {
-    if (name != null) {
-      return quotedPhrase(name) + " <" + email + ">";
+    if (name() != null) {
+      return quotedPhrase(name()) + " <" + email() + ">";
     } else if (isSimple()) {
-      return email;
+      return email();
     }
-    return "<" + email + ">";
+    return "<" + email() + ">";
   }
 
   private static final String MUST_QUOTE_EMAIL = "()<>,;:\\\"[]";
   private static final String MUST_QUOTE_NAME = MUST_QUOTE_EMAIL + "@.";
 
   private boolean isSimple() {
-    for (int i = 0; i < email.length(); i++) {
-      final char c = email.charAt(i);
+    for (int i = 0; i < email().length(); i++) {
+      final char c = email().charAt(i);
       if (c <= ' ' || 0x7F <= c || MUST_QUOTE_EMAIL.indexOf(c) != -1) {
         return false;
       }
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
index 46bc77a..59d8227 100644
--- a/java/com/google/gerrit/mail/BUILD
+++ b/java/com/google/gerrit/mail/BUILD
@@ -12,7 +12,6 @@
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/jsoup",
-        "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
         "//lib/mime4j:core",
         "//lib/mime4j:dom",
diff --git a/java/com/google/gerrit/mail/EmailHeader.java b/java/com/google/gerrit/mail/EmailHeader.java
index 69d5fcd..9b11101 100644
--- a/java/com/google/gerrit/mail/EmailHeader.java
+++ b/java/com/google/gerrit/mail/EmailHeader.java
@@ -183,7 +183,7 @@
     }
 
     public void remove(java.lang.String email) {
-      list.removeIf(address -> address.getEmail().equals(email));
+      list.removeIf(address -> address.email().equals(email));
     }
 
     @Override
diff --git a/java/com/google/gerrit/mail/MailHeaderParser.java b/java/com/google/gerrit/mail/MailHeaderParser.java
index a4a6a03..43b1e31 100644
--- a/java/com/google/gerrit/mail/MailHeaderParser.java
+++ b/java/com/google/gerrit/mail/MailHeaderParser.java
@@ -29,7 +29,7 @@
   public static MailMetadata parse(MailMessage m) {
     MailMetadata metadata = new MailMetadata();
     // Find author
-    metadata.author = m.from().getEmail();
+    metadata.author = m.from().email();
 
     // Check email headers for X-Gerrit-<Name>
     for (String header : m.additionalHeaders()) {
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 9c89d19..4e005a5 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -71,16 +71,16 @@
     // Add From, To and Cc
     if (mimeMessage.getFrom() != null && !mimeMessage.getFrom().isEmpty()) {
       Mailbox from = mimeMessage.getFrom().get(0);
-      messageBuilder.from(new Address(from.getName(), from.getAddress()));
+      messageBuilder.from(Address.create(from.getName(), from.getAddress()));
     }
     if (mimeMessage.getTo() != null) {
       for (Mailbox m : mimeMessage.getTo().flatten()) {
-        messageBuilder.addTo(new Address(m.getName(), m.getAddress()));
+        messageBuilder.addTo(Address.create(m.getName(), m.getAddress()));
       }
     }
     if (mimeMessage.getCc() != null) {
       for (Mailbox m : mimeMessage.getCc().flatten()) {
-        messageBuilder.addCc(new Address(m.getName(), m.getAddress()));
+        messageBuilder.addCc(Address.create(m.getName(), m.getAddress()));
       }
     }
 
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 8b8f13c..a57b37a 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -50,7 +50,6 @@
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
         "//lib/prolog:cafeteria",
         "//lib/prolog:compiler",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index c1a1d7f..0eaf75d 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -72,6 +72,7 @@
 import com.google.gerrit.server.config.DefaultUrlFormatter;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRuntime;
@@ -111,6 +112,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;
@@ -167,8 +169,18 @@
   @Option(name = "--headless", usage = "Don't start the UI frontend")
   private boolean headless;
 
-  @Option(name = "--polygerrit-dev", usage = "Force PolyGerrit UI for development")
-  private boolean polyGerritDev;
+  private String devCdn = "";
+
+  @Option(name = "--dev-cdn", usage = "Use specified cdn for serving static content.")
+  private void setDevCdn(String cdn) {
+    if (cdn == null) {
+      cdn = "";
+    }
+    if (cdn.endsWith("/")) {
+      cdn = cdn.substring(0, cdn.length() - 1);
+    }
+    devCdn = cdn;
+  }
 
   @Option(
       name = "--init",
@@ -444,6 +456,7 @@
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
     modules.add(new GerritInstanceNameModule());
+    modules.add(new GerritInstanceIdModule());
     if (MoreObjects.firstNonNull(httpd, true)) {
       modules.add(
           new CanonicalWebUrlModule() {
@@ -471,8 +484,7 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class)
-                .toInstance(new GerritOptions(headless, replica, polyGerritDev));
+            bind(GerritOptions.class).toInstance(new GerritOptions(headless, replica, devCdn));
             if (inMemoryTest) {
               bind(String.class)
                   .annotatedWith(SecureStoreClassName.class)
@@ -530,6 +542,7 @@
     modules.addAll(testSshModules);
     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/HttpLogJsonLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
index 7772660..95a5b07 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
@@ -27,17 +27,11 @@
 
 import com.google.gerrit.util.logging.JsonLayout;
 import com.google.gerrit.util.logging.JsonLogEntry;
-import java.time.format.DateTimeFormatter;
 import org.apache.log4j.spi.LoggingEvent;
 
 public class HttpLogJsonLayout extends JsonLayout {
 
   @Override
-  public DateTimeFormatter createDateTimeFormatter() {
-    return DateTimeFormatter.ofPattern("dd/MMM/yyyy:HH:mm:ss,SSS Z");
-  }
-
-  @Override
   public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
     return new HttpJsonLogEntry(event);
   }
@@ -61,7 +55,7 @@
       this.host = getMdcString(event, P_HOST);
       this.thread = event.getThreadName();
       this.user = getMdcString(event, P_USER);
-      this.timestamp = formatDate(event.getTimeStamp());
+      this.timestamp = timestampFormatter.format(event.getTimeStamp());
       this.method = getMdcString(event, P_METHOD);
       this.resource = getMdcString(event, P_RESOURCE);
       this.protocol = getMdcString(event, P_PROTOCOL);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index 4b52c6f..268f59f 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -14,24 +14,15 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.TimeZone;
+import com.google.gerrit.util.logging.LogTimestampFormatter;
 import org.apache.log4j.Layout;
 import org.apache.log4j.spi.LoggingEvent;
 
 public final class HttpLogLayout extends Layout {
-  private final SimpleDateFormat dateFormat;
-  private long lastTimeMillis;
-  private String lastTimeString;
+  private final LogTimestampFormatter timestampFormatter;
 
   public HttpLogLayout() {
-    final TimeZone tz = TimeZone.getDefault();
-    dateFormat = new SimpleDateFormat("dd/MMM/yyyy:HH:mm:ss Z");
-    dateFormat.setTimeZone(tz);
-
-    lastTimeMillis = System.currentTimeMillis();
-    lastTimeString = dateFormat.format(new Date(lastTimeMillis));
+    timestampFormatter = new LogTimestampFormatter();
   }
 
   @Override
@@ -53,7 +44,7 @@
 
     buf.append(' ');
     buf.append('[');
-    formatDate(event.getTimeStamp(), buf);
+    buf.append(timestampFormatter.format(event.getTimeStamp()));
     buf.append(']');
 
     buf.append(' ');
@@ -104,19 +95,6 @@
     }
   }
 
-  private void formatDate(long now, StringBuilder sbuf) {
-    final long rounded = now - (int) (now % 1000);
-    if (rounded != lastTimeMillis) {
-      synchronized (dateFormat) {
-        lastTimeMillis = rounded;
-        lastTimeString = dateFormat.format(new Date(lastTimeMillis));
-        sbuf.append(lastTimeString);
-      }
-    } else {
-      sbuf.append(lastTimeString);
-    }
-  }
-
   @Override
   public boolean ignoresThrowable() {
     return true;
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 3980bd8..89b4228 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -37,8 +37,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 java.util.concurrent.atomic.AtomicLong;
@@ -531,10 +533,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/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index 1b97971..f7c2b75 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -17,12 +17,13 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
+        "//java/com/google/gerrit/util/logging",
         "//lib:args4j",
+        "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index ce2b05d..a831b8e 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
+import com.google.gerrit.server.config.DefaultPreferencesCacheImpl;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
 import com.google.gerrit.server.config.EnableReverseDnsLookup;
 import com.google.gerrit.server.config.EnableReverseDnsLookupProvider;
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
+import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
@@ -155,6 +157,7 @@
     install(new GroupModule());
     install(new NoteDbModule());
     install(AccountCacheImpl.module());
+    install(DefaultPreferencesCacheImpl.module());
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
     install(ProjectCacheImpl.module());
@@ -179,6 +182,7 @@
     bind(EventUtil.class).toProvider(Providers.of(null));
     bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
     bind(RevisionCreated.class).toInstance(RevisionCreated.DISABLED);
+    bind(WorkInProgressStateChanged.class).toInstance(WorkInProgressStateChanged.DISABLED);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
   }
 }
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
index 8eae82a..634e56b 100644
--- a/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -18,9 +18,9 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.util.logging.LogTimestampFormatter;
 import java.io.IOException;
 import java.nio.file.Path;
-import net.logstash.log4j.JSONEventLayoutV1;
 import org.apache.log4j.ConsoleAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
@@ -72,7 +72,9 @@
     Logger root = LogManager.getRootLogger();
     root.removeAllAppenders();
 
-    PatternLayout errorLogLayout = new PatternLayout("[%d] [%t] %-5p %c %x: %m%n");
+    PatternLayout errorLogLayout =
+        new PatternLayout(
+            "[%d{" + LogTimestampFormatter.TIMESTAMP_FORMAT + "}] [%t] %-5p %c %x: %m%n");
 
     if (consoleLog) {
       ConsoleAppender dst = new ConsoleAppender();
@@ -93,9 +95,14 @@
     }
 
     if (json) {
+      Boolean enableReverseDnsLookup =
+          config.getBoolean("gerrit", null, "enableReverseDnsLookup", false);
       root.addAppender(
           SystemLog.createAppender(
-              logdir, LOG_NAME + JSON_SUFFIX, new JSONEventLayoutV1(), rotate));
+              logdir,
+              LOG_NAME + JSON_SUFFIX,
+              new ErrorLogJsonLayout(enableReverseDnsLookup),
+              rotate));
     }
   }
 }
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java b/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
new file mode 100644
index 0000000..85378a4
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
@@ -0,0 +1,138 @@
+// 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.pgm.util;
+
+import com.google.gerrit.util.logging.JsonLayout;
+import com.google.gerrit.util.logging.JsonLogEntry;
+import com.google.gson.annotations.SerializedName;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.log4j.spi.LoggingEvent;
+import org.apache.log4j.spi.ThrowableInformation;
+
+/** Layout for formatting error log events in the JSON format. */
+public class ErrorLogJsonLayout extends JsonLayout {
+  private final Boolean enableReverseDnsLookup;
+
+  public ErrorLogJsonLayout(Boolean enableDnsReverseLookup) {
+    super();
+    this.enableReverseDnsLookup = enableDnsReverseLookup;
+  }
+
+  @Override
+  public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
+    return new ErrorJsonLogEntry(event);
+  }
+
+  @SuppressWarnings("unused")
+  private class ErrorJsonLogEntry extends JsonLogEntry {
+    /** Timestamp of when the log entry was created. */
+    @SerializedName("@timestamp")
+    public final String timestamp;
+
+    /** Hostname of the machine running Gerrit. */
+    public final String sourceHost;
+    /** Logged message. */
+    public final String message;
+    /** File containing the code creating the log entry. */
+    public final String file;
+    /** Line number of code creating the log entry. */
+    public final String lineNumber;
+
+    /** Class from which the log entry was created. */
+    @SerializedName("class")
+    public final String clazz;
+
+    /** Method from which the log entry was created. */
+    public final String method;
+    /** Name of the logger creating the log entry. */
+    public final String loggerName;
+
+    /** Mapped diagnostic context. */
+    @SuppressWarnings("rawtypes")
+    public final Map mdc;
+
+    /** Nested diagnostic context. */
+    public final String ndc;
+    /** Logging level/severity. */
+    public final String level;
+    /** Thread executing the code creating the log entry. */
+    public final String threadName;
+
+    /** Version of log format. */
+    @SerializedName("@version")
+    public final int version = 2;
+
+    /**
+     * Map containing information of a logged exception. It contains the following key-value pairs:
+     * exception_class: Which class threw the exception exception_method: Which method threw the
+     * exception stacktrace: The exception stacktrace
+     */
+    public Map<String, String> exception;
+
+    public ErrorJsonLogEntry(LoggingEvent event) {
+      this.timestamp = timestampFormatter.format(event.getTimeStamp());
+      this.sourceHost = getSourceHost(enableReverseDnsLookup);
+      this.message = event.getRenderedMessage();
+      this.file = event.getLocationInformation().getFileName();
+      this.lineNumber = event.getLocationInformation().getLineNumber();
+      this.clazz = event.getLocationInformation().getClassName();
+      this.method = event.getLocationInformation().getMethodName();
+      this.loggerName = event.getLoggerName();
+      this.mdc = event.getProperties();
+      this.ndc = event.getNDC();
+      this.level = event.getLevel().toString();
+      this.threadName = event.getThreadName();
+      if (event.getThrowableInformation() != null) {
+        this.exception = getException(event.getThrowableInformation());
+      }
+    }
+
+    private String getSourceHost(Boolean enableReverseDnsLookup) {
+      InetAddress in;
+      try {
+        in = InetAddress.getLocalHost();
+        if (Boolean.TRUE.equals(enableReverseDnsLookup)) {
+          return in.getCanonicalHostName();
+        }
+        return in.getHostAddress();
+      } catch (UnknownHostException e) {
+        return "unknown-host";
+      }
+    }
+
+    private Map<String, String> getException(ThrowableInformation throwable) {
+      HashMap<String, String> exceptionInformation = new HashMap<>();
+
+      String throwableName = throwable.getThrowable().getClass().getCanonicalName();
+      if (throwableName != null) {
+        exceptionInformation.put("exception_class", throwableName);
+      }
+
+      String throwableMessage = throwable.getThrowable().getMessage();
+      if (throwableMessage != null) {
+        exceptionInformation.put("exception_message", throwableMessage);
+      }
+
+      String[] stackTrace = throwable.getThrowableStrRep();
+      if (stackTrace != null) {
+        exceptionInformation.put("stacktrace", String.join("\n", stackTrace));
+      }
+      return exceptionInformation;
+    }
+  }
+}
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/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 566a32b..417a4ef 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
@@ -26,7 +27,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
@@ -83,12 +82,13 @@
                 .changeId(notes.load().getChangeId().get())
                 .patchSetId(psId.get())
                 .build())) {
-      project = projectCache.checkedGet(notes.getProjectName());
+      project =
+          projectCache
+              .get(notes.getProjectName())
+              .orElseThrow(illegalState(notes.getProjectName()));
       Collection<PatchSetApproval> approvals =
           getForPatchSetWithoutNormalization(notes, project, psId, rw, repoConfig);
       return labelNormalizer.normalize(notes, approvals).getNormalized();
-    } catch (IOException e) {
-      throw new StorageException(e);
     }
   }
 
@@ -141,6 +141,18 @@
           psId.get(),
           project.getName());
       return true;
+    } else if (type.getCopyValues().contains(psa.value())) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set copyValue = %d on project %s",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          psa.value(),
+          project.getName());
+      return true;
     }
     switch (kind) {
       case MERGE_FIRST_PARENT_UPDATE:
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 58b601f..0280aee 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
@@ -49,7 +50,6 @@
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -220,14 +220,17 @@
 
   private boolean canSee(ChangeNotes notes, Account.Id accountId) {
     try {
-      if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
+      if (!projectCache
+          .get(notes.getProjectName())
+          .orElseThrow(illegalState(notes.getProjectName()))
+          .statePermitsRead()) {
         return false;
       }
       permissionBackend.absentUser(accountId).change(notes).check(ChangePermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
-    } catch (IOException | PermissionBackendException e) {
+    } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log(
           "Failed to check if account %d can see change %d",
           accountId.get(), notes.getChangeId().get());
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 6675595..1c46ed6 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",
@@ -112,12 +113,12 @@
         "//lib/commons:lang",
         "//lib/commons:net",
         "//lib/commons:validator",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
         "//lib/jsoup",
-        "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-core-and-backward-codecs",
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 5f00b69..dd48b93 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -49,6 +49,8 @@
   public static final String TAG_RESTORE = AUTOGENERATED_TAG_PREFIX + "gerrit:restore";
   public static final String TAG_REVERT = AUTOGENERATED_TAG_PREFIX + "gerrit:revert";
   public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_TAG_PREFIX + "gerrit:setAssignee";
+  public static final String TAG_UPDATE_ATTENTION_SET =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:updateAttentionSet";
   public static final String TAG_SET_DESCRIPTION =
       AUTOGENERATED_TAG_PREFIX + "gerrit:setPsDescription";
   public static final String TAG_SET_HASHTAGS = AUTOGENERATED_TAG_PREFIX + "gerrit:setHashtag";
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index 7a41fd1..673695b6 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -30,7 +30,6 @@
 import java.security.SecureRandom;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import java.util.Random;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -58,22 +57,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}.
@@ -103,9 +86,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/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index ae4ba4b..e9ba72d 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toCollection;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ComparisonChain;
@@ -24,6 +26,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -230,6 +233,51 @@
   }
 
   /**
+   * This method populates the "changeMessageId" field of the comments parameter based on timestamp
+   * matching. The comments objects will be modified.
+   *
+   * <p>Each comment will be matched to the nearest next change message in timestamp
+   *
+   * @param comments the list of comments
+   * @param changeMessages list of change messages
+   */
+  public static void linkCommentsToChangeMessages(
+      List<? extends CommentInfo> comments, List<ChangeMessage> changeMessages) {
+    ArrayList<ChangeMessage> sortedChangeMessages =
+        changeMessages.stream()
+            .sorted(comparing(ChangeMessage::getWrittenOn))
+            .collect(toCollection(ArrayList::new));
+
+    ArrayList<CommentInfo> sortedCommentInfos =
+        comments.stream().sorted(comparing(c -> c.updated)).collect(toCollection(ArrayList::new));
+
+    int cmItr = 0;
+    for (CommentInfo comment : sortedCommentInfos) {
+      // Keep advancing the change message pointer until we associate the comment to the next change
+      // message in timestamp
+      while (cmItr < sortedChangeMessages.size()) {
+        ChangeMessage cm = sortedChangeMessages.get(cmItr);
+        if (isAfter(comment, cm) || skipChangeMessage(cm)) {
+          cmItr += 1;
+        } else {
+          break;
+        }
+      }
+      if (cmItr < changeMessages.size()) {
+        comment.changeMessageId = sortedChangeMessages.get(cmItr).getKey().uuid();
+      }
+    }
+  }
+
+  private static boolean skipChangeMessage(ChangeMessage cm) {
+    return ChangeMessagesUtil.isAutogenerated(cm.getTag());
+  }
+
+  private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
+    return c.updated.after(cm.getWrittenOn());
+  }
+
+  /**
    * For the commit message the A side in a diff view is always empty when a comparison against an
    * ancestor is done, so there can't be any comments on this ancestor. However earlier we showed
    * the auto-merge commit message on side A when for a merge commit a comparison against the
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 996257c..17313e4 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server;
 
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Sets;
@@ -79,12 +78,8 @@
    * refs/groups/*}.
    */
   public void syncIfNeeded() throws IOException, ConfigInvalidException {
-    ProjectState allProjectsState = projectCache.checkedGet(allProjects);
-    requireNonNull(
-        allProjectsState, () -> String.format("Can't obtain project state for %s", allProjects));
-    ProjectState allUsersState = projectCache.checkedGet(allUsers);
-    requireNonNull(
-        allUsersState, () -> String.format("Can't obtain project state for %s", allUsers));
+    ProjectState allProjectsState = projectCache.getAllProjects();
+    ProjectState allUsersState = projectCache.getAllUsers();
 
     Set<PermissionRule> createGroupsGlobal =
         new HashSet<>(allProjectsState.getCapabilityCollection().createGroup);
@@ -115,11 +110,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..8884991
--- /dev/null
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -0,0 +1,124 @@
+// 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 com.google.gerrit.server.project.ProjectConfig;
+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.";
+  private static final String INVALID_PROJECT_CONFIG_USER_MESSAGE =
+      "Invalid " + ProjectConfig.PROJECT_CONFIG + " file.";
+  private static final String CONTACT_PROJECT_OWNER_USER_MESSAGE =
+      "Please contact the project owner.";
+
+  @Override
+  public boolean shouldRetry(String actionType, String actionName, Throwable throwable) {
+    return isLockFailure(throwable);
+  }
+
+  @Override
+  public boolean skipRetryWithTrace(String actionType, String actionName, Throwable throwable) {
+    return isInvalidProjectConfig(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");
+    }
+    if (isInvalidProjectConfig(throwable)) {
+      return Optional.of("invalid_project_config");
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public ImmutableList<String> getUserMessages(Throwable throwable, @Nullable String traceId) {
+    if (isLockFailure(throwable)) {
+      return ImmutableList.of(LOCK_FAILURE_USER_MESSAGE);
+    }
+    if (isInvalidProjectConfig(throwable)) {
+      return ImmutableList.of(
+          getInvalidConfigMessage(throwable).orElse(INVALID_PROJECT_CONFIG_USER_MESSAGE)
+              + "\n"
+              + CONTACT_PROJECT_OWNER_USER_MESSAGE);
+    }
+    return ImmutableList.of();
+  }
+
+  @Override
+  public Optional<Status> getStatus(Throwable throwable) {
+    if (isLockFailure(throwable)) {
+      return Optional.of(Status.create(503, "Lock failure"));
+    }
+    if (isInvalidProjectConfig(throwable)) {
+      return Optional.of(Status.create(409, "Conflict"));
+    }
+    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);
+  }
+
+  private static boolean isInvalidProjectConfig(Throwable throwable) {
+    return isMatching(
+        throwable,
+        t ->
+            t instanceof InvalidConfigFileException
+                && ProjectConfig.PROJECT_CONFIG.equals(
+                    ((InvalidConfigFileException) t).getFileName()));
+  }
+
+  private Optional<String> getInvalidConfigMessage(Throwable throwable) {
+    return Throwables.getCausalChain(throwable).stream()
+        .filter(InvalidConfigFileException.class::isInstance)
+        .map(ex -> ex.getMessage())
+        .findFirst();
+  }
+
+  /**
+   * 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/InvalidConfigFileException.java b/java/com/google/gerrit/server/InvalidConfigFileException.java
new file mode 100644
index 0000000..f10b618
--- /dev/null
+++ b/java/com/google/gerrit/server/InvalidConfigFileException.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;
+
+import com.google.gerrit.entities.Project;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Exception that is thrown if an invalid config file causes an error. */
+public class InvalidConfigFileException extends ConfigInvalidException {
+  private static final long serialVersionUID = 1L;
+
+  private final String fileName;
+
+  public InvalidConfigFileException(
+      Project.NameKey projectName,
+      String branchName,
+      ObjectId revision,
+      String fileName,
+      ConfigInvalidException cause) {
+    super(createMessage(projectName, branchName, revision, fileName, cause), cause);
+    this.fileName = fileName;
+  }
+
+  public String getFileName() {
+    return fileName;
+  }
+
+  private static String createMessage(
+      Project.NameKey projectName,
+      String branchName,
+      ObjectId revision,
+      String fileName,
+      ConfigInvalidException cause) {
+    StringBuilder msg =
+        new StringBuilder("Invalid config file ")
+            .append(fileName)
+            .append(" in project ")
+            .append(projectName.get())
+            .append(" in branch ")
+            .append(branchName)
+            .append(" in commit ")
+            .append(revision.name());
+    if (cause != null) {
+      msg.append(": ").append(cause.getMessage());
+    }
+    return msg.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index b53e666..aeef2b6 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableCollection;
@@ -130,8 +131,7 @@
   }
 
   /** Check if the current patch set of the change is locked. */
-  public void checkPatchSetNotLocked(ChangeNotes notes)
-      throws IOException, ResourceConflictException {
+  public void checkPatchSetNotLocked(ChangeNotes notes) throws ResourceConflictException {
     if (isPatchSetLocked(notes)) {
       throw new ResourceConflictException(
           String.format("The current patch set of change %s is locked", notes.getChangeId()));
@@ -139,15 +139,14 @@
   }
 
   /** Is the current patch set locked against state changes? */
-  public boolean isPatchSetLocked(ChangeNotes notes) throws IOException {
+  public boolean isPatchSetLocked(ChangeNotes notes) {
     Change change = notes.getChange();
     if (change.isMerged()) {
       return false;
     }
 
-    ProjectState projectState = projectCache.checkedGet(notes.getProjectName());
-    requireNonNull(
-        projectState, () -> String.format("Failed to load project %s", notes.getProjectName()));
+    ProjectState projectState =
+        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
     for (PatchSetApproval ap :
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/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
index 47cf25b..54bfa56 100644
--- a/java/com/google/gerrit/server/account/AccountCache.java
+++ b/java/com/google/gerrit/server/account/AccountCache.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import java.util.Map;
 import java.util.Optional;
@@ -73,14 +72,4 @@
    *     exists or if loading the external ID fails {@link Optional#empty()} is returned
    */
   Optional<AccountState> getByUsername(String username);
-
-  /**
-   * Evicts the account from the cache.
-   *
-   * @param accountId account ID of the account that should be evicted
-   */
-  void evict(@Nullable Account.Id accountId);
-
-  /** Evict all accounts from the cache. */
-  void evictAll();
 }
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index ef4e1c0..f68a1c7 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -20,12 +20,16 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.FanOutExecutor;
+import com.google.gerrit.entities.RefNames;
+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.cache.CacheModule;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CachedPreferences;
+import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -33,34 +37,33 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
+import java.util.Collections;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 /** Caches important (but small) account state to avoid database hits. */
 @Singleton
 public class AccountCacheImpl implements AccountCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String BYID_NAME = "accounts";
+  private static final String BYID_AND_REV_NAME = "accounts";
 
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(BYID_NAME, Account.Id.class, new TypeLiteral<AccountState>() {})
-            .loader(ByIdLoader.class);
+        persist(BYID_AND_REV_NAME, CachedAccountDetails.Key.class, CachedAccountDetails.class)
+            .version(1)
+            .keySerializer(CachedAccountDetails.Key.Serializer.INSTANCE)
+            .valueSerializer(CachedAccountDetails.Serializer.INSTANCE)
+            .loader(Loader.class);
 
         bind(AccountCacheImpl.class);
         bind(AccountCache.class).to(AccountCacheImpl.class);
@@ -69,76 +72,67 @@
   }
 
   private final ExternalIds externalIds;
-  private final LoadingCache<Account.Id, AccountState> byId;
-  private final ExecutorService executor;
+  private final LoadingCache<CachedAccountDetails.Key, CachedAccountDetails> accountDetailsCache;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final DefaultPreferencesCache defaultPreferenceCache;
 
   @Inject
   AccountCacheImpl(
       ExternalIds externalIds,
-      @Named(BYID_NAME) LoadingCache<Account.Id, AccountState> byId,
-      @FanOutExecutor ExecutorService executor) {
+      @Named(BYID_AND_REV_NAME)
+          LoadingCache<CachedAccountDetails.Key, CachedAccountDetails> accountDetailsCache,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      DefaultPreferencesCache defaultPreferenceCache) {
     this.externalIds = externalIds;
-    this.byId = byId;
-    this.executor = executor;
+    this.accountDetailsCache = accountDetailsCache;
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.defaultPreferenceCache = defaultPreferenceCache;
   }
 
   @Override
   public AccountState getEvenIfMissing(Account.Id accountId) {
-    try {
-      return byId.get(accountId);
-    } catch (ExecutionException e) {
-      if (!(e.getCause() instanceof AccountNotFoundException)) {
-        logger.atWarning().withCause(e).log("Cannot load AccountState for %s", accountId);
-      }
-      return missing(accountId);
-    }
+    return get(accountId).orElse(missing(accountId));
   }
 
   @Override
   public Optional<AccountState> get(Account.Id accountId) {
-    try {
-      return Optional.ofNullable(byId.get(accountId));
-    } catch (ExecutionException e) {
-      if (!(e.getCause() instanceof AccountNotFoundException)) {
-        logger.atWarning().withCause(e).log("Cannot load AccountState for %s", accountId);
-      }
-      return Optional.empty();
-    }
+    return Optional.ofNullable(get(Collections.singleton(accountId)).getOrDefault(accountId, null));
   }
 
   @Override
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
-    Map<Account.Id, AccountState> accountStates = new HashMap<>(accountIds.size());
-    List<Callable<Optional<AccountState>>> callables = new ArrayList<>();
-    for (Account.Id accountId : accountIds) {
-      AccountState state = byId.getIfPresent(accountId);
-      if (state != null) {
-        // The value is in-memory, so we just get the state
-        accountStates.put(accountId, state);
-      } else {
-        // Queue up a callable so that we can load accounts in parallel
-        callables.add(() -> get(accountId));
-      }
-    }
-    if (callables.isEmpty()) {
-      return accountStates;
-    }
-
-    List<Future<Optional<AccountState>>> futures;
     try {
-      futures = executor.invokeAll(callables);
-    } catch (InterruptedException e) {
-      logger.atSevere().withCause(e).log("Cannot load AccountStates");
-      return ImmutableMap.of();
-    }
-    for (Future<Optional<AccountState>> f : futures) {
-      try {
-        f.get().ifPresent(s -> accountStates.put(s.account().id(), s));
-      } catch (InterruptedException | ExecutionException e) {
-        logger.atSevere().withCause(e).log("Cannot load AccountState");
+      try (Repository allUsers = repoManager.openRepository(allUsersName)) {
+        // Get the default preferences for this Gerrit host
+        Ref ref = allUsers.exactRef(RefNames.REFS_USERS_DEFAULT);
+        CachedPreferences defaultPreferences =
+            ref != null
+                ? defaultPreferenceCache.get(ref.getObjectId())
+                : DefaultPreferencesCache.EMPTY;
+
+        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        for (Account.Id id : accountIds) {
+          Ref userRef = allUsers.exactRef(RefNames.refsUsers(id));
+          if (userRef == null) {
+            continue;
+          }
+
+          result.put(
+              id,
+              AccountState.forCachedAccount(
+                  accountDetailsCache.get(
+                      CachedAccountDetails.Key.create(id, userRef.getObjectId())),
+                  defaultPreferences,
+                  externalIds));
+        }
+        return result.build();
       }
+    } catch (IOException | ExecutionException e) {
+      throw new StorageException(e);
     }
-    return accountStates;
   }
 
   @Override
@@ -154,42 +148,35 @@
     }
   }
 
-  @Override
-  public void evict(@Nullable Account.Id accountId) {
-    if (accountId != null) {
-      logger.atFine().log("Evict account %d", accountId.get());
-      byId.invalidate(accountId);
-    }
-  }
-
-  @Override
-  public void evictAll() {
-    logger.atFine().log("Evict all accounts");
-    byId.invalidateAll();
-  }
-
   private AccountState missing(Account.Id accountId) {
     Account.Builder account = Account.builder(accountId, TimeUtil.nowTs());
     account.setActive(false);
     return AccountState.forAccount(account.build());
   }
 
-  static class ByIdLoader extends CacheLoader<Account.Id, AccountState> {
-    private final Accounts accounts;
+  @Singleton
+  static class Loader extends CacheLoader<CachedAccountDetails.Key, CachedAccountDetails> {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
 
     @Inject
-    ByIdLoader(Accounts accounts) {
-      this.accounts = accounts;
+    Loader(GitRepositoryManager repoManager, AllUsersName allUsersName) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
     }
 
     @Override
-    public AccountState load(Account.Id who) throws Exception {
-      try (TraceTimer timer =
-          TraceContext.newTimer(
-              "Loading account", Metadata.builder().accountId(who.get()).build())) {
-        return accounts
-            .get(who)
-            .orElseThrow(() -> new AccountNotFoundException(who + " not found"));
+    public CachedAccountDetails load(CachedAccountDetails.Key key) throws Exception {
+      try (TraceTimer ignored =
+              TraceContext.newTimer(
+                  "Loading account", Metadata.builder().accountId(key.accountId().get()).build());
+          Repository repo = repoManager.openRepository(allUsersName)) {
+        AccountConfig cfg = new AccountConfig(key.accountId(), allUsersName, repo).load(key.id());
+        Account account =
+            cfg.getLoadedAccount()
+                .orElseThrow(() -> new AccountNotFoundException(key.accountId() + " not found"));
+        return CachedAccountDetails.create(
+            account, cfg.getProjectWatches(), cfg.asCachedPreferences());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 5a1bb8a..76d9471 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -24,13 +24,11 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CachedPreferences;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -106,6 +104,11 @@
     return this;
   }
 
+  public AccountConfig load(ObjectId rev) throws IOException, ConfigInvalidException {
+    load(allUsersName, repo, rev);
+    return this;
+  }
+
   /**
    * Get the loaded account.
    *
@@ -143,36 +146,6 @@
   }
 
   /**
-   * Get the general preferences of the loaded account.
-   *
-   * @return the general preferences of the loaded account
-   */
-  public GeneralPreferencesInfo getGeneralPreferences() {
-    checkLoaded();
-    return preferences.getGeneralPreferences();
-  }
-
-  /**
-   * Get the diff preferences of the loaded account.
-   *
-   * @return the diff preferences of the loaded account
-   */
-  public DiffPreferencesInfo getDiffPreferences() {
-    checkLoaded();
-    return preferences.getDiffPreferences();
-  }
-
-  /**
-   * Get the edit preferences of the loaded account.
-   *
-   * @return the edit preferences of the loaded account
-   */
-  public EditPreferencesInfo getEditPreferences() {
-    checkLoaded();
-    return preferences.getEditPreferences();
-  }
-
-  /**
    * Sets the account. This means the loaded account will be overwritten with the given account.
    *
    * <p>Changing the registration date of an account is not supported.
@@ -190,6 +163,7 @@
             InternalAccountUpdate.builder()
                 .setActive(account.isActive())
                 .setFullName(account.fullName())
+                .setDisplayName(account.displayName())
                 .setPreferredEmail(account.preferredEmail())
                 .setStatus(account.status())
                 .build());
@@ -227,6 +201,15 @@
     return this;
   }
 
+  /**
+   * Returns the content of the {@code preferences.config} file wrapped as {@link
+   * CachedPreferences}.
+   */
+  CachedPreferences asCachedPreferences() {
+    checkLoaded();
+    return CachedPreferences.fromConfig(preferences.getRaw());
+  }
+
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
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/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 60c1678..63fa551 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -26,7 +26,7 @@
 public abstract class AccountDirectory {
   /** Fields to be populated for a REST API response. */
   public enum FillOptions {
-    /** Human friendly display name presented in the web interface. */
+    /** Full name or username. */
     NAME,
 
     /** Preferred email address to contact the user at. */
@@ -48,7 +48,10 @@
     STATUS,
 
     /** The state of the account (e.g. active or inactive) */
-    STATE
+    STATE,
+
+    /** Human friendly display name presented in the web interface chosen by the user. */
+    DISPLAY_NAME
   }
 
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index a8e4194..9acf078 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -41,6 +41,7 @@
               FillOptions.NAME,
               FillOptions.EMAIL,
               FillOptions.USERNAME,
+              FillOptions.DISPLAY_NAME,
               FillOptions.STATUS,
               FillOptions.STATE,
               FillOptions.AVATARS));
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 4f29b25..5ae5567 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -33,6 +33,7 @@
  *   [account]
  *     active = false
  *     fullName = John Doe
+ *     displayName = John
  *     preferredEmail = john.doe@foo.com
  *     status = Overloaded with reviews
  * </pre>
@@ -51,6 +52,7 @@
   public static final String ACCOUNT = "account";
   public static final String KEY_ACTIVE = "active";
   public static final String KEY_FULL_NAME = "fullName";
+  public static final String KEY_DISPLAY_NAME = "displayName";
   public static final String KEY_PREFERRED_EMAIL = "preferredEmail";
   public static final String KEY_STATUS = "status";
 
@@ -91,6 +93,7 @@
     Account.Builder accountBuilder = Account.builder(accountId, registeredOn);
     accountBuilder.setActive(accountConfig.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
     accountBuilder.setFullName(get(accountConfig, KEY_FULL_NAME));
+    accountBuilder.setDisplayName(get(accountConfig, KEY_DISPLAY_NAME));
 
     String preferredEmail = get(accountConfig, KEY_PREFERRED_EMAIL);
     accountBuilder.setPreferredEmail(preferredEmail);
@@ -109,6 +112,9 @@
     accountUpdate.getActive().ifPresent(active -> setActive(cfg, active));
     accountUpdate.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
     accountUpdate
+        .getDisplayName()
+        .ifPresent(displayName -> set(cfg, KEY_DISPLAY_NAME, displayName));
+    accountUpdate
         .getPreferredEmail()
         .ifPresent(preferredEmail -> set(cfg, KEY_PREFERRED_EMAIL, preferredEmail));
     accountUpdate.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 2324465..4081a63 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -349,6 +349,10 @@
 
       // More than one match. If there are any that match the full name as well, return only that
       // subset. Otherwise, all are equally non-matching, so return the full set.
+      if (lt == 0) {
+        // No name was specified in the input string.
+        return allMatches.stream();
+      }
       String name = nameOrEmail.substring(0, lt - 1);
       ImmutableList<AccountState> nameMatches =
           allMatches.stream()
@@ -519,12 +523,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);
   }
 
   /**
@@ -553,13 +567,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/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index a270a76..1e9914d 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.CachedPreferences;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Optional;
@@ -47,12 +48,14 @@
    *
    * @param externalIds class to access external IDs
    * @param accountConfig the account config, must already be loaded
+   * @param defaultPreferences the default preferences for this Gerrit installation
    * @return the account state, {@link Optional#empty()} if the account doesn't exist
    * @throws IOException if accessing the external IDs fails
    */
   public static Optional<AccountState> fromAccountConfig(
-      ExternalIds externalIds, AccountConfig accountConfig) throws IOException {
-    return fromAccountConfig(externalIds, accountConfig, null);
+      ExternalIds externalIds, AccountConfig accountConfig, CachedPreferences defaultPreferences)
+      throws IOException {
+    return fromAccountConfig(externalIds, accountConfig, null, defaultPreferences);
   }
 
   /**
@@ -68,11 +71,15 @@
    * @param externalIds class to access external IDs
    * @param accountConfig the account config, must already be loaded
    * @param extIdNotes external ID notes, must already be loaded, may be {@code null}
+   * @param defaultPreferences the default preferences for this Gerrit installation
    * @return the account state, {@link Optional#empty()} if the account doesn't exist
    * @throws IOException if accessing the external IDs fails
    */
   public static Optional<AccountState> fromAccountConfig(
-      ExternalIds externalIds, AccountConfig accountConfig, @Nullable ExternalIdNotes extIdNotes)
+      ExternalIds externalIds,
+      AccountConfig accountConfig,
+      @Nullable ExternalIdNotes extIdNotes,
+      CachedPreferences defaultPreferences)
       throws IOException {
     if (!accountConfig.getLoadedAccount().isPresent()) {
       return Optional.empty();
@@ -85,19 +92,13 @@
             : accountConfig.getExternalIdsRev();
     ImmutableSet<ExternalId> extIds =
         extIdsRev.isPresent()
-            ? ImmutableSet.copyOf(externalIds.byAccount(account.id(), extIdsRev.get()))
+            ? externalIds.byAccount(account.id(), extIdsRev.get())
             : ImmutableSet.of();
 
     // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
     // an open Repository instance.
     ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
         accountConfig.getProjectWatches();
-    Preferences.General generalPreferences =
-        Preferences.General.fromInfo(accountConfig.getGeneralPreferences());
-    Preferences.Diff diffPreferences =
-        Preferences.Diff.fromInfo(accountConfig.getDiffPreferences());
-    Preferences.Edit editPreferences =
-        Preferences.Edit.fromInfo(accountConfig.getEditPreferences());
 
     return Optional.of(
         new AutoValue_AccountState(
@@ -105,9 +106,8 @@
             extIds,
             ExternalId.getUserName(extIds),
             projectWatches,
-            generalPreferences,
-            diffPreferences,
-            editPreferences));
+            Optional.of(defaultPreferences),
+            Optional.of(accountConfig.asCachedPreferences())));
   }
 
   /**
@@ -122,6 +122,25 @@
   }
 
   /**
+   * Creates an AccountState for a given account and external IDs.
+   *
+   * @param account the account
+   * @return the account state
+   */
+  public static AccountState forCachedAccount(
+      CachedAccountDetails account, CachedPreferences defaultConfig, ExternalIds externalIds)
+      throws IOException {
+    ImmutableSet<ExternalId> extIds = externalIds.byAccount(account.account().id());
+    return new AutoValue_AccountState(
+        account.account(),
+        extIds,
+        ExternalId.getUserName(extIds),
+        account.projectWatches(),
+        Optional.of(defaultConfig),
+        Optional.of(account.preferences()));
+  }
+
+  /**
    * Creates an AccountState for a given account with no project watches and default preferences.
    *
    * @param account the account
@@ -134,9 +153,8 @@
         ImmutableSet.copyOf(extIds),
         ExternalId.getUserName(extIds),
         ImmutableMap.of(),
-        Preferences.General.fromInfo(GeneralPreferencesInfo.defaults()),
-        Preferences.Diff.fromInfo(DiffPreferencesInfo.defaults()),
-        Preferences.Edit.fromInfo(EditPreferencesInfo.defaults()));
+        Optional.empty(),
+        Optional.empty());
   }
 
   /** Get the cached account metadata. */
@@ -158,17 +176,20 @@
 
   /** The general preferences of the account. */
   public GeneralPreferencesInfo generalPreferences() {
-    return immutableGeneralPreferences().toInfo();
+    return CachedPreferences.general(
+        defaultPreferences(), userPreferences().orElse(CachedPreferences.EMPTY));
   }
 
   /** The diff preferences of the account. */
   public DiffPreferencesInfo diffPreferences() {
-    return immutableDiffPreferences().toInfo();
+    return CachedPreferences.diff(
+        defaultPreferences(), userPreferences().orElse(CachedPreferences.EMPTY));
   }
 
   /** The edit preferences of the account. */
   public EditPreferencesInfo editPreferences() {
-    return immutableEditPreferences().toInfo();
+    return CachedPreferences.edit(
+        defaultPreferences(), userPreferences().orElse(CachedPreferences.EMPTY));
   }
 
   @Override
@@ -178,9 +199,9 @@
     return h.toString();
   }
 
-  protected abstract Preferences.General immutableGeneralPreferences();
+  /** Gerrit's default preferences as stored in {@code preferences.config}. */
+  protected abstract Optional<CachedPreferences> defaultPreferences();
 
-  protected abstract Preferences.Diff immutableDiffPreferences();
-
-  protected abstract Preferences.Edit immutableEditPreferences();
+  /** User preferences as stored in {@code preferences.config}. */
+  protected abstract Optional<CachedPreferences> userPreferences();
 }
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index 8136631..976a7d89 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -21,8 +21,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CachedPreferences;
+import com.google.gerrit.server.config.VersionedDefaultPreferences;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -45,12 +50,23 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final ExternalIds externalIds;
+  private final Timer0 readSingleLatency;
 
   @Inject
-  Accounts(GitRepositoryManager repoManager, AllUsersName allUsersName, ExternalIds externalIds) {
+  Accounts(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      ExternalIds externalIds,
+      MetricMaker metricMaker) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.externalIds = externalIds;
+    this.readSingleLatency =
+        metricMaker.newTimer(
+            "notedb/read_single_account_config_latency",
+            new Description("Latency for reading a single account config.")
+                .setCumulative()
+                .setUnit(Description.Units.MILLISECONDS));
   }
 
   public Optional<AccountState> get(Account.Id accountId)
@@ -133,8 +149,16 @@
 
   private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
       throws IOException, ConfigInvalidException {
-    return AccountState.fromAccountConfig(
-        externalIds, new AccountConfig(accountId, allUsersName, allUsersRepository).load());
+    AccountConfig cfg;
+    CachedPreferences defaultPreferences;
+    try (Timer0.Context ignored = readSingleLatency.start()) {
+      cfg = new AccountConfig(accountId, allUsersName, allUsersRepository).load();
+      defaultPreferences =
+          CachedPreferences.fromConfig(
+              VersionedDefaultPreferences.get(allUsersRepository, allUsersName));
+    }
+
+    return AccountState.fromAccountConfig(externalIds, cfg, defaultPreferences);
   }
 
   public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 1caee58..1b3aa96 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -35,14 +35,15 @@
 import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CachedPreferences;
+import com.google.gerrit.server.config.VersionedDefaultPreferences;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 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;
@@ -84,14 +85,14 @@
  *
  * <p>The account updates are written to NoteDb. In NoteDb accounts are represented as user branches
  * in the {@code All-Users} repository. Optionally a user branch can contain a 'account.config' file
- * that stores account properties, such as full name, preferred email, status and the active flag.
- * The timestamp of the first commit on a user branch denotes the registration date. The initial
- * commit on the user branch may be empty (since having an 'account.config' is optional). See {@link
- * AccountConfig} for details of the 'account.config' file format. In addition the user branch can
- * contain a 'preferences.config' config file to store preferences (see {@link StoredPreferences})
- * and a 'watch.config' config file to store project watches (see {@link ProjectWatches}). External
- * IDs are stored separately in the {@code refs/meta/external-ids} notes branch (see {@link
- * ExternalIdNotes}).
+ * that stores account properties, such as full name, display name, preferred email, status and the
+ * active flag. The timestamp of the first commit on a user branch denotes the registration date.
+ * The initial commit on the user branch may be empty (since having an 'account.config' is
+ * optional). See {@link AccountConfig} for details of the 'account.config' file format. In addition
+ * the user branch can contain a 'preferences.config' config file to store preferences (see {@link
+ * StoredPreferences}) and a 'watch.config' config file to store project watches (see {@link
+ * ProjectWatches}). External IDs are stored separately in the {@code refs/meta/external-ids} notes
+ * branch (see {@link ExternalIdNotes}).
  *
  * <p>On updating an account the account is evicted from the account cache and reindexed. The
  * eviction from the account cache and the reindexing is done by the {@link ReindexAfterRefUpdate}
@@ -329,8 +330,12 @@
               accountConfig.setAccountUpdate(update);
               ExternalIdNotes extIdNotes =
                   createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
+              CachedPreferences defaultPreferences =
+                  CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+
               UpdatedAccount updatedAccounts =
-                  new UpdatedAccount(externalIds, message, accountConfig, extIdNotes);
+                  new UpdatedAccount(
+                      externalIds, message, accountConfig, extIdNotes, defaultPreferences);
               updatedAccounts.setCreated(true);
               return updatedAccounts;
             })
@@ -376,8 +381,10 @@
     return updateAccount(
         r -> {
           AccountConfig accountConfig = read(r, accountId);
+          CachedPreferences defaultPreferences =
+              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
           Optional<AccountState> account =
-              AccountState.fromAccountConfig(externalIds, accountConfig);
+              AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
           if (!account.isPresent()) {
             return null;
           }
@@ -389,8 +396,12 @@
           accountConfig.setAccountUpdate(update);
           ExternalIdNotes extIdNotes =
               createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
+          CachedPreferences cachedDefaultPreferences =
+              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+
           UpdatedAccount updatedAccounts =
-              new UpdatedAccount(externalIds, message, accountConfig, extIdNotes);
+              new UpdatedAccount(
+                  externalIds, message, accountConfig, extIdNotes, cachedDefaultPreferences);
           return updatedAccounts;
         });
   }
@@ -421,8 +432,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);
@@ -565,6 +575,7 @@
     private final String message;
     private final AccountConfig accountConfig;
     private final ExternalIdNotes extIdNotes;
+    private final CachedPreferences defaultPreferences;
 
     private boolean created;
 
@@ -572,12 +583,14 @@
         ExternalIds externalIds,
         String message,
         AccountConfig accountConfig,
-        ExternalIdNotes extIdNotes) {
+        ExternalIdNotes extIdNotes,
+        CachedPreferences defaultPreferences) {
       checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
       this.externalIds = requireNonNull(externalIds);
       this.message = requireNonNull(message);
       this.accountConfig = requireNonNull(accountConfig);
       this.extIdNotes = requireNonNull(extIdNotes);
+      this.defaultPreferences = defaultPreferences;
     }
 
     public String getMessage() {
@@ -589,7 +602,9 @@
     }
 
     public AccountState getAccount() throws IOException {
-      return AccountState.fromAccountConfig(externalIds, accountConfig, extIdNotes).get();
+      return AccountState.fromAccountConfig(
+              externalIds, accountConfig, extIdNotes, defaultPreferences)
+          .get();
     }
 
     public ExternalIdNotes getExternalIdNotes() {
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
new file mode 100644
index 0000000..2eb5770
--- /dev/null
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -0,0 +1,176 @@
+// 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.account;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.config.CachedPreferences;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Details of an account that are cached persistently in {@link AccountCache}. */
+@UsedAt(UsedAt.Project.GOOGLE)
+@AutoValue
+public abstract class CachedAccountDetails {
+  @AutoValue
+  public abstract static class Key {
+    static Key create(Account.Id accountId, ObjectId id) {
+      return new AutoValue_CachedAccountDetails_Key(accountId, id.copy());
+    }
+
+    /** Identifier of the account. */
+    abstract Account.Id accountId();
+
+    /**
+     * Git revision at which the account was loaded. Corresponds to a revision on the account ref
+     * ({@code refs/users/<sharded-id>}).
+     */
+    abstract ObjectId id();
+
+    /** Serializer used to read this entity from and write it to a persistent storage. */
+    enum Serializer implements CacheSerializer<Key> {
+      INSTANCE;
+
+      @Override
+      public byte[] serialize(Key object) {
+        return Protos.toByteArray(
+            Cache.AccountKeyProto.newBuilder()
+                .setAccountId(object.accountId().get())
+                .setId(ObjectIdConverter.create().toByteString(object.id()))
+                .build());
+      }
+
+      @Override
+      public Key deserialize(byte[] in) {
+        Cache.AccountKeyProto proto = Protos.parseUnchecked(Cache.AccountKeyProto.parser(), in);
+        return Key.create(
+            Account.id(proto.getAccountId()),
+            ObjectIdConverter.create().fromByteString(proto.getId()));
+      }
+    }
+  }
+
+  /** Essential attributes of the account, such as name or registration time. */
+  abstract Account account();
+
+  /** Projects that the user has configured to watch. */
+  abstract ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+      projectWatches();
+
+  /** Preferences that this user has. Serialized as Git-config style string. */
+  abstract CachedPreferences preferences();
+
+  static CachedAccountDetails create(
+      Account account,
+      ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+          projectWatches,
+      CachedPreferences preferences) {
+    return new AutoValue_CachedAccountDetails(account, projectWatches, preferences);
+  }
+
+  /** Serializer used to read this entity from and write it to a persistent storage. */
+  enum Serializer implements CacheSerializer<CachedAccountDetails> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CachedAccountDetails cachedAccountDetails) {
+      Cache.AccountDetailsProto.Builder serialized = Cache.AccountDetailsProto.newBuilder();
+      // We don't care about the difference of empty strings and null in the Account entity.
+      Account account = cachedAccountDetails.account();
+      Cache.AccountProto.Builder accountProto =
+          Cache.AccountProto.newBuilder()
+              .setId(account.id().get())
+              .setRegisteredOn(account.registeredOn().toInstant().toEpochMilli())
+              .setInactive(account.inactive())
+              .setFullName(Strings.nullToEmpty(account.fullName()))
+              .setDisplayName(Strings.nullToEmpty(account.displayName()))
+              .setPreferredEmail(Strings.nullToEmpty(account.preferredEmail()))
+              .setStatus(Strings.nullToEmpty(account.status()))
+              .setMetaId(Strings.nullToEmpty(account.metaId()));
+      serialized.setAccount(accountProto);
+
+      for (Map.Entry<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+          watch : cachedAccountDetails.projectWatches().entrySet()) {
+        Cache.ProjectWatchProto.Builder proto =
+            Cache.ProjectWatchProto.newBuilder().setProject(watch.getKey().project().get());
+        if (watch.getKey().filter() != null) {
+          proto.setFilter(watch.getKey().filter());
+        }
+        watch
+            .getValue()
+            .forEach(
+                n ->
+                    proto.addNotifyType(
+                        Enums.stringConverter(ProjectWatches.NotifyType.class)
+                            .reverse()
+                            .convert(n)));
+        serialized.addProjectWatchProto(proto);
+      }
+
+      serialized.setUserPreferences(cachedAccountDetails.preferences().config());
+      return Protos.toByteArray(serialized.build());
+    }
+
+    @Override
+    public CachedAccountDetails deserialize(byte[] in) {
+      Cache.AccountDetailsProto proto =
+          Protos.parseUnchecked(Cache.AccountDetailsProto.parser(), in);
+      Account account =
+          Account.builder(
+                  Account.id(proto.getAccount().getId()),
+                  Timestamp.from(Instant.ofEpochMilli(proto.getAccount().getRegisteredOn())))
+              .setFullName(Strings.emptyToNull(proto.getAccount().getFullName()))
+              .setDisplayName(Strings.emptyToNull(proto.getAccount().getDisplayName()))
+              .setPreferredEmail(Strings.emptyToNull(proto.getAccount().getPreferredEmail()))
+              .setInactive(proto.getAccount().getInactive())
+              .setStatus(Strings.emptyToNull(proto.getAccount().getStatus()))
+              .setMetaId(Strings.emptyToNull(proto.getAccount().getMetaId()))
+              .build();
+
+      ImmutableMap.Builder<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+          projectWatches = ImmutableMap.builder();
+      proto.getProjectWatchProtoList().stream()
+          .forEach(
+              p ->
+                  projectWatches.put(
+                      ProjectWatches.ProjectWatchKey.create(
+                          Project.nameKey(p.getProject()), p.getFilter()),
+                      p.getNotifyTypeList().stream()
+                          .map(
+                              e ->
+                                  Enums.stringConverter(ProjectWatches.NotifyType.class).convert(e))
+                          .collect(toImmutableSet())));
+
+      return CachedAccountDetails.create(
+          account,
+          projectWatches.build(),
+          CachedPreferences.fromString(proto.getUserPreferences()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index 2a764cc..ba58c3f 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -35,10 +35,6 @@
   }
 
   public void setGroupName(String n) {
-    groupName = n != null ? AccountGroup.nameKey(n) : null;
-  }
-
-  public void setGroupName(AccountGroup.NameKey n) {
-    groupName = n;
+    groupName = n != null ? AccountGroup.nameKey(n.trim()) : null;
   }
 }
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..073ff84 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
+import com.google.common.cache.Cache;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
@@ -24,7 +25,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto.ExternalGroupProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.logging.Metadata;
@@ -49,6 +55,7 @@
   private static final String PARENT_GROUPS_NAME = "groups_bysubgroup";
   private static final String GROUPS_WITH_MEMBER_NAME = "groups_bymember";
   private static final String EXTERNAL_NAME = "groups_external";
+  private static final String PERSISTED_EXTERNAL_NAME = "groups_external_persisted";
 
   public static Module module() {
     return new CacheModule() {
@@ -66,8 +73,26 @@
                 new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
             .loader(ParentGroupsLoader.class);
 
+        /**
+         * Splitting the groups external cache into 2 caches: The first one is in memory, used to
+         * serve the callers and has a single constant key "EXTERNAL_NAME". The second one is
+         * persisted, its key represents the groups' state in NoteDb. The in-memory cache is used on
+         * top of the persisted cache to enhance performance because the cache's value is used on
+         * every request to Gerrit, potentially many times per request and the key computation can
+         * become expensive.
+         */
         cache(EXTERNAL_NAME, String.class, new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
-            .loader(AllExternalLoader.class);
+            .loader(AllExternalInMemoryLoader.class);
+
+        persist(
+                PERSISTED_EXTERNAL_NAME,
+                String.class,
+                new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+            .diskLimit(-1)
+            .version(1)
+            .maximumWeight(0)
+            .keySerializer(StringCacheSerializer.INSTANCE)
+            .valueSerializer(ExternalGroupsSerializer.INSTANCE);
 
         bind(GroupIncludeCacheImpl.class);
         bind(GroupIncludeCache.class).to(GroupIncludeCacheImpl.class);
@@ -125,8 +150,13 @@
       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());
+        /**
+         * No need to invalidate the persistent cache, because this eviction will change the state
+         * of NoteDb causing the persistent cache's loader to use a new key that doesn't exist in
+         * its cache.n
+         */
         external.invalidate(EXTERNAL_NAME);
       }
     }
@@ -184,19 +214,54 @@
     }
   }
 
-  static class AllExternalLoader extends CacheLoader<String, ImmutableList<AccountGroup.UUID>> {
+  static class AllExternalInMemoryLoader
+      extends CacheLoader<String, ImmutableList<AccountGroup.UUID>> {
+    private final Cache<String, ImmutableList<AccountGroup.UUID>> persisted;
+    private final GroupsSnapshotReader snapshotReader;
     private final Groups groups;
 
     @Inject
-    AllExternalLoader(Groups groups) {
+    AllExternalInMemoryLoader(
+        @Named(PERSISTED_EXTERNAL_NAME) Cache<String, ImmutableList<AccountGroup.UUID>> persisted,
+        GroupsSnapshotReader snapshotReader,
+        Groups groups) {
+      this.persisted = persisted;
+      this.snapshotReader = snapshotReader;
       this.groups = groups;
     }
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading all external groups")) {
-        return groups.getExternalGroups().collect(toImmutableList());
-      }
+      GroupsSnapshotReader.Snapshot snapshot = snapshotReader.getSnapshot();
+      return persisted.get(
+          snapshot.hash(),
+          () -> {
+            try (TraceTimer timer = TraceContext.newTimer("Loading all external groups")) {
+              return groups.getExternalGroups(snapshot.groupsRefs()).collect(toImmutableList());
+            }
+          });
+    }
+  }
+
+  public enum ExternalGroupsSerializer
+      implements CacheSerializer<ImmutableList<AccountGroup.UUID>> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ImmutableList<AccountGroup.UUID> object) {
+      AllExternalGroupsProto.Builder allBuilder = AllExternalGroupsProto.newBuilder();
+      object.stream()
+          .map(group -> ExternalGroupProto.newBuilder().setGroupUuid(group.get()).build())
+          .forEach(allBuilder::addExternalGroup);
+      return Protos.toByteArray(allBuilder.build());
+    }
+
+    @Override
+    public ImmutableList<AccountGroup.UUID> deserialize(byte[] in) {
+      return Protos.parseUnchecked(AllExternalGroupsProto.parser(), in).getExternalGroupList()
+          .stream()
+          .map(groupProto -> AccountGroup.UUID.parse(groupProto.getGroupUuid()))
+          .collect(toImmutableList());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
index c2b935b..c03ffd0 100644
--- a/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
@@ -106,11 +107,7 @@
       return Collections.emptySet();
     }
 
-    ProjectState projectState = projectCache.checkedGet(project);
-    if (projectState == null) {
-      throw new NoSuchProjectException(project);
-    }
-
+    ProjectState projectState = projectCache.get(project).orElseThrow(noSuchProject(project));
     final HashSet<Account> projectOwners = new HashSet<>();
     for (AccountGroup.UUID ownerGroup : projectState.getAllOwners()) {
       if (!seen.contains(ownerGroup)) {
diff --git a/java/com/google/gerrit/server/account/GroupUUID.java b/java/com/google/gerrit/server/account/GroupUUID.java
deleted file mode 100644
index ac83482..0000000
--- a/java/com/google/gerrit/server/account/GroupUUID.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.entities.AccountGroup;
-import java.security.MessageDigest;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-
-public class GroupUUID {
-  public static AccountGroup.UUID make(String groupName, PersonIdent creator) {
-    MessageDigest md = Constants.newMessageDigest();
-    md.update(Constants.encode("group " + groupName + "\n"));
-    md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
-    md.update(Constants.encode(String.valueOf(Math.random())));
-    return AccountGroup.uuid(ObjectId.fromRaw(md.digest()).name());
-  }
-
-  private GroupUUID() {}
-}
diff --git a/java/com/google/gerrit/server/account/GroupUuid.java b/java/com/google/gerrit/server/account/GroupUuid.java
new file mode 100644
index 0000000..652420d
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupUuid.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.entities.AccountGroup;
+import java.security.MessageDigest;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class GroupUuid {
+  public static AccountGroup.UUID make(String groupName, PersonIdent creator) {
+    MessageDigest md = Constants.newMessageDigest();
+    md.update(Constants.encode("group " + groupName + "\n"));
+    md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
+    md.update(Constants.encode(String.valueOf(Math.random())));
+    return AccountGroup.uuid(ObjectId.fromRaw(md.digest()).name());
+  }
+
+  private GroupUuid() {}
+}
diff --git a/java/com/google/gerrit/server/account/GroupsSnapshotReader.java b/java/com/google/gerrit/server/account/GroupsSnapshotReader.java
new file mode 100644
index 0000000..62cdb17
--- /dev/null
+++ b/java/com/google/gerrit/server/account/GroupsSnapshotReader.java
@@ -0,0 +1,75 @@
+// 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.account;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class is used to compute a compound key that represents the state of the internal groups in
+ * NoteDb.
+ */
+@Singleton
+public class GroupsSnapshotReader {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  GroupsSnapshotReader(GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @AutoValue
+  public abstract static class Snapshot {
+    /**
+     * 128-bit hash of all group ref {@link com.google.gerrit.git.ObjectIds}. To be used as cache
+     * key.
+     */
+    public abstract String hash();
+
+    /** Snapshot of the state of all relevant NoteDb group refs. */
+    public abstract ImmutableList<Ref> groupsRefs();
+
+    public static Snapshot create(String hash, ImmutableList<Ref> groupsRefs) {
+      return new AutoValue_GroupsSnapshotReader_Snapshot(hash, groupsRefs);
+    }
+  }
+
+  /** Retrieves a snapshot key of all internal groups refs from NoteDb. */
+  public Snapshot getSnapshot() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ImmutableList<Ref> groupsRefs =
+          ImmutableList.copyOf(repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_GROUPS));
+      ByteBuffer buf = ByteBuffer.allocate(groupsRefs.size() * Constants.OBJECT_ID_LENGTH);
+      for (Ref groupRef : groupsRefs) {
+        groupRef.getObjectId().copyRawTo(buf);
+      }
+      String hash = Hashing.murmur3_128().hashBytes(buf.array()).toString();
+      return Snapshot.create(hash, groupsRefs);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/HashedPassword.java b/java/com/google/gerrit/server/account/HashedPassword.java
index 64a4495..0911550 100644
--- a/java/com/google/gerrit/server/account/HashedPassword.java
+++ b/java/com/google/gerrit/server/account/HashedPassword.java
@@ -31,6 +31,7 @@
  */
 public class HashedPassword {
   private static final String ALGORITHM_PREFIX = "bcrypt:";
+  private static final String ALGORITHM_PREFIX_0 = "bcrypt0:";
   private static final SecureRandom secureRandom = new SecureRandom();
   private static final BaseEncoding codec = BaseEncoding.base64();
 
@@ -52,7 +53,7 @@
    * @throws DecoderException if input is malformed.
    */
   public static HashedPassword decode(String encoded) throws DecoderException {
-    if (!encoded.startsWith(ALGORITHM_PREFIX)) {
+    if (!encoded.startsWith(ALGORITHM_PREFIX) && !encoded.startsWith(ALGORITHM_PREFIX_0)) {
       throw new DecoderException("unrecognized algorithm");
     }
 
@@ -74,19 +75,24 @@
     if (salt.length != 16) {
       throw new DecoderException("salt should be 16 bytes, got " + salt.length);
     }
-    return new HashedPassword(codec.decode(fields.get(3)), salt, cost);
+    return new HashedPassword(
+        codec.decode(fields.get(3)), salt, cost, encoded.startsWith(ALGORITHM_PREFIX_0));
   }
 
-  private static byte[] hashPassword(String password, byte[] salt, int cost) {
+  private static byte[] hashPassword(
+      String password, byte[] salt, int cost, boolean nullTerminate) {
     byte[] pwBytes = password.getBytes(StandardCharsets.UTF_8);
-
+    if (nullTerminate && !password.endsWith("\0")) {
+      pwBytes = Arrays.append(pwBytes, (byte) 0);
+    }
     return BCrypt.generate(pwBytes, salt, cost);
   }
 
   public static HashedPassword fromPassword(String password) {
     byte[] salt = newSalt();
 
-    return new HashedPassword(hashPassword(password, salt, DEFAULT_COST), salt, DEFAULT_COST);
+    return new HashedPassword(
+        hashPassword(password, salt, DEFAULT_COST, true), salt, DEFAULT_COST, true);
   }
 
   private static byte[] newSalt() {
@@ -98,11 +104,15 @@
   private byte[] salt;
   private byte[] hashed;
   private int cost;
+  // Raw bcrypt repeats the password, so "ABC" works for "ABCABC" too. To prevent this, add
+  // the terminating null char to the password.
+  boolean nullTerminate;
 
-  private HashedPassword(byte[] hashed, byte[] salt, int cost) {
+  private HashedPassword(byte[] hashed, byte[] salt, int cost, boolean nullTerminate) {
     this.salt = salt;
     this.hashed = hashed;
     this.cost = cost;
+    this.nullTerminate = nullTerminate;
 
     checkState(cost >= 4 && cost < 32);
 
@@ -116,11 +126,16 @@
    * @return one-line string encoding the hash and salt.
    */
   public String encode() {
-    return ALGORITHM_PREFIX + cost + ":" + codec.encode(salt) + ":" + codec.encode(hashed);
+    return (nullTerminate ? ALGORITHM_PREFIX_0 : ALGORITHM_PREFIX)
+        + cost
+        + ":"
+        + codec.encode(salt)
+        + ":"
+        + codec.encode(hashed);
   }
 
   public boolean checkPassword(String password) {
     // Constant-time comparison, because we're paranoid.
-    return Arrays.areEqual(hashPassword(password, salt, cost), hashed);
+    return Arrays.areEqual(hashPassword(password, salt, cost, nullTerminate), hashed);
   }
 }
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index e27b77c..3137c95 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -143,6 +143,10 @@
       info.username = accountState.userName().orElse(null);
     }
 
+    if (options.contains(FillOptions.DISPLAY_NAME)) {
+      info.displayName = account.displayName();
+    }
+
     if (options.contains(FillOptions.STATUS)) {
       info.status = account.status();
     }
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
index cf77a75..bfbe917 100644
--- a/java/com/google/gerrit/server/account/InternalAccountUpdate.java
+++ b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
@@ -61,6 +62,15 @@
   public abstract Optional<String> getFullName();
 
   /**
+   * Returns the new value for the display name.
+   *
+   * @return the new value for the display name, {@code Optional#empty()} if the display name is not
+   *     being updated, {@code Optional#of("")} if the display name is unset, the wrapped value is
+   *     never {@code null}
+   */
+  public abstract Optional<String> getDisplayName();
+
+  /**
    * Returns the new value for the preferred email.
    *
    * @return the new value for the preferred email, {@code Optional#empty()} if the preferred email
@@ -166,25 +176,30 @@
      * Sets a new full name for the account.
      *
      * @param fullName the new full name, if {@code null} or empty string the full name is unset
-     * @return the builder
      */
-    public abstract Builder setFullName(String fullName);
+    public abstract Builder setFullName(@Nullable String fullName);
+
+    /**
+     * Sets a new display name for the account.
+     *
+     * @param displayName the new display name, if {@code null} or empty string the display name is
+     *     unset
+     */
+    public abstract Builder setDisplayName(@Nullable String displayName);
 
     /**
      * Sets a new preferred email for the account.
      *
      * @param preferredEmail the new preferred email, if {@code null} or empty string the preferred
      *     email is unset
-     * @return the builder
      */
-    public abstract Builder setPreferredEmail(String preferredEmail);
+    public abstract Builder setPreferredEmail(@Nullable String preferredEmail);
 
     /**
      * Sets the active flag for the account.
      *
      * @param active {@code true} if the account should be set to active, {@code false} if the
      *     account should be set to inactive
-     * @return the builder
      */
     public abstract Builder setActive(boolean active);
 
@@ -192,9 +207,8 @@
      * Sets a new status for the account.
      *
      * @param status the new status, if {@code null} or empty string the status is unset
-     * @return the builder
      */
-    public abstract Builder setStatus(String status);
+    public abstract Builder setStatus(@Nullable String status);
 
     /**
      * Returns a builder for the set of created external IDs.
@@ -487,6 +501,12 @@
       }
 
       @Override
+      public Builder setDisplayName(String displayName) {
+        delegate.setDisplayName(Strings.nullToEmpty(displayName));
+        return this;
+      }
+
+      @Override
       public Builder setPreferredEmail(String preferredEmail) {
         delegate.setPreferredEmail(Strings.nullToEmpty(preferredEmail));
         return this;
diff --git a/java/com/google/gerrit/server/account/Preferences.java b/java/com/google/gerrit/server/account/Preferences.java
deleted file mode 100644
index ece610b..0000000
--- a/java/com/google/gerrit/server/account/Preferences.java
+++ /dev/null
@@ -1,429 +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.account;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
-import com.google.gerrit.extensions.client.MenuItem;
-import java.util.Optional;
-
-@AutoValue
-public abstract class Preferences {
-  @AutoValue
-  public abstract static class General {
-    public abstract Optional<Integer> changesPerPage();
-
-    public abstract Optional<String> downloadScheme();
-
-    public abstract Optional<DateFormat> dateFormat();
-
-    public abstract Optional<TimeFormat> timeFormat();
-
-    public abstract Optional<Boolean> expandInlineDiffs();
-
-    public abstract Optional<Boolean> highlightAssigneeInChangeTable();
-
-    public abstract Optional<Boolean> relativeDateInChangeTable();
-
-    public abstract Optional<DiffView> diffView();
-
-    public abstract Optional<Boolean> sizeBarInChangeTable();
-
-    public abstract Optional<Boolean> legacycidInChangeTable();
-
-    public abstract Optional<Boolean> muteCommonPathPrefixes();
-
-    public abstract Optional<Boolean> signedOffBy();
-
-    public abstract Optional<EmailStrategy> emailStrategy();
-
-    public abstract Optional<EmailFormat> emailFormat();
-
-    public abstract Optional<DefaultBase> defaultBaseForMerges();
-
-    public abstract Optional<Boolean> publishCommentsOnPush();
-
-    public abstract Optional<Boolean> workInProgressByDefault();
-
-    public abstract Optional<ImmutableList<MenuItem>> my();
-
-    public abstract Optional<ImmutableList<String>> changeTable();
-
-    @AutoValue.Builder
-    public abstract static class Builder {
-      abstract Builder changesPerPage(@Nullable Integer val);
-
-      abstract Builder downloadScheme(@Nullable String val);
-
-      abstract Builder dateFormat(@Nullable DateFormat val);
-
-      abstract Builder timeFormat(@Nullable TimeFormat val);
-
-      abstract Builder expandInlineDiffs(@Nullable Boolean val);
-
-      abstract Builder highlightAssigneeInChangeTable(@Nullable Boolean val);
-
-      abstract Builder relativeDateInChangeTable(@Nullable Boolean val);
-
-      abstract Builder diffView(@Nullable DiffView val);
-
-      abstract Builder sizeBarInChangeTable(@Nullable Boolean val);
-
-      abstract Builder legacycidInChangeTable(@Nullable Boolean val);
-
-      abstract Builder muteCommonPathPrefixes(@Nullable Boolean val);
-
-      abstract Builder signedOffBy(@Nullable Boolean val);
-
-      abstract Builder emailStrategy(@Nullable EmailStrategy val);
-
-      abstract Builder emailFormat(@Nullable EmailFormat val);
-
-      abstract Builder defaultBaseForMerges(@Nullable DefaultBase val);
-
-      abstract Builder publishCommentsOnPush(@Nullable Boolean val);
-
-      abstract Builder workInProgressByDefault(@Nullable Boolean val);
-
-      abstract Builder my(@Nullable ImmutableList<MenuItem> val);
-
-      abstract Builder changeTable(@Nullable ImmutableList<String> val);
-
-      abstract General build();
-    }
-
-    public static General fromInfo(GeneralPreferencesInfo info) {
-      return (new AutoValue_Preferences_General.Builder())
-          .changesPerPage(info.changesPerPage)
-          .downloadScheme(info.downloadScheme)
-          .dateFormat(info.dateFormat)
-          .timeFormat(info.timeFormat)
-          .expandInlineDiffs(info.expandInlineDiffs)
-          .highlightAssigneeInChangeTable(info.highlightAssigneeInChangeTable)
-          .relativeDateInChangeTable(info.relativeDateInChangeTable)
-          .diffView(info.diffView)
-          .sizeBarInChangeTable(info.sizeBarInChangeTable)
-          .legacycidInChangeTable(info.legacycidInChangeTable)
-          .muteCommonPathPrefixes(info.muteCommonPathPrefixes)
-          .signedOffBy(info.signedOffBy)
-          .emailStrategy(info.emailStrategy)
-          .emailFormat(info.emailFormat)
-          .defaultBaseForMerges(info.defaultBaseForMerges)
-          .publishCommentsOnPush(info.publishCommentsOnPush)
-          .workInProgressByDefault(info.workInProgressByDefault)
-          .my(info.my == null ? null : ImmutableList.copyOf(info.my))
-          .changeTable(info.changeTable == null ? null : ImmutableList.copyOf(info.changeTable))
-          .build();
-    }
-
-    public GeneralPreferencesInfo toInfo() {
-      GeneralPreferencesInfo info = new GeneralPreferencesInfo();
-      info.changesPerPage = changesPerPage().orElse(null);
-      info.downloadScheme = downloadScheme().orElse(null);
-      info.dateFormat = dateFormat().orElse(null);
-      info.timeFormat = timeFormat().orElse(null);
-      info.expandInlineDiffs = expandInlineDiffs().orElse(null);
-      info.highlightAssigneeInChangeTable = highlightAssigneeInChangeTable().orElse(null);
-      info.relativeDateInChangeTable = relativeDateInChangeTable().orElse(null);
-      info.diffView = diffView().orElse(null);
-      info.sizeBarInChangeTable = sizeBarInChangeTable().orElse(null);
-      info.legacycidInChangeTable = legacycidInChangeTable().orElse(null);
-      info.muteCommonPathPrefixes = muteCommonPathPrefixes().orElse(null);
-      info.signedOffBy = signedOffBy().orElse(null);
-      info.emailStrategy = emailStrategy().orElse(null);
-      info.emailFormat = emailFormat().orElse(null);
-      info.defaultBaseForMerges = defaultBaseForMerges().orElse(null);
-      info.publishCommentsOnPush = publishCommentsOnPush().orElse(null);
-      info.workInProgressByDefault = workInProgressByDefault().orElse(null);
-      info.my = my().orElse(null);
-      info.changeTable = changeTable().orElse(null);
-      return info;
-    }
-  }
-
-  @AutoValue
-  public abstract static class Edit {
-    public abstract Optional<Integer> tabSize();
-
-    public abstract Optional<Integer> lineLength();
-
-    public abstract Optional<Integer> indentUnit();
-
-    public abstract Optional<Integer> cursorBlinkRate();
-
-    public abstract Optional<Boolean> hideTopMenu();
-
-    public abstract Optional<Boolean> showTabs();
-
-    public abstract Optional<Boolean> showWhitespaceErrors();
-
-    public abstract Optional<Boolean> syntaxHighlighting();
-
-    public abstract Optional<Boolean> hideLineNumbers();
-
-    public abstract Optional<Boolean> matchBrackets();
-
-    public abstract Optional<Boolean> lineWrapping();
-
-    public abstract Optional<Boolean> indentWithTabs();
-
-    public abstract Optional<Boolean> autoCloseBrackets();
-
-    public abstract Optional<Boolean> showBase();
-
-    @AutoValue.Builder
-    public abstract static class Builder {
-      abstract Builder tabSize(@Nullable Integer val);
-
-      abstract Builder lineLength(@Nullable Integer val);
-
-      abstract Builder indentUnit(@Nullable Integer val);
-
-      abstract Builder cursorBlinkRate(@Nullable Integer val);
-
-      abstract Builder hideTopMenu(@Nullable Boolean val);
-
-      abstract Builder showTabs(@Nullable Boolean val);
-
-      abstract Builder showWhitespaceErrors(@Nullable Boolean val);
-
-      abstract Builder syntaxHighlighting(@Nullable Boolean val);
-
-      abstract Builder hideLineNumbers(@Nullable Boolean val);
-
-      abstract Builder matchBrackets(@Nullable Boolean val);
-
-      abstract Builder lineWrapping(@Nullable Boolean val);
-
-      abstract Builder indentWithTabs(@Nullable Boolean val);
-
-      abstract Builder autoCloseBrackets(@Nullable Boolean val);
-
-      abstract Builder showBase(@Nullable Boolean val);
-
-      abstract Edit build();
-    }
-
-    public static Edit fromInfo(EditPreferencesInfo info) {
-      return (new AutoValue_Preferences_Edit.Builder())
-          .tabSize(info.tabSize)
-          .lineLength(info.lineLength)
-          .indentUnit(info.indentUnit)
-          .cursorBlinkRate(info.cursorBlinkRate)
-          .hideTopMenu(info.hideTopMenu)
-          .showTabs(info.showTabs)
-          .showWhitespaceErrors(info.showWhitespaceErrors)
-          .syntaxHighlighting(info.syntaxHighlighting)
-          .hideLineNumbers(info.hideLineNumbers)
-          .matchBrackets(info.matchBrackets)
-          .lineWrapping(info.lineWrapping)
-          .indentWithTabs(info.indentWithTabs)
-          .autoCloseBrackets(info.autoCloseBrackets)
-          .showBase(info.showBase)
-          .build();
-    }
-
-    public EditPreferencesInfo toInfo() {
-      EditPreferencesInfo info = new EditPreferencesInfo();
-      info.tabSize = tabSize().orElse(null);
-      info.lineLength = lineLength().orElse(null);
-      info.indentUnit = indentUnit().orElse(null);
-      info.cursorBlinkRate = cursorBlinkRate().orElse(null);
-      info.hideTopMenu = hideTopMenu().orElse(null);
-      info.showTabs = showTabs().orElse(null);
-      info.showWhitespaceErrors = showWhitespaceErrors().orElse(null);
-      info.syntaxHighlighting = syntaxHighlighting().orElse(null);
-      info.hideLineNumbers = hideLineNumbers().orElse(null);
-      info.matchBrackets = matchBrackets().orElse(null);
-      info.lineWrapping = lineWrapping().orElse(null);
-      info.indentWithTabs = indentWithTabs().orElse(null);
-      info.autoCloseBrackets = autoCloseBrackets().orElse(null);
-      info.showBase = showBase().orElse(null);
-      return info;
-    }
-  }
-
-  @AutoValue
-  public abstract static class Diff {
-    public abstract Optional<Integer> context();
-
-    public abstract Optional<Integer> tabSize();
-
-    public abstract Optional<Integer> fontSize();
-
-    public abstract Optional<Integer> lineLength();
-
-    public abstract Optional<Integer> cursorBlinkRate();
-
-    public abstract Optional<Boolean> expandAllComments();
-
-    public abstract Optional<Boolean> intralineDifference();
-
-    public abstract Optional<Boolean> manualReview();
-
-    public abstract Optional<Boolean> showLineEndings();
-
-    public abstract Optional<Boolean> showTabs();
-
-    public abstract Optional<Boolean> showWhitespaceErrors();
-
-    public abstract Optional<Boolean> syntaxHighlighting();
-
-    public abstract Optional<Boolean> hideTopMenu();
-
-    public abstract Optional<Boolean> autoHideDiffTableHeader();
-
-    public abstract Optional<Boolean> hideLineNumbers();
-
-    public abstract Optional<Boolean> renderEntireFile();
-
-    public abstract Optional<Boolean> hideEmptyPane();
-
-    public abstract Optional<Boolean> matchBrackets();
-
-    public abstract Optional<Boolean> lineWrapping();
-
-    public abstract Optional<Whitespace> ignoreWhitespace();
-
-    public abstract Optional<Boolean> retainHeader();
-
-    public abstract Optional<Boolean> skipDeleted();
-
-    public abstract Optional<Boolean> skipUnchanged();
-
-    public abstract Optional<Boolean> skipUncommented();
-
-    @AutoValue.Builder
-    public abstract static class Builder {
-      abstract Builder context(@Nullable Integer val);
-
-      abstract Builder tabSize(@Nullable Integer val);
-
-      abstract Builder fontSize(@Nullable Integer val);
-
-      abstract Builder lineLength(@Nullable Integer val);
-
-      abstract Builder cursorBlinkRate(@Nullable Integer val);
-
-      abstract Builder expandAllComments(@Nullable Boolean val);
-
-      abstract Builder intralineDifference(@Nullable Boolean val);
-
-      abstract Builder manualReview(@Nullable Boolean val);
-
-      abstract Builder showLineEndings(@Nullable Boolean val);
-
-      abstract Builder showTabs(@Nullable Boolean val);
-
-      abstract Builder showWhitespaceErrors(@Nullable Boolean val);
-
-      abstract Builder syntaxHighlighting(@Nullable Boolean val);
-
-      abstract Builder hideTopMenu(@Nullable Boolean val);
-
-      abstract Builder autoHideDiffTableHeader(@Nullable Boolean val);
-
-      abstract Builder hideLineNumbers(@Nullable Boolean val);
-
-      abstract Builder renderEntireFile(@Nullable Boolean val);
-
-      abstract Builder hideEmptyPane(@Nullable Boolean val);
-
-      abstract Builder matchBrackets(@Nullable Boolean val);
-
-      abstract Builder lineWrapping(@Nullable Boolean val);
-
-      abstract Builder ignoreWhitespace(@Nullable Whitespace val);
-
-      abstract Builder retainHeader(@Nullable Boolean val);
-
-      abstract Builder skipDeleted(@Nullable Boolean val);
-
-      abstract Builder skipUnchanged(@Nullable Boolean val);
-
-      abstract Builder skipUncommented(@Nullable Boolean val);
-
-      abstract Diff build();
-    }
-
-    public static Diff fromInfo(DiffPreferencesInfo info) {
-      return (new AutoValue_Preferences_Diff.Builder())
-          .context(info.context)
-          .tabSize(info.tabSize)
-          .fontSize(info.fontSize)
-          .lineLength(info.lineLength)
-          .cursorBlinkRate(info.cursorBlinkRate)
-          .expandAllComments(info.expandAllComments)
-          .intralineDifference(info.intralineDifference)
-          .manualReview(info.manualReview)
-          .showLineEndings(info.showLineEndings)
-          .showTabs(info.showTabs)
-          .showWhitespaceErrors(info.showWhitespaceErrors)
-          .syntaxHighlighting(info.syntaxHighlighting)
-          .hideTopMenu(info.hideTopMenu)
-          .autoHideDiffTableHeader(info.autoHideDiffTableHeader)
-          .hideLineNumbers(info.hideLineNumbers)
-          .renderEntireFile(info.renderEntireFile)
-          .hideEmptyPane(info.hideEmptyPane)
-          .matchBrackets(info.matchBrackets)
-          .lineWrapping(info.lineWrapping)
-          .ignoreWhitespace(info.ignoreWhitespace)
-          .retainHeader(info.retainHeader)
-          .skipDeleted(info.skipDeleted)
-          .skipUnchanged(info.skipUnchanged)
-          .skipUncommented(info.skipUncommented)
-          .build();
-    }
-
-    public DiffPreferencesInfo toInfo() {
-      DiffPreferencesInfo info = new DiffPreferencesInfo();
-      info.context = context().orElse(null);
-      info.tabSize = tabSize().orElse(null);
-      info.fontSize = fontSize().orElse(null);
-      info.lineLength = lineLength().orElse(null);
-      info.cursorBlinkRate = cursorBlinkRate().orElse(null);
-      info.expandAllComments = expandAllComments().orElse(null);
-      info.intralineDifference = intralineDifference().orElse(null);
-      info.manualReview = manualReview().orElse(null);
-      info.showLineEndings = showLineEndings().orElse(null);
-      info.showTabs = showTabs().orElse(null);
-      info.showWhitespaceErrors = showWhitespaceErrors().orElse(null);
-      info.syntaxHighlighting = syntaxHighlighting().orElse(null);
-      info.hideTopMenu = hideTopMenu().orElse(null);
-      info.autoHideDiffTableHeader = autoHideDiffTableHeader().orElse(null);
-      info.hideLineNumbers = hideLineNumbers().orElse(null);
-      info.renderEntireFile = renderEntireFile().orElse(null);
-      info.hideEmptyPane = hideEmptyPane().orElse(null);
-      info.matchBrackets = matchBrackets().orElse(null);
-      info.lineWrapping = lineWrapping().orElse(null);
-      info.ignoreWhitespace = ignoreWhitespace().orElse(null);
-      info.retainHeader = retainHeader().orElse(null);
-      info.skipDeleted = skipDeleted().orElse(null);
-      info.skipUnchanged = skipUnchanged().orElse(null);
-      info.skipUncommented = skipUncommented().orElse(null);
-      return info;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index b153b78..cf63346 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -79,6 +79,7 @@
 public class ProjectWatches {
   @AutoValue
   public abstract static class ProjectWatchKey {
+
     public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
       return new AutoValue_ProjectWatches_ProjectWatchKey(project, Strings.emptyToNull(filter));
     }
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 40cc185..4b68198 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -33,6 +33,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>
@@ -119,9 +120,9 @@
 
     Response<String> res;
     if (alreadyActive.get()) {
-      res = Response.ok("");
+      res = Response.ok();
     } else {
-      res = Response.created("");
+      res = Response.created();
 
       int id = accountId.get();
       accountActivationListeners.runEach(l -> l.onAccountActivated(id));
diff --git a/java/com/google/gerrit/server/account/StoredPreferences.java b/java/com/google/gerrit/server/account/StoredPreferences.java
index 0e8eb04..1b3ff40 100644
--- a/java/com/google/gerrit/server/account/StoredPreferences.java
+++ b/java/com/google/gerrit/server/account/StoredPreferences.java
@@ -30,24 +30,22 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.VersionedDefaultPreferences;
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -184,6 +182,11 @@
     return cfg;
   }
 
+  /** Returns the content of the {@code preferences.config} file as {@link Config}. */
+  Config getRaw() {
+    return cfg;
+  }
+
   private GeneralPreferencesInfo parseGeneralPreferences(@Nullable GeneralPreferencesInfo input) {
     try {
       return parseGeneralPreferences(cfg, defaultCfg, input);
@@ -224,7 +227,12 @@
     }
   }
 
-  private static GeneralPreferencesInfo parseGeneralPreferences(
+  /**
+   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
+   * the server's default configs and {@code cfg} for the user's config. These configs are then
+   * overlaid to inherit values (default -> user -> input (if provided).
+   */
+  public static GeneralPreferencesInfo parseGeneralPreferences(
       Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
       throws ConfigInvalidException {
     GeneralPreferencesInfo r =
@@ -247,7 +255,12 @@
     return r;
   }
 
-  private static DiffPreferencesInfo parseDiffPreferences(
+  /**
+   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config. These configs are then overlaid
+   * to inherit values (default -> user -> input (if provided).
+   */
+  public static DiffPreferencesInfo parseDiffPreferences(
       Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
       throws ConfigInvalidException {
     return loadSection(
@@ -261,7 +274,12 @@
         input);
   }
 
-  private static EditPreferencesInfo parseEditPreferences(
+  /**
+   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config. These configs are then overlaid
+   * to inherit values (default -> user -> input (if provided).
+   */
+  public static EditPreferencesInfo parseEditPreferences(
       Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
       throws ConfigInvalidException {
     return loadSection(
@@ -386,7 +404,7 @@
       my = my(defaultCfg);
     }
     if (my.isEmpty()) {
-      my.add(new MenuItem("Changes", "#/dashboard/self", null));
+      my.add(new MenuItem("Dashboard", "#/dashboard/self", null));
       my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
       my.add(new MenuItem("Edits", "#/q/has:edit", null));
       my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
@@ -543,32 +561,4 @@
       cfg.unsetSection(section, subsection);
     }
   }
-
-  private static class VersionedDefaultPreferences extends VersionedMetaData {
-    private Config cfg;
-
-    @Override
-    protected String getRefName() {
-      return RefNames.REFS_USERS_DEFAULT;
-    }
-
-    private Config getConfig() {
-      checkState(cfg != null, "Default preferences not loaded yet.");
-      return cfg;
-    }
-
-    @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
-      cfg = readConfig(PREFERENCES_CONFIG);
-    }
-
-    @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-      if (Strings.isNullOrEmpty(commit.getMessage())) {
-        commit.setMessage("Update default preferences\n");
-      }
-      saveConfig(PREFERENCES_CONFIG, cfg);
-      return true;
-    }
-  }
 }
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/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 0edf154..92e7c71 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.common.collect.SetMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -36,17 +36,17 @@
       Collection<ExternalId> toRemove,
       Collection<ExternalId> toAdd);
 
-  Set<ExternalId> byAccount(Account.Id accountId) throws IOException;
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
-  Set<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
 
-  SetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
+  ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
 
-  SetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
+  ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
 
-  SetMultimap<String, ExternalId> allByEmail() throws IOException;
+  ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException;
 
-  default Set<ExternalId> byEmail(String email) throws IOException {
+  default ImmutableSet<ExternalId> byEmail(String email) throws IOException {
     return byEmails(email).get(email);
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 84b25c0..9084de7 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account.externalids;
 
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
@@ -25,7 +26,6 @@
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
@@ -73,22 +73,22 @@
   }
 
   @Override
-  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
     return get().byAccount().get(accountId);
   }
 
   @Override
-  public Set<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
     return get(rev).byAccount().get(accountId);
   }
 
   @Override
-  public SetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
     return get().byAccount();
   }
 
   @Override
-  public SetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
     AllExternalIds allExternalIds = get();
     ImmutableSetMultimap.Builder<String, ExternalId> byEmails = ImmutableSetMultimap.builder();
     for (String email : emails) {
@@ -98,7 +98,7 @@
   }
 
   @Override
-  public SetMultimap<String, ExternalId> allByEmail() throws IOException {
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
     return get().byEmail();
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index ec4f5534..e999c93 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -745,9 +745,6 @@
               .map(ExternalId::accountId)
               .filter(i -> !accountsToSkip.contains(i))
               .collect(toSet())) {
-        if (accountCache != null) {
-          accountCache.evict(id);
-        }
         if (accountIndexer != null) {
           accountIndexer.get().index(id);
         }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index 6334265..c055313 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -68,6 +68,7 @@
   private final AllUsersName allUsersName;
   private boolean failOnLoad = false;
   private final Timer0 readAllLatency;
+  private final Timer0 readSingleLatency;
 
   @Inject
   ExternalIdReader(
@@ -80,6 +81,12 @@
             new Description("Latency for reading all external IDs from NoteDb.")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS));
+    this.readSingleLatency =
+        metricMaker.newTimer(
+            "notedb/read_single_external_id_latency",
+            new Description("Latency for reading a single external ID from NoteDb.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
   }
 
   @VisibleForTesting
@@ -126,7 +133,8 @@
   Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
+    try (Timer0.Context ctx = readSingleLatency.start();
+        Repository repo = repoManager.openRepository(allUsersName)) {
       return ExternalIdNotes.loadReadOnly(allUsersName, repo).get(key);
     }
   }
@@ -136,7 +144,8 @@
       throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
+    try (Timer0.Context ctx = readSingleLatency.start();
+        Repository repo = repoManager.openRepository(allUsersName)) {
       return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev).get(key);
     }
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 28d3af2..302a25e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -17,13 +17,12 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.SetMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -65,24 +64,25 @@
   }
 
   /** Returns the external IDs of the specified account. */
-  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
     return externalIdCache.byAccount(accountId);
   }
 
   /** Returns the external IDs of the specified account that have the given scheme. */
-  public Set<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException {
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme)
+      throws IOException {
     return byAccount(accountId).stream()
         .filter(e -> e.key().isScheme(scheme))
         .collect(toImmutableSet());
   }
 
   /** Returns the external IDs of the specified account. */
-  public Set<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
     return externalIdCache.byAccount(accountId, rev);
   }
 
   /** Returns the external IDs of the specified account that have the given scheme. */
-  public Set<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
       throws IOException {
     return byAccount(accountId, rev).stream()
         .filter(e -> e.key().isScheme(scheme))
@@ -90,7 +90,7 @@
   }
 
   /** Returns all external IDs by account. */
-  public SetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
     return externalIdCache.allByAccount();
   }
 
@@ -107,7 +107,7 @@
    *
    * @see #byEmails(String...)
    */
-  public Set<ExternalId> byEmail(String email) throws IOException {
+  public ImmutableSet<ExternalId> byEmail(String email) throws IOException {
     return externalIdCache.byEmail(email);
   }
 
@@ -125,12 +125,12 @@
    *
    * @see #byEmail(String)
    */
-  public SetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
     return externalIdCache.byEmails(emails);
   }
 
   /** Returns all external IDs by email. */
-  public SetMultimap<String, ExternalId> allByEmail() throws IOException {
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
     return externalIdCache.allByEmail();
   }
 }
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/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 7fa9767..4f85412 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.api.accounts.AgreementInput;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
+import com.google.gerrit.extensions.api.accounts.DisplayNameInput;
 import com.google.gerrit.extensions.api.accounts.EmailApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
@@ -76,6 +77,7 @@
 import com.google.gerrit.server.restapi.account.PostWatchedProjects;
 import com.google.gerrit.server.restapi.account.PutActive;
 import com.google.gerrit.server.restapi.account.PutAgreement;
+import com.google.gerrit.server.restapi.account.PutDisplayName;
 import com.google.gerrit.server.restapi.account.PutHttpPassword;
 import com.google.gerrit.server.restapi.account.PutName;
 import com.google.gerrit.server.restapi.account.PutStatus;
@@ -134,6 +136,7 @@
   private final DeleteExternalIds deleteExternalIds;
   private final DeleteDraftComments deleteDraftComments;
   private final PutStatus putStatus;
+  private final PutDisplayName putDisplayName;
   private final GetGroups getGroups;
   private final EmailApiImpl.Factory emailApi;
   private final PutName putName;
@@ -177,6 +180,7 @@
       DeleteExternalIds deleteExternalIds,
       DeleteDraftComments deleteDraftComments,
       PutStatus putStatus,
+      PutDisplayName putDisplayName,
       GetGroups getGroups,
       EmailApiImpl.Factory emailApi,
       PutName putName,
@@ -219,6 +223,7 @@
     this.deleteExternalIds = deleteExternalIds;
     this.deleteDraftComments = deleteDraftComments;
     this.putStatus = putStatus;
+    this.putDisplayName = putDisplayName;
     this.getGroups = getGroups;
     this.emailApi = emailApi;
     this.putName = putName;
@@ -477,6 +482,16 @@
   }
 
   @Override
+  public void setDisplayName(String displayName) throws RestApiException {
+    DisplayNameInput in = new DisplayNameInput(displayName);
+    try {
+      putDisplayName.apply(account, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set display name", e);
+    }
+  }
+
+  @Override
   public List<SshKeyInfo> listSshKeys() throws RestApiException {
     try {
       return getSshKeys.apply(account).value();
diff --git a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
new file mode 100644
index 0000000..8dc44b7
--- /dev/null
+++ b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
@@ -0,0 +1,51 @@
+// 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.api.changes;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.AttentionSetApi;
+import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.restapi.change.RemoveFromAttentionSet;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class AttentionSetApiImpl implements AttentionSetApi {
+  interface Factory {
+    AttentionSetApiImpl create(AttentionSetEntryResource attentionSetEntryResource);
+  }
+
+  private final RemoveFromAttentionSet removeFromAttentionSet;
+  private final AttentionSetEntryResource attentionSetEntryResource;
+
+  @Inject
+  AttentionSetApiImpl(
+      RemoveFromAttentionSet removeFromAttentionSet,
+      @Assisted AttentionSetEntryResource attentionSetEntryResource) {
+    this.removeFromAttentionSet = removeFromAttentionSet;
+    this.attentionSetEntryResource = attentionSetEntryResource;
+  }
+
+  @Override
+  public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+    try {
+      removeFromAttentionSet.apply(attentionSetEntryResource, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove from attention set", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a04be30..5122f8a 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -23,7 +23,9 @@
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
@@ -48,8 +50,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,9 +66,10 @@
 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.AddToAttentionSet;
+import com.google.gerrit.server.restapi.change.AttentionSet;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
 import com.google.gerrit.server.restapi.change.ChangeMessages;
 import com.google.gerrit.server.restapi.change.Check;
@@ -96,6 +101,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 +138,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;
@@ -144,6 +151,9 @@
   private final Provider<GetChange> getChangeProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
+  private final AttentionSet attentionSet;
+  private final AttentionSetApiImpl.Factory attentionSetApi;
+  private final AddToAttentionSet addToAttentionSet;
   private final PutAssignee putAssignee;
   private final GetAssignee getAssignee;
   private final GetPastAssignees getPastAssignees;
@@ -181,6 +191,7 @@
       ListReviewers listReviewers,
       Abandon abandon,
       Revert revert,
+      RevertSubmission revertSubmission,
       Restore restore,
       CreateMergePatchSet updateByMerge,
       Provider<SubmittedTogether> submittedTogether,
@@ -193,6 +204,9 @@
       Provider<GetChange> getChangeProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
+      AttentionSet attentionSet,
+      AttentionSetApiImpl.Factory attentionSetApi,
+      AddToAttentionSet addToAttentionSet,
       PutAssignee putAssignee,
       GetAssignee getAssignee,
       GetPastAssignees getPastAssignees,
@@ -219,6 +233,7 @@
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
+    this.revertSubmission = revertSubmission;
     this.reviewers = reviewers;
     this.revisions = revisions;
     this.reviewerApi = reviewerApi;
@@ -240,6 +255,9 @@
     this.getChangeProvider = getChangeProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
+    this.attentionSet = attentionSet;
+    this.attentionSetApi = attentionSetApi;
+    this.addToAttentionSet = addToAttentionSet;
     this.putAssignee = putAssignee;
     this.getAssignee = getAssignee;
     this.getPastAssignees = getPastAssignees;
@@ -319,7 +337,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 +376,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();
@@ -516,6 +543,24 @@
   }
 
   @Override
+  public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+    try {
+      return addToAttentionSet.apply(change, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add to attention set", e);
+    }
+  }
+
+  @Override
+  public AttentionSetApi attention(String id) throws RestApiException {
+    try {
+      return attentionSetApi.create(attentionSet.parse(change, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse account", e);
+    }
+  }
+
+  @Override
   public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
     try {
       return putAssignee.apply(change, input).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/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index b9635fb..d6ef61c 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -99,6 +99,15 @@
   }
 
   @Override
+  public ChangeInfo createAsInfo(ChangeInput in) throws RestApiException {
+    try {
+      return createChange.apply(TopLevelResource.INSTANCE, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change", e);
+    }
+  }
+
+  @Override
   public QueryRequest query() {
     return new QueryRequest() {
       @Override
diff --git a/java/com/google/gerrit/server/api/changes/Module.java b/java/com/google/gerrit/server/api/changes/Module.java
index 0edd58a..f54d1fe 100644
--- a/java/com/google/gerrit/server/api/changes/Module.java
+++ b/java/com/google/gerrit/server/api/changes/Module.java
@@ -32,5 +32,6 @@
     factory(RevisionReviewerApiImpl.Factory.class);
     factory(ChangeEditApiImpl.Factory.class);
     factory(ChangeMessageApiImpl.Factory.class);
+    factory(AttentionSetApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 01dfe36..b515dfe 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -37,13 +37,15 @@
 import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
 import com.google.gerrit.extensions.api.changes.RobotCommentApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.CherryPickChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 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;
@@ -69,8 +71,10 @@
 import com.google.gerrit.server.restapi.change.DraftComments;
 import com.google.gerrit.server.restapi.change.Files;
 import com.google.gerrit.server.restapi.change.Fixes;
+import com.google.gerrit.server.restapi.change.GetArchive;
 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;
@@ -94,6 +98,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.lib.Repository;
@@ -126,6 +131,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;
@@ -143,6 +149,7 @@
   private final GetRelated getRelated;
   private final PutDescription putDescription;
   private final GetDescription getDescription;
+  private final Provider<GetArchive> getArchiveProvider;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
 
@@ -169,6 +176,7 @@
       ListRevisionComments listComments,
       ListRobotComments listRobotComments,
       ApplyFix applyFix,
+      GetFixPreview getFixPreview,
       Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
@@ -186,6 +194,7 @@
       GetRelated getRelated,
       PutDescription putDescription,
       GetDescription getDescription,
+      Provider<GetArchive> getArchiveProvider,
       ApprovalsUtil approvalsUtil,
       AccountLoader.Factory accountLoaderFactory,
       @Assisted RevisionResource r) {
@@ -211,6 +220,7 @@
     this.robotComments = robotComments;
     this.listRobotComments = listRobotComments;
     this.applyFix = applyFix;
+    this.getFixPreview = getFixPreview;
     this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
@@ -227,6 +237,7 @@
     this.getRelated = getRelated;
     this.putDescription = putDescription;
     this.getDescription = getDescription;
+    this.getArchiveProvider = getArchiveProvider;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
     this.revision = r;
@@ -289,7 +300,7 @@
   }
 
   @Override
-  public CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
+  public ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
     try {
       return cherryPick.apply(revision, in).value();
     } catch (Exception e) {
@@ -452,6 +463,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);
@@ -635,4 +655,15 @@
   public String etag() throws RestApiException {
     return revisionActions.getETag(revision);
   }
+
+  @Override
+  public BinaryResult getArchive(ArchiveFormat format) throws RestApiException {
+    GetArchive getArchive = getArchiveProvider.get();
+    getArchive.setFormat(format != null ? format.name().toLowerCase(Locale.US) : null);
+    try {
+      return getArchive.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get archive", e);
+    }
+  }
 }
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..d00f447
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
@@ -0,0 +1,123 @@
+// 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 static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+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
+                  .get(project.getNameKey())
+                  .orElseThrow(illegalState(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/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index 61dbd2c..a1e45e9 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
+import java.util.Optional;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -74,10 +74,10 @@
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
     Project.NameKey nameKey = Project.nameKey(nameWithoutSuffix);
 
-    ProjectState state;
+    Optional<ProjectState> state;
     try {
-      state = projectCache.checkedGet(nameKey);
-      if (state == null) {
+      state = projectCache.get(nameKey);
+      if (!state.isPresent()) {
         throw new CmdLineException(owner, localizable("project %s not found"), nameWithoutSuffix);
       }
       // Hidden projects(permitsRead = false) should only be accessible by the project owners.
@@ -85,18 +85,18 @@
       // be allowed for other users). Allowing project owners to access here will help them to view
       // and update the config of hidden projects easily.
       ProjectPermission permissionToCheck =
-          state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
+          state.get().statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
       permissionBackend.currentUser().project(nameKey).check(permissionToCheck);
     } catch (AuthException e) {
       throw new CmdLineException(
           owner, localizable(new NoSuchProjectException(nameKey, e).getMessage()));
-    } catch (PermissionBackendException | IOException e) {
+    } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log("Cannot load project %s", nameWithoutSuffix);
       throw new CmdLineException(
           owner, localizable(new NoSuchProjectException(nameKey).getMessage()));
     }
 
-    setter.addValue(state);
+    setter.addValue(state.get());
     return 1;
   }
 
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 95929d3..0870786 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",
@@ -68,7 +67,6 @@
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
         "//lib/jsoup",
-        "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-queryparser",
diff --git a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
new file mode 100644
index 0000000..c2123cb
--- /dev/null
+++ b/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
@@ -0,0 +1,87 @@
+// 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.auth.ldap;
+
+import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.Collection;
+import java.util.Collections;
+
+/** Fake Implementation of an LDAP group backend used for testing */
+public class FakeLdapGroupBackend implements GroupBackend {
+
+  FakeLdapGroupBackend() {}
+
+  @Override
+  public boolean handles(AccountGroup.UUID uuid) {
+    /** Returns true if the provided parameter is an LDAP UUID */
+    return uuid.get().startsWith(LDAP_UUID);
+  }
+
+  @Override
+  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
+    if (!handles(uuid)) {
+      return null;
+    }
+
+    return new GroupDescription.Basic() {
+      @Override
+      public AccountGroup.UUID getGroupUUID() {
+        return uuid;
+      }
+
+      @Override
+      public String getName() {
+        return "fake_group";
+      }
+
+      @Override
+      @Nullable
+      public String getEmailAddress() {
+        return null;
+      }
+
+      @Override
+      @Nullable
+      public String getUrl() {
+        return null;
+      }
+    };
+  }
+
+  @Override
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
+    return Collections.emptySet();
+  }
+
+  @Override
+  public GroupMembership membershipsOf(IdentifiedUser user) {
+    return new ListGroupMembership(Collections.emptyList());
+  }
+
+  @Override
+  public boolean isVisibleToAll(AccountGroup.UUID uuid) {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index f58b0f7..3bb88e5 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -65,7 +65,7 @@
               .setToken(object.getToken())
               .setSecret(object.getSecret())
               .setRaw(object.getRaw())
-              .setExpiresAt(object.getExpiresAt())
+              .setExpiresAtMillis(object.getExpiresAt())
               .setProviderId(Strings.nullToEmpty(object.getProviderId()))
               .build());
     }
@@ -77,7 +77,7 @@
           proto.getToken(),
           proto.getSecret(),
           proto.getRaw(),
-          proto.getExpiresAt(),
+          proto.getExpiresAtMillis(),
           Strings.emptyToNull(proto.getProviderId()));
     }
   }
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/AccountPatchReviewStore.java b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
index 8da2a90..1b9008d 100644
--- a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -96,8 +96,8 @@
    *
    * @param psId patch set ID
    * @param accountId account ID of the user
-   * @return optionally, all files the have been reviewed by the given user that belong to the patch
-   *     set that is smaller or equals to the given patch set
+   * @return optionally, all files that have been reviewed by the given user that belong to the
+   *     patch set that is smaller or equals to the given patch set
    */
   Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId);
 }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index d493b31..6f28dad 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -89,14 +89,12 @@
     return Lists.newArrayList(visitorSet);
   }
 
-  public ChangeInfo addChangeActions(ChangeInfo to, ChangeNotes notes) {
+  void addChangeActions(ChangeInfo to, ChangeNotes notes) {
     List<ActionVisitor> visitors = visitors();
     to.actions = toActionMap(notes, visitors, copy(visitors, to));
-    return to;
   }
 
-  public RevisionInfo addRevisionActions(
-      @Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
+  void addRevisionActions(@Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
     List<ActionVisitor> visitors = visitors();
     if (!visitors.isEmpty()) {
       if (changeInfo != null) {
@@ -106,7 +104,6 @@
       }
     }
     to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
-    return to;
   }
 
   private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
@@ -119,6 +116,8 @@
     copy.project = changeInfo.project;
     copy.branch = changeInfo.branch;
     copy.topic = changeInfo.topic;
+    copy.attentionSet =
+        changeInfo.attentionSet == null ? null : ImmutableMap.copyOf(changeInfo.attentionSet);
     copy.assignee = changeInfo.assignee;
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
@@ -136,6 +135,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 +143,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/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index 664b84d..2778bdd 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -20,25 +20,33 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
 @Singleton
 public class AddReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final ExecutorService sendEmailsExecutor;
 
   @Inject
-  AddReviewersEmail(AddReviewerSender.Factory addReviewerSenderFactory) {
+  AddReviewersEmail(
+      AddReviewerSender.Factory addReviewerSenderFactory,
+      @SendEmailExecutor ExecutorService sendEmailsExecutor) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.sendEmailsExecutor = sendEmailsExecutor;
   }
 
-  public void emailReviewers(
+  public void emailReviewersAsync(
       IdentifiedUser user,
       Change change,
       Collection<Account.Id> added,
@@ -48,26 +56,41 @@
       NotifyResolver.Result notify) {
     // The user knows they added themselves, don't bother emailing them.
     Account.Id userId = user.getAccountId();
-    ImmutableList<Account.Id> toMail =
+    ImmutableList<Account.Id> immutableToMail =
         added.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
-    ImmutableList<Account.Id> toCopy =
+    ImmutableList<Account.Id> immutableToCopy =
         copied.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
-    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+    if (immutableToMail.isEmpty()
+        && immutableToCopy.isEmpty()
+        && addedByEmail.isEmpty()
+        && copiedByEmail.isEmpty()) {
       return;
     }
 
-    try {
-      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
-      cm.setNotify(notify);
-      cm.setFrom(userId);
-      cm.addReviewers(toMail);
-      cm.addReviewersByEmail(addedByEmail);
-      cm.addExtraCC(toCopy);
-      cm.addExtraCCByEmail(copiedByEmail);
-      cm.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot send email to new reviewers of change %s", change.getId());
-    }
+    // Make immutable copies of collections and hand over only immutable data types to the other
+    // thread.
+    Change.Id cId = change.getId();
+    Project.NameKey projectNameKey = change.getProject();
+    ImmutableList<Address> immutableAddedByEmail = ImmutableList.copyOf(addedByEmail);
+    ImmutableList<Address> immutableCopiedByEmail = ImmutableList.copyOf(copiedByEmail);
+
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailsExecutor.submit(
+            () -> {
+              try {
+                AddReviewerSender cm = addReviewerSenderFactory.create(projectNameKey, cId);
+                cm.setNotify(notify);
+                cm.setFrom(userId);
+                cm.addReviewers(immutableToMail);
+                cm.addReviewersByEmail(immutableAddedByEmail);
+                cm.addExtraCC(immutableToCopy);
+                cm.addExtraCCByEmail(immutableCopiedByEmail);
+                cm.send();
+              } catch (Exception err) {
+                logger.atSevere().withCause(err).log(
+                    "Cannot send email to new reviewers of change %s", change.getId());
+              }
+            });
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index 87d34a4..7b87a29 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
@@ -175,7 +176,10 @@
             approvalsUtil.addReviewers(
                 ctx.getNotes(),
                 ctx.getUpdate(change.currentPatchSetId()),
-                projectCache.checkedGet(change.getProject()).getLabelTypes(change.getDest()),
+                projectCache
+                    .get(change.getProject())
+                    .orElseThrow(illegalState(change.getProject()))
+                    .getLabelTypes(change.getDest()),
                 change,
                 accountIds);
       }
@@ -243,7 +247,7 @@
             .setAddedCCsByEmail(addedCCsByEmail)
             .build();
     if (sendEmail) {
-      addReviewersEmail.emailReviewers(
+      addReviewersEmail.emailReviewersAsync(
           ctx.getUser().asIdentifiedUser(),
           change,
           Lists.transform(addedReviewers, PatchSetApproval::accountId),
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
new file mode 100644
index 0000000..829c290
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -0,0 +1,85 @@
+// 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 static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+import java.util.function.Function;
+
+/** Add a specified user to the attention set. */
+public class AddToAttentionSetOp implements BatchUpdateOp {
+
+  public interface Factory {
+    AddToAttentionSetOp create(Account.Id attentionUserId, String reason);
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Account.Id attentionUserId;
+  private final String reason;
+
+  @Inject
+  AddToAttentionSetOp(
+      ChangeData.Factory changeDataFactory,
+      ChangeMessagesUtil cmUtil,
+      @Assisted Account.Id attentionUserId,
+      @Assisted String reason) {
+    this.changeDataFactory = changeDataFactory;
+    this.cmUtil = cmUtil;
+    this.attentionUserId = requireNonNull(attentionUserId, "user");
+    this.reason = requireNonNull(reason, "reason");
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    ChangeData changeData = changeDataFactory.create(ctx.getNotes());
+    Map<Account.Id, AttentionSetUpdate> attentionMap =
+        changeData.attentionSet().stream()
+            .collect(toImmutableMap(AttentionSetUpdate::account, Function.identity()));
+    AttentionSetUpdate existingEntry = attentionMap.get(attentionUserId);
+    if (existingEntry != null && existingEntry.operation() == Operation.ADD) {
+      return false;
+    }
+
+    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    update.setAttentionSetUpdates(
+        ImmutableSet.of(
+            AttentionSetUpdate.createForWrite(
+                attentionUserId, AttentionSetUpdate.Operation.ADD, reason)));
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+    String message = "Added to attention set: " + attentionUserId;
+    cmUtil.addChangeMessage(
+        update,
+        ChangeMessagesUtil.newMessage(ctx, message, ChangeMessagesUtil.TAG_UPDATE_ATTENTION_SET));
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ArchiveFormat.java b/java/com/google/gerrit/server/change/ArchiveFormat.java
deleted file mode 100644
index d895a66..0000000
--- a/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright 2013 Google Inc. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 java.io.Closeable;
-import java.io.IOException;
-import java.io.OutputStream;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.api.ArchiveCommand;
-import org.eclipse.jgit.api.ArchiveCommand.Format;
-import org.eclipse.jgit.archive.TarFormat;
-import org.eclipse.jgit.archive.Tbz2Format;
-import org.eclipse.jgit.archive.TgzFormat;
-import org.eclipse.jgit.archive.TxzFormat;
-import org.eclipse.jgit.archive.ZipFormat;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectLoader;
-
-public enum ArchiveFormat {
-  TGZ("application/x-gzip", new TgzFormat()),
-  TAR("application/x-tar", new TarFormat()),
-  TBZ2("application/x-bzip2", new Tbz2Format()),
-  TXZ("application/x-xz", new TxzFormat()),
-  ZIP("application/x-zip", new ZipFormat());
-
-  @SuppressWarnings("ImmutableEnumChecker") // ArchiveCommand.Format is effectively immutable.
-  private final ArchiveCommand.Format<?> format;
-
-  private final String mimeType;
-
-  ArchiveFormat(String mimeType, ArchiveCommand.Format<?> format) {
-    this.format = format;
-    this.mimeType = mimeType;
-    ArchiveCommand.registerFormat(name(), format);
-  }
-
-  public String getShortName() {
-    return name().toLowerCase();
-  }
-
-  public String getMimeType() {
-    return mimeType;
-  }
-
-  public String getDefaultSuffix() {
-    return getSuffixes().iterator().next();
-  }
-
-  public Iterable<String> getSuffixes() {
-    return format.suffixes();
-  }
-
-  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
-    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
-  }
-
-  public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
-    @SuppressWarnings("unchecked")
-    ArchiveCommand.Format<T> fmt = (Format<T>) format;
-    fmt.putEntry(
-        out,
-        null,
-        path,
-        FileMode.REGULAR_FILE,
-        new ObjectLoader.SmallObject(FileMode.TYPE_FILE, data));
-  }
-}
diff --git a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
new file mode 100644
index 0000000..f6e9ff9
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
@@ -0,0 +1,79 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 java.io.Closeable;
+import java.io.IOException;
+import java.io.OutputStream;
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.eclipse.jgit.api.ArchiveCommand;
+import org.eclipse.jgit.api.ArchiveCommand.Format;
+import org.eclipse.jgit.archive.TarFormat;
+import org.eclipse.jgit.archive.Tbz2Format;
+import org.eclipse.jgit.archive.TgzFormat;
+import org.eclipse.jgit.archive.TxzFormat;
+import org.eclipse.jgit.archive.ZipFormat;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectLoader;
+
+public enum ArchiveFormatInternal {
+  TGZ("application/x-gzip", new TgzFormat()),
+  TAR("application/x-tar", new TarFormat()),
+  TBZ2("application/x-bzip2", new Tbz2Format()),
+  TXZ("application/x-xz", new TxzFormat()),
+  ZIP("application/x-zip", new ZipFormat());
+
+  @SuppressWarnings("ImmutableEnumChecker") // ArchiveCommand.Format is effectively immutable.
+  private final ArchiveCommand.Format<?> format;
+
+  private final String mimeType;
+
+  ArchiveFormatInternal(String mimeType, ArchiveCommand.Format<?> format) {
+    this.format = format;
+    this.mimeType = mimeType;
+    ArchiveCommand.registerFormat(name(), format);
+  }
+
+  public String getShortName() {
+    return name().toLowerCase();
+  }
+
+  public String getMimeType() {
+    return mimeType;
+  }
+
+  public String getDefaultSuffix() {
+    return getSuffixes().iterator().next();
+  }
+
+  public Iterable<String> getSuffixes() {
+    return format.suffixes();
+  }
+
+  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
+    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
+  }
+
+  public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
+    @SuppressWarnings("unchecked")
+    ArchiveCommand.Format<T> fmt = (Format<T>) format;
+    fmt.putEntry(
+        out,
+        null,
+        path,
+        FileMode.REGULAR_FILE,
+        new ObjectLoader.SmallObject(FileMode.TYPE_FILE, data));
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
new file mode 100644
index 0000000..6c6c765
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
@@ -0,0 +1,46 @@
+// 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 com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+/** REST resource that represents an entry in the attention set of a change. */
+public class AttentionSetEntryResource implements RestResource {
+  public static final TypeLiteral<RestView<AttentionSetEntryResource>> ATTENTION_SET_ENTRY_KIND =
+      new TypeLiteral<RestView<AttentionSetEntryResource>>() {};
+
+  public interface Factory {
+    AttentionSetEntryResource create(ChangeResource change, Account.Id id);
+  }
+
+  private final ChangeResource changeResource;
+  private final Account.Id accountId;
+
+  public AttentionSetEntryResource(ChangeResource changeResource, Account.Id accountId) {
+    this.changeResource = changeResource;
+    this.accountId = accountId;
+  }
+
+  public ChangeResource getChangeResource() {
+    return changeResource;
+  }
+
+  public Account.Id getAccountId() {
+    return accountId;
+  }
+}
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/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index 8d2d83d..71d7ba0 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -49,6 +50,8 @@
 
 @Singleton
 public class ChangeFinder {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final String CACHE_NAME = "changeid_project";
 
   public static Module module() {
@@ -96,12 +99,12 @@
                 .build());
   }
 
-  public ChangeNotes findOne(String id) {
+  public Optional<ChangeNotes> findOne(String id) {
     List<ChangeNotes> ctls = find(id);
     if (ctls.size() != 1) {
-      return null;
+      return Optional.empty();
     }
-    return ctls.get(0);
+    return Optional.of(ctls.get(0));
   }
 
   /**
@@ -180,12 +183,12 @@
     }
   }
 
-  public ChangeNotes findOne(Change.Id id) {
+  public Optional<ChangeNotes> findOne(Change.Id id) {
     List<ChangeNotes> notes = find(id);
     if (notes.size() != 1) {
-      throw new NoSuchChangeException(id);
+      return Optional.empty();
     }
-    return notes.get(0);
+    return Optional.of(notes.get(0));
   }
 
   public List<ChangeNotes> find(Change.Id id) {
@@ -208,7 +211,11 @@
     List<ChangeNotes> notes = new ArrayList<>(cds.size());
     if (!indexConfig.separateChangeSubIndexes()) {
       for (ChangeData cd : cds) {
-        notes.add(cd.notes());
+        try {
+          notes.add(cd.notes());
+        } catch (NoSuchChangeException e) {
+          logger.atWarning().log("Change %s seen in index, but missing in NoteDb", e.getMessage());
+        }
       }
       return notes;
     }
@@ -222,7 +229,11 @@
     Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
     for (ChangeData cd : cds) {
       if (seen.add(cd.getId())) {
-        notes.add(cd.notes());
+        try {
+          notes.add(cd.notes());
+        } catch (NoSuchChangeException e) {
+          logger.atWarning().log("Change %s seen in index, but missing in NoteDb", e.getMessage());
+        }
       }
     }
     return notes;
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index a00f1f8..bbb94ea 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
 import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.MoreObjects;
@@ -37,8 +38,10 @@
 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.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -69,6 +72,7 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -111,6 +115,7 @@
   private final String refName;
 
   // Fields exposed as setters.
+  private PatchSet.Id cherryPickOf;
   private Change.Status status;
   private String topic;
   private String message;
@@ -189,6 +194,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);
@@ -227,6 +233,11 @@
     return this;
   }
 
+  public ChangeInserter setCherryPickOf(PatchSet.Id cherryPickOf) {
+    this.cherryPickOf = cherryPickOf;
+    return this;
+  }
+
   public ChangeInserter setMessage(String message) {
     this.message = message;
     return this;
@@ -351,7 +362,7 @@
   @Override
   public void updateRepo(RepoContext ctx) throws ResourceConflictException, IOException {
     cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, psId.toRefName());
-    projectState = projectCache.checkedGet(ctx.getProject());
+    projectState = projectCache.get(ctx.getProject()).orElseThrow(illegalState(ctx.getProject()));
     validate(ctx);
     if (!updateRef) {
       return;
@@ -371,13 +382,20 @@
     update.setChangeId(change.getKey().get());
     update.setSubjectForCommit("Create change");
     update.setBranch(change.getDest().branch());
-    update.setTopic(change.getTopic());
+    try {
+      update.setTopic(change.getTopic());
+    } catch (ValidationException ex) {
+      throw new BadRequestException(ex.getMessage());
+    }
     update.setPsDescription(patchSetDescription);
     update.setPrivate(isPrivate);
     update.setWorkInProgress(workInProgress);
     if (revertOf != null) {
       update.setRevertOf(revertOf.get());
     }
+    if (cherryPickOf != null) {
+      update.setCherryPickOf(cherryPickOf.getCommaSeparatedChangeAndPatchSetId());
+    }
 
     List<String> newGroups = groups;
     if (newGroups.isEmpty()) {
@@ -387,7 +405,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
@@ -395,7 +413,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);
@@ -552,10 +574,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..8c4f275 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
@@ -29,10 +30,10 @@
 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;
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
@@ -61,6 +62,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.AttentionSetEntry;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
@@ -103,6 +105,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -111,7 +114,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.function.Supplier;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -138,6 +140,7 @@
           COMMIT_FOOTERS,
           CURRENT_ACTIONS,
           CURRENT_COMMIT,
+          DETAILED_LABELS, // may need to load ChangeNotes to check remove reviewer permissions
           MESSAGES);
 
   @Singleton
@@ -218,7 +221,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 +259,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;
 
@@ -277,17 +279,13 @@
     return format(changeDataFactory.create(change));
   }
 
-  public ChangeInfo format(Project.NameKey project, Change.Id id) {
-    return format(project, id, ChangeInfo::new);
-  }
-
   public ChangeInfo format(ChangeData cd) {
-    return format(cd, Optional.empty(), true, ChangeInfo::new);
+    return format(cd, Optional.empty(), true);
   }
 
   public ChangeInfo format(RevisionResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, ChangeInfo::new);
+    return format(cd, Optional.of(rsrc.getPatchSet().id()), true);
   }
 
   public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
@@ -313,14 +311,13 @@
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
     for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false, ChangeInfo::new));
+      out.add(format(cd, Optional.empty(), false));
     }
     accountLoader.fill();
     return out;
   }
 
-  public <I extends ChangeInfo> I format(
-      Project.NameKey project, Change.Id id, Supplier<I> changeInfoSupplier) {
+  public ChangeInfo format(Project.NameKey project, Change.Id id) {
     ChangeNotes notes;
     try {
       notes = notesFactory.createChecked(project, id);
@@ -328,9 +325,9 @@
       if (!has(CHECK)) {
         throw e;
       }
-      return checkOnly(changeDataFactory.create(project, id), changeInfoSupplier);
+      return checkOnly(changeDataFactory.create(project, id));
     }
-    return format(changeDataFactory.create(notes), Optional.empty(), true, changeInfoSupplier);
+    return format(changeDataFactory.create(notes), Optional.empty(), true);
   }
 
   private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
@@ -347,7 +344,7 @@
   }
 
   private static SubmitRequirementInfo requirementToInfo(SubmitRequirement req, Status status) {
-    return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type(), req.data());
+    return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
   }
 
   private static void finish(ChangeInfo info) {
@@ -361,19 +358,16 @@
     return !Sets.intersection(toFind, set).isEmpty();
   }
 
-  private <I extends ChangeInfo> I format(
-      ChangeData cd,
-      Optional<PatchSet.Id> limitToPsId,
-      boolean fillAccountLoader,
-      Supplier<I> changeInfoSupplier) {
+  private ChangeInfo format(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader) {
     try {
       if (fillAccountLoader) {
         accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        I res = toChangeInfo(cd, limitToPsId, changeInfoSupplier);
+        ChangeInfo res = toChangeInfo(cd, limitToPsId);
         accountLoader.fill();
         return res;
       }
-      return toChangeInfo(cd, limitToPsId, changeInfoSupplier);
+      return toChangeInfo(cd, limitToPsId);
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
@@ -383,7 +377,7 @@
         Throwables.throwIfInstanceOf(e, StorageException.class);
         throw new StorageException(e);
       }
-      return checkOnly(cd, changeInfoSupplier);
+      return checkOnly(cd);
     }
   }
 
@@ -432,7 +426,7 @@
         // Compute and cache if possible
         try {
           ensureLoaded(Collections.singleton(cd));
-          info = format(cd, Optional.empty(), false, ChangeInfo::new);
+          info = format(cd, Optional.empty(), false);
           changeInfos.add(info);
           if (isCacheable) {
             cache.put(Change.id(info._number), info);
@@ -446,14 +440,14 @@
     }
   }
 
-  private <I extends ChangeInfo> I checkOnly(ChangeData cd, Supplier<I> changeInfoSupplier) {
+  private ChangeInfo checkOnly(ChangeData cd) {
     ChangeNotes notes;
     try {
       notes = cd.notes();
     } catch (StorageException e) {
       String msg = "Error loading change";
       logger.atWarning().withCause(e).log(msg + " %s", cd.getId());
-      I info = changeInfoSupplier.get();
+      ChangeInfo info = new ChangeInfo();
       info._number = cd.getId().get();
       ProblemInfo p = new ProblemInfo();
       p.message = msg;
@@ -462,7 +456,7 @@
     }
 
     ConsistencyChecker.Result result = checkerProvider.get().check(notes, fix);
-    I info = changeInfoSupplier.get();
+    ChangeInfo info = new ChangeInfo();
     Change c = result.change();
     if (c != null) {
       info.project = c.getProject().get();
@@ -487,18 +481,16 @@
     return info;
   }
 
-  private <I extends ChangeInfo> I toChangeInfo(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, Supplier<I> changeInfoSupplier)
+  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
-      return toChangeInfoImpl(cd, limitToPsId, changeInfoSupplier);
+      return toChangeInfoImpl(cd, limitToPsId);
     }
   }
 
-  private <I extends ChangeInfo> I toChangeInfoImpl(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, Supplier<I> changeInfoSupplier)
+  private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
-    I out = changeInfoSupplier.get();
+    ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
 
     if (has(CHECK)) {
@@ -516,6 +508,19 @@
     out.project = in.getProject().get();
     out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
+    if (!cd.attentionSet().isEmpty()) {
+      out.attentionSet =
+          // This filtering should match GetAttentionSet.
+          additionsOnly(cd.attentionSet()).stream()
+              .collect(
+                  toImmutableMap(
+                      a -> a.account().get(),
+                      a ->
+                          new AttentionSetEntry(
+                              accountLoader.get(a.account()),
+                              Timestamp.from(a.timestamp()),
+                              a.reason())));
+    }
     out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
@@ -524,7 +529,7 @@
       if (str.isOk()) {
         out.submitType = str.type;
       }
-      if (!excludeMergeableInChangeInfo && !has(SKIP_MERGEABLE)) {
+      if (includeMergeable) {
         out.mergeable = cd.isMergeable();
       }
       if (has(SUBMITTABLE)) {
@@ -586,6 +591,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);
@@ -774,7 +786,7 @@
 
   private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
     return addresses.stream()
-        .map(a -> new AccountInfo(a.getName(), a.getEmail()))
+        .map(a -> new AccountInfo(a.name(), a.email()))
         .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
         .collect(toList());
   }
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/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 8b8ce54..a7fc5de 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.entities.Account;
@@ -47,15 +46,12 @@
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class ChangeResource implements RestResource, HasETag {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   /**
    * JSON format version number for ETag computations.
    *
@@ -189,14 +185,8 @@
     // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
     // and edits.
 
-    Iterable<ProjectState> projectStateTree;
-    try {
-      projectStateTree = projectCache.checkedGet(getProject()).tree();
-    } catch (IOException e) {
-      logger.atSevere().log("could not load project %s while computing etag", getProject());
-      projectStateTree = ImmutableList.of();
-    }
-
+    Iterable<ProjectState> projectStateTree =
+        projectCache.get(getProject()).orElseThrow(illegalState(getProject())).tree();
     for (ProjectState p : projectStateTree) {
       hashObjectId(h, p.getConfig().getRevision(), buf);
     }
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/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index c4de02c..b70b059 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
@@ -118,7 +120,11 @@
     currChange = ctx.getChange();
     currPs = psUtil.current(ctx.getNotes());
 
-    LabelTypes labelTypes = projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes());
+    LabelTypes labelTypes =
+        projectCache
+            .get(ctx.getProject())
+            .orElseThrow(illegalState(ctx.getProject()))
+            .getLabelTypes(ctx.getNotes());
     // removing a reviewer will remove all her votes
     for (LabelType lt : labelTypes.getLabelTypes()) {
       newApprovals.put(lt.getName(), (short) 0);
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/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 1ef3aee..67cd0df 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -30,7 +31,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
 
@@ -81,12 +81,15 @@
    * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
    *     unknown labels are not included in the output.
    */
-  public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals)
-      throws IOException {
+  public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals) {
     List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
-    LabelTypes labelTypes = projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes);
+    LabelTypes labelTypes =
+        projectCache
+            .get(notes.getProjectName())
+            .orElseThrow(illegalState(notes.getProjectName()))
+            .getLabelTypes(notes);
     for (PatchSetApproval psa : approvals) {
       Change.Id changeId = psa.key().patchSetId().changeId();
       checkArgument(
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/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 71c54b1..988d178 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
@@ -26,6 +27,7 @@
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
+import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
@@ -49,6 +52,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -74,6 +78,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
+  private final WorkInProgressStateChanged wipStateChanged;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -86,12 +91,14 @@
   // Fields exposed as setters.
   private String message;
   private String description;
+  private Boolean workInProgress;
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private List<String> groups = Collections.emptyList();
   private boolean fireRevisionCreated = true;
   private boolean allowClosed;
   private boolean sendEmail = true;
+  private String topic;
 
   // Fields set during some phase of BatchUpdate.Op.
   private Change change;
@@ -99,6 +106,7 @@
   private PatchSetInfo patchSetInfo;
   private ChangeMessage changeMessage;
   private ReviewerSet oldReviewers;
+  private boolean oldWorkInProgressState;
 
   @Inject
   public PatchSetInserter(
@@ -111,6 +119,7 @@
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
+      WorkInProgressStateChanged wipStateChanged,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -123,6 +132,7 @@
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
+    this.wipStateChanged = wipStateChanged;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -143,6 +153,11 @@
     return this;
   }
 
+  public PatchSetInserter setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+    return this;
+  }
+
   public PatchSetInserter setValidate(boolean validate) {
     this.validate = validate;
     return this;
@@ -174,6 +189,11 @@
     return this;
   }
 
+  public PatchSetInserter setTopic(String topic) {
+    this.topic = topic;
+    return this;
+  }
+
   public Change getChange() {
     checkState(change != null, "getChange() only valid after executing update");
     return change;
@@ -192,7 +212,8 @@
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, IOException {
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, IOException, BadRequestException {
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setSubjectForCommit("Create patch set " + psId.get());
@@ -230,6 +251,13 @@
       changeMessage.setMessage(message);
     }
 
+    oldWorkInProgressState = change.isWorkInProgress();
+    if (workInProgress != null) {
+      change.setWorkInProgress(workInProgress);
+      change.setReviewStarted(!workInProgress);
+      update.setWorkInProgress(workInProgress);
+    }
+
     patchSetInfo =
         patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
     if (!allowClosed) {
@@ -239,6 +267,14 @@
     if (changeMessage != null) {
       cmUtil.addChangeMessage(update, changeMessage);
     }
+    if (topic != null) {
+      change.setTopic(topic);
+      try {
+        update.setTopic(topic);
+      } catch (ValidationException ex) {
+        throw new BadRequestException(ex.getMessage());
+      }
+    }
     return true;
   }
 
@@ -265,6 +301,10 @@
     if (fireRevisionCreated) {
       revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
     }
+
+    if (workInProgress != null && oldWorkInProgressState != workInProgress) {
+      wipStateChanged.fire(change, patchSet, ctx.getAccount(), ctx.getWhen());
+    }
   }
 
   private void validate(RepoContext ctx)
@@ -275,7 +315,10 @@
     if (checkAddPatchSetPermission) {
       permissionBackend.user(ctx.getUser()).change(origNotes).check(ChangePermission.ADD_PATCH_SET);
     }
-    projectCache.checkedGet(ctx.getProject()).checkStatePermitsWrite();
+    projectCache
+        .get(ctx.getProject())
+        .orElseThrow(illegalState(ctx.getProject()))
+        .checkStatePermitsWrite();
     if (!validate) {
       return;
     }
@@ -287,7 +330,10 @@
                 ObjectId.zeroId(),
                 commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
-            projectCache.checkedGet(origNotes.getProjectName()).getProject(),
+            projectCache
+                .get(origNotes.getProjectName())
+                .orElseThrow(illegalState(origNotes.getProjectName()))
+                .getProject(),
             origNotes.getChange().getDest().branch(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 4723af8..231359b 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -207,7 +209,8 @@
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, IOException {
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, IOException, BadRequestException {
     boolean ret = patchSetInserter.updateChange(ctx);
     rebasedPatchSet = patchSetInserter.getPatchSet();
     return ret;
@@ -233,8 +236,9 @@
     return rebasedPatchSet;
   }
 
-  private MergeUtil newMergeUtil() throws IOException {
-    ProjectState project = projectCache.checkedGet(notes.getProjectName());
+  private MergeUtil newMergeUtil() {
+    ProjectState project =
+        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
     return forceContentMerge
         ? mergeUtilFactory.create(project, true)
         : mergeUtilFactory.create(project);
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
new file mode 100644
index 0000000..07f0d78
--- /dev/null
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -0,0 +1,84 @@
+// 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 static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+import java.util.function.Function;
+
+/** Remove a specified user from the attention set. */
+public class RemoveFromAttentionSetOp implements BatchUpdateOp {
+
+  public interface Factory {
+    RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason);
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Account.Id attentionUserId;
+  private final String reason;
+
+  @Inject
+  RemoveFromAttentionSetOp(
+      ChangeData.Factory changeDataFactory,
+      ChangeMessagesUtil cmUtil,
+      @Assisted Account.Id attentionUserId,
+      @Assisted String reason) {
+    this.changeDataFactory = changeDataFactory;
+    this.cmUtil = cmUtil;
+    this.attentionUserId = requireNonNull(attentionUserId, "user");
+    this.reason = requireNonNull(reason, "reason");
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    ChangeData changeData = changeDataFactory.create(ctx.getNotes());
+    Map<Account.Id, AttentionSetUpdate> attentionMap =
+        changeData.attentionSet().stream()
+            .collect(toImmutableMap(AttentionSetUpdate::account, Function.identity()));
+    AttentionSetUpdate existingEntry = attentionMap.get(attentionUserId);
+    if (existingEntry == null || existingEntry.operation() == Operation.REMOVE) {
+      return false;
+    }
+
+    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    update.setAttentionSetUpdates(
+        ImmutableSet.of(
+            AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason)));
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+    String message = "Removed from attention set: " + attentionUserId;
+    cmUtil.addChangeMessage(
+        update,
+        ChangeMessagesUtil.newMessage(ctx, message, ChangeMessagesUtil.TAG_UPDATE_ATTENTION_SET));
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index ba6ba21..d9462bf 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -20,6 +20,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
@@ -57,6 +58,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -81,6 +84,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 +129,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;
@@ -194,40 +202,44 @@
   public ReviewerAddition prepare(
       ChangeNotes notes, CurrentUser user, AddReviewerInput input, boolean allowGroup)
       throws IOException, PermissionBackendException, ConfigInvalidException {
-    requireNonNull(input.reviewer);
-    boolean confirmed = input.confirmed();
-    boolean allowByEmail =
-        projectCache
-            .checkedGet(notes.getProjectName())
-            .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) {
+      requireNonNull(input.reviewer);
+      boolean confirmed = input.confirmed();
+      boolean allowByEmail =
+          projectCache
+              .get(notes.getProjectName())
+              .orElseThrow(illegalState(notes.getProjectName()))
+              .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
 
-    ReviewerAddition byAccountId = addByAccountId(input, notes, user);
+      ReviewerAddition byAccountId = addByAccountId(input, notes, user);
 
-    ReviewerAddition wholeGroup = null;
-    if (!byAccountId.exactMatchFound) {
-      wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
-      if (wholeGroup != null && wholeGroup.exactMatchFound) {
+      ReviewerAddition wholeGroup = null;
+      if (!byAccountId.exactMatchFound) {
+        wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
+        if (wholeGroup != null && wholeGroup.exactMatchFound) {
+          return wholeGroup;
+        }
+      }
+
+      if (wholeGroup != null
+          && byAccountId.failureType == FailureType.NOT_FOUND
+          && wholeGroup.failureType == FailureType.NOT_FOUND) {
+        return fail(
+            byAccountId.input,
+            FailureType.NOT_FOUND,
+            byAccountId.result.error + "\n" + wholeGroup.result.error);
+      }
+
+      if (byAccountId.failureType != FailureType.NOT_FOUND) {
+        return byAccountId;
+      }
+      if (wholeGroup != null) {
         return wholeGroup;
       }
-    }
 
-    if (wholeGroup != null
-        && byAccountId.failureType == FailureType.NOT_FOUND
-        && wholeGroup.failureType == FailureType.NOT_FOUND) {
-      return fail(
-          byAccountId.input,
-          FailureType.NOT_FOUND,
-          byAccountId.result.error + "\n" + wholeGroup.result.error);
+      return addByEmail(input, notes, user);
     }
-
-    if (byAccountId.failureType != FailureType.NOT_FOUND) {
-      return byAccountId;
-    }
-    if (wholeGroup != null) {
-      return wholeGroup;
-    }
-
-    return addByEmail(input, notes, user);
   }
 
   public ReviewerAddition ccCurrentUser(CurrentUser user, RevisionResource revision) {
@@ -367,7 +379,7 @@
     }
 
     Address adr = Address.tryParse(input.reviewer);
-    if (adr == null || !validator.isValid(adr.getEmail())) {
+    if (adr == null || !validator.isValid(adr.email())) {
       return fail(
           input,
           FailureType.NOT_FOUND,
@@ -469,7 +481,7 @@
         }
         accountLoaderFactory.create(true).fill(result.ccs);
         for (Address a : opResult.addedCCsByEmail()) {
-          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+          result.ccs.add(new AccountInfo(a.name(), a.email()));
         }
       } else {
         result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
@@ -484,7 +496,7 @@
         }
         accountLoaderFactory.create(true).fill(result.reviewers);
         for (Address a : opResult.addedReviewersByEmail()) {
-          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
+          result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
         }
       }
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 6686ed8..39e5f74 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -75,7 +75,7 @@
       ReviewerInfo info;
       if (rsrc.isByEmail()) {
         Address address = rsrc.getReviewerByEmail();
-        info = ReviewerInfo.byEmail(address.getName(), address.getEmail());
+        info = ReviewerInfo.byEmail(address.name(), address.email());
       } else {
         Account.Id reviewerAccountId = rsrc.getReviewerUser().getAccountId();
         info = format(new ReviewerInfo(reviewerAccountId.get()), reviewerAccountId, cd);
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index fbd14c4..001a532 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -27,6 +27,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -49,6 +50,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;
@@ -236,7 +238,7 @@
   }
 
   private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in)
-      throws PermissionBackendException, IOException {
+      throws PermissionBackendException {
     Map<String, FetchInfo> r = new LinkedHashMap<>();
     for (Extension<DownloadScheme> e : downloadSchemes) {
       String schemeName = e.getExportName();
@@ -305,15 +307,19 @@
         }
         out.commitWithFooters =
             mergeUtilFactory
-                .create(projectCache.get(project))
+                .create(projectCache.get(project).orElseThrow(illegalState(project)))
                 .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.id());
       }
     }
 
     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()) {
@@ -352,18 +358,15 @@
         : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
   }
 
-  private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException, IOException {
+  private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException {
     try {
       permissionBackendForChange(permissionBackend.user(anonymous), cd)
           .check(ChangePermission.READ);
     } catch (AuthException ae) {
       return false;
     }
-    ProjectState projectState = projectCache.checkedGet(cd.project());
-    if (projectState == null) {
-      logger.atSevere().log("project state for project %s is null", cd.project());
-      return false;
-    }
+    ProjectState projectState =
+        projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
     return projectState.statePermitsRead();
   }
 
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/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
new file mode 100644
index 0000000..f4dcd10
--- /dev/null
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -0,0 +1,97 @@
+// 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.config;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.server.account.StoredPreferences;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Container class for preferences serialized as Git-style config files. Keeps the values as {@link
+ * String}s as they are immutable and thread-safe.
+ */
+@AutoValue
+public abstract class CachedPreferences {
+
+  public static CachedPreferences EMPTY = fromString("");
+
+  public abstract String config();
+
+  /** Returns a cache-able representation of the config. */
+  public static CachedPreferences fromConfig(Config cfg) {
+    return new AutoValue_CachedPreferences(cfg.toText());
+  }
+
+  /**
+   * Returns a cache-able representation of the config. To be used only when constructing a {@link
+   * CachedPreferences} from a serialized, cached value.
+   */
+  public static CachedPreferences fromString(String cfg) {
+    return new AutoValue_CachedPreferences(cfg);
+  }
+
+  public static GeneralPreferencesInfo general(
+      Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
+    try {
+      return StoredPreferences.parseGeneralPreferences(
+          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
+    } catch (ConfigInvalidException e) {
+      return GeneralPreferencesInfo.defaults();
+    }
+  }
+
+  public static EditPreferencesInfo edit(
+      Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
+    try {
+      return StoredPreferences.parseEditPreferences(
+          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
+    } catch (ConfigInvalidException e) {
+      return EditPreferencesInfo.defaults();
+    }
+  }
+
+  public static DiffPreferencesInfo diff(
+      Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
+    try {
+      return StoredPreferences.parseDiffPreferences(
+          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
+    } catch (ConfigInvalidException e) {
+      return DiffPreferencesInfo.defaults();
+    }
+  }
+
+  public Config asConfig() {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(config());
+    } catch (ConfigInvalidException e) {
+      // Programmer error: We have parsed this config before and are unable to parse it now.
+      throw new StorageException(e);
+    }
+    return cfg;
+  }
+
+  @Nullable
+  private static Config configOrNull(Optional<CachedPreferences> cachedPreferences) {
+    return cachedPreferences.map(CachedPreferences::asConfig).orElse(null);
+  }
+}
diff --git a/java/com/google/gerrit/server/config/ChangeCleanupConfig.java b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
index 0a38ee8..cb4bff8 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 final String SECTION = "changeCleanup";
   private static final String KEY_ABANDON_AFTER = "abandonAfter";
   private static final 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/DefaultPreferencesCache.java b/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
new file mode 100644
index 0000000..39adb48
--- /dev/null
+++ b/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
@@ -0,0 +1,32 @@
+// 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.config;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache for Gerrit's default preferences (general, diff edit). */
+public interface DefaultPreferencesCache {
+  /**
+   * Static member to be returned when there is no default config. This prevents re-instantiating
+   * many {@link CachedPreferences} in this case.
+   */
+  CachedPreferences EMPTY = CachedPreferences.fromString("");
+
+  /** Returns a cached instance of {@link CachedPreferences}. */
+  CachedPreferences get();
+
+  /** Returns a cached instance of {@link CachedPreferences} at the specified revision. */
+  CachedPreferences get(ObjectId rev);
+}
diff --git a/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java b/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
new file mode 100644
index 0000000..f8156a7
--- /dev/null
+++ b/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
@@ -0,0 +1,106 @@
+// 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.config;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class DefaultPreferencesCacheImpl implements DefaultPreferencesCache {
+  private static final String NAME = "default_preferences";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        // Bind an in-memory cache that allows exactly 1 value to be cached.
+        cache(NAME, ObjectId.class, CachedPreferences.class).loader(Loader.class).maximumWeight(1);
+        bind(DefaultPreferencesCacheImpl.class);
+        bind(DefaultPreferencesCache.class).to(DefaultPreferencesCacheImpl.class);
+      }
+    };
+  }
+
+  private final GitRepositoryManager repositoryManager;
+  private final AllUsersName allUsersName;
+  private final LoadingCache<ObjectId, CachedPreferences> cache;
+
+  @Inject
+  DefaultPreferencesCacheImpl(
+      GitRepositoryManager repositoryManager,
+      AllUsersName allUsersName,
+      @Named(NAME) LoadingCache<ObjectId, CachedPreferences> cache) {
+    this.repositoryManager = repositoryManager;
+    this.allUsersName = allUsersName;
+    this.cache = cache;
+  }
+
+  @Override
+  public CachedPreferences get() {
+    try (Repository allUsersRepo = repositoryManager.openRepository(allUsersName)) {
+      Ref ref = allUsersRepo.exactRef(RefNames.REFS_USERS_DEFAULT);
+      if (ref == null) {
+        return EMPTY;
+      }
+      return get(ref.getObjectId());
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
+  public CachedPreferences get(ObjectId rev) {
+    try {
+      return cache.get(rev);
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Singleton
+  private static class Loader extends CacheLoader<ObjectId, CachedPreferences> {
+    private final GitRepositoryManager repositoryManager;
+    private final AllUsersName allUsersName;
+
+    @Inject
+    Loader(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
+      this.repositoryManager = repositoryManager;
+      this.allUsersName = allUsersName;
+    }
+
+    @Override
+    public CachedPreferences load(ObjectId key) throws IOException, ConfigInvalidException {
+      try (Repository allUsersRepo = repositoryManager.openRepository(allUsersName)) {
+        VersionedDefaultPreferences versionedDefaultPreferences = new VersionedDefaultPreferences();
+        versionedDefaultPreferences.load(allUsersName, allUsersRepo, key);
+        return CachedPreferences.fromConfig(versionedDefaultPreferences.getConfig());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 6dea07d..58ce098 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
-import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.ArchiveFormatInternal;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.lang.reflect.Field;
@@ -37,7 +37,7 @@
 public class DownloadConfig {
   private final ImmutableSet<String> downloadSchemes;
   private final ImmutableSet<DownloadCommand> downloadCommands;
-  private final ImmutableSet<ArchiveFormat> archiveFormats;
+  private final ImmutableSet<ArchiveFormatInternal> archiveFormats;
 
   @Inject
   DownloadConfig(@GerritServerConfig Config cfg) {
@@ -69,13 +69,13 @@
 
     String v = cfg.getString("download", null, "archive");
     if (v == null) {
-      archiveFormats = ImmutableSet.copyOf(EnumSet.allOf(ArchiveFormat.class));
+      archiveFormats = ImmutableSet.copyOf(EnumSet.allOf(ArchiveFormatInternal.class));
     } else if (v.isEmpty() || "off".equalsIgnoreCase(v)) {
       archiveFormats = ImmutableSet.of();
     } else {
       archiveFormats =
           ImmutableSet.copyOf(
-              ConfigUtil.getEnumList(cfg, "download", null, "archive", ArchiveFormat.TGZ));
+              ConfigUtil.getEnumList(cfg, "download", null, "archive", ArchiveFormatInternal.TGZ));
     }
   }
 
@@ -110,7 +110,7 @@
   }
 
   /** Archive formats for downloading. */
-  public ImmutableSet<ArchiveFormat> getArchiveFormats() {
+  public ImmutableSet<ArchiveFormatInternal> getArchiveFormats() {
     return archiveFormats;
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 25f2b20..cf592bf 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.CloneCommand;
@@ -79,6 +80,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 +129,9 @@
 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.CommentCumulativeSizeValidator;
+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 +162,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;
@@ -229,6 +235,7 @@
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
     install(ConflictsCacheImpl.module());
+    install(DefaultPreferencesCacheImpl.module());
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
     install(MergeabilityCacheImpl.module());
@@ -262,11 +269,15 @@
     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);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+    AccountDefaultDisplayName accountDefaultDisplayName =
+        cfg.getEnum("accounts", null, "defaultDisplayName", AccountDefaultDisplayName.FULL_NAME);
+    bind(AccountDefaultDisplayName.class).toInstance(accountDefaultDisplayName);
     factory(ProjectOwnerGroupsProvider.Factory.class);
     factory(SubmitRuleEvaluator.Factory.class);
 
@@ -395,6 +406,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);
@@ -406,6 +418,16 @@
     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);
+    bind(CommentValidator.class)
+        .annotatedWith(Exports.named(CommentCumulativeSizeValidator.class.getSimpleName()))
+        .to(CommentCumulativeSizeValidator.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/GerritInstanceId.java b/java/com/google/gerrit/server/config/GerritInstanceId.java
new file mode 100644
index 0000000..dba7eac
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritInstanceId.java
@@ -0,0 +1,30 @@
+// 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.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on a {@link String} holding the instance id for this server.
+ *
+ * <p>Note that the String may be null, if the administrator has not configured the value. Clients
+ * must handle such cases explicitly.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritInstanceId {}
diff --git a/java/com/google/gerrit/server/config/GerritInstanceIdModule.java b/java/com/google/gerrit/server/config/GerritInstanceIdModule.java
new file mode 100644
index 0000000..33af528
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritInstanceIdModule.java
@@ -0,0 +1,30 @@
+// 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.config;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.inject.AbstractModule;
+
+/** Supports binding the {@link GerritInstanceId} annotation. */
+public class GerritInstanceIdModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(String.class)
+        .annotatedWith(GerritInstanceId.class)
+        .toProvider(GerritInstanceIdProvider.class)
+        .in(SINGLETON);
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritInstanceIdProvider.java b/java/com/google/gerrit/server/config/GerritInstanceIdProvider.java
new file mode 100644
index 0000000..891ca76
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritInstanceIdProvider.java
@@ -0,0 +1,36 @@
+// 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.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+/** Provides {@link GerritInstanceId} from {@code gerrit.instanceId}. */
+@Singleton
+public class GerritInstanceIdProvider implements Provider<String> {
+  private final String instanceId;
+
+  @Inject
+  public GerritInstanceIdProvider(@GerritServerConfig Config cfg) {
+    instanceId = cfg.getString("gerrit", null, "instanceId");
+  }
+
+  @Override
+  public String get() {
+    return instanceId;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
index 17b65c9..d9edf23 100644
--- a/java/com/google/gerrit/server/config/GerritOptions.java
+++ b/java/com/google/gerrit/server/config/GerritOptions.java
@@ -17,12 +17,12 @@
 public class GerritOptions {
   private final boolean headless;
   private final boolean slave;
-  private final boolean forcePolyGerritDev;
+  private final String devCdn;
 
-  public GerritOptions(boolean headless, boolean slave, boolean forcePolyGerritDev) {
+  public GerritOptions(boolean headless, boolean slave, String devCdn) {
     this.headless = headless;
     this.slave = slave;
-    this.forcePolyGerritDev = forcePolyGerritDev;
+    this.devCdn = devCdn;
   }
 
   public boolean headless() {
@@ -33,7 +33,11 @@
     return !slave;
   }
 
-  public boolean forcePolyGerritDev() {
-    return !headless && forcePolyGerritDev;
+  public String devCdn() {
+    return devCdn;
+  }
+
+  public boolean useDevCdn() {
+    return !headless && devCdn.length() > 0;
   }
 }
diff --git a/java/com/google/gerrit/server/config/HasOperandAliasConfig.java b/java/com/google/gerrit/server/config/HasOperandAliasConfig.java
new file mode 100644
index 0000000..7941c04
--- /dev/null
+++ b/java/com/google/gerrit/server/config/HasOperandAliasConfig.java
@@ -0,0 +1,46 @@
+// 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.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 HasOperandAliasConfig {
+  private static final String SECTION = "has-operand-alias";
+  private static final String SUBSECTION_CHANGE = "change";
+  private final Config cfg;
+  private final Map<String, String> changeQueryHasOperandAliases;
+
+  @Inject
+  HasOperandAliasConfig(@GerritServerConfig Config cfg) {
+    this.cfg = cfg;
+    changeQueryHasOperandAliases = new HashMap<>();
+    loadChangeHasOperandAliases();
+  }
+
+  public Map<String, String> getChangeQueryHasOperandAliases() {
+    return changeQueryHasOperandAliases;
+  }
+
+  private void loadChangeHasOperandAliases() {
+    for (String name : cfg.getNames(SECTION, SUBSECTION_CHANGE, true)) {
+      changeQueryHasOperandAliases.put(name, cfg.getString(SECTION, SUBSECTION_CHANGE, name));
+    }
+  }
+}
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..4cdfa4e5
--- /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, true)) {
+      changeQueryOperatorAliases.put(name, cfg.getString(SECTION, SUBSECTION_CHANGE, name));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 9e45701..483fc0a 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.plugins.Plugin;
@@ -129,10 +131,8 @@
    */
   public PluginConfig getFromProjectConfig(Project.NameKey projectName, String pluginName)
       throws NoSuchProjectException {
-    ProjectState projectState = projectCache.get(projectName);
-    if (projectState == null) {
-      throw new NoSuchProjectException(projectName);
-    }
+    ProjectState projectState =
+        projectCache.get(projectName).orElseThrow(noSuchProject(projectName));
     return getFromProjectConfig(projectState, pluginName);
   }
 
@@ -364,10 +364,8 @@
 
   private ProjectLevelConfig getPluginConfig(Project.NameKey projectName, String pluginName)
       throws NoSuchProjectException {
-    ProjectState projectState = projectCache.get(projectName);
-    if (projectState == null) {
-      throw new NoSuchProjectException(projectName);
-    }
+    ProjectState projectState =
+        projectCache.get(projectName).orElseThrow(noSuchProject(projectName));
     return projectState.getConfig(pluginName + EXTENSION);
   }
 
diff --git a/java/com/google/gerrit/server/config/TrackingFooters.java b/java/com/google/gerrit/server/config/TrackingFooters.java
index 85528d9..e57039c 100644
--- a/java/com/google/gerrit/server/config/TrackingFooters.java
+++ b/java/com/google/gerrit/server/config/TrackingFooters.java
@@ -37,9 +37,6 @@
 
   public ListMultimap<String, String> extract(List<FooterLine> lines) {
     ListMultimap<String, String> r = MultimapBuilder.hashKeys().arrayListValues().build();
-    if (lines == null) {
-      return r;
-    }
 
     for (FooterLine footer : lines) {
       for (TrackingFooter config : trackingFooters) {
diff --git a/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
new file mode 100644
index 0000000..bea6dd3
--- /dev/null
+++ b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
@@ -0,0 +1,72 @@
+// 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.config;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.meta.VersionedMetaData;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Low-level storage API to load Gerrit's default config from {@code All-Users}. Should not be used
+ * directly.
+ */
+public class VersionedDefaultPreferences extends VersionedMetaData {
+  private static final String PREFERENCES_CONFIG = "preferences.config";
+
+  private Config cfg;
+
+  public static Config get(Repository allUsersRepo, AllUsersName allUsersName)
+      throws StorageException, ConfigInvalidException {
+    VersionedDefaultPreferences versionedDefaultPreferences = new VersionedDefaultPreferences();
+    try {
+      versionedDefaultPreferences.load(allUsersName, allUsersRepo);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+    return versionedDefaultPreferences.getConfig();
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_USERS_DEFAULT;
+  }
+
+  public Config getConfig() {
+    checkState(cfg != null, "Default preferences not loaded yet.");
+    return cfg;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    cfg = readConfig(PREFERENCES_CONFIG);
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException {
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Update default preferences\n");
+    }
+    saveConfig(PREFERENCES_CONFIG, cfg);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index a6da2b9..ec27c0c 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -19,6 +19,11 @@
 import com.google.gson.annotations.SerializedName;
 import java.util.List;
 
+/**
+ * A selection of change properties used in events. Suitable for serialization to JSON.
+ *
+ * @see com.google.gerrit.server.events.EventFactory
+ */
 public class ChangeAttribute {
   public String project;
   public String branch;
@@ -31,6 +36,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/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
index 3203024..2364ec4 100644
--- a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.server.data;
 
-import java.util.Map;
-
 /**
  * Represents a {@link com.google.gerrit.common.data.SubmitRequirement} that does not depend on
  * Gerrit internal classes, to be serialized
  */
 public class SubmitRequirementAttribute {
-  public Map<String, String> data;
   public String type;
   public String fallbackText;
 }
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 bd0b96f..2f04cae 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.edit;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
@@ -25,6 +27,7 @@
 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;
@@ -232,12 +235,20 @@
         createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
 
     ChangeUtil.ensureChangeIdIsCorrect(
-        projectCache.checkedGet(project).is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
         notes.getChange().getKey().get(),
         newCommitMessage);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(
+          notes.getProjectName(),
+          repository,
+          optionalChangeEdit.get(),
+          newEditCommit,
+          nowTimestamp);
     } else {
       createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
     }
@@ -343,7 +354,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);
     }
@@ -396,13 +412,18 @@
         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);
   }
 
   private void assertCanEdit(ChangeNotes notes)
-      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
+      throws AuthException, PermissionBackendException, ResourceConflictException {
     if (!currentUser.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
@@ -417,7 +438,10 @@
     patchSetUtil.checkPatchSetNotLocked(notes);
     try {
       permissionBackend.currentUser().change(notes).check(ChangePermission.ADD_PATCH_SET);
-      projectCache.checkedGet(notes.getProjectName()).checkStatePermitsWrite();
+      projectCache
+          .get(notes.getProjectName())
+          .orElseThrow(illegalState(notes.getProjectName()))
+          .checkStatePermitsWrite();
     } catch (AuthException denied) {
       throw new AuthException("edit not permitted", denied);
     }
@@ -548,7 +572,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);
@@ -561,11 +591,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);
@@ -574,6 +609,7 @@
   }
 
   private void updateReference(
+      Project.NameKey projectName,
       Repository repository,
       String refName,
       ObjectId currentObjectId,
@@ -588,14 +624,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 ea34b76..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;
@@ -251,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/Event.java b/java/com/google/gerrit/server/events/Event.java
index c07987a..4cf4a5a 100644
--- a/java/com/google/gerrit/server/events/Event.java
+++ b/java/com/google/gerrit/server/events/Event.java
@@ -19,6 +19,7 @@
 public abstract class Event {
   public final String type;
   public long eventCreatedOn = TimeUtil.nowMs() / 1000L;
+  public String instanceId;
 
   protected Event(String type) {
     this.type = type;
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index a6db081..0fcb64e 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -23,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritInstanceId;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -37,6 +40,7 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Optional;
 
 /** Distributes Events to listeners if they are allowed to see them */
 @Singleton
@@ -64,18 +68,22 @@
 
   protected final ChangeNotes.Factory notesFactory;
 
+  protected final String gerritInstanceId;
+
   @Inject
   public EventBroker(
       PluginSetContext<UserScopedEventListener> listeners,
       PluginSetContext<EventListener> unrestrictedListeners,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory) {
+      ChangeNotes.Factory notesFactory,
+      @Nullable @GerritInstanceId String gerritInstanceId) {
     this.listeners = listeners;
     this.unrestrictedListeners = unrestrictedListeners;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
+    this.gerritInstanceId = gerritInstanceId;
   }
 
   @Override
@@ -104,6 +112,7 @@
   }
 
   protected void fireEvent(Change change, ChangeEvent event) throws PermissionBackendException {
+    setInstanceIdWhenEmpty(event);
     for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
       CurrentUser user = c.call(UserScopedEventListener::getUser);
       if (isVisibleTo(change, user)) {
@@ -114,7 +123,9 @@
   }
 
   protected void fireEvent(Project.NameKey project, ProjectEvent event) {
+    setInstanceIdWhenEmpty(event);
     for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
+
       CurrentUser user = c.call(UserScopedEventListener::getUser);
       if (isVisibleTo(project, user)) {
         c.run(l -> l.onEvent(event));
@@ -125,6 +136,7 @@
 
   protected void fireEvent(BranchNameKey branchName, RefEvent event)
       throws PermissionBackendException {
+    setInstanceIdWhenEmpty(event);
     for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
       CurrentUser user = c.call(UserScopedEventListener::getUser);
       if (isVisibleTo(branchName, user)) {
@@ -135,6 +147,7 @@
   }
 
   protected void fireEvent(Event event) throws PermissionBackendException {
+    setInstanceIdWhenEmpty(event);
     for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
       CurrentUser user = c.call(UserScopedEventListener::getUser);
       if (isVisibleTo(event, user)) {
@@ -144,10 +157,16 @@
     fireEventForUnrestrictedListeners(event);
   }
 
+  protected void setInstanceIdWhenEmpty(Event event) {
+    if (Strings.isNullOrEmpty(event.instanceId)) {
+      event.instanceId = gerritInstanceId;
+    }
+  }
+
   protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
     try {
-      ProjectState state = projectCache.get(project);
-      if (state == null || !state.statePermitsRead()) {
+      Optional<ProjectState> state = projectCache.get(project);
+      if (!state.isPresent() || !state.get().statePermitsRead()) {
         return false;
       }
 
@@ -162,8 +181,8 @@
     if (change == null) {
       return false;
     }
-    ProjectState pe = projectCache.get(change.getProject());
-    if (pe == null || !pe.statePermitsRead()) {
+    Optional<ProjectState> pe = projectCache.get(change.getProject());
+    if (!pe.isPresent() || !pe.get().statePermitsRead()) {
       return false;
     }
     try {
@@ -179,8 +198,8 @@
 
   protected boolean isVisibleTo(BranchNameKey branchName, CurrentUser user)
       throws PermissionBackendException {
-    ProjectState pe = projectCache.get(branchName.project());
-    if (pe == null || !pe.statePermitsRead()) {
+    Optional<ProjectState> pe = projectCache.get(branchName.project());
+    if (!pe.isPresent() || !pe.get().statePermitsRead()) {
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 3f22d7f..f0da560 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.UserIdentity;
@@ -117,12 +116,6 @@
     this.indexConfig = indexConfig;
   }
 
-  /**
-   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
-   *
-   * @param change
-   * @return object suitable for serialization to JSON
-   */
   public ChangeAttribute asChangeAttribute(Change change) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
@@ -144,16 +137,14 @@
     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;
   }
 
-  /**
-   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
-   *
-   * @param change
-   * @param notes
-   * @return object suitable for serialization to JSON
-   */
+  /** Create a {@link ChangeAttribute} instance from the specified change. */
   public ChangeAttribute asChangeAttribute(Change change, ChangeNotes notes) {
     ChangeAttribute a = asChangeAttribute(change);
     Set<String> hashtags = notes.load().getHashtags();
@@ -164,13 +155,8 @@
     return a;
   }
   /**
-   * Create a RefUpdateAttribute for the given old ObjectId, new ObjectId, and branch that is
-   * suitable for serialization to JSON.
-   *
-   * @param oldId
-   * @param newId
-   * @param refName
-   * @return object suitable for serialization to JSON
+   * Create a {@link RefUpdateAttribute} for the given old ObjectId, new ObjectId, and branch that
+   * is suitable for serialization to JSON.
    */
   public RefUpdateAttribute asRefUpdateAttribute(
       ObjectId oldId, ObjectId newId, BranchNameKey refName) {
@@ -182,23 +168,13 @@
     return ru;
   }
 
-  /**
-   * Extend the existing ChangeAttribute with additional fields.
-   *
-   * @param a
-   * @param change
-   */
+  /** Extend the existing {@link ChangeAttribute} with additional fields. */
   public void extend(ChangeAttribute a, Change change) {
     a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
     a.open = change.isNew();
   }
 
-  /**
-   * Add allReviewers to an existing ChangeAttribute.
-   *
-   * @param a
-   * @param notes
-   */
+  /** Add allReviewers to an existing {@link ChangeAttribute}. */
   public void addAllReviewers(ChangeAttribute a, ChangeNotes notes) {
     Collection<Account.Id> reviewers = approvalsUtil.getReviewers(notes).all();
     if (!reviewers.isEmpty()) {
@@ -209,12 +185,7 @@
     }
   }
 
-  /**
-   * Add submitRecords to an existing ChangeAttribute.
-   *
-   * @param ca
-   * @param submitRecords
-   */
+  /** Add submitRecords to an existing {@link ChangeAttribute}. */
   public void addSubmitRecords(ChangeAttribute ca, List<SubmitRecord> submitRecords) {
     ca.submitRecords = new ArrayList<>();
 
@@ -255,7 +226,6 @@
         SubmitRequirementAttribute re = new SubmitRequirementAttribute();
         re.fallbackText = req.fallbackText();
         re.type = req.type();
-        re.data = req.data();
         sa.requirements.add(re);
       }
     }
@@ -454,12 +424,7 @@
     }
   }
 
-  /**
-   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
-   *
-   * @param patchSet
-   * @return object suitable for serialization to JSON
-   */
+  /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */
   public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
     PatchSetAttribute p = new PatchSetAttribute();
     p.revision = patchSet.commitId().name();
@@ -485,13 +450,9 @@
         p.author = asAccountAttribute(author.getAccount());
       }
 
-      List<Patch> list = patchListCache.get(change, patchSet).toPatchList(pId);
-      for (Patch pe : list) {
-        if (!Patch.isMagic(pe.getFileName())) {
-          p.sizeDeletions -= pe.getDeletions();
-          p.sizeInsertions += pe.getInsertions();
-        }
-      }
+      PatchList patchList = patchListCache.get(change, patchSet);
+      p.sizeDeletions = patchList.getDeletions();
+      p.sizeInsertions = patchList.getInsertions();
       p.kind = changeKindCache.getChangeKind(change, patchSet);
     } catch (IOException | StorageException e) {
       logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
@@ -529,12 +490,7 @@
     }
   }
 
-  /**
-   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
-   *
-   * @param id
-   * @return object suitable for serialization to JSON
-   */
+  /** Create an AuthorAttribute for the given account suitable for serialization to JSON. */
   public AccountAttribute asAccountAttribute(Account.Id id) {
     if (id == null) {
       return null;
@@ -542,12 +498,7 @@
     return accountCache.get(id).map(this::asAccountAttribute).orElse(null);
   }
 
-  /**
-   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
-   *
-   * @param accountState the account state
-   * @return object suitable for serialization to JSON
-   */
+  /** Create an AuthorAttribute for the given account suitable for serialization to JSON. */
   public AccountAttribute asAccountAttribute(AccountState accountState) {
     AccountAttribute who = new AccountAttribute();
     who.name = accountState.account().fullName();
@@ -556,12 +507,7 @@
     return who;
   }
 
-  /**
-   * Create an AuthorAttribute for the given person ident suitable for serialization to JSON.
-   *
-   * @param ident
-   * @return object suitable for serialization to JSON
-   */
+  /** Create an AuthorAttribute for the given person ident suitable for serialization to JSON. */
   public AccountAttribute asAccountAttribute(PersonIdent ident) {
     AccountAttribute who = new AccountAttribute();
     who.name = ident.getName();
@@ -572,7 +518,6 @@
   /**
    * Create an ApprovalAttribute for the given approval suitable for serialization to JSON.
    *
-   * @param approval
    * @param labelTypes label types for the containing project
    * @return object suitable for serialization to JSON
    */
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 18b6a5e..f286eef 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.events;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.Sets;
@@ -24,6 +26,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -215,7 +218,9 @@
     final Map<String, Short> approvals = convertApprovalsMap(newApprovals);
     return Suppliers.memoize(
         () -> {
-          LabelTypes labelTypes = projectCache.get(change.getProject()).getLabelTypes();
+          Project.NameKey nameKey = change.getProject();
+          LabelTypes labelTypes =
+              projectCache.get(nameKey).orElseThrow(illegalState(nameKey)).getLabelTypes();
           if (approvals.size() > 0) {
             ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
             int i = 0;
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..06b244b 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -33,10 +33,17 @@
 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();
 
+  public static final WorkInProgressStateChanged DISABLED =
+      new WorkInProgressStateChanged() {
+        @Override
+        public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {}
+      };
+
   private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
   private final EventUtil util;
 
@@ -47,6 +54,11 @@
     this.util = util;
   }
 
+  private WorkInProgressStateChanged() {
+    this.listeners = null;
+    this.util = null;
+  }
+
   public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
@@ -68,6 +80,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..df53133 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -14,19 +14,42 @@
 
 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.gerrit.server.util.CommitMessageUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,6 +57,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 +74,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 +135,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 = CommitMessageUtil.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 +230,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 +246,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 dd39198..9e0f2ee 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 ad87843..dccb97a 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.naturalOrder;
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Strings;
@@ -41,10 +40,9 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
+import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -58,10 +56,8 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.server.submit.CommitMergeStatus;
-import com.google.gerrit.server.submit.IntegrationException;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.submit.MergeSorter;
-import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -127,44 +123,6 @@
    */
   private static final int NAME_ABBREV_LEN = 6;
 
-  static class PluggableCommitMessageGenerator {
-    private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
-
-    @Inject
-    PluggableCommitMessageGenerator(DynamicSet<ChangeMessageModifier> changeMessageModifiers) {
-      this.changeMessageModifiers = changeMessageModifiers;
-    }
-
-    public String generate(
-        RevCommit original, RevCommit mergeTip, BranchNameKey dest, String originalMessage) {
-      requireNonNull(original.getRawBuffer());
-      if (mergeTip != null) {
-        requireNonNull(mergeTip.getRawBuffer());
-      }
-
-      int count = 0;
-      String current = originalMessage;
-      for (Extension<ChangeMessageModifier> ext : changeMessageModifiers.entries()) {
-        ChangeMessageModifier changeMessageModifier = ext.get();
-        String className = changeMessageModifier.getClass().getName();
-        current = changeMessageModifier.onSubmit(current, original, mergeTip, dest);
-        checkState(
-            current != null,
-            "%s.onSubmit from plugin %s returned null instead of new commit message",
-            className,
-            ext.getPluginName());
-        count++;
-        logger.atFine().log(
-            "Invoked %s from plugin %s, message length now %d",
-            className, ext.getPluginName(), current.length());
-      }
-      logger.atFine().log(
-          "Invoked %d ChangeMessageModifiers on message with original length %d",
-          count, originalMessage.length());
-      return current;
-    }
-  }
-
   private static final String R_HEADS_MASTER = Constants.R_HEADS + Constants.MASTER;
 
   public static boolean useRecursiveMerge(Config cfg) {
@@ -226,8 +184,7 @@
   }
 
   public CodeReviewCommit getFirstFastForward(
-      CodeReviewCommit mergeTip, RevWalk rw, List<CodeReviewCommit> toMerge)
-      throws IntegrationException {
+      CodeReviewCommit mergeTip, RevWalk rw, List<CodeReviewCommit> toMerge) {
     for (Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext(); ) {
       try {
         final CodeReviewCommit n = i.next();
@@ -236,19 +193,19 @@
           return n;
         }
       } catch (IOException e) {
-        throw new IntegrationException("Cannot fast-forward test during merge", e);
+        throw new StorageException("Cannot fast-forward test during merge", e);
       }
     }
     return mergeTip;
   }
 
   public List<CodeReviewCommit> reduceToMinimalMerge(
-      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort) throws IntegrationException {
+      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort) {
     List<CodeReviewCommit> result = new ArrayList<>();
     try {
       result.addAll(mergeSorter.sort(toSort));
     } catch (IOException | StorageException e) {
-      throw new IntegrationException("Branch head sorting failed", e);
+      throw new StorageException("Branch head sorting failed", e);
     }
     result.sort(CodeReviewCommit.ORDER);
     return result;
@@ -424,15 +381,42 @@
     return dc.writeTree(ins);
   }
 
-  public static RevCommit createMergeCommit(
+  public static CodeReviewCommit createMergeCommit(
       ObjectInserter inserter,
       Config repoConfig,
       RevCommit mergeTip,
       RevCommit originalCommit,
       String mergeStrategy,
-      PersonIdent committerIndent,
+      boolean allowConflicts,
+      PersonIdent committerIdent,
       String commitMsg,
-      RevWalk rw)
+      CodeReviewRevWalk rw)
+      throws IOException, MergeIdenticalTreeException, MergeConflictException,
+          InvalidMergeStrategyException {
+    return createMergeCommit(
+        inserter,
+        repoConfig,
+        mergeTip,
+        originalCommit,
+        mergeStrategy,
+        allowConflicts,
+        committerIdent,
+        committerIdent,
+        commitMsg,
+        rw);
+  }
+
+  public static CodeReviewCommit createMergeCommit(
+      ObjectInserter inserter,
+      Config repoConfig,
+      RevCommit mergeTip,
+      RevCommit originalCommit,
+      String mergeStrategy,
+      boolean allowConflicts,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      String commitMsg,
+      CodeReviewRevWalk rw)
       throws IOException, MergeIdenticalTreeException, MergeConflictException,
           InvalidMergeStrategyException {
 
@@ -443,22 +427,63 @@
     }
 
     Merger m = newMerger(inserter, repoConfig, mergeStrategy);
-    if (m.merge(false, mergeTip, originalCommit)) {
-      ObjectId tree = m.getResultTreeId();
 
-      CommitBuilder mergeCommit = new CommitBuilder();
-      mergeCommit.setTreeId(tree);
-      mergeCommit.setParentIds(mergeTip, originalCommit);
-      mergeCommit.setAuthor(committerIndent);
-      mergeCommit.setCommitter(committerIndent);
-      mergeCommit.setMessage(commitMsg);
-      return rw.parseCommit(inserter.insert(mergeCommit));
+    DirCache dc = DirCache.newInCore();
+    if (allowConflicts && m instanceof ResolveMerger) {
+      // The DirCache must be set on ResolveMerger before calling
+      // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get populated.
+      ((ResolveMerger) m).setDirCache(dc);
     }
-    List<String> conflicts = ImmutableList.of();
-    if (m instanceof ResolveMerger) {
-      conflicts = ((ResolveMerger) m).getUnmergedPaths();
+
+    ObjectId tree;
+    ImmutableSet<String> filesWithGitConflicts;
+    if (m.merge(false, mergeTip, originalCommit)) {
+      filesWithGitConflicts = null;
+      tree = m.getResultTreeId();
+    } else {
+      List<String> conflicts = ImmutableList.of();
+      if (m instanceof ResolveMerger) {
+        conflicts = ((ResolveMerger) m).getUnmergedPaths();
+      }
+
+      if (!allowConflicts) {
+        throw new MergeConflictException(createConflictMessage(conflicts));
+      }
+
+      // For merging with conflict markers we need a ResolveMerger, double-check that we have one.
+      if (!(m instanceof ResolveMerger)) {
+        throw new MergeWithConflictsNotSupportedException(MergeStrategy.get(mergeStrategy));
+      }
+      Map<String, MergeResult<? extends Sequence>> mergeResults =
+          ((ResolveMerger) m).getMergeResults();
+
+      filesWithGitConflicts =
+          mergeResults.entrySet().stream()
+              .filter(e -> e.getValue().containsConflicts())
+              .map(Map.Entry::getKey)
+              .collect(toImmutableSet());
+
+      tree =
+          mergeWithConflicts(
+              rw,
+              inserter,
+              dc,
+              "TARGET BRANCH",
+              mergeTip,
+              "SOURCE BRANCH",
+              originalCommit,
+              mergeResults);
     }
-    throw new MergeConflictException(createConflictMessage(conflicts));
+
+    CommitBuilder mergeCommit = new CommitBuilder();
+    mergeCommit.setTreeId(tree);
+    mergeCommit.setParentIds(mergeTip, originalCommit);
+    mergeCommit.setAuthor(authorIdent);
+    mergeCommit.setCommitter(committerIdent);
+    mergeCommit.setMessage(commitMsg);
+    CodeReviewCommit commit = rw.parseCommit(inserter.insert(mergeCommit));
+    commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    return commit;
   }
 
   public static String createConflictMessage(List<String> conflicts) {
@@ -646,8 +671,10 @@
   }
 
   public boolean canMerge(
-      MergeSorter mergeSorter, Repository repo, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
+      MergeSorter mergeSorter,
+      Repository repo,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit toMerge) {
     if (hasMissingDependencies(mergeSorter, toMerge)) {
       return false;
     }
@@ -660,7 +687,7 @@
     } catch (NoMergeBaseException e) {
       return false;
     } catch (IOException e) {
-      throw new IntegrationException("Cannot merge " + toMerge.name(), e);
+      throw new StorageException("Cannot merge " + toMerge.name(), e);
     }
   }
 
@@ -668,8 +695,7 @@
       MergeSorter mergeSorter,
       CodeReviewCommit mergeTip,
       CodeReviewRevWalk rw,
-      CodeReviewCommit toMerge)
-      throws IntegrationException {
+      CodeReviewCommit toMerge) {
     if (hasMissingDependencies(mergeSorter, toMerge)) {
       return false;
     }
@@ -679,7 +705,7 @@
           || rw.isMergedInto(mergeTip, toMerge)
           || rw.isMergedInto(toMerge, mergeTip);
     } catch (IOException e) {
-      throw new IntegrationException("Cannot fast-forward test during merge", e);
+      throw new StorageException("Cannot fast-forward test during merge", e);
     }
   }
 
@@ -688,8 +714,7 @@
       Repository repo,
       CodeReviewCommit mergeTip,
       CodeReviewRevWalk rw,
-      CodeReviewCommit toMerge)
-      throws IntegrationException {
+      CodeReviewCommit toMerge) {
     if (mergeTip == null) {
       // The branch is unborn. Fast-forward is possible.
       //
@@ -713,7 +738,7 @@
         m.setBase(toMerge.getParent(0));
         return m.merge(mergeTip, toMerge);
       } catch (IOException e) {
-        throw new IntegrationException(
+        throw new StorageException(
             String.format(
                 "Cannot merge commit %s with mergetip %s", toMerge.name(), mergeTip.name()),
             e);
@@ -730,12 +755,11 @@
         || canMerge(mergeSorter, repo, mergeTip, toMerge);
   }
 
-  public boolean hasMissingDependencies(MergeSorter mergeSorter, CodeReviewCommit toMerge)
-      throws IntegrationException {
+  public boolean hasMissingDependencies(MergeSorter mergeSorter, CodeReviewCommit toMerge) {
     try {
       return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
     } catch (IOException | StorageException e) {
-      throw new IntegrationException("Branch head sorting failed", e);
+      throw new StorageException("Branch head sorting failed", e);
     }
   }
 
@@ -748,7 +772,7 @@
       BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
-      throws IntegrationException, InvalidMergeStrategyException {
+      throws InvalidMergeStrategyException {
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     try {
       if (m.merge(mergeTip, n)) {
@@ -761,10 +785,10 @@
         failed(rw, mergeTip, n, getCommitMergeStatus(e.getReason()));
       } catch (IOException e2) {
         logger.atSevere().withCause(e2).log("Failed to set merge failure status for " + n.name());
-        throw new IntegrationException("Cannot merge " + n.name(), e);
+        throw new StorageException("Cannot merge " + n.name(), e);
       }
     } catch (IOException e) {
-      throw new IntegrationException("Cannot merge " + n.name(), e);
+      throw new StorageException("Cannot merge " + n.name(), e);
     }
     return mergeTip;
   }
@@ -880,18 +904,27 @@
   }
 
   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(
@@ -928,8 +961,7 @@
   }
 
   public void markCleanMerges(
-      RevWalk rw, RevFlag canMergeFlag, CodeReviewCommit mergeTip, Set<RevCommit> alreadyAccepted)
-      throws IntegrationException {
+      RevWalk rw, RevFlag canMergeFlag, CodeReviewCommit mergeTip, Set<RevCommit> alreadyAccepted) {
     if (mergeTip == null) {
       // If mergeTip is null here, branchTip was null, indicating a new branch
       // at the start of the merge process. We also elected to merge nothing,
@@ -957,7 +989,7 @@
         }
       }
     } catch (IOException e) {
-      throw new IntegrationException("Cannot mark clean merges", e);
+      throw new StorageException("Cannot mark clean merges", e);
     }
   }
 
@@ -967,8 +999,7 @@
       RevFlag canMergeFlag,
       CodeReviewCommit oldTip,
       CodeReviewCommit mergeTip,
-      Iterable<Change.Id> alreadyMerged)
-      throws IntegrationException {
+      Iterable<Change.Id> alreadyMerged) {
     if (mergeTip == null) {
       return expected;
     }
@@ -999,7 +1030,7 @@
       }
       return Sets.difference(expected, found);
     } catch (IOException e) {
-      throw new IntegrationException("Cannot check if changes were merged", e);
+      throw new StorageException("Cannot check if changes were merged", e);
     }
   }
 
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/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index bfc5135..2d854a5 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.UncheckedExecutionException;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.List;
@@ -164,8 +165,12 @@
    *
    * @see #waitFor(Future, long, TimeUnit)
    */
-  public void waitFor(Future<?> workerFuture) throws ExecutionException {
-    waitFor(workerFuture, 0, null);
+  public <T> T waitFor(Future<T> workerFuture) {
+    try {
+      return waitFor(workerFuture, 0, null);
+    } catch (TimeoutException e) {
+      throw new IllegalStateException("timout exception without setting a timeout", e);
+    }
   }
 
   /**
@@ -179,14 +184,13 @@
    * @param timeoutTime overall timeout for the task; the future is forcefully cancelled if the task
    *     exceeds the timeout. Non-positive values indicate no timeout.
    * @param timeoutUnit unit for overall task timeout.
-   * @throws ExecutionException if this thread or a worker thread was interrupted, the worker was
+   * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was
    *     cancelled, or timed out waiting for a worker to call {@link #end()}.
    */
-  public void waitFor(Future<?> workerFuture, long timeoutTime, TimeUnit timeoutUnit)
-      throws ExecutionException {
+  public <T> T waitFor(Future<T> workerFuture, long timeoutTime, TimeUnit timeoutUnit)
+      throws TimeoutException {
     long overallStart = System.nanoTime();
     long deadline;
-    String detailMessage = "";
     if (timeoutTime > 0) {
       deadline = overallStart + NANOSECONDS.convert(timeoutTime, timeoutUnit);
     } else {
@@ -200,7 +204,7 @@
         try {
           NANOSECONDS.timedWait(this, left);
         } catch (InterruptedException e) {
-          throw new ExecutionException(e);
+          throw new UncheckedExecutionException(e);
         }
 
         // Send an update on every wakeup (manual or spurious), but only move
@@ -210,13 +214,10 @@
         if (deadline > 0 && now > deadline) {
           workerFuture.cancel(true);
           if (workerFuture.isCancelled()) {
-            detailMessage =
-                String.format(
-                    "(timeout %sms, cancelled)",
-                    TimeUnit.MILLISECONDS.convert(now - deadline, NANOSECONDS));
             logger.atWarning().log(
-                "MultiProgressMonitor worker killed after %sms: %s",
-                TimeUnit.MILLISECONDS.convert(now - overallStart, NANOSECONDS), detailMessage);
+                "MultiProgressMonitor worker killed after %sms: (timeout %sms, cancelled)",
+                TimeUnit.MILLISECONDS.convert(now - overallStart, NANOSECONDS),
+                TimeUnit.MILLISECONDS.convert(now - deadline, NANOSECONDS));
           }
           break;
         }
@@ -240,14 +241,15 @@
     // The loop exits as soon as the worker calls end(), but we give it another
     // maxInterval to finish up and return.
     try {
-      workerFuture.get(maxIntervalNanos, NANOSECONDS);
-    } catch (InterruptedException e) {
-      throw new ExecutionException(e);
-    } catch (CancellationException e) {
-      throw new ExecutionException(detailMessage, e);
+      return workerFuture.get(maxIntervalNanos, NANOSECONDS);
+    } catch (InterruptedException | CancellationException e) {
+      logger.atWarning().withCause(e).log("unable to finish processing");
+      throw new UncheckedExecutionException(e);
     } catch (TimeoutException e) {
       workerFuture.cancel(true);
-      throw new ExecutionException(e);
+      throw e;
+    } catch (ExecutionException e) {
+      throw new UncheckedExecutionException(e);
     }
   }
 
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/PluggableCommitMessageGenerator.java b/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java
new file mode 100644
index 0000000..804a218
--- /dev/null
+++ b/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.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;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** Helper to call plugins that want to change the commit message before a change is merged. */
+public class PluggableCommitMessageGenerator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+  @Inject
+  PluggableCommitMessageGenerator(DynamicSet<ChangeMessageModifier> changeMessageModifiers) {
+    this.changeMessageModifiers = changeMessageModifiers;
+  }
+
+  /**
+   * Returns the commit message as modified by plugins. The returned message can be equal to {@code
+   * originalMessage} in case no plugins are registered or the registered plugins decided not to
+   * modify the message.
+   */
+  public String generate(
+      RevCommit original, RevCommit mergeTip, BranchNameKey dest, String originalMessage) {
+    requireNonNull(original.getRawBuffer());
+    if (mergeTip != null) {
+      requireNonNull(mergeTip.getRawBuffer());
+    }
+
+    int count = 0;
+    String current = originalMessage;
+    for (Extension<ChangeMessageModifier> ext : changeMessageModifiers.entries()) {
+      ChangeMessageModifier changeMessageModifier = ext.get();
+      String className = changeMessageModifier.getClass().getName();
+      current = changeMessageModifier.onSubmit(current, original, mergeTip, dest);
+      checkState(
+          current != null,
+          "%s.onSubmit from plugin %s returned null instead of new commit message",
+          className,
+          ext.getPluginName());
+      count++;
+      logger.atFine().log(
+          "Invoked %s from plugin %s, message length now %d",
+          className, ext.getPluginName(), current.length());
+    }
+    logger.atFine().log(
+        "Invoked %d ChangeMessageModifiers on message with original length %d",
+        count, originalMessage.length());
+    return current;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/ProjectRunnable.java b/java/com/google/gerrit/server/git/ProjectRunnable.java
index e74bf2d..76831a2 100644
--- a/java/com/google/gerrit/server/git/ProjectRunnable.java
+++ b/java/com/google/gerrit/server/git/ProjectRunnable.java
@@ -13,13 +13,70 @@
 // limitations under the License.
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import java.util.concurrent.Callable;
+import java.util.concurrent.FutureTask;
 
 /** Used to retrieve the project name from an operation * */
 public interface ProjectRunnable extends Runnable {
   Project.NameKey getProjectNameKey();
 
+  @Nullable
   String getRemoteName();
 
   boolean hasCustomizedPrint();
+
+  /**
+   * Wraps the callable as a {@link FutureTask} and makes it comply with the {@link ProjectRunnable}
+   * interface.
+   */
+  static <T> FutureTask<T> fromCallable(
+      Callable<T> callable,
+      Project.NameKey projectName,
+      String operationName,
+      @Nullable String remoteHostname,
+      boolean hasCustomPrint) {
+    return new FromCallable<>(callable, projectName, operationName, remoteHostname, hasCustomPrint);
+  }
+
+  class FromCallable<T> extends FutureTask<T> implements ProjectRunnable {
+    private final Project.NameKey project;
+    private final String operationName;
+    private final String remoteHostname;
+    private final boolean hasCustomPrint;
+
+    FromCallable(
+        Callable<T> callable,
+        Project.NameKey project,
+        String operationName,
+        @Nullable String remoteHostname,
+        boolean hasCustomPrint) {
+      super(callable);
+      this.project = project;
+      this.operationName = operationName;
+      this.remoteHostname = remoteHostname;
+      this.hasCustomPrint = hasCustomPrint;
+    }
+
+    @Override
+    public Project.NameKey getProjectNameKey() {
+      return project;
+    }
+
+    @Override
+    public String getRemoteName() {
+      return remoteHostname;
+    }
+
+    @Override
+    public boolean hasCustomizedPrint() {
+      return hasCustomPrint;
+    }
+
+    @Override
+    public String toString() {
+      return operationName;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 9f9530c..3910393 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
@@ -178,7 +180,7 @@
           // Rebase claimed revert onto claimed original
           ThreeWayMerger merger =
               mergeUtilFactory
-                  .create(projectCache.checkedGet(project))
+                  .create(projectCache.get(project).orElseThrow(illegalState(project)))
                   .newThreeWayMerger(oi, repo.getConfig());
           merger.setBase(claimedRevertCommit.getParent(0));
           boolean success = merger.merge(claimedRevertCommit, claimedOriginalCommit);
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 64ae6dd..c9a8e77 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/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index 8ab2779..feb038a 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.InvalidConfigFileException;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -471,15 +472,7 @@
       try {
         rc.fromText(text);
       } catch (ConfigInvalidException err) {
-        StringBuilder msg =
-            new StringBuilder("Invalid config file ")
-                .append(fileName)
-                .append(" in commit ")
-                .append(revision.name());
-        if (err.getCause() != null) {
-          msg.append(": ").append(err.getCause());
-        }
-        throw new ConfigInvalidException(msg.toString(), err);
+        throw new InvalidConfigFileException(projectName, getRefName(), revision, fileName, err);
       }
     }
     return rc;
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index f5c3aa9..fc80490 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.git.receive;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.Capable;
@@ -33,6 +35,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.AllUsersName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -65,10 +68,13 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Collection;
-import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.FutureTask;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PreReceiveHook;
@@ -83,7 +89,7 @@
  * of time, it runs in the background so it can be monitored for timeouts and cancelled, and have
  * stalls reported to the user from the main thread.
  */
-public class AsyncReceiveCommits implements PreReceiveHook {
+public class AsyncReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final String TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
@@ -105,6 +111,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));
     }
 
@@ -117,74 +124,30 @@
     }
   }
 
-  private class Worker implements ProjectRunnable {
-    final MultiProgressMonitor progress;
-    final String name;
+  private static MultiProgressMonitor newMultiProgressMonitor(MessageSender messageSender) {
+    return new MultiProgressMonitor(
+        new OutputStream() {
+          @Override
+          public void write(int b) {
+            messageSender.sendBytes(new byte[] {(byte) b});
+          }
 
-    private final Collection<ReceiveCommand> commands;
+          @Override
+          public void write(byte[] what, int off, int len) {
+            messageSender.sendBytes(what, off, len);
+          }
 
-    private Worker(Collection<ReceiveCommand> commands, String name) {
-      this.commands = commands;
-      this.name = name;
-      progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
-    }
+          @Override
+          public void write(byte[] what) {
+            messageSender.sendBytes(what);
+          }
 
-    @Override
-    public void run() {
-      String oldName = Thread.currentThread().getName();
-      Thread.currentThread().setName(oldName + "-for-" + name);
-      try {
-        receiveCommits.processCommands(commands, progress);
-      } finally {
-        Thread.currentThread().setName(oldName);
-      }
-    }
-
-    @Override
-    public Project.NameKey getProjectNameKey() {
-      return receiveCommits.getProject().getNameKey();
-    }
-
-    @Override
-    public String getRemoteName() {
-      return null;
-    }
-
-    @Override
-    public boolean hasCustomizedPrint() {
-      return true;
-    }
-
-    @Override
-    public String toString() {
-      return "receive-commits";
-    }
-
-    void sendMessages() {
-      receiveCommits.sendMessages();
-    }
-
-    private class MessageSenderOutputStream extends OutputStream {
-      @Override
-      public void write(int b) {
-        receiveCommits.getMessageSender().sendBytes(new byte[] {(byte) b});
-      }
-
-      @Override
-      public void write(byte[] what, int off, int len) {
-        receiveCommits.getMessageSender().sendBytes(what, off, len);
-      }
-
-      @Override
-      public void write(byte[] what) {
-        receiveCommits.getMessageSender().sendBytes(what);
-      }
-
-      @Override
-      public void flush() {
-        receiveCommits.getMessageSender().flush();
-      }
-    }
+          @Override
+          public void flush() {
+            messageSender.flush();
+          }
+        },
+        "Processing changes");
   }
 
   private enum PushType {
@@ -243,7 +206,6 @@
 
   private final Metrics metrics;
   private final ReceiveCommits receiveCommits;
-  private final ResultChangeIds resultChangeIds;
   private final PermissionBackend.ForProject perm;
   private final ReceivePack receivePack;
   private final ExecutorService executor;
@@ -301,7 +263,7 @@
     receivePack.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
     receivePack.setRefFilter(new ReceiveRefFilter());
     receivePack.setAllowPushOptions(true);
-    receivePack.setPreReceiveHook(this);
+    receivePack.setPreReceiveHook(asHook());
     receivePack.setPostReceiveHook(lazyPostReceive.create(user, projectName));
 
     try {
@@ -315,11 +277,14 @@
     allRefsWatcher = new AllRefsWatcher();
     receivePack.setAdvertiseRefsHook(
         ReceiveCommitsAdvertiseRefsHookChain.create(
-            allRefsWatcher, usersSelfAdvertiseRefsHook, allUsersName, queryProvider, projectName));
-    resultChangeIds = new ResultChangeIds();
+            allRefsWatcher,
+            usersSelfAdvertiseRefsHook,
+            allUsersName,
+            queryProvider,
+            projectName,
+            user.getAccountId()));
     receiveCommits =
-        factory.create(
-            projectState, user, receivePack, repo, allRefsWatcher, messageSender, resultChangeIds);
+        factory.create(projectState, user, receivePack, repo, allRefsWatcher, messageSender);
     receiveCommits.init();
     QuotaResponse.Aggregated availableTokens =
         quotaBackend.user(user).project(projectName).availableTokens(REPOSITORY_SIZE_GROUP);
@@ -354,48 +319,90 @@
     return Capable.OK;
   }
 
-  @Override
-  public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+  /**
+   * Returns a {@link PreReceiveHook} implementation that can be used directly by JGit when
+   * processing a push.
+   */
+  public PreReceiveHook asHook() {
+    return (rp, commands) -> {
+      checkState(receivePack == rp, "can't perform PreReceive for a different receive pack");
+      long startNanos = System.nanoTime();
+      ReceiveCommitsResult result;
+      try {
+        result = preReceive(commands);
+      } catch (TimeoutException e) {
+        metrics.timeouts.increment();
+        logger.atWarning().withCause(e).log(
+            "Timeout in ReceiveCommits while processing changes for project %s",
+            projectState.getName());
+        receivePack.sendError("timeout while processing changes");
+        rejectCommandsNotAttempted(commands);
+        return;
+      } catch (Exception e) {
+        logger.atSevere().withCause(e.getCause()).log("error while processing push");
+        receivePack.sendError("internal error");
+        rejectCommandsNotAttempted(commands);
+        return;
+      } finally {
+        // Flush the messages queued up until now (if any).
+        receiveCommits.sendMessages();
+      }
+      reportMetrics(result, System.nanoTime() - startNanos);
+    };
+  }
+
+  /** Processes {@code commands}, applies them to Git storage and communicates back on the wire. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public ReceiveCommitsResult preReceive(Collection<ReceiveCommand> commands)
+      throws TimeoutException, UncheckedExecutionException {
     if (commands.stream().anyMatch(c -> c.getResult() != Result.NOT_ATTEMPTED)) {
       // Stop processing when command was already processed by previously invoked
       // pre-receive hooks
-      return;
+      return ReceiveCommitsResult.empty();
     }
+    String currentThreadName = Thread.currentThread().getName();
+    MultiProgressMonitor monitor = newMultiProgressMonitor(receiveCommits.getMessageSender());
+    Callable<ReceiveCommitsResult> callable =
+        () -> {
+          String oldName = Thread.currentThread().getName();
+          Thread.currentThread().setName(oldName + "-for-" + currentThreadName);
+          try {
+            return receiveCommits.processCommands(commands, monitor);
+          } finally {
+            Thread.currentThread().setName(oldName);
+          }
+        };
 
-    long startNanos = System.nanoTime();
-    Worker w = new Worker(commands, Thread.currentThread().getName());
     try {
-      w.progress.waitFor(
-          executor.submit(scopePropagator.wrap(w)), timeoutMillis, TimeUnit.MILLISECONDS);
-    } catch (ExecutionException e) {
-      metrics.timeouts.increment();
-      logger.atWarning().withCause(e).log(
-          "Error in ReceiveCommits while processing changes for project %s",
-          projectState.getName());
-      rp.sendError("internal error while processing changes");
-      // ReceiveCommits has tried its best to catch errors, so anything at this
-      // point is very bad.
-      for (ReceiveCommand c : commands) {
-        if (c.getResult() == Result.NOT_ATTEMPTED) {
-          c.setResult(Result.REJECTED_OTHER_REASON, "internal error");
-        }
+      // WorkQueue does not support Callable<T>, so we have to covert it here.
+      FutureTask<ReceiveCommitsResult> runnable =
+          ProjectRunnable.fromCallable(
+              callable, receiveCommits.getProject().getNameKey(), "receive-commits", null, false);
+      monitor.waitFor(
+          executor.submit(scopePropagator.wrap(runnable)), timeoutMillis, TimeUnit.MILLISECONDS);
+      if (!runnable.isDone()) {
+        // At this point we are either done or have thrown a TimeoutException and bailed out.
+        throw new IllegalStateException("unable to get receive commits result");
       }
-    } finally {
-      w.sendMessages();
+      return runnable.get();
+    } catch (InterruptedException | ExecutionException e) {
+      throw new UncheckedExecutionException(e);
     }
+  }
 
-    long deltaNanos = System.nanoTime() - startNanos;
-    int totalChanges = 0;
-
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public void reportMetrics(ReceiveCommitsResult result, long deltaNanos) {
     PushType pushType;
-    if (resultChangeIds.isMagicPush()) {
+    int totalChanges = 0;
+    if (result.magicPush()) {
       pushType = PushType.CREATE_REPLACE;
-      List<Change.Id> created = resultChangeIds.get(ResultChangeIds.Key.CREATED);
-      List<Change.Id> replaced = resultChangeIds.get(ResultChangeIds.Key.REPLACED);
+      Set<Change.Id> created = result.changes().get(ReceiveCommitsResult.ChangeStatus.CREATED);
+      Set<Change.Id> replaced = result.changes().get(ReceiveCommitsResult.ChangeStatus.REPLACED);
       metrics.changes.record(pushType, created.size() + replaced.size());
       totalChanges = replaced.size() + created.size();
     } else {
-      List<Change.Id> autoclosed = resultChangeIds.get(ResultChangeIds.Key.AUTOCLOSED);
+      Set<Change.Id> autoclosed =
+          result.changes().get(ReceiveCommitsResult.ChangeStatus.AUTOCLOSED);
       if (!autoclosed.isEmpty()) {
         pushType = PushType.AUTOCLOSE;
         metrics.changes.record(pushType, autoclosed.size());
@@ -404,21 +411,25 @@
         pushType = PushType.NORMAL;
       }
     }
-
     if (totalChanges > 0) {
       metrics.latencyPerChange.record(pushType, deltaNanos / totalChanges, NANOSECONDS);
     }
-
     metrics.latencyPerPush.record(pushType, deltaNanos, NANOSECONDS);
   }
 
-  /** Returns the Change.Ids that were processed in onPreReceive */
-  @UsedAt(UsedAt.Project.GOOGLE)
-  public ResultChangeIds getResultChangeIds() {
-    return resultChangeIds;
-  }
-
   public ReceivePack getReceivePack() {
     return receivePack;
   }
+
+  /**
+   * Marks all commands that were not processed yet as {@link Result#REJECTED_OTHER_REASON}.
+   * Intended to be used to finish up remaining commands when errors occur during processing.
+   */
+  private static void rejectCommandsNotAttempted(Collection<ReceiveCommand> commands) {
+    for (ReceiveCommand c : commands) {
+      if (c.getResult() == Result.NOT_ATTEMPTED) {
+        c.setResult(Result.REJECTED_OTHER_REASON, "internal error");
+      }
+    }
+  }
 }
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 3b5b080..100bd5b 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -30,6 +30,7 @@
 import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_ID_IN_MULTIPLE_CHANGES;
 import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
@@ -43,7 +44,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 +74,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 +93,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 +105,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 +128,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;
@@ -157,7 +163,6 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.MergeOp;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.SubmoduleException;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -166,13 +171,12 @@
 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;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -222,7 +226,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.revwalk.filter.RevFilter;
 import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
 import org.eclipse.jgit.transport.ReceivePack;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
@@ -251,8 +254,7 @@
         ReceivePack receivePack,
         Repository repository,
         AllRefsWatcher allRefsWatcher,
-        MessageSender messageSender,
-        ResultChangeIds resultChangeIds);
+        MessageSender messageSender);
   }
 
   private class ReceivePackMessageSender implements MessageSender {
@@ -296,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
@@ -334,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;
@@ -344,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;
@@ -364,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;
@@ -378,9 +377,12 @@
   private Optional<String> tracePushOption;
 
   private MessageSender messageSender;
-  private ResultChangeIds resultChangeIds;
+  private ReceiveCommitsResult.Builder result;
   private ImmutableMap<String, String> loggingTags;
 
+  /** This object is for single use only. */
+  private boolean used;
+
   @Inject
   ReceiveCommits(
       AccountResolver accountResolver,
@@ -410,6 +412,7 @@
       Provider<InternalChangeQuery> queryProvider,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeOpRepoManager> ormProvider,
+      PublishCommentsOp.Factory publishCommentsOp,
       ReceiveConfig receiveConfig,
       RefOperationValidators.Factory refValidatorsFactory,
       ReplaceOp.Factory replaceOpFactory,
@@ -426,8 +429,7 @@
       @Assisted ReceivePack rp,
       @Assisted Repository repository,
       @Assisted AllRefsWatcher allRefsWatcher,
-      @Nullable @Assisted MessageSender messageSender,
-      @Assisted ResultChangeIds resultChangeIds)
+      @Nullable @Assisted MessageSender messageSender)
       throws IOException {
     // Injected fields.
     this.accountResolver = accountResolver;
@@ -456,6 +458,7 @@
     this.projectCache = projectCache;
     this.psUtil = psUtil;
     this.performanceLoggers = performanceLoggers;
+    this.publishCommentsOp = publishCommentsOp;
     this.queryProvider = queryProvider;
     this.receiveConfig = receiveConfig;
     this.refValidatorsFactory = refValidatorsFactory;
@@ -470,7 +473,6 @@
     this.setPrivateOpFactory = setPrivateOpFactory;
 
     // Assisted injected fields.
-    this.allRefsWatcher = allRefsWatcher;
     this.projectState = projectState;
     this.user = user;
     this.receivePack = rp;
@@ -491,6 +493,8 @@
     replaceByChange = new LinkedHashMap<>();
     updateGroups = new ArrayList<>();
 
+    used = false;
+
     this.allowProjectOwnersToChangeParent =
         config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
 
@@ -500,8 +504,15 @@
 
     // Handles for outputting back over the wire to the end user.
     this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
-    this.resultChangeIds = resultChangeIds;
+    this.result = ReceiveCommitsResult.builder();
     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() {
@@ -556,7 +567,9 @@
     }
   }
 
-  void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+  ReceiveCommitsResult processCommands(
+      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) throws StorageException {
+    checkState(!used, "Tried to re-use a ReceiveCommits objects that is single-use only");
     parsePushOptions();
     int commandCount = commands.size();
     try (TraceContext traceContext =
@@ -595,6 +608,7 @@
       loggingTags = traceContext.getTags();
       logger.atFine().log("Processing commands done.");
     }
+    return result.build();
   }
 
   // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
@@ -623,9 +637,7 @@
       }
     }
 
-    int commandTypes = (magicCommands.isEmpty() ? 0 : 1) + (regularCommands.isEmpty() ? 0 : 1);
-
-    if (commandTypes > 1) {
+    if (!magicCommands.isEmpty() && !regularCommands.isEmpty()) {
       rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
       return;
     }
@@ -654,17 +666,25 @@
     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) {
+          throw new StorageException("Failed to select new changes in " + project.getName(), e);
+        }
+      }
+
+      // 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(
@@ -687,7 +707,7 @@
       throws PermissionBackendException, IOException, NoSuchProjectException {
     try (TraceTimer traceTimer =
         newTimer("handleRegularCommands", Metadata.builder().resourceCount(cmds.size()))) {
-      resultChangeIds.setMagicPush(false);
+      result.magicPush(false);
       for (ReceiveCommand cmd : cmds) {
         parseRegularCommand(cmd);
       }
@@ -711,8 +731,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
         bu.execute();
       } catch (UpdateException | RestApiException e) {
-        rejectRemaining(cmds, INTERNAL_SERVER_ERROR);
-        logger.atSevere().withCause(e).log("update failed:");
+        throw new StorageException(e);
       }
 
       Set<BranchNameKey> branches = new HashSet<>();
@@ -746,8 +765,8 @@
           orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
           SubmoduleOp op = subOpFactory.create(branches, orm);
           op.updateSuperProjects();
-        } catch (SubmoduleException e) {
-          logger.atSevere().withCause(e).log("Can't update the superprojects");
+        } catch (RestApiException e) {
+          logger.atWarning().withCause(e).log("Can't update the superprojects");
         }
       }
     }
@@ -896,10 +915,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());
@@ -911,16 +935,27 @@
         updateGroups.forEach(r -> r.addOps(bu));
 
         logger.atFine().log("Executing batch");
+
         try {
-          bu.execute();
-        } catch (UpdateException e) {
+          retryHelper
+              .changeUpdate(
+                  "insertChangesAndPatchSets",
+                  () -> {
+                    bu.execute();
+                    return null;
+                  })
+              .call();
+        } catch (Exception e) {
           throw asRestApiException(e);
         }
 
         replaceByChange.values().stream()
-            .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.REPLACED, req.ontoChange));
+            .forEach(
+                req ->
+                    result.addChange(ReceiveCommitsResult.ChangeStatus.REPLACED, req.ontoChange));
         newChanges.stream()
-            .forEach(req -> resultChangeIds.add(ResultChangeIds.Key.CREATED, req.changeId));
+            .forEach(
+                req -> result.addChange(ReceiveCommitsResult.ChangeStatus.CREATED, req.changeId));
 
         if (magicBranchCmd != null) {
           magicBranchCmd.setResult(OK);
@@ -945,9 +980,7 @@
         logger.atFine().withCause(e).log("Rejecting due to client error");
         reject(magicBranchCmd, e.getMessage());
       } catch (RestApiException | IOException e) {
-        logger.atSevere().withCause(e).log(
-            "Can't insert change/patch set for %s", project.getName());
-        reject(magicBranchCmd, String.format("%s: %s", INTERNAL_SERVER_ERROR, e.getMessage()));
+        throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
       }
 
       if (magicBranch != null && magicBranch.submit) {
@@ -972,7 +1005,15 @@
   private String buildError(String error, List<String> branches) {
     StringBuilder sb = new StringBuilder();
     if (branches.size() == 1) {
-      sb.append("branch ").append(branches.get(0)).append(":\n");
+      String branch = branches.get(0);
+      sb.append("branch ").append(branch).append(":\n");
+      // As of 2020, there are still many git-review <1.27 installations in the wild.
+      // These users will see failures as their old git-review assumes that
+      // `refs/publish/...` is still magic, which it isn't. As Gerrit's default error messages are
+      // misleading for these users, we hint them at upgrading their git-review.
+      if (branch.startsWith("refs/publish/")) {
+        sb.append("If you are using git-review, update to at least git-review 1.27. Otherwise:\n");
+      }
       sb.append(error);
       return sb.toString();
     }
@@ -1183,7 +1224,7 @@
                 }
               }
 
-              if (projectCache.get(newParent) == null) {
+              if (!projectCache.get(newParent).isPresent()) {
                 reject(cmd, "invalid project configuration: parent does not exist");
                 return;
               }
@@ -1261,11 +1302,11 @@
       RevObject obj;
       try {
         obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
-      } catch (IOException err) {
-        logger.atSevere().withCause(err).log(
-            "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName());
-        reject(cmd, "invalid object");
-        return;
+      } catch (IOException e) {
+        throw new StorageException(
+            String.format(
+                "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName()),
+            e);
       }
       logger.atFine().log("Creating %s", cmd);
 
@@ -1317,11 +1358,11 @@
     RevObject obj;
     try {
       obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Invalid object %s for %s", cmd.getNewId().name(), cmd.getRefName());
-      reject(cmd, "invalid object");
-      return false;
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName()),
+          e);
     }
 
     if (obj instanceof RevCommit) {
@@ -1355,11 +1396,11 @@
     try (TraceTimer traceTimer = newTimer("parseRewind")) {
       try {
         receivePack.getRevWalk().parseCommit(cmd.getNewId());
-      } catch (IOException err) {
-        logger.atSevere().withCause(err).log(
-            "Invalid object %s for %s forced update", cmd.getNewId().name(), cmd.getRefName());
-        reject(cmd, "invalid object");
-        return;
+      } catch (IOException e) {
+        throw new StorageException(
+            String.format(
+                "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName()),
+            e);
       }
       logger.atFine().log("Rewinding %s", cmd);
 
@@ -1419,15 +1460,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;
@@ -1581,7 +1628,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();
@@ -1625,18 +1671,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;
@@ -1645,8 +1692,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);
@@ -1668,28 +1714,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() {
@@ -1735,7 +1760,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);
@@ -1744,7 +1769,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");
@@ -1776,7 +1801,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);
@@ -1805,7 +1830,10 @@
       }
 
       boolean privateByDefault =
-          projectCache.get(project.getNameKey()).is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
+          projectCache
+              .get(project.getNameKey())
+              .orElseThrow(illegalState(project.getNameKey()))
+              .is(BooleanProjectConfig.PRIVATE_BY_DEFAULT);
       setChangeAsPrivate =
           magicBranch.isPrivate || (privateByDefault && !magicBranch.removePrivate);
 
@@ -1851,11 +1879,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;
@@ -1885,15 +1914,14 @@
               reject(cmd, "base not found");
               return;
             } catch (IOException e) {
-              logger.atWarning().withCause(e).log(
-                  "Project %s cannot read %s", project.getName(), id.name());
-              reject(cmd, INTERNAL_SERVER_ERROR);
-              return;
+              throw new StorageException(
+                  String.format("Project %s cannot read %s", project.getName(), id.name()), e);
             }
           }
         } 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 {
@@ -1910,23 +1938,14 @@
             }
           }
         }
-      } catch (IOException ex) {
-        logger.atWarning().withCause(ex).log(
-            "Error walking to %s in project %s", destBranch, project.getName());
-        reject(cmd, INTERNAL_SERVER_ERROR);
-        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());
+      } catch (IOException e) {
+        throw new StorageException(
+            String.format("Error walking to %s in project %s", destBranch, project.getName()), e);
       }
 
       if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
         this.magicBranch = magicBranch;
-        this.resultChangeIds.setMagicPush(true);
+        this.result.magicPush(true);
       }
     }
   }
@@ -1940,7 +1959,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
@@ -1982,19 +2001,10 @@
       logger.atFine().log("HEAD = %s", head);
       return head;
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot read HEAD symref");
-      return null;
+      throw new StorageException("Cannot read HEAD symref", e);
     }
   }
 
-  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.
@@ -2002,7 +2012,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(
@@ -2027,14 +2038,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(
@@ -2052,7 +2067,7 @@
       try {
         receivePack.getRevWalk().parseBody(create.commit);
       } catch (IOException e) {
-        continue;
+        throw new StorageException("Can't parse commit", e);
       }
       List<String> idList = create.commit.getFooterLines(FooterConstants.CHANGE_ID);
 
@@ -2064,14 +2079,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);
@@ -2091,6 +2106,7 @@
             start.getParentCount() == 1
                 && projectCache
                     .get(project.getNameKey())
+                    .orElseThrow(illegalState(project.getNameKey()))
                     .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES)
                 // Don't worry about implicit merges when creating changes for
                 // already-merged commits; they're already in history, so it's too
@@ -2112,7 +2128,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());
@@ -2135,9 +2152,10 @@
             //      A's group.
             // C) Commit is a PatchSet of a pre-existing change uploaded with a
             //    different target branch.
-            for (Ref ref : existingRefs) {
-              updateGroups.add(new UpdateGroupsRequest(ref, c));
-            }
+            existingRefs.stream()
+                .map(r -> PatchSet.Id.fromRef(r.getName()))
+                .filter(Objects::nonNull)
+                .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
             }
@@ -2276,7 +2294,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();
@@ -2294,14 +2313,7 @@
       } catch (IOException e) {
         // Should never happen, the core receive process would have
         // identified the missing object earlier before we got control.
-        //
-        magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-        logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
-        return Collections.emptyList();
-      } catch (StorageException e) {
-        logger.atSevere().withCause(e).log("Cannot query database to locate prior changes");
-        reject(magicBranch.cmd, "database error");
-        return Collections.emptyList();
+        throw new StorageException("Invalid pack upload; one or more objects weren't sent", e);
       }
 
       if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
@@ -2313,25 +2325,20 @@
         return newChanges;
       }
 
-      try {
-        SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
-        List<Integer> newIds = seq.nextChangeIds(newChanges.size());
-        for (int i = 0; i < newChanges.size(); i++) {
-          CreateRequest create = newChanges.get(i);
-          create.setChangeId(newIds.get(i));
-          create.groups = ImmutableList.copyOf(groups.get(create.commit));
-        }
-        for (ReplaceRequest replace : replaceByChange.values()) {
-          replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
-        }
-        for (UpdateGroupsRequest update : updateGroups) {
-          update.groups = ImmutableList.copyOf((groups.get(update.commit)));
-        }
-        logger.atFine().log("Finished updating groups from GroupCollector");
-      } catch (StorageException e) {
-        logger.atSevere().withCause(e).log("Error collecting groups for changes");
-        reject(magicBranch.cmd, INTERNAL_SERVER_ERROR);
+      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
+      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
+      for (int i = 0; i < newChanges.size(); i++) {
+        CreateRequest create = newChanges.get(i);
+        create.setChangeId(newIds.get(i));
+        create.groups = ImmutableList.copyOf(groups.get(create.commit));
       }
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
+      }
+      for (UpdateGroupsRequest update : updateGroups) {
+        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+      }
+      logger.atFine().log("Finished updating groups from GroupCollector");
       return newChanges;
     }
   }
@@ -2383,7 +2390,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",
@@ -2398,7 +2405,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());
@@ -2433,13 +2440,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++;
@@ -2571,15 +2580,7 @@
                     .setFireEvent(false));
           }
           if (!Strings.isNullOrEmpty(magicBranch.topic)) {
-            bu.addOp(
-                changeId,
-                new BatchUpdateOp() {
-                  @Override
-                  public boolean updateChange(ChangeContext ctx) {
-                    ctx.getUpdate(psId).setTopic(magicBranch.topic);
-                    return true;
-                  }
-                });
+            bu.addOp(changeId, new SetTopicOp(magicBranch.topic));
           }
           bu.addOp(
               changeId,
@@ -2650,14 +2651,9 @@
             req.validateNewPatchSet();
           }
         }
-      } catch (StorageException err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot read database before replacement for project %s", project.getName());
-        rejectRemainingRequests(replaceByChange.values(), INTERNAL_SERVER_ERROR);
-      } catch (IOException | PermissionBackendException err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot read repository before replacement for project %s", project.getName());
-        rejectRemainingRequests(replaceByChange.values(), INTERNAL_SERVER_ERROR);
+      } catch (IOException | PermissionBackendException e) {
+        throw new StorageException(
+            "Cannot read repository before replacement for project " + project.getName(), e);
       }
       logger.atFine().log("Read %d changes to replace", replaceByChange.size());
 
@@ -2665,11 +2661,11 @@
         // Cancel creations tied to refs/for/ command.
         for (ReplaceRequest req : replaceByChange.values()) {
           if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
-            req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+            req.cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, "aborted");
           }
         }
         for (CreateRequest req : newChanges) {
-          req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+          req.cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, "aborted");
         }
       }
     }
@@ -2704,7 +2700,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);
@@ -2716,11 +2713,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());
@@ -2786,6 +2784,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;
         }
@@ -2813,11 +2821,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")) {
@@ -2951,14 +2964,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) {
@@ -2989,7 +3008,8 @@
                     info,
                     groups,
                     magicBranch,
-                    receivePack.getPushCertificate())
+                    receivePack.getPushCertificate(),
+                    notes.getChange())
                 .setRequestScopePropagator(requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
@@ -3008,8 +3028,8 @@
     final RevCommit commit;
     List<String> groups = ImmutableList.of();
 
-    UpdateGroupsRequest(Ref ref, RevCommit commit) {
-      this.psId = requireNonNull(PatchSet.Id.fromRef(ref.getName()));
+    UpdateGroupsRequest(PatchSet.Id psId, RevCommit commit) {
+      this.psId = psId;
       this.commit = commit;
     }
 
@@ -3061,18 +3081,19 @@
       if (isConfig(cmd)) {
         logger.atFine().log("Reloading project in cache");
         projectCache.evict(project);
-        ProjectState ps = projectCache.get(project.getNameKey());
+        ProjectState ps =
+            projectCache.get(project.getNameKey()).orElseThrow(illegalState(project.getNameKey()));
         try {
           logger.atFine().log("Updating project description");
           repo.setGitwebDescription(ps.getProject().getDescription());
         } catch (IOException e) {
-          logger.atWarning().withCause(e).log("cannot update description of %s", project.getName());
+          throw new StorageException("cannot update description of " + project.getName(), e);
         }
         if (allProjectsName.equals(project.getNameKey())) {
           try {
             createGroupPermissionSyncer.syncIfNeeded();
           } catch (IOException | ConfigInvalidException e) {
-            logger.atSevere().withCause(e).log("Can't sync create group permissions");
+            throw new StorageException("cannot update description of " + project.getName(), e);
           }
         }
       }
@@ -3087,45 +3108,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;
@@ -3210,7 +3192,6 @@
         if (!(parsedObject instanceof RevCommit)) {
           return;
         }
-        ListMultimap<ObjectId, Ref> existing = changeRefsById();
         walk.markStart((RevCommit) parsedObject);
         markHeadsAsUninteresting(walk, cmd.getRefName());
         int limit = receiveConfig.maxBatchCommits;
@@ -3227,7 +3208,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;
           }
 
@@ -3257,107 +3238,136 @@
       // 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)) {
+                        if (!PatchSet.isChangeRef(ref.getName())) {
+                          continue;
+                        }
+                        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) {
+                    throw new StorageException("Failed to auto-close changes", e);
                   }
-                }
 
-                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 -> result.addChange(ReceiveCommitsResult.ChangeStatus.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) {
@@ -3374,21 +3384,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) {
@@ -3399,13 +3400,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);
   }
@@ -3436,10 +3430,6 @@
     commands.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED).forEach(cmd -> reject(cmd, why));
   }
 
-  private static void rejectRemainingRequests(Collection<ReplaceRequest> requests, String why) {
-    rejectRemaining(requests.stream().map(req -> req.cmd), why);
-  }
-
   private static boolean isHead(ReceiveCommand cmd) {
     return cmd.getRefName().startsWith(Constants.R_HEADS);
   }
@@ -3458,4 +3448,19 @@
     b.append(")\n");
     return b.toString();
   }
+
+  private static class SetTopicOp implements BatchUpdateOp {
+
+    private final String topic;
+
+    public SetTopicOp(String topic) {
+      this.topic = topic;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws ValidationException {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setTopic(topic);
+      return true;
+    }
+  }
 }
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 d574466..dd11c57 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.CurrentUser;
 import com.google.gerrit.server.config.AllUsersName;
@@ -42,13 +43,15 @@
       UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook,
       AllUsersName allUsersName,
       Provider<InternalChangeQuery> queryProvider,
-      Project.NameKey projectName) {
+      Project.NameKey projectName,
+      Account.Id user) {
     return create(
         allRefsWatcher,
         usersSelfAdvertiseRefsHook,
         allUsersName,
         queryProvider,
         projectName,
+        user,
         false);
   }
 
@@ -67,6 +70,7 @@
         new AllUsersName(AllUsersNameProvider.DEFAULT),
         queryProvider,
         projectName,
+        user.getAccountId(),
         true);
   }
 
@@ -76,10 +80,11 @@
       AllUsersName allUsersName,
       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/ReceiveCommitsResult.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsResult.java
new file mode 100644
index 0000000..ecbdcbc
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsResult.java
@@ -0,0 +1,81 @@
+// 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.receive;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.gerrit.entities.Change;
+import java.util.Arrays;
+import java.util.EnumMap;
+
+/** Keeps track of the change IDs thus far updated by {@link ReceiveCommits}. */
+@AutoValue
+public abstract class ReceiveCommitsResult {
+  /** Status of a change. Used to aggregate metrics. */
+  public enum ChangeStatus {
+    CREATED,
+    REPLACED,
+    AUTOCLOSED,
+  }
+
+  /**
+   * Returns change IDs of the given type for which the BatchUpdate succeeded, or empty list if
+   * there are none.
+   */
+  public abstract ImmutableMap<ChangeStatus, ImmutableSet<Change.Id>> changes();
+
+  /** Indicate that the ReceiveCommits call involved a magic branch, such as {@code refs/for/}. */
+  public abstract boolean magicPush();
+
+  public static Builder builder() {
+    return new AutoValue_ReceiveCommitsResult.Builder().magicPush(false);
+  }
+
+  public static ReceiveCommitsResult empty() {
+    return builder().build();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    private EnumMap<ChangeStatus, ImmutableSet.Builder<Change.Id>> changes;
+
+    Builder() {
+      changes = Maps.newEnumMap(ChangeStatus.class);
+      Arrays.stream(ChangeStatus.values()).forEach(k -> changes.put(k, ImmutableSet.builder()));
+    }
+
+    /** Record a change ID update as having completed. */
+    public Builder addChange(ChangeStatus key, Change.Id id) {
+      changes.get(key).add(id);
+      return this;
+    }
+
+    public abstract Builder magicPush(boolean isMagicPush);
+
+    public ReceiveCommitsResult build() {
+      ImmutableMap.Builder<ChangeStatus, ImmutableSet<Change.Id>> changesBuilder =
+          ImmutableMap.builder();
+      changes.entrySet().forEach(e -> changesBuilder.put(e.getKey(), e.getValue().build()));
+      changes(changesBuilder.build());
+      return autoBuild();
+    }
+
+    protected abstract Builder changes(ImmutableMap<ChangeStatus, ImmutableSet<Change.Id>> changes);
+
+    protected abstract ReceiveCommitsResult autoBuild();
+  }
+}
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..0baecf5 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
@@ -32,25 +33,23 @@
 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;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.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;
@@ -74,6 +73,7 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.util.Providers;
@@ -109,7 +109,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 +120,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 +140,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 +150,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 +163,6 @@
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
       ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      EmailReviewComments.Factory emailCommentsFactory,
       RevisionCreated revisionCreated,
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
@@ -176,6 +171,7 @@
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
+      Change change,
       @Assisted ProjectState projectState,
       @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
@@ -193,9 +189,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 +210,7 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.change = change;
   }
 
   @Override
@@ -236,7 +230,11 @@
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
-                requestScopePropagator, patchSetId, mergedInto, mergeResultRevId);
+                requestScopePropagator,
+                patchSetId,
+                new SubmissionId(change),
+                mergedInto,
+                mergeResultRevId);
       }
     }
 
@@ -246,7 +244,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
+      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException,
+          ValidationException {
     notes = ctx.getNotes();
     Change change = notes.getChange();
     if (change == null || change.isClosed()) {
@@ -276,7 +275,11 @@
         update.setHashtags(hashtags);
       }
       if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
-        update.setTopic(magicBranch.topic);
+        try {
+          update.setTopic(magicBranch.topic);
+        } catch (ValidationException ex) {
+          throw new BadRequestException(ex.getMessage());
+        }
       }
       if (magicBranch.removePrivate) {
         change.setPrivate(false);
@@ -293,13 +296,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 +355,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 +403,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 +481,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 +494,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,18 +547,21 @@
     }
   }
 
-  private void fireCommentAddedEvent(Context ctx) throws IOException {
+  private void fireApprovalsEvent(Context ctx) {
     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
      * show a transition from an oldValue of 0 to the new value.
      */
     List<LabelType> labels =
-        projectCache.checkedGet(ctx.getProject()).getLabelTypes(notes).getLabelTypes();
+        projectCache
+            .get(ctx.getProject())
+            .orElseThrow(illegalState(ctx.getProject()))
+            .getLabelTypes(notes)
+            .getLabelTypes();
     Map<String, Short> allApprovals = new HashMap<>();
     Map<String, Short> oldApprovals = new HashMap<>();
     for (LabelType lt : labels) {
@@ -597,7 +574,6 @@
         oldApprovals.put(entry.getKey(), (short) 0);
       }
     }
-
     commentAdded.fire(
         notes.getChange(),
         newPatchSet,
@@ -648,8 +624,4 @@
       return null;
     }
   }
-
-  private boolean shouldPublishComments() {
-    return magicBranch != null && magicBranch.shouldPublishComments();
-  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
deleted file mode 100644
index 805822c..0000000
--- a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
+++ /dev/null
@@ -1,67 +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.git.receive;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Change;
-import java.util.ArrayList;
-import java.util.EnumMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Keeps track of the change IDs thus far updated by ReceiveCommit.
- *
- * <p>This class is thread-safe.
- */
-public class ResultChangeIds {
-  public enum Key {
-    CREATED,
-    REPLACED,
-    AUTOCLOSED,
-  }
-
-  private boolean isMagicPush;
-  private final Map<Key, List<Change.Id>> ids;
-
-  ResultChangeIds() {
-    ids = new EnumMap<>(Key.class);
-    for (Key k : Key.values()) {
-      ids.put(k, new ArrayList<>());
-    }
-  }
-
-  /** Record a change ID update as having completed. Thread-safe. */
-  public synchronized void add(Key key, Change.Id id) {
-    ids.get(key).add(id);
-  }
-
-  /** Indicate that the ReceiveCommits call involved a magic branch. */
-  public synchronized void setMagicPush(boolean magic) {
-    isMagicPush = magic;
-  }
-
-  public synchronized boolean isMagicPush() {
-    return isMagicPush;
-  }
-
-  /**
-   * Returns change IDs of the given type for which the BatchUpdate succeeded, or empty list if
-   * there are none. Thread-safe.
-   */
-  public synchronized List<Change.Id> get(Key key) {
-    return ImmutableList.copyOf(ids.get(key));
-  }
-}
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..67aa3bd
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
@@ -0,0 +1,65 @@
+// 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 numExistingCommentsAndChangeMessages =
+        notes.getComments().size()
+            + notes.getRobotComments().size()
+            + notes.getChangeMessages().size();
+    if (!comments.isEmpty()
+        && numExistingCommentsAndChangeMessages + 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",
+                  numExistingCommentsAndChangeMessages, comments.size(), maxComments)));
+    }
+    return failures.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
new file mode 100644
index 0000000..d9a1420
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -0,0 +1,76 @@
+// 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.Comment;
+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 java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Limits the total size of all comments and change messages to prevent space/time complexity
+ * issues. Note that autogenerated change messages are not subject to validation.
+ */
+public class CommentCumulativeSizeValidator implements CommentValidator {
+  private final int maxCumulativeSize;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  CommentCumulativeSizeValidator(
+      @GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
+    this.notesFactory = notesFactory;
+    maxCumulativeSize = serverConfig.getInt("change", "cumulativeCommentSizeLimit", 3 << 20);
+  }
+
+  @Override
+  public ImmutableList<CommentValidationFailure> validateComments(
+      CommentValidationContext ctx, ImmutableList<CommentForValidation> comments) {
+    ChangeNotes notes =
+        notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
+    int existingCumulativeSize =
+        Stream.concat(
+                    notes.getComments().values().stream(),
+                    notes.getRobotComments().values().stream())
+                .mapToInt(Comment::getApproximateSize)
+                .sum()
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+    int newCumulativeSize =
+        comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
+    ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
+    if (!comments.isEmpty() && existingCumulativeSize + newCumulativeSize > maxCumulativeSize) {
+      // 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 cumulative size of comments and change messages:"
+                      + " %d (existing) + %d (new) > %d",
+                  existingCumulativeSize, newCumulativeSize, maxCumulativeSize)));
+    }
+    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 6773d29..7535f51 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -64,6 +65,8 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -74,9 +77,11 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
- * 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 +99,15 @@
     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 Config config;
 
     @Inject
     Factory(
         @GerritPersonIdent PersonIdent gerritIdent,
         DynamicItem<UrlFormatter> urlFormatter,
-        @GerritServerConfig Config cfg,
+        @GerritServerConfig Config config,
         PluginSetContext<CommitValidationListener> pluginValidators,
         GitRepositoryManager repoManager,
         AllUsersName allUsers,
@@ -113,14 +118,13 @@
         ProjectConfig.Factory projectConfigFactory) {
       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;
     }
@@ -133,26 +137,22 @@
         NoteMap rejectCommits,
         RevWalk rw,
         @Nullable Change change,
-        boolean skipValidation)
-        throws IOException {
+        boolean skipValidation) {
       PermissionBackend.ForRef perm = forProject.ref(branch.branch());
-      ProjectState projectState = projectCache.checkedGet(branch.project());
+      ProjectState projectState =
+          projectCache.get(branch.project()).orElseThrow(illegalState(branch.project()));
       ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
       validators
           .add(new UploadMergesPermissionValidator(perm))
           .add(new ProjectStateValidationListener(projectState))
           .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
+          .add(new FileCountValidator(repoManager, config))
           .add(new CommitterUploaderValidator(user, perm, urlFormatter.get()))
           .add(new SignedOffByValidator(user, perm, projectState))
           .add(
               new ChangeIdValidator(
-                  projectState,
-                  user,
-                  urlFormatter.get(),
-                  installCommitMsgHookCommand,
-                  sshInfo,
-                  change))
+                  projectState, user, urlFormatter.get(), config, sshInfo, change))
           .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects))
           .add(new BannedCommitsValidator(rejectCommits))
           .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
@@ -168,25 +168,21 @@
         IdentifiedUser user,
         SshInfo sshInfo,
         RevWalk rw,
-        @Nullable Change change)
-        throws IOException {
+        @Nullable Change change) {
       PermissionBackend.ForRef perm = forProject.ref(branch.branch());
-      ProjectState projectState = projectCache.checkedGet(branch.project());
+      ProjectState projectState =
+          projectCache.get(branch.project()).orElseThrow(illegalState(branch.project()));
       ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
       validators
           .add(new UploadMergesPermissionValidator(perm))
           .add(new ProjectStateValidationListener(projectState))
           .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
-          .add(new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.project())))
+          .add(new FileCountValidator(repoManager, config))
+          .add(new SignedOffByValidator(user, perm, projectState))
           .add(
               new ChangeIdValidator(
-                  projectState,
-                  user,
-                  urlFormatter.get(),
-                  installCommitMsgHookCommand,
-                  sshInfo,
-                  change))
+                  projectState, user, urlFormatter.get(), config, sshInfo, change))
           .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects))
           .add(new PluginCommitValidationListener(pluginValidators))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
@@ -196,8 +192,7 @@
     }
 
     public CommitValidators forMergedCommits(
-        PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user)
-        throws IOException {
+        PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user) {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
       // validators that would require amending the change in order to correct.
@@ -212,10 +207,12 @@
       //  - Plugin validators may do things like require certain commit message
       //    formats, so we play it safe and exclude them.
       PermissionBackend.ForRef perm = forProject.ref(branch.branch());
+      ProjectState projectState =
+          projectCache.get(branch.project()).orElseThrow(illegalState(branch.project()));
       ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
       validators
           .add(new UploadMergesPermissionValidator(perm))
-          .add(new ProjectStateValidationListener(projectCache.checkedGet(branch.project())))
+          .add(new ProjectStateValidationListener(projectState))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
           .add(new CommitterUploaderValidator(user, perm, urlFormatter.get()));
       return new CommitValidators(validators.build());
@@ -273,14 +270,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;
     }
 
@@ -392,6 +389,67 @@
     }
   }
 
+  /** Limits the number of files per change. */
+  private static class FileCountValidator implements CommitValidationListener {
+
+    private final GitRepositoryManager repoManager;
+    private final int maxFileCount;
+
+    FileCountValidator(GitRepositoryManager repoManager, Config config) {
+      this.repoManager = repoManager;
+      maxFileCount = config.getInt("change", null, "maxFiles", 100_000);
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      // TODO(zieren): Refactor interface to signal the intent of the event instead of hard-coding
+      // it here. Due to interface limitations, this method is called from both receive commits
+      // and from main Gerrit (e.g. when publishing a change edit). This is why we need to gate the
+      // early return on REFS_CHANGES (though pushes to refs/changes are not possible).
+      String refName = receiveEvent.command.getRefName();
+      if (!refName.startsWith("refs/for/") && !refName.startsWith(RefNames.REFS_CHANGES)) {
+        // This is a direct push bypassing review. We don't need to enforce any file-count limits
+        // here.
+        return Collections.emptyList();
+      }
+
+      // Use DiffFormatter to compute the number of files in the change. This should be faster than
+      // the previous approach of using the PatchListCache.
+      try {
+        long changedFiles = countChangedFiles(receiveEvent);
+        if (changedFiles > maxFileCount) {
+          throw new CommitValidationException(
+              String.format(
+                  "Exceeding maximum number of files per change (%d > %d)",
+                  changedFiles, maxFileCount));
+        }
+      } catch (IOException 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();
+    }
+
+    private long countChangedFiles(CommitReceivedEvent receiveEvent) throws IOException {
+      try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey());
+          RevWalk revWalk = new RevWalk(repository);
+          DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        diffFormatter.setReader(revWalk.getObjectReader(), repository.getConfig());
+        diffFormatter.setDetectRenames(true);
+        // For merge commits, i.e. >1 parents, we use parent #0 by convention.
+        List<DiffEntry> diffEntries =
+            diffFormatter.scan(
+                receiveEvent.commit.getParentCount() > 0 ? receiveEvent.commit.getParent(0) : null,
+                receiveEvent.commit);
+        return diffEntries.stream().map(DiffEntry::getNewPath).distinct().count();
+      }
+    }
+  }
+
   /** 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..04cbe36 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.";
@@ -202,7 +216,7 @@
                     String.format(
                         " %s must inherit from %s", allUsersName.get(), allProjectsName.get()));
               }
-              if (projectCache.get(newParent) == null) {
+              if (!projectCache.get(newParent).isPresent()) {
                 throw new MergeValidationException(PARENT_NOT_FOUND);
               }
             }
@@ -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/OnSubmitValidators.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
index 6faa7af..834356b 100644
--- a/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener.Arguments;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.submit.IntegrationException;
+import com.google.gerrit.server.submit.IntegrationConflictException;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -38,12 +38,12 @@
 
   public void validate(
       Project.NameKey project, ObjectReader objectReader, ChainedReceiveCommands commands)
-      throws IntegrationException {
+      throws IntegrationConflictException {
     try (RevWalk rw = new RevWalk(objectReader)) {
       Arguments args = new Arguments(project, rw, commands);
       listeners.runEach(l -> l.preBranchUpdate(args), ValidationException.class);
     } catch (ValidationException e) {
-      throw new IntegrationException(e.getMessage(), e);
+      throw new IntegrationConflictException(e.getMessage(), e);
     }
   }
 }
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/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index ab5c9b8..60a1e3e 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -144,8 +144,26 @@
   public static GroupConfig loadForGroup(
       Project.NameKey projectName, Repository repository, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
+    return loadForGroup(projectName, repository, groupUuid, null);
+  }
+
+  /**
+   * Load the group for a specific revision.
+   *
+   * @see GroupConfig#loadForGroup(Project.NameKey, Repository, AccountGroup.UUID)
+   */
+  public static GroupConfig loadForGroup(
+      Project.NameKey projectName,
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      ObjectId groupRefObjectId)
+      throws IOException, ConfigInvalidException {
     GroupConfig groupConfig = new GroupConfig(groupUuid);
-    groupConfig.load(projectName, repository);
+    if (groupRefObjectId == null) {
+      groupConfig.load(projectName, repository);
+    } else {
+      groupConfig.load(projectName, repository, groupRefObjectId);
+    }
     return groupConfig;
   }
 
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 2925cb3..163b9c6 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
@@ -30,6 +31,8 @@
 import java.util.Optional;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -47,6 +50,8 @@
  */
 @Singleton
 public class Groups {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final AuditLogReader auditLogReader;
@@ -74,10 +79,28 @@
     }
   }
 
+  /**
+   * Loads an internal group from NoteDb using the group UUID. This method returns the latest state
+   * of the internal group.
+   */
   private static Optional<InternalGroup> getGroupFromNoteDb(
       AllUsersName allUsersName, Repository allUsersRepository, AccountGroup.UUID groupUuid)
       throws IOException, ConfigInvalidException {
-    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepository, groupUuid);
+    return getGroupFromNoteDb(allUsersName, allUsersRepository, groupUuid, null);
+  }
+
+  /**
+   * Loads an internal group from NoteDb at the revision provided as {@link ObjectId}. This method
+   * is used to get a specific state of this group.
+   */
+  private static Optional<InternalGroup> getGroupFromNoteDb(
+      AllUsersName allUsersName,
+      Repository allUsersRepository,
+      AccountGroup.UUID uuid,
+      ObjectId groupRefObjectId)
+      throws IOException, ConfigInvalidException {
+    GroupConfig groupConfig =
+        GroupConfig.loadForGroup(allUsersName, allUsersRepository, uuid, groupRefObjectId);
     Optional<InternalGroup> loadedGroup = groupConfig.getLoadedGroup();
     if (loadedGroup.isPresent()) {
       // Check consistency with group name notes.
@@ -104,28 +127,34 @@
    * Returns all known external groups. External groups are 'known' when they are specified as a
    * subgroup of an internal group.
    *
+   * @param internalGroupsRefs contains a list of all groups refs that we should inspect
    * @return a stream of the UUIDs of the known external groups
    * @throws IOException if an error occurs while reading from NoteDb
    * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
    */
-  public Stream<AccountGroup.UUID> getExternalGroups() throws IOException, ConfigInvalidException {
+  public Stream<AccountGroup.UUID> getExternalGroups(ImmutableList<Ref> internalGroupsRefs)
+      throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      return getExternalGroupsFromNoteDb(allUsersName, allUsersRepo);
+      return getExternalGroupsFromNoteDb(allUsersName, allUsersRepo, internalGroupsRefs);
     }
   }
 
   private static Stream<AccountGroup.UUID> getExternalGroupsFromNoteDb(
-      AllUsersName allUsersName, Repository allUsersRepo)
+      AllUsersName allUsersName, Repository allUsersRepo, ImmutableList<Ref> internalGroupsRefs)
       throws IOException, ConfigInvalidException {
-    ImmutableList<GroupReference> allInternalGroups = GroupNameNotes.loadAllGroups(allUsersRepo);
     ImmutableSet.Builder<AccountGroup.UUID> allSubgroups = ImmutableSet.builder();
-    for (GroupReference internalGroup : allInternalGroups) {
+    for (Ref internalGroupRef : internalGroupsRefs) {
+      AccountGroup.UUID uuid = AccountGroup.UUID.fromRef(internalGroupRef.getName());
+      if (uuid == null) {
+        logger.atWarning().log(
+            "Failed to get the group UUID from ref: %s", internalGroupRef.getName());
+        continue;
+      }
       Optional<InternalGroup> group =
-          getGroupFromNoteDb(allUsersName, allUsersRepo, internalGroup.getUUID());
+          getGroupFromNoteDb(allUsersName, allUsersRepo, uuid, internalGroupRef.getObjectId());
       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/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index 35ff513..420dd33e 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.group.db;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
@@ -85,7 +87,8 @@
   public void run() {
     Iterable<Project.NameKey> names = tryingAgain ? retryOn : projectCache.all();
     for (Project.NameKey projectName : names) {
-      ProjectConfig config = projectCache.get(projectName).getConfig();
+      ProjectConfig config =
+          projectCache.get(projectName).orElseThrow(illegalState(projectName)).getConfig();
       GroupReference ref = config.getGroup(uuid);
       if (ref == null || newName.equals(ref.getName())) {
         continue;
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 0941c6c..9f0622e 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..6949946 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);
 
@@ -77,7 +83,6 @@
 
   @Override
   public void index(Account.Id id) {
-    byIdCache.evict(id);
     Optional<AccountState> accountState = byIdCache.get(id);
 
     if (accountState.isPresent()) {
@@ -97,6 +102,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 +118,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 +133,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..63889b7 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);
   }
@@ -86,7 +90,6 @@
           executor.submit(
               () -> {
                 try {
-                  accountCache.evict(id);
                   Optional<AccountState> a = accountCache.get(id);
                   if (a.isPresent()) {
                     index.replace(a.get());
@@ -109,11 +112,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 005f4c5..e9349c4 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -25,6 +25,7 @@
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -45,7 +46,6 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -53,6 +53,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();
   private static final int PROJECT_SLICE_MAX_REFS = 1000;
@@ -142,7 +147,7 @@
         projectsFailed++;
         if (projectsFailed > projectCache.all().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);
@@ -211,7 +216,7 @@
                 return null;
               },
               directExecutor()));
-    } catch (ExecutionException e) {
+    } catch (UncheckedExecutionException e) {
       logger.atSevere().withCause(e).log("Error in batch indexer");
       ok.set(false);
     }
@@ -228,7 +233,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..a7c4016 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -24,15 +24,16 @@
 import static com.google.gerrit.index.FieldDef.prefix;
 import static com.google.gerrit.index.FieldDef.storedOnly;
 import static com.google.gerrit.index.FieldDef.timestamp;
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
@@ -44,6 +45,7 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -73,6 +75,7 @@
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gson.Gson;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -269,6 +272,63 @@
   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);
+
+  /** This class decouples the internal and API types from storage. */
+  private static class StoredAttentionSetEntry {
+    final long timestampMillis;
+    final int userId;
+    final String reason;
+    final AttentionSetUpdate.Operation operation;
+
+    StoredAttentionSetEntry(AttentionSetUpdate attentionSetUpdate) {
+      timestampMillis = attentionSetUpdate.timestamp().toEpochMilli();
+      userId = attentionSetUpdate.account().get();
+      reason = attentionSetUpdate.reason();
+      operation = attentionSetUpdate.operation();
+    }
+
+    AttentionSetUpdate toAttentionSetUpdate() {
+      return AttentionSetUpdate.createFromRead(
+          Instant.ofEpochMilli(timestampMillis), Account.id(userId), operation, reason);
+    }
+  }
+
+  /**
+   * Users included in the attention set of the change. This omits timestamp, reason and possible
+   * future fields.
+   *
+   * @see #ATTENTION_SET_FULL
+   */
+  public static final FieldDef<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS =
+      integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
+          .buildRepeatable(ChangeField::getAttentionSetUserIds);
+
+  /**
+   * The full attention set data including timestamp, reason and possible future fields.
+   *
+   * @see #ATTENTION_SET_USERS
+   */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
+      storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
+          .buildRepeatable(ChangeField::storedAttentionSet);
+
   /** The user assigned to the change. */
   public static final FieldDef<ChangeData, Integer> ASSIGNEE =
       integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
@@ -324,9 +384,9 @@
         reviewersByEmail.asTable().cellSet()) {
       String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
-      if (c.getColumnKey().getName() != null) {
+      if (c.getColumnKey().name() != null) {
         // Add another entry without the name to provide search functionality on the email
-        Address emailOnly = new Address(c.getColumnKey().getEmail());
+        Address emailOnly = Address.create(c.getColumnKey().email());
         r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
       }
       r.add(v + ',' + c.getValue().getTime());
@@ -361,8 +421,7 @@
         continue;
       }
 
-      com.google.common.base.Optional<ReviewerStateInternal> reviewerState =
-          Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i));
+      Optional<ReviewerStateInternal> reviewerState = getReviewerState(v.substring(0, i));
       if (!reviewerState.isPresent()) {
         logger.atWarning().log(
             "Failed to parse reviewer state of reviewer field from change %s: %s",
@@ -413,8 +472,7 @@
         continue;
       }
 
-      com.google.common.base.Optional<ReviewerStateInternal> reviewerState =
-          Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i));
+      Optional<ReviewerStateInternal> reviewerState = getReviewerState(v.substring(0, i));
       if (!reviewerState.isPresent()) {
         logger.atWarning().log(
             "Failed to parse reviewer state of reviewer by email field from change %s: %s",
@@ -444,6 +502,41 @@
     return ReviewerByEmailSet.fromTable(b.build());
   }
 
+  private static Optional<ReviewerStateInternal> getReviewerState(String value) {
+    try {
+      return Optional.of(ReviewerStateInternal.valueOf(value));
+    } catch (IllegalArgumentException | NullPointerException e) {
+      return Optional.empty();
+    }
+  }
+
+  private static ImmutableSet<Integer> getAttentionSetUserIds(ChangeData changeData) {
+    return additionsOnly(changeData.attentionSet()).stream()
+        .map(update -> update.account().get())
+        .collect(toImmutableSet());
+  }
+
+  private static ImmutableSet<byte[]> storedAttentionSet(ChangeData changeData) {
+    return changeData.attentionSet().stream()
+        .map(StoredAttentionSetEntry::new)
+        .map(storedAttentionSetEntry -> GSON.toJson(storedAttentionSetEntry).getBytes(UTF_8))
+        .collect(toImmutableSet());
+  }
+
+  /**
+   * Deserializes the specified attention set entries from JSON and stores them in the specified
+   * change.
+   */
+  public static void parseAttentionSet(
+      Collection<String> storedAttentionSetEntriesJson, ChangeData changeData) {
+    ImmutableSet<AttentionSetUpdate> attentionSet =
+        storedAttentionSetEntriesJson.stream()
+            .map(
+                entry -> GSON.fromJson(entry, StoredAttentionSetEntry.class).toAttentionSetUpdate())
+            .collect(toImmutableSet());
+    changeData.setAttentionSet(attentionSet);
+  }
+
   /** Commit ID of any patch set on the change, using prefix match. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
       prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
@@ -603,6 +696,19 @@
                 return m ? "1" : "0";
               });
 
+  /** Whether the change is a merge commit. */
+  public static final FieldDef<ChangeData, String> MERGE =
+      exact(ChangeQueryBuilder.FIELD_MERGE)
+          .stored()
+          .build(
+              cd -> {
+                Boolean m = cd.isMerge();
+                if (m == null) {
+                  return null;
+                }
+                return m ? "1" : "0";
+              });
+
   /** The number of inserted lines in this change. */
   public static final FieldDef<ChangeData, Integer> ADDED =
       intRange(ChangeQueryBuilder.FIELD_ADDED)
@@ -727,7 +833,7 @@
     static class StoredRequirement {
       String fallbackText;
       String type;
-      Map<String, String> data;
+      @Deprecated Map<String, String> data;
     }
 
     SubmitRecord.Status status;
@@ -754,7 +860,12 @@
           StoredRequirement sr = new StoredRequirement();
           sr.type = requirement.type();
           sr.fallbackText = requirement.fallbackText();
-          sr.data = requirement.data();
+          // For backwards compatibility, write an empty map to the index.
+          // This is required, because the SubmitRequirement AutoValue can't
+          // handle null in the old code.
+          // TODO(hiesel): Remove once we have rolled out the new code
+          //  and waited long enough to not need to roll back.
+          sr.data = ImmutableMap.of();
           this.requirements.add(sr);
         }
       }
@@ -781,7 +892,6 @@
               SubmitRequirement.builder()
                   .setType(req.type)
                   .setFallbackText(req.fallbackText)
-                  .setData(req.data)
                   .build();
           rec.requirements.add(sr);
         }
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..928f21c 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 =
@@ -84,12 +85,18 @@
           ChangeField.UPDATED,
           ChangeField.WIP);
 
-  // The computation of the 'extension' field is changed, hence reindexing is required.
+  /**
+   * The computation of the {@link ChangeField#EXTENSION} field is changed, hence reindexing is
+   * required.
+   */
   @Deprecated static final Schema<ChangeData> V56 = schema(V55);
 
-  // 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.
+  /**
+   * 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, {@link
+   * ChangeField#LEGACY_ID} is replaced with {@link ChangeField#LEGACY_ID_STR}.
+   */
+  @Deprecated
   static final Schema<ChangeData> V57 =
       new Schema.Builder<ChangeData>()
           .add(V56)
@@ -98,7 +105,40 @@
           .legacyNumericFields(false)
           .build();
 
+  /**
+   * Added new fields {@link ChangeField#CHERRY_PICK_OF_CHANGE} and {@link
+   * ChangeField#CHERRY_PICK_OF_PATCHSET}.
+   */
+  @Deprecated
+  static final Schema<ChangeData> V58 =
+      new Schema.Builder<ChangeData>()
+          .add(V57)
+          .add(ChangeField.CHERRY_PICK_OF_CHANGE)
+          .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
+          .build();
+
+  /**
+   * Added new fields {@link ChangeField#ATTENTION_SET_USERS} and {@link
+   * ChangeField#ATTENTION_SET_FULL}.
+   */
+  @Deprecated
+  static final Schema<ChangeData> V59 =
+      new Schema.Builder<ChangeData>()
+          .add(V58)
+          .add(ChangeField.ATTENTION_SET_USERS)
+          .add(ChangeField.ATTENTION_SET_FULL)
+          .build();
+
+  /** Added new fields {@link ChangeField#MERGE} */
+  static final Schema<ChangeData> V60 =
+      new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).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..e39873e 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.entities.Project;
 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 +44,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();
 
@@ -52,7 +61,6 @@
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeIndexCollection indexes;
   private final AllUsersName allUsersName;
-  private final AccountCache accountCache;
   private final Provider<AccountIndexer> indexer;
   private final ListeningExecutorService executor;
   private final boolean enabled;
@@ -65,7 +73,6 @@
       ChangeIndexer.Factory indexerFactory,
       ChangeIndexCollection indexes,
       AllUsersName allUsersName,
-      AccountCache accountCache,
       Provider<AccountIndexer> indexer,
       @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
     this.requestContext = requestContext;
@@ -73,10 +80,9 @@
     this.indexerFactory = indexerFactory;
     this.indexes = indexes;
     this.allUsersName = allUsersName;
-    this.accountCache = accountCache;
     this.indexer = indexer;
     this.executor = executor;
-    this.enabled = cfg.getBoolean("index", null, "reindexAfterRefUpdate", true);
+    this.enabled = MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
   }
 
   @Override
@@ -84,7 +90,6 @@
     if (allUsersName.get().equals(event.getProjectName())) {
       Account.Id accountId = Account.Id.fromRef(event.getRefName());
       if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
-        accountCache.evict(accountId);
         indexer.get().index(accountId);
       }
     }
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..0e4b688 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.project;
 
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.flogger.FluentLogger;
@@ -37,6 +38,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();
@@ -74,7 +79,8 @@
               () -> {
                 try {
                   projectCache.evict(name);
-                  index.replace(projectCache.get(name).toProjectData());
+                  index.replace(
+                      projectCache.get(name).orElseThrow(illegalState(name)).toProjectData());
                   verboseWriter.println("Reindexed " + desc);
                   done.incrementAndGet();
                 } catch (Exception e) {
@@ -91,11 +97,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..88a5cf5 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;
@@ -33,7 +34,12 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Optional;
 
+/**
+ * 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();
 
@@ -72,10 +78,10 @@
 
   @Override
   public void index(Project.NameKey nameKey) {
-    ProjectState projectState = projectCache.get(nameKey);
-    if (projectState != null) {
+    Optional<ProjectState> projectState = projectCache.get(nameKey);
+    if (projectState.isPresent()) {
       logger.atFine().log("Replace project %s in index", nameKey.get());
-      ProjectData projectData = projectState.toProjectData();
+      ProjectData projectData = projectState.get().toProjectData();
       for (ProjectIndex i : getWriteIndexes()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
@@ -85,6 +91,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 +111,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..9c44c00 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.index.project;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
@@ -27,10 +29,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 +54,23 @@
     this.indexConfig = indexConfig;
   }
 
-  public boolean isStale(Project.NameKey project) {
-    ProjectData projectData = projectCache.get(project).toProjectData();
+  /**
+   * 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).orElseThrow(illegalState(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 +86,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/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index 1549f8d..23f7e12 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -52,7 +52,7 @@
       return true;
     }
 
-    boolean match = mailPattern.matcher(message.from().getEmail()).find();
+    boolean match = mailPattern.matcher(message.from().email()).find();
     if ((mode == ListFilterMode.WHITELIST && !match)
         || (mode == ListFilterMode.BLACKLIST && match)) {
       logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
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/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..572c972 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -17,13 +17,13 @@
 import com.google.common.base.Joiner;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.inject.assistedinject.Assisted;
 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);
@@ -57,7 +57,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
-    add(RecipientType.TO, new Address(getEmail()));
+    add(RecipientType.TO, user.getAccountId());
   }
 
   @Override
@@ -78,15 +78,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 +106,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/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 19c1fa2..22d332a 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -142,7 +142,7 @@
   @Override
   protected void init() throws EmailException {
     if (args.projectCache != null) {
-      projectState = args.projectCache.get(change.getProject());
+      projectState = args.projectCache.get(change.getProject()).orElse(null);
     } else {
       projectState = null;
     }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index c9bb1e4..bbbfa1d 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Joiner;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.inject.assistedinject.Assisted;
@@ -25,6 +24,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);
@@ -60,7 +62,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
-    add(RecipientType.TO, new Address(getEmail()));
+    add(RecipientType.TO, user.getAccountId());
   }
 
   @Override
@@ -76,15 +78,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 +106,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 1bf1ff1..dfaabbe 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;
 
@@ -52,8 +52,7 @@
 
     if (from == null || "MIXED".equalsIgnoreCase(from)) {
       ParameterizedString name = new ParameterizedString("${user} (Code Review)");
-      generator =
-          new PatternGen(srvAddr, accountCache, anonymousCowardName, name, srvAddr.getEmail());
+      generator = new PatternGen(srvAddr, accountCache, anonymousCowardName, name, srvAddr.email());
     } else if ("USER".equalsIgnoreCase(from)) {
       String[] domains = cfg.getStringList("sendemail", null, "allowedDomain");
       Pattern domainPattern = MailUtil.glob(domains);
@@ -64,18 +63,17 @@
       generator = new ServerGen(srvAddr);
     } else {
       final Address a = Address.parse(from);
-      final ParameterizedString name =
-          a.getName() != null ? new ParameterizedString(a.getName()) : null;
+      final ParameterizedString name = a.name() != null ? new ParameterizedString(a.name()) : null;
       if (name == null || name.getParameterNames().isEmpty()) {
         generator = new ServerGen(a);
       } else {
-        generator = new PatternGen(srvAddr, accountCache, anonymousCowardName, name, a.getEmail());
+        generator = new PatternGen(srvAddr, accountCache, anonymousCowardName, name, a.email());
       }
     }
   }
 
   private static Address toAddress(PersonIdent myIdent) {
-    return new Address(myIdent.getName(), myIdent.getEmailAddress());
+    return Address.create(myIdent.getName(), myIdent.getEmailAddress());
   }
 
   @Override
@@ -127,7 +125,7 @@
         String fullName = a.map(Account::fullName).orElse(null);
         String userEmail = a.map(Account::preferredEmail).orElse(null);
         if (canRelay(userEmail)) {
-          return new Address(fullName, userEmail);
+          return Address.create(fullName, userEmail);
         }
 
         if (fullName == null || "".equals(fullName.trim())) {
@@ -135,17 +133,17 @@
         }
         senderName = nameRewriteTmpl.replace("user", fullName).toString();
       } else {
-        senderName = serverAddress.getName();
+        senderName = serverAddress.name();
       }
 
       String senderEmail;
-      ParameterizedString senderEmailPattern = new ParameterizedString(serverAddress.getEmail());
+      ParameterizedString senderEmailPattern = new ParameterizedString(serverAddress.email());
       if (senderEmailPattern.getParameterNames().isEmpty()) {
         senderEmail = senderEmailPattern.getRawPattern();
       } else {
         senderEmail = senderEmailPattern.replace("userHash", hashOf(senderName)).toString();
       }
-      return new Address(senderName, senderEmail);
+      return Address.create(senderName, senderEmail);
     }
 
     /** check if Gerrit is allowed to send from {@code userEmail}. */
@@ -215,7 +213,7 @@
         senderName = namePattern.replace("user", fullName).toString();
 
       } else {
-        senderName = serverAddress.getName();
+        senderName = serverAddress.name();
       }
 
       String senderEmail;
@@ -224,7 +222,7 @@
       } else {
         senderEmail = senderEmailPattern.replace("userHash", hashOf(senderName)).toString();
       }
-      return new Address(senderName, senderEmail);
+      return Address.create(senderName, senderEmail);
     }
   }
 
@@ -232,7 +230,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 IllegalStateException("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..abb8eda 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -16,11 +16,11 @@
 
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 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);
@@ -41,7 +41,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
-    add(RecipientType.TO, new Address(getEmail()));
+    add(RecipientType.TO, user.getAccountId());
   }
 
   @Override
@@ -58,19 +58,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 +70,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 4f214ed..b35bbec 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,11 +154,15 @@
           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()));
+                Address.create(thisUserAccount.fullName(), thisUserAccount.preferredEmail()));
           }
         }
         if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) {
@@ -166,11 +179,11 @@
         if (fromId != null) {
           Address address = toAddress(fromId);
           if (address != null) {
-            j.add(address.getEmail());
+            j.add(address.email());
           }
         }
-        smtpRcptTo.stream().forEach(a -> j.add(a.getEmail()));
-        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.getEmail()));
+        smtpRcptTo.stream().forEach(a -> j.add(a.email()));
+        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.email()));
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
@@ -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);
@@ -503,16 +518,16 @@
   }
 
   protected void add(RecipientType rt, Address addr, boolean override) {
-    if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
-      if (!args.validator.isValid(addr.getEmail())) {
-        logger.atWarning().log("Not emailing %s (invalid email address)", addr.getEmail());
-      } else if (args.emailSender.canEmail(addr.getEmail())) {
+    if (addr != null && addr.email() != null && addr.email().length() > 0) {
+      if (!args.validator.isValid(addr.email())) {
+        logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
+      } else if (args.emailSender.canEmail(addr.email())) {
         if (!smtpRcptTo.add(addr)) {
           if (!override) {
             return;
           }
-          ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.getEmail());
-          ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.getEmail());
+          ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
+          ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
         }
         switch (rt) {
           case TO:
@@ -539,7 +554,7 @@
     if (!account.isActive() || e == null) {
       return null;
     }
-    return new Address(account.fullName(), e);
+    return Address.create(account.fullName(), e);
   }
 
   protected void setupSoyContext() {
@@ -573,13 +588,16 @@
 
   /** 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) {
     String fromEmail = user.preferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
-      if (j.next().getEmail().equals(fromEmail)) {
+      if (j.next().email().equals(fromEmail)) {
         j.remove();
       }
     }
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 2530d7e..ef58744 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -185,7 +185,7 @@
       }
       if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
         // If the group has an email address, do not expand membership.
-        matching.emails.add(new Address(group.getEmailAddress()));
+        matching.emails.add(Address.create(group.getEmailAddress()));
         logger.atFine().log(
             "notify group email address %s; skip expanding to members", group.getEmailAddress());
         continue;
@@ -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.isVisible();
     }
 
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index 91d8e81..bb2efe6 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);
@@ -50,7 +54,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", "[Gerrit Code Review] Email Verification");
-    add(RecipientType.TO, new Address(addr));
+    add(RecipientType.TO, Address.create(addr));
   }
 
   @Override
@@ -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/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 4e1ab66..cd342f7 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -206,9 +206,8 @@
     try {
       final SMTPClient client = open();
       try {
-        if (!client.setSender(from.getEmail())) {
-          throw new EmailException(
-              "Server " + smtpHost + " rejected from address " + from.getEmail());
+        if (!client.setSender(from.email())) {
+          throw new EmailException("Server " + smtpHost + " rejected from address " + from.email());
         }
 
         /* Do not prevent the email from being sent to "good" users simply
@@ -219,7 +218,7 @@
          * error(s) logged.
          */
         for (Address addr : rcpt) {
-          if (!client.addRecipient(addr.getEmail())) {
+          if (!client.addRecipient(addr.email())) {
             String error = client.getReplyString();
             rejected
                 .append("Server ")
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 ee3ccd6..7136d2b 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,
@@ -110,7 +110,7 @@
       ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
+      return noteUtil.newIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
@@ -164,15 +164,11 @@
     return accountId;
   }
 
-  protected PersonIdent newIdent(Account.Id authorId, Date when) {
-    return noteUtil.newIdent(authorId, when, serverIdent);
-  }
-
   /** Whether no updates have been done. */
   public abstract boolean isEmpty();
 
   /** Wether this update can only be a root commit. */
-  public boolean isRootOnly() {
+  boolean isRootOnly() {
     return rootOnly;
   }
 
@@ -260,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;
@@ -274,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 112f687..5417494 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 05fdee9..4b538f3 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..86b6ed7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,10 +15,13 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gson.Gson;
 import com.google.inject.Inject;
+import java.time.Instant;
 import java.util.Date;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -27,41 +30,31 @@
 import org.eclipse.jgit.util.RawParseUtils;
 
 public class ChangeNoteUtil {
-  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
-  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
-  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
-      new FooterKey("Patch-set-description");
-  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
-  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
-  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
-  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  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");
 
-  static final String AUTHOR = "Author";
-  static final String BASE_PATCH_SET = "Base-for-patch-set";
-  static final String COMMENT_RANGE = "Comment-range";
-  static final String FILE = "File";
-  static final String LENGTH = "Bytes";
-  static final String PARENT = "Parent";
-  static final String PARENT_NUMBER = "Parent-number";
-  static final String PATCH_SET = "Patch-set";
-  static final String REAL_AUTHOR = "Real-author";
-  static final String REVISION = "Revision";
-  static final String UUID = "UUID";
-  static final String UNRESOLVED = "Unresolved";
-  static final String TAG = FOOTER_TAG.getName();
+  static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
+  static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+  static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description");
+  static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
+  static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+  static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+  static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
+  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
+  static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
+  static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
+
+  private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
 
   private final ChangeNoteJson changeNoteJson;
   private final String serverId;
@@ -76,21 +69,17 @@
     return changeNoteJson;
   }
 
-  public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
+  public PersonIdent newIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
     return new PersonIdent(
-        "Gerrit User " + authorId.toString(),
-        authorId.get() + "@" + serverId,
-        when,
-        serverIdent.getTimeZone());
+        getUsername(accountId), getEmailAddress(accountId), when, serverIdent.getTimeZone());
   }
 
-  @VisibleForTesting
-  public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
-    return new PersonIdent(
-        "Gerrit User " + author.id(),
-        author.id().get() + "@" + serverId,
-        when,
-        serverIdent.getTimeZone());
+  private static String getUsername(Account.Id accountId) {
+    return "Gerrit User " + accountId.toString();
+  }
+
+  private String getEmailAddress(Account.Id accountId) {
+    return accountId.get() + "@" + serverId;
   }
 
   public static Optional<CommitMessageRange> parseCommitMessageRange(RevCommit commit) {
@@ -150,6 +139,7 @@
 
   @AutoValue
   public abstract static class CommitMessageRange {
+
     public abstract int subjectStart();
 
     public abstract int subjectEnd();
@@ -164,6 +154,7 @@
 
     @AutoValue.Builder
     public abstract static class Builder {
+
       abstract Builder subjectStart(int subjectStart);
 
       abstract Builder subjectEnd(int subjectEnd);
@@ -175,4 +166,53 @@
       abstract CommitMessageRange build();
     }
   }
+
+  /** Helper class for JSON serialization. Timestamp is taken from the commit. */
+  private static class AttentionStatusInNoteDb {
+
+    final String personIdent;
+    final AttentionSetUpdate.Operation operation;
+    final String reason;
+
+    AttentionStatusInNoteDb(
+        String personIndent, AttentionSetUpdate.Operation operation, String reason) {
+      this.personIdent = personIndent;
+      this.operation = operation;
+      this.reason = reason;
+    }
+  }
+
+  /** The returned {@link Optional} holds the parsed entity or is empty if parsing failed. */
+  static Optional<AttentionSetUpdate> attentionStatusFromJson(
+      Instant timestamp, String attentionString) {
+    AttentionStatusInNoteDb inNoteDb =
+        gson.fromJson(attentionString, AttentionStatusInNoteDb.class);
+    PersonIdent personIdent = RawParseUtils.parsePersonIdent(inNoteDb.personIdent);
+    if (personIdent == null) {
+      return Optional.empty();
+    }
+    Optional<Account.Id> account = NoteDbUtil.parseIdent(personIdent);
+    return account.map(
+        id ->
+            AttentionSetUpdate.createFromRead(timestamp, id, inNoteDb.operation, inNoteDb.reason));
+  }
+
+  String attentionSetUpdateToJson(AttentionSetUpdate attentionSetUpdate) {
+    PersonIdent personIdent =
+        new PersonIdent(
+            getUsername(attentionSetUpdate.account()),
+            getEmailAddress(attentionSetUpdate.account()));
+    StringBuilder stringBuilder = new StringBuilder();
+    appendIdentString(stringBuilder, personIdent.getName(), personIdent.getEmailAddress());
+    return gson.toJson(
+        new AttentionStatusInNoteDb(
+            stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
+  }
+
+  static void appendIdentString(StringBuilder stringBuilder, String name, String emailAddress) {
+    PersonIdent.appendSanitized(stringBuilder, name);
+    stringBuilder.append(" <");
+    PersonIdent.appendSanitized(stringBuilder, emailAddress);
+    stringBuilder.append('>');
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index bd673d6..36a61cc0 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -213,6 +214,7 @@
         Repository repo, Project.NameKey project, Predicate<Change.Id> changeIdPredicate)
         throws IOException {
       ScanResult sr = scanChangeIds(repo);
+
       Stream<Change.Id> idStream = sr.all().stream();
       if (changeIdPredicate != null) {
         idStream = idStream.filter(changeIdPredicate);
@@ -384,6 +386,11 @@
     return state.reviewerUpdates();
   }
 
+  /** Returns the most recent update (i.e. status) per user. */
+  public ImmutableSet<AttentionSetUpdate> getAttentionSet() {
+    return state.attentionSet();
+  }
+
   /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
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..a884b70 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -16,8 +16,10 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
 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 +38,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;
 
@@ -57,6 +58,7 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
@@ -65,7 +67,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;
@@ -75,6 +77,7 @@
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -102,7 +105,6 @@
 
   // Private final members initialized in the constructor.
   private final ChangeNoteJson changeNoteJson;
-
   private final NoteDbMetrics metrics;
   private final Change.Id id;
   private final ObjectId tip;
@@ -114,6 +116,9 @@
   private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
+  /** Holds only the most recent update per user. Older updates are discarded. */
+  private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
   private final ListMultimap<ObjectId, Comment> comments;
@@ -148,6 +153,7 @@
   private ReviewerByEmailSet pendingReviewersByEmail;
   private Change.Id revertOf;
   private int updateCount;
+  private PatchSet.Id cherryPickOf;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -168,6 +174,7 @@
     pendingReviewersByEmail = ReviewerByEmailSet.empty();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
+    latestAttentionStatus = new HashMap<>();
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -185,7 +192,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);
@@ -238,6 +245,7 @@
         pendingReviewersByEmail,
         allPastReviewers,
         buildReviewerUpdates(),
+        ImmutableSet.copyOf(latestAttentionStatus.values()),
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
@@ -246,6 +254,7 @@
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
         revertOf,
+        cherryPickOf,
         updateCount);
   }
 
@@ -357,6 +366,7 @@
     }
 
     parseHashtags(commit);
+    parseAttentionSetUpdates(commit);
     parseAssigneeUpdates(ts, commit);
 
     if (submissionId == null) {
@@ -417,6 +427,10 @@
       revertOf = parseRevertOf(commit);
     }
 
+    if (cherryPickOf == null) {
+      cherryPickOf = parseCherryPickOf(commit);
+    }
+
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
   }
@@ -563,6 +577,21 @@
     }
   }
 
+  private void parseAttentionSetUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
+    List<String> attentionStrings = commit.getFooterLineValues(FOOTER_ATTENTION);
+    for (String attentionString : attentionStrings) {
+
+      Optional<AttentionSetUpdate> attentionStatus =
+          ChangeNoteUtil.attentionStatusFromJson(
+              Instant.ofEpochSecond(commit.getCommitTime()), attentionString);
+      if (!attentionStatus.isPresent()) {
+        throw invalidFooter(FOOTER_ATTENTION, attentionString);
+      }
+      // Processing is in reverse chronological order. Keep only the latest update.
+      latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+    }
+  }
+
   private void parseAssigneeUpdates(Timestamp ts, ChangeNotesCommit commit)
       throws ConfigInvalidException {
     String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
@@ -968,6 +997,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..0f27b75 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -55,6 +56,7 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@@ -66,6 +68,7 @@
 import com.google.protobuf.ByteString;
 import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -83,11 +86,12 @@
  */
 @AutoValue
 public abstract class ChangeNotesState {
+
   static ChangeNotesState empty(Change change) {
     return Builder.empty(change.getId()).build();
   }
 
-  static Builder builder() {
+  private static Builder builder() {
     return new AutoValue_ChangeNotesState.Builder();
   }
 
@@ -115,6 +119,7 @@
       ReviewerByEmailSet pendingReviewersByEmail,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
+      Set<AttentionSetUpdate> attentionSetUpdates,
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
@@ -123,6 +128,7 @@
       boolean workInProgress,
       boolean reviewStarted,
       @Nullable Change.Id revertOf,
+      @Nullable PatchSet.Id cherryPickOf,
       int updateCount) {
     requireNonNull(
         metaId,
@@ -152,6 +158,7 @@
                 .workInProgress(workInProgress)
                 .reviewStarted(reviewStarted)
                 .revertOf(revertOf)
+                .cherryPickOf(cherryPickOf)
                 .build())
         .hashtags(hashtags)
         .serverId(serverId)
@@ -163,6 +170,7 @@
         .pendingReviewersByEmail(pendingReviewersByEmail)
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
+        .attentionSet(attentionSetUpdates)
         .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
@@ -178,6 +186,7 @@
    */
   @AutoValue
   abstract static class ChangeColumns {
+
     static Builder builder() {
       return new AutoValue_ChangeNotesState_ChangeColumns.Builder();
     }
@@ -220,10 +229,14 @@
     @Nullable
     abstract Change.Id revertOf();
 
+    @Nullable
+    abstract PatchSet.Id cherryPickOf();
+
     abstract Builder toBuilder();
 
     @AutoValue.Builder
     abstract static class Builder {
+
       abstract Builder changeKey(Change.Key changeKey);
 
       abstract Builder createdOn(Timestamp createdOn);
@@ -254,6 +267,8 @@
 
       abstract Builder revertOf(@Nullable Change.Id revertOf);
 
+      abstract Builder cherryPickOf(@Nullable PatchSet.Id cherryPickOf);
+
       abstract ChangeColumns build();
     }
   }
@@ -290,6 +305,9 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
+  /** Returns the most recent update (i.e. current status status) per user. */
+  abstract ImmutableSet<AttentionSetUpdate> attentionSet();
+
   abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
 
   abstract ImmutableList<SubmitRecord> submitRecords();
@@ -341,6 +359,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());
@@ -353,6 +372,7 @@
 
   @AutoValue.Builder
   abstract static class Builder {
+
     static Builder empty(Change.Id changeId) {
       return new AutoValue_ChangeNotesState.Builder()
           .changeId(changeId)
@@ -365,6 +385,7 @@
           .pendingReviewersByEmail(ReviewerByEmailSet.empty())
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
+          .attentionSet(ImmutableSet.of())
           .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
@@ -398,6 +419,8 @@
 
     abstract Builder reviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates);
 
+    abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
+
     abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
 
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -465,6 +488,7 @@
 
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
+      object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
       object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
@@ -488,8 +512,8 @@
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
               .setChangeKey(cols.changeKey().get())
-              .setCreatedOn(cols.createdOn().getTime())
-              .setLastUpdatedOn(cols.lastUpdatedOn().getTime())
+              .setCreatedOnMillis(cols.createdOn().getTime())
+              .setLastUpdatedOnMillis(cols.lastUpdatedOn().getTime())
               .setOwner(cols.owner().get())
               .setBranch(cols.branch());
       if (cols.currentPatchSetId() != null) {
@@ -514,6 +538,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();
     }
 
@@ -522,7 +550,7 @@
       return ReviewerSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAccountId(c.getColumnKey().get())
-          .setTimestamp(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().getTime())
           .build();
     }
 
@@ -531,23 +559,33 @@
       return ReviewerByEmailSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAddress(c.getColumnKey().toHeaderString())
-          .setTimestamp(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().getTime())
           .build();
     }
 
     private static ReviewerStatusUpdateProto toReviewerStatusUpdateProto(ReviewerStatusUpdate u) {
       return ReviewerStatusUpdateProto.newBuilder()
-          .setDate(u.date().getTime())
+          .setTimestampMillis(u.date().getTime())
           .setUpdatedBy(u.updatedBy().get())
           .setReviewer(u.reviewer().get())
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(u.state()))
           .build();
     }
 
+    private static AttentionSetUpdateProto toAttentionSetUpdateProto(
+        AttentionSetUpdate attentionSetUpdate) {
+      return AttentionSetUpdateProto.newBuilder()
+          .setTimestampMillis(attentionSetUpdate.timestamp().toEpochMilli())
+          .setAccount(attentionSetUpdate.account().get())
+          .setOperation(attentionSetUpdate.operation().name())
+          .setReason(attentionSetUpdate.reason())
+          .build();
+    }
+
     private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
       AssigneeStatusUpdateProto.Builder builder =
           AssigneeStatusUpdateProto.newBuilder()
-              .setDate(u.date().getTime())
+              .setTimestampMillis(u.date().getTime())
               .setUpdatedBy(u.updatedBy().get())
               .setHasCurrentAssignee(u.currentAssignee().isPresent());
 
@@ -584,6 +622,7 @@
               .allPastReviewers(
                   proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
+              .attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
               .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -611,8 +650,8 @@
       ChangeColumns.Builder b =
           ChangeColumns.builder()
               .changeKey(Change.key(proto.getChangeKey()))
-              .createdOn(new Timestamp(proto.getCreatedOn()))
-              .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()))
+              .createdOn(new Timestamp(proto.getCreatedOnMillis()))
+              .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOnMillis()))
               .owner(Account.id(proto.getOwner()))
               .branch(proto.getBranch());
       if (proto.getHasCurrentPatchSetId()) {
@@ -637,6 +676,9 @@
       if (proto.getHasRevertOf()) {
         b.revertOf(Change.id(proto.getRevertOf()));
       }
+      if (proto.getHasCherryPickOf()) {
+        b.cherryPickOf(PatchSet.Id.parse(proto.getCherryPickOf()));
+      }
       return b.build();
     }
 
@@ -647,7 +689,7 @@
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Account.id(e.getAccountId()),
-            new Timestamp(e.getTimestamp()));
+            new Timestamp(e.getTimestampMillis()));
       }
       return ReviewerSet.fromTable(b.build());
     }
@@ -660,7 +702,7 @@
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Address.parse(e.getAddress()),
-            new Timestamp(e.getTimestamp()));
+            new Timestamp(e.getTimestampMillis()));
       }
       return ReviewerByEmailSet.fromTable(b.build());
     }
@@ -671,7 +713,7 @@
       for (ReviewerStatusUpdateProto proto : protos) {
         b.add(
             ReviewerStatusUpdate.create(
-                new Timestamp(proto.getDate()),
+                new Timestamp(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 Account.id(proto.getReviewer()),
                 REVIEWER_STATE_CONVERTER.convert(proto.getState())));
@@ -679,13 +721,27 @@
       return b.build();
     }
 
+    private static ImmutableSet<AttentionSetUpdate> toAttentionSetUpdates(
+        List<AttentionSetUpdateProto> protos) {
+      ImmutableSet.Builder<AttentionSetUpdate> b = ImmutableSet.builder();
+      for (AttentionSetUpdateProto proto : protos) {
+        b.add(
+            AttentionSetUpdate.createFromRead(
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
+                Account.id(proto.getAccount()),
+                AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+                proto.getReason()));
+      }
+      return b.build();
+    }
+
     private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
         List<AssigneeStatusUpdateProto> protos) {
       ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
       for (AssigneeStatusUpdateProto proto : protos) {
         b.add(
             AssigneeStatusUpdate.create(
-                new Timestamp(proto.getDate()),
+                new Timestamp(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 proto.getHasCurrentAssignee()
                     ? Optional.of(Account.id(proto.getCurrentAssignee()))
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..63f4e5d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -19,8 +19,10 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
 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;
@@ -39,6 +41,7 @@
 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.NoteDbUtil.sanitizeFooter;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.naturalOrder;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -51,17 +54,19 @@
 import com.google.common.collect.TreeBasedTable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 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.gerrit.server.validators.ValidationException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -124,6 +129,7 @@
   private String submissionId;
   private String topic;
   private String commit;
+  private Set<AttentionSetUpdate> attentionSetUpdates;
   private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
@@ -137,6 +143,7 @@
   private Boolean isPrivate;
   private Boolean workInProgress;
   private Integer revertOf;
+  private String cherryPickOf;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
@@ -164,7 +171,11 @@
         notes,
         user,
         when,
-        projectCache.get(notes.getProjectName()).getLabelTypes().nameComparator(),
+        projectCache
+            .get(notes.getProjectName())
+            .orElseThrow(illegalState(notes.getProjectName()))
+            .getLabelTypes()
+            .nameComparator(),
         noteUtil);
   }
 
@@ -220,8 +231,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) {
@@ -232,7 +245,7 @@
     approvals.put(label, reviewer, Optional.of(value));
   }
 
-  public void removeApproval(String label) {
+  void removeApproval(String label) {
     removeApprovalFor(getAccountId(), label);
   }
 
@@ -240,9 +253,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");
   }
@@ -319,8 +332,7 @@
     return draftUpdate;
   }
 
-  @VisibleForTesting
-  RobotCommentUpdate createRobotCommentUpdateIfNull() {
+  private void createRobotCommentUpdateIfNull() {
     if (robotCommentUpdate == null) {
       ChangeNotes notes = getNotes();
       if (notes != null) {
@@ -332,10 +344,13 @@
                 getChange(), accountId, realAccountId, authorIdent, when);
       }
     }
-    return robotCommentUpdate;
   }
 
-  public void setTopic(String topic) {
+  public void setTopic(String topic) throws ValidationException {
+
+    if (isIllegalTopic(topic)) {
+      throw new ValidationException("topic can't contain quotation marks.");
+    }
     this.topic = Strings.nullToEmpty(topic);
   }
 
@@ -351,19 +366,25 @@
     this.pushCert = pushCert;
   }
 
-  /**
-   * Set the revision without depending on the commit being present in the repository; should only
-   * be used for converting old corrupt commits.
-   */
-  public void setRevisionForMissingCommit(String id, String pushCert) {
-    commit = id;
-    this.pushCert = pushCert;
-  }
-
   public void setHashtags(Set<String> hashtags) {
     this.hashtags = hashtags;
   }
 
+  /**
+   * All updates must have a timestamp of null since we use the commit's timestamp. There also must
+   * not be multiple updates for a single user.
+   */
+  public void setAttentionSetUpdates(Set<AttentionSetUpdate> attentionSetUpdates) {
+    checkArgument(
+        attentionSetUpdates.stream().noneMatch(a -> a.timestamp() != null),
+        "must not specify timestamp for write");
+    checkArgument(
+        attentionSetUpdates.stream().map(AttentionSetUpdate::account).distinct().count()
+            == attentionSetUpdates.size(),
+        "must not specify multiple updates for single user");
+    this.attentionSetUpdates = attentionSetUpdates;
+  }
+
   public void setAssignee(Account.Id assignee) {
     checkArgument(assignee != null, "use removeAssignee");
     this.assignee = Optional.of(assignee);
@@ -415,6 +436,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 {
@@ -520,8 +545,6 @@
         deleteCommentRewriter == null && deleteChangeMessageRewriter == null,
         "cannot update and rewrite ref in one BatchUpdate");
 
-    CommitBuilder cb = new CommitBuilder();
-
     int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
     StringBuilder msg = new StringBuilder();
     if (commitSubject != null) {
@@ -570,6 +593,12 @@
       addFooter(msg, FOOTER_COMMIT, commit);
     }
 
+    if (attentionSetUpdates != null) {
+      for (AttentionSetUpdate attentionSetUpdate : attentionSetUpdates) {
+        addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      }
+    }
+
     if (assignee != null) {
       if (assignee.isPresent()) {
         addFooter(msg, FOOTER_ASSIGNEE);
@@ -663,6 +692,11 @@
       addFooter(msg, FOOTER_REVERT_OF, revertOf);
     }
 
+    if (cherryPickOf != null) {
+      addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf);
+    }
+
+    CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -701,6 +735,7 @@
         && status == null
         && submissionId == null
         && submitRecords == null
+        && attentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
@@ -712,7 +747,8 @@
         && !currentPatchSet
         && isPrivate == null
         && workInProgress == null
-        && revertOf == null;
+        && revertOf == null
+        && cherryPickOf == null;
   }
 
   ChangeDraftUpdate getDraftUpdate() {
@@ -723,11 +759,11 @@
     return robotCommentUpdate;
   }
 
-  public DeleteCommentRewriter getDeleteCommentRewriter() {
+  DeleteCommentRewriter getDeleteCommentRewriter() {
     return deleteCommentRewriter;
   }
 
-  public DeleteChangeMessageRewriter getDeleteChangeMessageRewriter() {
+  DeleteChangeMessageRewriter getDeleteChangeMessageRewriter() {
     return deleteChangeMessageRewriter;
   }
 
@@ -760,13 +796,13 @@
     sb.append('\n');
   }
 
-  private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
-    PersonIdent ident = newIdent(accountId, when);
+  private static boolean isIllegalTopic(String topic) {
+    return (topic != null && topic.contains("\""));
+  }
 
-    PersonIdent.appendSanitized(sb, ident.getName());
-    sb.append(" <");
-    PersonIdent.appendSanitized(sb, ident.getEmailAddress());
-    sb.append('>');
+  private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
+    PersonIdent ident = noteUtil.newIdent(accountId, when, serverIdent);
+    ChangeNoteUtil.appendIdentString(sb, ident.getName(), ident.getEmailAddress());
     return sb;
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 128e185..1ead03c 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -27,6 +27,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -47,12 +48,16 @@
 
   private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
   private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-  private static final int CHUNK_SIZE = 100; // log progress after deleting every CHUNK_SIZE refs
+
+  // Number of refs deleted at once in a batch ref-update.
+  // Log progress after deleting every CHUNK_SIZE refs
+  private static final int CHUNK_SIZE = 3000;
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
   private Repository allUsersRepo;
+  private final Consumer<String> uiConsumer;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
@@ -63,9 +68,18 @@
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
       @Assisted Integer cleanupPercentage) {
+    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+  }
+
+  public DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      Consumer<String> uiConsumer) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
+    this.uiConsumer = uiConsumer;
   }
 
   public void execute() throws IOException {
@@ -74,15 +88,17 @@
     List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
     List<Ref> zombieRefs = filterZombieRefs(draftRefs);
 
-    logger.atInfo().log(
-        "Found a total of %d zombie draft refs in %s repo.", zombieRefs.size(), allUsers.get());
+    logInfo(
+        String.format(
+            "Found a total of %d zombie draft refs in %s repo.",
+            zombieRefs.size(), allUsers.get()));
 
-    logger.atInfo().log("Cleanup percentage = %d", cleanupPercentage);
+    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
     zombieRefs =
         zombieRefs.stream()
             .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
             .collect(toImmutableList());
-    logger.atInfo().log("Number of zombie refs to be cleaned = %d", zombieRefs.size());
+    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
 
     long zombieRefsCnt = zombieRefs.size();
     long deletedRefsCnt = 0;
@@ -124,8 +140,15 @@
     return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
   }
 
+  private void logInfo(String message) {
+    logger.atInfo().log(message);
+    uiConsumer.accept(message);
+  }
+
   private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
-    logger.atInfo().log(
-        "Deleted %d/%d zombie draft refs (%d seconds)\n", deletedRefsCount, allRefsCount, elapsed);
+    logInfo(
+        String.format(
+            "Deleted %d/%d zombie draft refs (%d seconds)",
+            deletedRefsCount, allRefsCount, elapsed));
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/IntBlob.java b/java/com/google/gerrit/server/notedb/IntBlob.java
index 5efc2b0..ec5a83a 100644
--- a/java/com/google/gerrit/server/notedb/IntBlob.java
+++ b/java/com/google/gerrit/server/notedb/IntBlob.java
@@ -40,6 +40,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 {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
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/NoteDbTable.java b/java/com/google/gerrit/server/notedb/NoteDbTable.java
deleted file mode 100644
index e299fdf..0000000
--- a/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-public enum NoteDbTable {
-  ACCOUNTS,
-  GROUPS,
-  CHANGES;
-
-  public String key() {
-    return name().toLowerCase();
-  }
-
-  @Override
-  public String toString() {
-    return key();
-  }
-}
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 e52b84c..47e12ff 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -296,4 +296,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..3c1d359 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;
@@ -60,13 +61,13 @@
   }
 
   final byte[] baseRaw;
-  final List<? extends Comment> baseComments;
+  private final List<? extends Comment> baseComments;
   final Map<Comment.Key, Comment> put;
-  final Set<Comment.Key> delete;
+  private final Set<Comment.Key> delete;
 
   private String pushCert;
 
-  RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
+  private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
     if (base != null) {
       baseRaw = base.getRaw();
       baseComments = base.getEntities();
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 3e1bad1..98c9873 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -17,49 +17,56 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Comment;
 import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 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 */
   final ImmutableMap<ObjectId, T> revisionNotes;
 
+  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<ObjectId, T> revisionNotes) {
+    this.noteMap = noteMap;
+    this.revisionNotes = revisionNotes;
+  }
+
   static RevisionNoteMap<ChangeRevisionNote> parse(
       ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
       throws ConfigInvalidException, IOException {
-    Map<ObjectId, ChangeRevisionNote> result = new HashMap<>();
+    ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
     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));
+    return new RevisionNoteMap<>(noteMap, result.build());
   }
 
   static RevisionNoteMap<RobotCommentsRevisionNote> parseRobotComments(
       ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws ConfigInvalidException, IOException {
-    Map<ObjectId, RobotCommentsRevisionNote> result = new HashMap<>();
+    ImmutableMap.Builder<ObjectId, RobotCommentsRevisionNote> result = ImmutableMap.builder();
     for (Note note : noteMap) {
       RobotCommentsRevisionNote rn =
           new RobotCommentsRevisionNote(changeNoteJson, reader, note.getData());
       rn.parse();
       result.put(note.copy(), rn);
     }
-    return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
+    return new RevisionNoteMap<>(noteMap, result.build());
   }
 
   static <T extends RevisionNote<? extends Comment>> RevisionNoteMap<T> emptyMap() {
     return new RevisionNoteMap<>(NoteMap.newEmptyMap(), ImmutableMap.of());
   }
-
-  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<ObjectId, T> revisionNotes) {
-    this.noteMap = noteMap;
-    this.revisionNotes = revisionNotes;
-  }
 }
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 9f19725..7a8e28f 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -143,4 +143,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/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index fee9088..c9e45ba 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -26,7 +26,6 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -34,7 +33,6 @@
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
@@ -132,29 +130,6 @@
     return deletions;
   }
 
-  /**
-   * Get a sorted, modifiable list of all files in this list.
-   *
-   * <p>The returned list items do not populate:
-   *
-   * <ul>
-   *   <li>{@link Patch#getCommentCount()}
-   *   <li>{@link Patch#getDraftCount()}
-   *   <li>{@link Patch#isReviewedByCurrentUser()}
-   * </ul>
-   *
-   * @param setId the patch set identity these patches belong to. This really should not need to be
-   *     specified, but is a current legacy artifact of how the cache is keyed versus how the
-   *     database is keyed.
-   */
-  public List<Patch> toPatchList(PatchSet.Id setId) {
-    final ArrayList<Patch> r = new ArrayList<>(patches.length);
-    for (PatchListEntry e : patches) {
-      r.add(e.toPatch(setId));
-    }
-    return r;
-  }
-
   /** Find an entry by name, returning an empty entry if not present. */
   public PatchListEntry get(String fileName) {
     final int index = search(fileName);
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..c91355a 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -33,11 +33,9 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Patch.PatchType;
-import com.google.gerrit.entities.PatchSet;
 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 +212,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,17 +224,7 @@
       }
       headerLines.add(RawParseUtils.decode(UTF_8, header, b, e));
     }
-    return headerLines;
-  }
-
-  Patch toPatch(PatchSet.Id setId) {
-    final Patch p = new Patch(Patch.key(setId, getNewName()));
-    p.setChangeType(getChangeType());
-    p.setPatchType(getPatchType());
-    p.setSourceFileName(getOldName());
-    p.setInsertions(insertions);
-    p.setDeletions(deletions);
-    return p;
+    return headerLines.build();
   }
 
   private static ImmutableList<Edit> deepCopyEdits(List<Edit> edits) {
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..9b8409d 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.extensions.restapi.ResourceNotFoundException;
+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,151 +51,122 @@
 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) {
-    this.change = c;
   }
 
   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)
+      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);
+  }
+
+  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, ResourceNotFoundException {
+    SidesResolver sidesResolver = new SidesResolver(git, ComparisonType.againstOtherPatchSet());
+    PatchSide a = resolveSideA(git, sidesResolver, fileName, baseId);
+    if (a.mode == FileMode.MISSING) {
+      throw new ResourceNotFoundException(String.format("File %s not found", fileName));
+    }
+    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);
   }
 
-  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) {
 
-    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(),
         content.getChangeType(),
         content.getOldName(),
         content.getNewName(),
@@ -205,25 +174,22 @@
         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,
-        history,
-        hugeFile,
-        intralineFailure,
-        intralineTimeout,
+        a.mimeType,
+        b.mimeType,
+        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 +204,7 @@
     }
   }
 
-  private static String oldName(PatchListEntry entry) {
+  private static String oldName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case ADDED:
         return null;
@@ -253,7 +219,7 @@
     }
   }
 
-  private static String newName(PatchListEntry entry) {
+  private static String newName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case DELETED:
         return null;
@@ -267,7 +233,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 +243,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 +435,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..29a89d6 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;
@@ -24,9 +25,9 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
-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,30 +39,32 @@
 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;
 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.Provider;
 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 +95,11 @@
   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(
@@ -177,10 +174,6 @@
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
   }
 
-  public void setLoadHistory(boolean load) {
-    loadHistory = load;
-  }
-
   public void setLoadComments(boolean load) {
     loadComments = load;
   }
@@ -189,23 +182,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);
@@ -213,19 +189,38 @@
       throw new NoSuchChangeException(changeId, e);
     }
 
-    if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
+    if (!projectCache
+        .get(notes.getProjectName())
+        .map(ProjectState::statePermitsRead)
+        .orElse(false)) {
       throw new NoSuchChangeException(changeId);
     }
 
     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<CommentDetail> comments = loadComments(content, changeEdit);
 
-        return b.toPatchScript(content, comments, history);
+        return b.toPatchScript(git, list, content, comments.orElse(null));
       } catch (PatchListNotAvailableException e) {
         throw new NoSuchChangeException(changeId, e);
       } catch (IOException e) {
@@ -243,7 +238,32 @@
     }
   }
 
-  private PatchListKey keyFor(Whitespace whitespace) {
+  private Optional<CommentDetail> loadComments(PatchListEntry content, boolean changeEdit) {
+    if (!loadComments) {
+      return Optional.empty();
+    }
+    return new CommentsLoader(psa, psb, userProvider, notes, commentsUtil)
+        .load(changeEdit, content.getChangeType(), content.getOldName(), content.getNewName());
+  }
+
+  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 +274,13 @@
     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 +292,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,64 +300,52 @@
     }
   }
 
-  private void loadCommentsAndHistory(ChangeType changeType, String oldName, String newName) {
-    Map<Patch.Key, Patch> byKey = new HashMap<>();
+  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;
 
-    if (loadHistory) {
-      // 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<>();
-      for (PatchSet ps : psUtil.byChange(notes)) {
-        String name = fileName;
-        if (psa != null) {
-          switch (changeType) {
-            case COPIED:
-            case RENAMED:
-              if (ps.id().equals(psa)) {
-                name = oldName;
-              }
-              break;
-
-            case MODIFIED:
-            case DELETED:
-            case ADDED:
-            case REWRITE:
-              break;
-          }
-        }
-
-        Patch p = new Patch(Patch.key(ps.id(), name));
-        history.add(p);
-        byKey.put(p.getKey(), p);
-      }
-      if (edit != null && edit.isPresent()) {
-        Patch p = new Patch(Patch.key(PatchSet.id(psb.changeId(), 0), fileName));
-        history.add(p);
-        byKey.put(p.getKey(), p);
-      }
+    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) {
+      // 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();
+      }
+
       comments = new CommentDetail(psa, psb);
       switch (changeType) {
         case ADDED:
         case MODIFIED:
-          loadPublished(byKey, newName);
+          loadPublished(newName);
           break;
 
         case DELETED:
-          loadPublished(byKey, newName);
+          loadPublished(newName);
           break;
 
         case COPIED:
         case RENAMED:
           if (psa != null) {
-            loadPublished(byKey, oldName);
+            loadPublished(oldName);
           }
-          loadPublished(byKey, newName);
+          loadPublished(newName);
           break;
 
         case REWRITE:
@@ -357,48 +358,86 @@
         switch (changeType) {
           case ADDED:
           case MODIFIED:
-            loadDrafts(byKey, me, newName);
+            loadDrafts(me, newName);
             break;
 
           case DELETED:
-            loadDrafts(byKey, me, newName);
+            loadDrafts(me, newName);
             break;
 
           case COPIED:
           case RENAMED:
             if (psa != null) {
-              loadDrafts(byKey, me, oldName);
+              loadDrafts(me, oldName);
             }
-            loadDrafts(byKey, me, newName);
+            loadDrafts(me, newName);
             break;
 
           case REWRITE:
             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(String file) {
+      for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
+        comments.include(notes.getChangeId(), c);
+      }
+    }
+
+    private void loadDrafts(Account.Id me, String file) {
+      for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
+        comments.include(notes.getChangeId(), c);
       }
     }
   }
 
-  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..accd2bd
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
@@ -0,0 +1,138 @@
+// 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.extensions.restapi.ResourceNotFoundException;
+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.gerrit.server.project.ProjectState;
+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, ResourceNotFoundException {
+
+    try {
+      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new NoSuchChangeException(changeId, e);
+    }
+
+    if (!projectCache
+        .get(notes.getProjectName())
+        .map(ProjectState::statePermitsRead)
+        .orElse(false)) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    return createPatchScript();
+  }
+
+  private PatchScript createPatchScript() throws LargeObjectException, ResourceNotFoundException {
+    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.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 cecdb95..2c1894e 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -75,15 +75,10 @@
   }
 
   ForChange asForChange(@Nullable ChangeData cd) {
-    setChangeData(cd);
-    return new ForChangeImpl();
-  }
-
-  ChangeControl setChangeData(@Nullable ChangeData cd) {
     if (cd != null) {
       this.cd = cd;
     }
-    return this;
+    return new ForChangeImpl();
   }
 
   private CurrentUser getUser() {
@@ -135,6 +130,12 @@
     return canAbandon() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
+  /** Can this user revert this change? */
+  private boolean canRevert() {
+    return (refControl.canRevert())
+        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+  }
+
   /** The range of permitted values associated with a label permission. */
   private PermissionRange getRange(String permission) {
     return refControl.getRange(permission, isOwner());
@@ -162,7 +163,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);
@@ -171,9 +172,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());
     }
@@ -313,6 +313,8 @@
             return canRebase();
           case RESTORE:
             return canRestore();
+          case REVERT:
+            return canRevert();
           case SUBMIT:
             return refControl.canSubmit(isOwner());
           case TOGGLE_WORK_IN_PROGRESS_STATE:
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 2fba4ef..63b0378 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -54,6 +54,7 @@
    * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
    */
   REBASE,
+  REVERT,
   SUBMIT,
   SUBMIT_AS("submit on behalf of other users"),
   TOGGLE_WORK_IN_PROGRESS_STATE;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 6142bc0..cb0d48a 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermissionName;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
@@ -103,7 +104,7 @@
     @Override
     public ForProject project(Project.NameKey project) {
       try {
-        ProjectState state = projectCache.checkedGet(project);
+        ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
         ProjectControl control =
             PerThreadCache.getOrCompute(
                 PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 8215083..8479f02 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -96,6 +96,7 @@
           .put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER)
           .put(ChangePermission.ADD_PATCH_SET, Permission.ADD_PATCH_SET)
           .put(ChangePermission.REBASE, Permission.REBASE)
+          .put(ChangePermission.REVERT, Permission.REVERT)
           .put(ChangePermission.SUBMIT, Permission.SUBMIT)
           .put(ChangePermission.SUBMIT_AS, Permission.SUBMIT_AS)
           .put(
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index f887f61..c7b1060 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -17,11 +17,10 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
-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;
@@ -52,6 +51,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;
@@ -120,7 +120,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)",
@@ -136,10 +136,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);
@@ -148,18 +148,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/* "
@@ -168,12 +168,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());
             }
@@ -193,7 +193,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);
 
@@ -222,9 +222,9 @@
         permissionBackend
             .user(projectControl.getUser())
             .testOrFalse(GlobalPermission.ACCESS_DATABASE);
-    Map<String, Ref> resultRefs = Maps.newHashMapWithExpectedSize(refs.size());
+    List<Ref> resultRefs = new ArrayList<>(refs.size());
     List<Ref> deferredTags = new ArrayList<>();
-    for (Ref ref : refs.values()) {
+    for (Ref ref : refs) {
       String refName = ref.getName();
       Change.Id changeId;
       if (opts.filterMeta() && isMetadata(refName)) {
@@ -242,7 +242,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/*", refName);
-          resultRefs.put(refName, ref);
+          resultRefs.add(ref);
         } else {
           // If its a tag, consider it later.
           if (ref.getObjectId() != null) {
@@ -257,17 +257,17 @@
         // visibility of these refs just fine. But instead, we use highly-optimized logic that
         // looks only on the last 10k most recent changes using the change index and a cache.
         if (hasAccessDatabase) {
-          resultRefs.put(refName, ref);
+          resultRefs.add(ref);
         } else if (!visible(repo, changeId)) {
           logger.atFinest().log("Filter out invisible change ref %s", refName);
         } else if (RefNames.isRefsEdit(refName) && !visibleEdit(repo, refName)) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
-          resultRefs.put(refName, ref);
+          resultRefs.add(ref);
         }
       } else if (refVisibilityControl.isVisible(projectControl, ref.getLeaf().getName())) {
-        resultRefs.put(refName, ref);
+        resultRefs.add(ref);
       }
     }
     Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
@@ -277,30 +277,38 @@
 
   /**
    * Returns all refs tag we regard as starting points for reachability computation for tags. In
-   * general, these are all refs not managed by Gerrit.
+   * general, these are all refs not managed by Gerrit excluding symbolic refs and tags.
+   *
+   * <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()
-                      && !REFS_CONFIG.equals(r.getName()))
-          .collect(toMap(Ref::getName, r -> r));
+                      && !r.getName().equals(RefNames.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<>(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;
   }
@@ -488,7 +496,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 e0c5927..653c3b5f 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 c0fbf36..edffcc6 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -417,7 +417,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 945ae06..69a7797 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -193,6 +193,10 @@
     return projectControl.controlForRef("refs/for/" + refName).canPerform(Permission.PUSH);
   }
 
+  boolean canRevert() {
+    return canPerform(Permission.REVERT);
+  }
+
   /** @return true if this user can submit merge patch sets to this ref */
   private boolean canUploadMerges() {
     return projectControl.controlForRef("refs/for/" + refName).canPerform(Permission.PUSH_MERGE);
@@ -419,8 +423,8 @@
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
       logger.atFine().log(
-          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)"
-              + " because this permission is blocked",
+          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+              + " because this permission is blocked (caller: %s)",
           getUser().getLoggableName(),
           permissionName,
           withForce,
@@ -471,8 +475,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);
       }
@@ -686,7 +689,7 @@
         return projectControl.asForProject()
             .filter(
                 ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
-            .values().stream()
+            .stream()
             .anyMatch(r -> refName.equals(r.getName()));
       } catch (IOException e) {
         throw new PermissionBackendException(e);
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index 23a7db1..c8a1ebe 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -109,7 +109,7 @@
         // Edits are visible only to the owning user, if change is visible.
         return visibleEdit(refName, projectControl, cd);
       }
-      return projectControl.controlFor(cd.notes()).setChangeData(cd).isVisible();
+      return projectControl.controlFor(cd.notes()).isVisible();
     }
 
     // Account visibility
@@ -153,7 +153,7 @@
       throw new IllegalStateException("unable to parse change id from edit ref " + refName);
     }
 
-    if (!projectControl.controlFor(cd.notes()).setChangeData(cd).isVisible()) {
+    if (!projectControl.controlFor(cd.notes()).isVisible()) {
       // The user can't see the change so they can't see any edits.
       return false;
     }
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/ChildProjects.java b/java/com/google/gerrit/server/project/ChildProjects.java
index 2069a48..ce5ce2f 100644
--- a/java/com/google/gerrit/server/project/ChildProjects.java
+++ b/java/com/google/gerrit/server/project/ChildProjects.java
@@ -65,7 +65,16 @@
   private Map<Project.NameKey, Project> readAllReadableProjects() {
     Map<Project.NameKey, Project> projects = new HashMap<>();
     for (Project.NameKey name : projectCache.all()) {
-      ProjectState c = projectCache.get(name);
+
+      ProjectState c =
+          projectCache
+              .get(name)
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException(
+                          "race while traversing projects. got "
+                              + name
+                              + " when loading all projects, but can't load it now"));
       if (c != null && c.statePermitsRead()) {
         projects.put(c.getNameKey(), c.getProject());
       }
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index a2de4ef..c2eb79d 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -78,11 +78,8 @@
   public void check(Project.NameKey project, CurrentUser user) throws IOException, AuthException {
     metrics.claCheckCount.increment();
 
-    ProjectState projectState = projectCache.checkedGet(project);
-    if (projectState == null) {
-      throw new IOException("Can't load All-Projects");
-    }
-
+    ProjectState projectState =
+        projectCache.get(project).orElseThrow(() -> new IOException("Can't load " + project));
     if (!projectState.is(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS)) {
       return;
     }
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index abbd9f6..69ac93e 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
@@ -68,10 +70,8 @@
       Provider<? extends CurrentUser> user, Repository repo, BranchNameKey branch, RevObject object)
       throws AuthException, PermissionBackendException, NoSuchProjectException, IOException,
           ResourceConflictException {
-    ProjectState ps = projectCache.checkedGet(branch.project());
-    if (ps == null) {
-      throw new NoSuchProjectException(branch.project());
-    }
+    ProjectState ps =
+        projectCache.get(branch.project()).orElseThrow(noSuchProject(branch.project()));
     ps.checkStatePermitsWrite();
 
     PermissionBackend.ForRef perm = permissionBackend.user(user.get()).ref(branch);
@@ -137,7 +137,7 @@
         repo,
         commit,
         repo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS, Constants.R_TAGS),
-        Optional.of(user))) {
+        Optional.of(user.get()))) {
       // If the user has no push permissions, check whether the object is
       // merged into a branch or tag readable by this user. If so, they are
       // not effectively "pushing" more objects, so they can create the ref
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..0452d0b
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.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.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.copyValues = labelType.getCopyValues().isEmpty() ? null : labelType.getCopyValues();
+    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/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index 0baaa11e..3fba7d3 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -18,11 +18,30 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import java.io.IOException;
+import java.util.Optional;
 import java.util.Set;
+import java.util.function.Supplier;
 
 /** Cache of project information, including access rights. */
 public interface ProjectCache {
+  /**
+   * Returns a supplier to be used as a short-hand when unwrapping an {@link Optional} returned from
+   * this cache.
+   */
+  static Supplier<IllegalStateException> illegalState(Project.NameKey nameKey) {
+    return () -> new IllegalStateException("unable to find project " + nameKey);
+  }
+
+  /**
+   * Returns a supplier to be used as a short-hand when unwrapping an {@link Optional} returned from
+   * this cache.
+   */
+  static Supplier<NoSuchProjectException> noSuchProject(Project.NameKey nameKey) {
+    return () -> new NoSuchProjectException(nameKey);
+  }
+
   /** @return the parent state for all projects on this server. */
   ProjectState getAllProjects();
 
@@ -33,31 +52,11 @@
    * Get the cached data for a project by its unique name.
    *
    * @param projectName name of the project.
-   * @return the cached data; null if no such project exists, projectName is null or an error
-   *     occurred.
-   * @see #checkedGet(com.google.gerrit.entities.Project.NameKey)
+   * @return an {@link Optional} wrapping the the cached data; {@code absent} if no such project
+   *     exists or the projectName is null
+   * @throws StorageException when there was an error.
    */
-  ProjectState get(@Nullable Project.NameKey projectName);
-
-  /**
-   * Get the cached data for a project by its unique name.
-   *
-   * @param projectName name of the project.
-   * @throws IOException when there was an error.
-   * @return the cached data; null if no such project exists or projectName is null.
-   */
-  ProjectState checkedGet(@Nullable Project.NameKey projectName) throws IOException;
-
-  /**
-   * Get the cached data for a project by its unique name.
-   *
-   * @param projectName name of the project.
-   * @param strict true when any error generates an exception
-   * @throws Exception in case of any error (strict = true) or only for I/O or other internal
-   *     errors.
-   * @return the cached data or null when strict = false
-   */
-  ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
+  Optional<ProjectState> get(@Nullable Project.NameKey projectName) throws StorageException;
 
   /**
    * Invalidate the cached information about the given project, and triggers reindexing for it
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index e88bfc6..9d09eeb 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -14,18 +14,20 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.Description;
@@ -47,6 +49,7 @@
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
@@ -124,70 +127,38 @@
 
   @Override
   public ProjectState getAllProjects() {
-    ProjectState state = get(allProjectsName);
-    if (state == null) {
-      // This should never occur, the server must have this
-      // project to process anything.
-      throw new IllegalStateException("Missing project " + allProjectsName);
-    }
-    return state;
+    return get(allProjectsName).orElseThrow(illegalState(allProjectsName));
   }
 
   @Override
   public ProjectState getAllUsers() {
-    ProjectState state = get(allUsersName);
-    if (state == null) {
-      // This should never occur.
-      throw new IllegalStateException("Missing project " + allUsersName);
-    }
-    return state;
+    return get(allUsersName).orElseThrow(illegalState(allUsersName));
   }
 
   @Override
-  public ProjectState get(Project.NameKey projectName) {
-    try {
-      return checkedGet(projectName);
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log("Cannot read project %s", projectName);
-      return null;
-    }
-  }
-
-  @Override
-  public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
+  public Optional<ProjectState> get(@Nullable Project.NameKey projectName) {
     if (projectName == null) {
-      return null;
+      return Optional.empty();
     }
+
     try {
-      return strictCheckedGet(projectName);
-    } catch (Exception e) {
-      if (!(e.getCause() instanceof RepositoryNotFoundException)) {
-        logger.atWarning().withCause(e).log("Cannot read project %s", projectName.get());
-        if (e.getCause() != null) {
-          Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
-        }
-        throw new IOException(e);
+      ProjectState state = byName.get(projectName.get());
+      if (state != null && state.needsRefresh(clock.read())) {
+        byName.invalidate(projectName.get());
+        state = byName.get(projectName.get());
       }
-      logger.atFine().log("Cannot find project %s", projectName.get());
-      return null;
+      return Optional.of(state);
+    } catch (Exception e) {
+      if ((e.getCause() instanceof RepositoryNotFoundException)) {
+        logger.atFine().log("Cannot find project %s", projectName.get());
+        return Optional.empty();
+      }
+      throw new StorageException(
+          String.format("project state of project %s not available", projectName.get()), e);
     }
   }
 
   @Override
-  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception {
-    return strict ? strictCheckedGet(projectName) : checkedGet(projectName);
-  }
-
-  private ProjectState strictCheckedGet(Project.NameKey projectName) throws Exception {
-    ProjectState state = byName.get(projectName.get());
-    if (state != null && state.needsRefresh(clock.read())) {
-      byName.invalidate(projectName.get());
-      state = byName.get(projectName.get());
-    }
-    return state;
-  }
-
-  @Override
   public void evict(Project p) {
     evict(p.getNameKey());
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index d1f31a3..332aba7 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -53,7 +53,16 @@
           new Thread(
               () -> {
                 for (Project.NameKey name : cache.all()) {
-                  pool.execute(() -> cache.get(name));
+                  pool.execute(
+                      () ->
+                          cache
+                              .get(name)
+                              .orElseThrow(
+                                  () ->
+                                      new IllegalStateException(
+                                          "race while traversing projects. got "
+                                              + name
+                                              + " when loading all projects, but can't load it now")));
                 }
                 pool.shutdown();
                 try {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index e6b8d44..4ab583d 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;
@@ -108,14 +109,15 @@
   public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
   public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
   public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  public static final String KEY_COPY_VALUE = "copyValue";
   public static final String KEY_VALUE = "value";
   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 +293,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;
@@ -1008,6 +1015,27 @@
               name,
               KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
               LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
+      Set<Short> copyValues = new HashSet<>();
+      for (String value : rc.getStringList(LABEL, name, KEY_COPY_VALUE)) {
+        try {
+          short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
+          if (!copyValues.add(copyValue)) {
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG,
+                    String.format(
+                        "Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name)));
+          }
+        } catch (IllegalArgumentException notValue) {
+          error(
+              new ValidationError(
+                  PROJECT_CONFIG,
+                  String.format(
+                      "Invalid %s \"%s\" for label \"%s\": %s",
+                      KEY_COPY_VALUE, value, name, notValue.getMessage())));
+        }
+      }
+      label.setCopyValues(copyValues);
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
       label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
@@ -1486,6 +1514,11 @@
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+      rc.setStringList(
+          LABEL,
+          name,
+          KEY_COPY_VALUE,
+          label.getCopyValues().stream().map(LabelValue::formatValue).collect(toList()));
       setBooleanConfigKey(
           rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = new ArrayList<>(label.getValues().size());
@@ -1497,6 +1530,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..cc10f27 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.AccessSection;
@@ -28,6 +30,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 +57,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();
 
@@ -117,7 +126,7 @@
 
         fire(nameKey, head);
 
-        return projectCache.get(nameKey);
+        return projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
       }
     } catch (RepositoryCaseMismatchException e) {
       throw new ResourceConflictException(
@@ -201,10 +210,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/ProjectHierarchyIterator.java b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index 694c541..ccb5651 100644
--- a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -23,6 +23,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.NoSuchElementException;
+import java.util.Optional;
 import java.util.Set;
 
 /**
@@ -65,16 +66,16 @@
   private ProjectState computeNext(ProjectState n) {
     Project.NameKey parentName = n.getProject().getParent();
     if (parentName != null && visit(parentName)) {
-      ProjectState p = cache.get(parentName);
-      if (p != null) {
-        return p;
+      Optional<ProjectState> p = cache.get(parentName);
+      if (p.isPresent()) {
+        return p.get();
       }
     }
 
     // Parent does not exist or was already visited.
     // Fall back to visit All-Projects exactly once.
     if (seen.add(allProjectsName)) {
-      return cache.get(allProjectsName);
+      return cache.getAllProjects();
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index fab6c11..f00df53 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -32,6 +32,7 @@
 import com.google.inject.Singleton;
 import java.util.HashMap;
 
+/** Collection of routines to populate {@link ProjectInfo}. */
 @Singleton
 public class ProjectJson {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
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/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 4b879a1..e52f344 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -37,18 +37,12 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.index.project.ProjectData;
-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.account.CapabilityCollection;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -92,9 +86,6 @@
   private final long globalMaxObjectSizeLimit;
   private final boolean inheritProjectMaxObjectSizeLimit;
 
-  // TODO(hiesel): Remove this once we got production data
-  private final Timer1<String> computationLatency;
-
   /** Last system time the configuration's revision was examined. */
   private volatile long lastCheckGeneration;
 
@@ -113,7 +104,6 @@
       List<CommentLinkInfo> commentLinks,
       CapabilityCollection.Factory limitsFactory,
       TransferConfig transferConfig,
-      MetricMaker metricMaker,
       @Assisted ProjectConfig config) {
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
@@ -130,14 +120,6 @@
     this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
     this.inheritProjectMaxObjectSizeLimit = transferConfig.inheritProjectMaxObjectSizeLimit();
 
-    this.computationLatency =
-        metricMaker.newTimer(
-            "permissions/project_state/computation_latency",
-            new Description("Latency for access computations in ProjectState")
-                .setCumulative()
-                .setUnit(Units.NANOSECONDS),
-            Field.ofString("method", Metadata.Builder::methodName).build());
-
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
       localOwners = Collections.emptySet();
     } else {
@@ -355,21 +337,15 @@
    * cached. Callers should try to cache this result per-request as much as possible.
    */
   public List<SectionMatcher> getAllSections() {
-    try (Timer1.Context<String> ignored = computationLatency.start("getAllSections")) {
-      if (isAllProjects) {
-        return getLocalAccessSections();
-      }
-
-      List<SectionMatcher> all = new ArrayList<>();
-      Iterable<ProjectState> tree = tree();
-      try (Timer1.Context<String> ignored2 =
-          computationLatency.start("getAllSections-parsing-only")) {
-        for (ProjectState s : tree) {
-          all.addAll(s.getLocalAccessSections());
-        }
-      }
-      return all;
+    if (isAllProjects) {
+      return getLocalAccessSections();
     }
+
+    List<SectionMatcher> all = new ArrayList<>();
+    for (ProjectState s : tree()) {
+      all.addAll(s.getLocalAccessSections());
+    }
+    return all;
   }
 
   /**
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index a3b4126..7ed2491 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,15 +258,13 @@
 
     try {
       List<ChangeData> queryResult =
-          retryHelper.execute(
-              ActionType.INDEX_QUERY,
-              () ->
-                  // Execute the query.
-                  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<>();
@@ -281,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 b12f43b..331b7da 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -18,15 +18,17 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 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.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -58,20 +60,28 @@
     return fromRefs(project, repo, commit, refs, Optional.empty());
   }
 
-  public boolean fromRefs(
+  boolean fromRefs(
       Project.NameKey project,
       Repository repo,
       RevCommit commit,
       List<Ref> refs,
-      Optional<Provider<? extends CurrentUser>> userProvider) {
+      Optional<CurrentUser> optionalUserProvider) {
     try (RevWalk rw = new RevWalk(repo)) {
-      Map<String, Ref> filtered =
-          userProvider
-              .map(up -> permissionBackend.user(up.get()))
+      Collection<Ref> filtered =
+          optionalUserProvider
+              .map(permissionBackend::user)
               .orElse(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/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index cc7591c..cf3819d 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
+
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitRecord;
@@ -108,17 +110,13 @@
   public List<SubmitRecord> evaluate(ChangeData cd) {
     try (Timer0.Context ignored = submitRuleEvaluationLatency.start()) {
       Change change;
-      ProjectState projectState;
       try {
         change = cd.change();
         if (change == null) {
           throw new StorageException("Change not found");
         }
 
-        projectState = projectCache.get(cd.project());
-        if (projectState == null) {
-          throw new NoSuchProjectException(cd.project());
-        }
+        projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
       } catch (StorageException | NoSuchProjectException e) {
         return Collections.singletonList(ruleError("Error looking up change " + cd.getId(), e));
       }
@@ -152,12 +150,8 @@
    */
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
     try (Timer0.Context ignored = submitTypeEvaluationLatency.start()) {
-      ProjectState projectState;
       try {
-        projectState = projectCache.get(cd.project());
-        if (projectState == null) {
-          throw new NoSuchProjectException(cd.project());
-        }
+        projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
       } catch (NoSuchProjectException e) {
         return typeError("Error looking up change " + cd.getId(), e);
       }
diff --git a/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/java/com/google/gerrit/server/project/SuggestParentCandidates.java
index fdc8b50..07addb4 100644
--- a/java/com/google/gerrit/server/project/SuggestParentCandidates.java
+++ b/java/com/google/gerrit/server/project/SuggestParentCandidates.java
@@ -25,6 +25,7 @@
 import com.google.inject.Singleton;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 @Singleton
@@ -51,9 +52,9 @@
   private Set<Project.NameKey> readableParents() {
     Set<Project.NameKey> parents = new HashSet<>();
     for (Project.NameKey p : projectCache.all()) {
-      ProjectState ps = projectCache.get(p);
-      if (ps != null && ps.statePermitsRead()) {
-        Project.NameKey parent = ps.getProject().getParent();
+      Optional<ProjectState> ps = projectCache.get(p);
+      if (ps.isPresent() && ps.get().statePermitsRead()) {
+        Project.NameKey parent = ps.get().getProject().getParent();
         if (parent != null) {
           parents.add(parent);
         }
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/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 42a8310..4a95b2e 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -41,6 +41,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
+import java.util.Optional;
 
 /** Parses a query string meant to be applied to account objects. */
 public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQueryBuilder> {
@@ -115,20 +116,23 @@
   @Operator
   public Predicate<AccountState> cansee(String change)
       throws QueryParseException, PermissionBackendException {
-    ChangeNotes changeNotes = args.changeFinder.findOne(change);
-    if (changeNotes == null) {
+    Optional<ChangeNotes> changeNotes = args.changeFinder.findOne(change);
+    if (!changeNotes.isPresent()) {
       throw error(String.format("change %s not found", change));
     }
 
     try {
-      args.permissionBackend.user(args.getUser()).change(changeNotes).check(ChangePermission.READ);
+      args.permissionBackend
+          .user(args.getUser())
+          .change(changeNotes.get())
+          .check(ChangePermission.READ);
     } catch (AuthException e) {
       String msg = String.format("change %s not found", change);
       logger.atSevere().withCause(e).log(msg);
       throw error(msg);
     }
 
-    return AccountPredicates.cansee(args, changeNotes);
+    return AccountPredicates.cansee(args, changeNotes.get());
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java b/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java
new file mode 100644
index 0000000..2b18767
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.index.change.ChangeField;
+
+/** Simple predicate for searching by attention set. */
+public class AttentionSetPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
+
+  AttentionSetPredicate(Account.Id id) {
+    super(ChangeField.ATTENTION_SET_USERS, id.toString());
+    this.id = id;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return additionsOnly(changeData.attentionSet()).stream()
+        .anyMatch(update -> update.account().equals(id));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index c6beac4..69f1a4e 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
@@ -36,6 +37,7 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
@@ -45,7 +47,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;
@@ -283,6 +284,7 @@
   private Optional<ChangedLines> changedLines;
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
+  private Boolean merge;
   private Set<String> hashtags;
   private Map<Account.Id, Ref> editsByUser;
   private Set<Account.Id> reviewedBy;
@@ -297,6 +299,7 @@
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
+  private ImmutableSet<AttentionSetUpdate> attentionSet;
   private int parentCount;
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
@@ -396,12 +399,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()));
@@ -478,12 +476,7 @@
 
   public LabelTypes getLabelTypes() {
     if (labelTypes == null) {
-      ProjectState state;
-      try {
-        state = projectCache.checkedGet(project());
-      } catch (IOException e) {
-        throw new StorageException("project state not available", e);
-      }
+      ProjectState state = projectCache.get(project()).orElseThrow(illegalState(project()));
       labelTypes = state.getLabelTypes(change().getDest());
     }
     return labelTypes;
@@ -553,10 +546,11 @@
     return commitMessage;
   }
 
+  /** Returns the list of commit footers (which may be empty). */
   public List<FooterLine> commitFooters() {
     if (commitFooters == null) {
       if (!loadCommitData()) {
-        return null;
+        return ImmutableList.of();
       }
     }
     return commitFooters;
@@ -598,11 +592,41 @@
       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;
   }
 
+  /** Returns the most recent update (i.e. status) per user. */
+  public ImmutableSet<AttentionSetUpdate> attentionSet() {
+    if (attentionSet == null) {
+      if (!lazyLoad) {
+        return ImmutableSet.of();
+      }
+      attentionSet = notes().getAttentionSet();
+    }
+    return attentionSet;
+  }
+
+  /**
+   * Sets the specified attention set. If two or more entries refer to the same user, throws an
+   * {@link IllegalStateException}.
+   */
+  public void setAttentionSet(ImmutableSet<AttentionSetUpdate> attentionSet) {
+    if (attentionSet.stream().map(AttentionSetUpdate::account).distinct().count()
+        != attentionSet.size()) {
+      throw new IllegalStateException(
+          String.format(
+              "Stored attention set for change %d contains duplicate update",
+              change.getId().get()));
+    }
+    this.attentionSet = attentionSet;
+  }
+
   /** @return patches for the change, in patch set ID order. */
   public Collection<PatchSet> patchSets() {
     if (patchSets == null) {
@@ -917,7 +941,9 @@
             return false;
           }
           String mergeStrategy =
-              mergeUtilFactory.create(projectCache.get(project())).mergeStrategyName();
+              mergeUtilFactory
+                  .create(projectCache.get(project()).orElseThrow(illegalState(project())))
+                  .mergeStrategyName();
           mergeable =
               mergeabilityCache.get(ps.commitId(), ref, str.type, mergeStrategy, c.getDest(), repo);
         } catch (IOException e) {
@@ -928,6 +954,16 @@
     return mergeable;
   }
 
+  @Nullable
+  public Boolean isMerge() {
+    if (merge == null) {
+      if (!loadCommitData()) {
+        return null;
+      }
+    }
+    return parentCount > 1;
+  }
+
   public Set<Account.Id> editsByUser() {
     return editRefs().keySet();
   }
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/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 819fc2b..40a3a07 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -32,7 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
@@ -75,19 +74,14 @@
     }
 
     ChangeNotes notes = notesFactory.createFromIndexedChange(change);
-
-    try {
-      ProjectState projectState = projectCache.checkedGet(cd.project());
-      if (projectState == null) {
-        logger.atFine().log("Filter out change %s of non-existing project %s", cd, cd.project());
-        return false;
-      }
-      if (!projectState.statePermitsRead()) {
-        logger.atFine().log("Filter out change %s of non-reabable project %s", cd, cd.project());
-        return false;
-      }
-    } catch (IOException e) {
-      throw new StorageException("unable to read project state", e);
+    Optional<ProjectState> projectState = projectCache.get(cd.project());
+    if (!projectState.isPresent()) {
+      logger.atFine().log("Filter out change %s of non-existing project %s", cd, cd.project());
+      return false;
+    }
+    if (!projectState.get().statePermitsRead()) {
+      logger.atFine().log("Filter out change %s of non-reabable project %s", cd, cd.project());
+      return false;
     }
 
     PermissionBackend.WithUser withUser =
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 6f278ab..d762746 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
@@ -25,6 +26,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 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 +35,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;
@@ -60,8 +63,12 @@
 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.HasOperandAliasConfig;
+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;
@@ -91,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> {}
 
   /**
@@ -125,6 +135,8 @@
 
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
+  public static final String FIELD_ATTENTION_SET_USERS = "attentionusers";
+  public static final String FIELD_ATTENTION_SET_FULL = "attentionfull";
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_EXACTAUTHOR = "exactauthor";
@@ -181,6 +193,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";
@@ -216,6 +231,9 @@
     final SubmitDryRun submitDryRun;
     final GroupMembers groupMembers;
     final ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory;
+    final OperatorAliasConfig operatorAliasConfig;
+    final boolean indexMergeable;
+    final HasOperandAliasConfig hasOperandAliasConfig;
 
     private final Provider<CurrentUser> self;
 
@@ -246,6 +264,9 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         GroupMembers groupMembers,
+        OperatorAliasConfig operatorAliasConfig,
+        @GerritServerConfig Config gerritConfig,
+        HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory) {
       this(
           queryProvider,
@@ -272,6 +293,9 @@
           starredChangesUtil,
           accountCache,
           groupMembers,
+          operatorAliasConfig,
+          MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex(),
+          hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory);
     }
 
@@ -300,6 +324,9 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         GroupMembers groupMembers,
+        OperatorAliasConfig operatorAliasConfig,
+        boolean indexMergeable,
+        HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory) {
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
@@ -326,6 +353,9 @@
       this.hasOperands = hasOperands;
       this.groupMembers = groupMembers;
       this.changeIsVisbleToPredicateFactory = changeIsVisbleToPredicateFactory;
+      this.operatorAliasConfig = operatorAliasConfig;
+      this.indexMergeable = indexMergeable;
+      this.hasOperandAliasConfig = hasOperandAliasConfig;
     }
 
     Arguments asUser(CurrentUser otherUser) {
@@ -354,6 +384,9 @@
           starredChangesUtil,
           accountCache,
           groupMembers,
+          operatorAliasConfig,
+          indexMergeable,
+          hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory);
     }
 
@@ -395,10 +428,12 @@
   }
 
   private final Arguments args;
+  protected Map<String, String> hasOperandAliases = Collections.emptyMap();
 
   @Inject
   ChangeQueryBuilder(Arguments args) {
     this(mydef, args);
+    setupAliases();
   }
 
   @VisibleForTesting
@@ -407,6 +442,11 @@
     this.args = args;
   }
 
+  private void setupAliases() {
+    setOperatorAliases(args.operatorAliasConfig.getChangeQueryOperatorAliases());
+    hasOperandAliases = args.hasOperandAliasConfig.getChangeQueryHasOperandAliases();
+  }
+
   public Arguments getArgs() {
     return args;
   }
@@ -482,6 +522,7 @@
 
   @Operator
   public Predicate<ChangeData> has(String value) throws QueryParseException {
+    value = hasOperandAliases.getOrDefault(value, value);
     if ("star".equalsIgnoreCase(value)) {
       return starredby(self());
     }
@@ -550,9 +591,19 @@
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
+      if (!args.indexMergeable) {
+        throw new QueryParseException("'is:mergeable' operator is not supported by server");
+      }
       return new BooleanPredicate(ChangeField.MERGEABLE);
     }
 
+    if ("merge".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.MERGE)) {
+        return new BooleanPredicate(ChangeField.MERGE);
+      }
+      throw new QueryParseException("'is:merge' operator is not supported by change index version");
+    }
+
     if ("private".equalsIgnoreCase(value)) {
       if (args.getSchema().hasField(ChangeField.PRIVATE)) {
         return new BooleanPredicate(ChangeField.PRIVATE);
@@ -672,7 +723,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> branch(String name) {
+  public Predicate<ChangeData> branch(String name) throws QueryParseException {
     if (name.startsWith("^")) {
       return ref("^" + RefNames.fullName(name.substring(1)));
     }
@@ -701,7 +752,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> ref(String ref) {
+  public Predicate<ChangeData> ref(String ref) throws QueryParseException {
     if (ref.startsWith("^")) {
       return new RegexRefPredicate(ref);
     }
@@ -1011,6 +1062,20 @@
   }
 
   @Operator
+  public Predicate<ChangeData> attention(String who)
+      throws QueryParseException, IOException, ConfigInvalidException {
+    if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
+      throw new QueryParseException(
+          "'attention' operator is not supported by change index version");
+    }
+    return attention(parseAccount(who, (AccountState s) -> true));
+  }
+
+  private Predicate<ChangeData> attention(Set<Account.Id> who) {
+    return Predicate.or(who.stream().map(AttentionSetPredicate::new).collect(toImmutableSet()));
+  }
+
+  @Operator
   public Predicate<ChangeData> assignee(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     return assignee(parseAccount(who, (AccountState s) -> true));
@@ -1233,6 +1298,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 +1460,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 +1507,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/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 17e4a59..6eb6871d 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.flogger.LazyArgs.lazy;
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.common.flogger.FluentLogger;
@@ -34,7 +35,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gerrit.server.submit.IntegrationException;
 import com.google.gerrit.server.submit.SubmitDryRun;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -162,7 +162,7 @@
           args.conflictsCache.put(conflictsKey, conflicts);
           return conflicts;
         }
-      } catch (IntegrationException | NoSuchProjectException | StorageException | IOException e) {
+      } catch (NoSuchProjectException | StorageException | IOException e) {
         ObjectId finalOther = other;
         warnWithOccasionalStackTrace(
             e,
@@ -180,8 +180,7 @@
       return 5;
     }
 
-    private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
-        throws IntegrationException {
+    private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw) {
       try {
         Set<RevCommit> accepted = new HashSet<>();
         SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
@@ -191,7 +190,7 @@
         }
         return accepted;
       } catch (StorageException | IOException e) {
-        throw new IntegrationException("Failed to determine already accepted commits.", e);
+        throw new StorageException("Failed to determine already accepted commits.", e);
       }
     }
   }
@@ -218,10 +217,7 @@
 
     ProjectState getProjectState() throws NoSuchProjectException {
       if (projectState == null) {
-        projectState = projectCache.get(cd.project());
-        if (projectState == null) {
-          throw new NoSuchProjectException(cd.project());
-        }
+        projectState = projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
       }
       return projectState;
     }
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 62b1144..569d7cb 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import java.io.IOException;
+import java.util.Optional;
 
 public class EqualsLabelPredicate extends ChangeIndexPredicate {
   protected final ProjectCache projectCache;
@@ -60,14 +60,14 @@
       return false;
     }
 
-    ProjectState project = projectCache.get(c.getDest().project());
-    if (project == null) {
+    Optional<ProjectState> project = projectCache.get(c.getDest().project());
+    if (!project.isPresent()) {
       // The project has disappeared.
       //
       return false;
     }
 
-    LabelType labelType = type(project.getLabelTypes(), label);
+    LabelType labelType = type(project.get().getLabelTypes(), label);
     if (labelType == null) {
       return false; // Label is not defined by this project.
     }
@@ -119,14 +119,13 @@
     // Check the user has 'READ' permission.
     try {
       PermissionBackend.ForChange perm = permissionBackend.absentUser(approver).change(cd);
-      ProjectState projectState = projectCache.checkedGet(cd.project());
-      if (projectState == null || !projectState.statePermitsRead()) {
+      if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
         return false;
       }
 
       perm.check(ChangePermission.READ);
       return true;
-    } catch (PermissionBackendException | IOException | AuthException e) {
+    } catch (PermissionBackendException | AuthException e) {
       return false;
     }
   }
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 5deb7f5..2c82075 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -26,6 +26,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 public class ParentProjectPredicate extends OrPredicate<ChangeData> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -40,15 +41,15 @@
 
   protected static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache, ChildProjects childProjects, String value) {
-    ProjectState projectState = projectCache.get(Project.nameKey(value));
-    if (projectState == null) {
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(value));
+    if (!projectState.isPresent()) {
       return Collections.emptyList();
     }
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
-    r.add(new ProjectPredicate(projectState.getName()));
+    r.add(new ProjectPredicate(projectState.get().getName()));
     try {
-      for (ProjectInfo p : childProjects.list(projectState.getNameKey())) {
+      for (ProjectInfo p : childProjects.list(projectState.get().getNameKey())) {
         r.add(new ProjectPredicate(p.name));
       }
     } catch (PermissionBackendException e) {
diff --git a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index 6d404b4..b2dba72 100644
--- a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
@@ -22,7 +23,7 @@
 public class RegexRefPredicate extends ChangeRegexPredicate {
   protected final RunAutomaton pattern;
 
-  public RegexRefPredicate(String re) {
+  public RegexRefPredicate(String re) throws QueryParseException {
     super(ChangeField.REF, re);
 
     if (re.startsWith("^")) {
@@ -33,7 +34,11 @@
       re = re.substring(0, re.length() - 1);
     }
 
-    this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+    try {
+      this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", re), e);
+    }
   }
 
   @Override
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..3f28a03 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -17,13 +17,12 @@
         "//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/logging",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
@@ -34,7 +33,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 0b51bf8..445a5d6 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;
@@ -67,7 +73,7 @@
   public Response<?> apply(AccountResource resource, List<String> extIds)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(resource.getUser())) {
-      permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
+      permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (extIds == null || extIds.isEmpty()) {
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..a3c48b9 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;
@@ -62,7 +67,7 @@
   public Response<List<AccountExternalIdInfo>> apply(AccountResource resource)
       throws RestApiException, IOException, PermissionBackendException {
     if (!self.get().hasSameAccountId(resource.getUser())) {
-      permissionBackend.currentUser().check(GlobalPermission.ACCESS_DATABASE);
+      permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
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/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
index f41764d..51055b8 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -53,6 +53,7 @@
     delete(ACCOUNT_KIND, "name").to(PutName.class);
     get(ACCOUNT_KIND, "status").to(GetStatus.class);
     put(ACCOUNT_KIND, "status").to(PutStatus.class);
+    put(ACCOUNT_KIND, "displayname").to(PutDisplayName.class);
     get(ACCOUNT_KIND, "username").to(GetUsername.class);
     put(ACCOUNT_KIND, "username").to(PutUsername.class);
     get(ACCOUNT_KIND, "active").to(GetActive.class);
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/PutDisplayName.java b/java/com/google/gerrit/server/restapi/account/PutDisplayName.java
new file mode 100644
index 0000000..557e660
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/PutDisplayName.java
@@ -0,0 +1,89 @@
+// 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.restapi.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.accounts.DisplayNameInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+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;
+
+/**
+ * REST endpoint to set the display name of an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/displayname} requests.
+ *
+ * <p>The display name is a free-form text that a user can set for their own account. It defines how
+ * the user's name will be rendered in the UI in most screens. It is optional, and if not set, then
+ * the UI falls back to whatever is configured as the default display name, e.g. the full name.
+ */
+@Singleton
+public class PutDisplayName implements RestModifyView<AccountResource, DisplayNameInput> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final Provider<AccountsUpdate> accountsUpdateProvider;
+
+  @Inject
+  PutDisplayName(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdateProvider = accountsUpdateProvider;
+  }
+
+  @Override
+  public Response<String> apply(AccountResource rsrc, @Nullable DisplayNameInput input)
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException,
+          ConfigInvalidException {
+    IdentifiedUser user = rsrc.getUser();
+    if (!self.get().hasSameAccountId(user)) {
+      permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+    }
+    if (input == null) {
+      input = new DisplayNameInput();
+    }
+
+    String newDisplayName = input.displayName;
+    AccountState accountState =
+        accountsUpdateProvider
+            .get()
+            .update(
+                "Set Display Name via API",
+                user.getAccountId(),
+                u -> u.setDisplayName(newDisplayName))
+            .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+    return Strings.isNullOrEmpty(accountState.account().displayName())
+        ? Response.none()
+        : Response.ok(accountState.account().displayName());
+  }
+}
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 3e7753f..2427def 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 0389014..78430c3 100644
--- a/java/com/google/gerrit/server/restapi/account/PutName.java
+++ b/java/com/google/gerrit/server/restapi/account/PutName.java
@@ -40,6 +40,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 d5897e5..05bf1fd 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..b207390 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.
@@ -146,7 +145,7 @@
       if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (StorageException | IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
new file mode 100644
index 0000000..5176fe9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -0,0 +1,93 @@
+// 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.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+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.RestCollectionModifyView;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Adds a single user to the attention set. */
+@Singleton
+public class AddToAttentionSet
+    implements RestCollectionModifyView<
+        ChangeResource, AttentionSetEntryResource, AddToAttentionSetInput> {
+  private final BatchUpdate.Factory updateFactory;
+  private final AccountResolver accountResolver;
+  private final AddToAttentionSetOp.Factory opFactory;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  AddToAttentionSet(
+      BatchUpdate.Factory updateFactory,
+      AccountResolver accountResolver,
+      AddToAttentionSetOp.Factory opFactory,
+      AccountLoader.Factory accountLoaderFactory,
+      PermissionBackend permissionBackend) {
+    this.updateFactory = updateFactory;
+    this.accountResolver = accountResolver;
+    this.opFactory = opFactory;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource changeResource, AddToAttentionSetInput input)
+      throws Exception {
+    input.user = Strings.nullToEmpty(input.user).trim();
+    if (input.user.isEmpty()) {
+      throw new BadRequestException("missing field: user");
+    }
+    input.reason = Strings.nullToEmpty(input.reason).trim();
+    if (input.reason.isEmpty()) {
+      throw new BadRequestException("missing field: reason");
+    }
+
+    Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
+    try {
+      permissionBackend
+          .absentUser(attentionUserId)
+          .change(changeResource.getNotes())
+          .check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new AuthException("read not permitted for " + attentionUserId, e);
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(
+            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason);
+      bu.addOp(changeResource.getId(), op);
+      bu.execute();
+      return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
index 2e313a1..ebec3295 100644
--- a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
+++ b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
-import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.ArchiveFormatInternal;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -28,13 +28,13 @@
 
 @Singleton
 public class AllowedFormats {
-  final ImmutableMap<String, ArchiveFormat> extensions;
-  final ImmutableSet<ArchiveFormat> allowed;
+  final ImmutableMap<String, ArchiveFormatInternal> extensions;
+  final ImmutableSet<ArchiveFormatInternal> allowed;
 
   @Inject
   AllowedFormats(DownloadConfig cfg) {
-    Map<String, ArchiveFormat> exts = new HashMap<>();
-    for (ArchiveFormat format : cfg.getArchiveFormats()) {
+    Map<String, ArchiveFormatInternal> exts = new HashMap<>();
+    for (ArchiveFormatInternal format : cfg.getArchiveFormats()) {
       for (String ext : format.getSuffixes()) {
         exts.put(ext, format);
       }
@@ -46,14 +46,14 @@
     // valid JAR file, whose code would have access to cookies on the domain.
     allowed =
         Sets.immutableEnumSet(
-            Iterables.filter(cfg.getArchiveFormats(), f -> f != ArchiveFormat.ZIP));
+            Iterables.filter(cfg.getArchiveFormats(), f -> f != ArchiveFormatInternal.ZIP));
   }
 
-  public Set<ArchiveFormat> getAllowed() {
+  public Set<ArchiveFormatInternal> getAllowed() {
     return allowed;
   }
 
-  public ImmutableMap<String, ArchiveFormat> getExtensions() {
+  public ImmutableMap<String, ArchiveFormatInternal> getExtensions() {
     return extensions;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index 74c5bc2..40e4fc2 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.EditInfo;
@@ -70,7 +72,7 @@
           ResourceNotFoundException, PermissionBackendException {
     RevisionResource revisionResource = fixResource.getRevisionResource();
     Project.NameKey project = revisionResource.getProject();
-    ProjectState projectState = projectCache.checkedGet(project);
+    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     PatchSet patchSet = revisionResource.getPatchSet();
 
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
diff --git a/java/com/google/gerrit/server/restapi/change/AttentionSet.java b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
new file mode 100644
index 0000000..45d78dc
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
@@ -0,0 +1,69 @@
+// 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.restapi.change;
+
+import com.google.gerrit.entities.Account;
+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.RestView;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class AttentionSet implements ChildCollection<ChangeResource, AttentionSetEntryResource> {
+  private final DynamicMap<RestView<AttentionSetEntryResource>> views;
+  private final AccountResolver accountResolver;
+  private final GetAttentionSet getAttentionSet;
+
+  @Inject
+  AttentionSet(
+      DynamicMap<RestView<AttentionSetEntryResource>> views,
+      GetAttentionSet getAttentionSet,
+      AccountResolver accountResolver) {
+    this.views = views;
+    this.accountResolver = accountResolver;
+    this.getAttentionSet = getAttentionSet;
+  }
+
+  @Override
+  public DynamicMap<RestView<AttentionSetEntryResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<ChangeResource> list() throws ResourceNotFoundException {
+    return getAttentionSet;
+  }
+
+  @Override
+  public AttentionSetEntryResource parse(ChangeResource changeResource, IdString idString)
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
+    try {
+      Account.Id accountId = accountResolver.resolve(idString.get()).asUnique().account().id();
+      return new AttentionSetEntryResource(changeResource, accountId);
+    } catch (UnresolvableAccountException e) {
+      throw new ResourceNotFoundException(idString, e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 8449f8a..d510fce 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -14,15 +14,18 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 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;
@@ -65,9 +68,12 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.Base64;
 import org.kohsuke.args4j.Option;
 
 @Singleton
@@ -111,8 +117,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
@@ -121,14 +128,15 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource resource, IdString id, Put.Input input)
-        throws AuthException, ResourceConflictException, BadRequestException, IOException,
-            PermissionBackendException, BadRequestException {
-      putEdit.apply(resource, id.get(), input.content);
+    public Response<Object> apply(ChangeResource resource, IdString id, FileContentInput input)
+        throws AuthException, BadRequestException, ResourceConflictException, IOException,
+            PermissionBackendException {
+      putEdit.apply(resource, id.get(), input);
       return Response.none();
     }
   }
 
+  @Singleton
   public static class DeleteFile
       implements RestCollectionDeleteMissingView<ChangeResource, ChangeEditResource, Input> {
     private final DeleteContent deleteContent;
@@ -139,7 +147,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, IdString id, Input in)
+    public Response<Object> apply(ChangeResource rsrc, IdString id, Input in)
         throws IOException, AuthException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
       return deleteContent.apply(rsrc, id.get());
@@ -187,7 +195,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();
@@ -241,7 +250,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource resource, Post.Input input)
+    public Response<Object> apply(ChangeResource resource, Post.Input input)
         throws AuthException, BadRequestException, IOException, ResourceConflictException,
             PermissionBackendException {
       Project.NameKey project = resource.getProject();
@@ -272,10 +281,10 @@
 
   /** 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 static final Pattern BINARY_DATA_PATTERN =
+        Pattern.compile("data:([\\w/.-]*);([\\w]+),(.*)");
+    private static final String BASE64 = "base64";
 
     private final ChangeEditModifier editModifier;
     private final GitRepositoryManager repositoryManager;
@@ -292,21 +301,39 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException, BadRequestException, IOException,
-            PermissionBackendException, BadRequestException {
-      return apply(rsrc.getChangeResource(), rsrc.getPath(), input.content);
+    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput input)
+        throws AuthException, BadRequestException, ResourceConflictException, IOException,
+            PermissionBackendException {
+      return apply(rsrc.getChangeResource(), rsrc.getPath(), input);
     }
 
-    public Response<?> apply(ChangeResource rsrc, String path, RawInput newContent)
-        throws ResourceConflictException, AuthException, BadRequestException, IOException,
-            PermissionBackendException, BadRequestException {
-      if (Patch.COMMIT_MSG.equals(path)) {
+    public Response<Object> apply(ChangeResource rsrc, String path, FileContentInput input)
+        throws AuthException, BadRequestException, ResourceConflictException, IOException,
+            PermissionBackendException {
+
+      if (input.content == null && input.binary_content == null) {
+        throw new BadRequestException("either content or binary_content is required");
+      }
+
+      RawInput newContent;
+      if (input.binary_content != null) {
+        Matcher m = BINARY_DATA_PATTERN.matcher(input.binary_content);
+        if (m.matches() && BASE64.equals(m.group(2))) {
+          newContent = RawInputUtil.create(Base64.decode(m.group(3)));
+        } else {
+          throw new BadRequestException("binary_content must be encoded as base64 data uri");
+        }
+      } else {
+        newContent = input.content;
+      }
+
+      if (Patch.COMMIT_MSG.equals(path) && input.binary_content == null) {
         EditMessage.Input editCommitMessageInput = new EditMessage.Input();
         editCommitMessageInput.message =
             new String(ByteStreams.toByteArray(newContent.getInputStream()), UTF_8);
         return editMessage.apply(rsrc, editCommitMessageInput);
       }
+
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
@@ -339,13 +366,13 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc, Input input)
+    public Response<Object> apply(ChangeEditResource rsrc, Input input)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
       return apply(rsrc.getChangeResource(), rsrc.getPath());
     }
 
-    public Response<?> apply(ChangeResource rsrc, String filePath)
+    public Response<Object> apply(ChangeResource rsrc, String filePath)
         throws AuthException, BadRequestException, IOException, ResourceConflictException,
             PermissionBackendException {
       try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
@@ -383,9 +410,10 @@
         }
 
         ChangeEdit edit = rsrc.getChangeEdit();
+        Project.NameKey project = rsrc.getChangeResource().getProject();
         return Response.ok(
             fileContentUtil.getContent(
-                projectCache.checkedGet(rsrc.getChangeResource().getProject()),
+                projectCache.get(project).orElseThrow(illegalState(project)),
                 base ? edit.getBasePatchSet().commitId() : edit.getEditCommit(),
                 rsrc.getPath(),
                 null));
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index 3b0321d..95b74f8 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -39,6 +39,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class ChangesCollection implements RestCollection<TopLevelResource, ChangeResource> {
@@ -97,8 +98,7 @@
   }
 
   public ChangeResource parse(Change.Id id)
-      throws ResourceConflictException, ResourceNotFoundException, PermissionBackendException,
-          IOException {
+      throws ResourceConflictException, ResourceNotFoundException, PermissionBackendException {
     List<ChangeNotes> notes = changeFinder.find(id);
     if (notes.isEmpty()) {
       throw new ResourceNotFoundException(toIdString(id));
@@ -122,25 +122,24 @@
     return changeResourceFactory.create(notes, user);
   }
 
-  private boolean canRead(ChangeNotes notes) throws PermissionBackendException, IOException {
+  private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
     try {
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
     } catch (AuthException e) {
       return false;
     }
-    ProjectState projectState = projectCache.checkedGet(notes.getProjectName());
-    if (projectState == null) {
+    Optional<ProjectState> projectState = projectCache.get(notes.getProjectName());
+    if (!projectState.isPresent()) {
       return false;
     }
-    return projectState.statePermitsRead();
+    return projectState.get().statePermitsRead();
   }
 
   private void checkProjectStatePermitsRead(Project.NameKey project)
-      throws IOException, ResourceNotFoundException, ResourceConflictException {
-    ProjectState projectState = projectCache.checkedGet(project);
-    if (projectState == null) {
-      throw new ResourceNotFoundException("project not found: " + project.get());
-    }
-    projectState.checkStatePermitsRead();
+      throws ResourceNotFoundException, ResourceConflictException {
+    projectCache
+        .get(project)
+        .orElseThrow(() -> new ResourceNotFoundException("project not found: " + project.get()))
+        .checkStatePermitsRead();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPick.java b/java/com/google/gerrit/server/restapi/change/CherryPick.java
index 1a89935..f74940e 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPick.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPick.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.CherryPickChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 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.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.RevisionResource;
@@ -37,10 +38,7 @@
 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.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.project.ProjectState;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -49,10 +47,7 @@
 
 @Singleton
 public class CherryPick
-    extends RetryingRestModifyView<RevisionResource, CherryPickInput, CherryPickChangeInfo>
-    implements UiAction<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
+    implements RestModifyView<RevisionResource, CherryPickInput>, UiAction<RevisionResource> {
   private final PermissionBackend permissionBackend;
   private final CherryPickChange cherryPickChange;
   private final ChangeJson.Factory json;
@@ -62,12 +57,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 +69,7 @@
   }
 
   @Override
-  public Response<CherryPickChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, CherryPickInput input)
       throws IOException, UpdateException, RestApiException, PermissionBackendException,
           ConfigInvalidException, NoSuchProjectException {
     input.parent = input.parent == null ? 1 : input.parent;
@@ -93,38 +85,34 @@
         .project(rsrc.getChange().getProject())
         .ref(refName)
         .check(RefPermission.CREATE_CHANGE);
-    projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
 
     try {
       CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
-              updateFactory,
               rsrc.getChange(),
               rsrc.getPatchSet(),
               input,
               BranchNameKey.create(rsrc.getProject(), refName));
-      CherryPickChangeInfo changeInfo =
-          json.noOptions()
-              .format(rsrc.getProject(), cherryPickResult.changeId(), CherryPickChangeInfo::new);
+      ChangeInfo changeInfo =
+          json.noOptions().format(rsrc.getProject(), cherryPickResult.changeId());
       changeInfo.containsGitConflicts =
           !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
       return Response.ok(changeInfo);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
-    } catch (IntegrationException | NoSuchChangeException e) {
+    } catch (NoSuchChangeException e) {
       throw new ResourceConflictException(e.getMessage());
     }
   }
 
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) {
-    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());
-    }
+    boolean projectStatePermitsWrite =
+        projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
     return new UiAction.Description()
         .setLabel("Cherry Pick")
         .setTitle("Cherry pick change to a different branch")
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 407c560..44dc6e1 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
@@ -41,9 +42,11 @@
 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;
+import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -54,7 +57,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.submit.IntegrationException;
+import com.google.gerrit.server.submit.IntegrationConflictException;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -102,11 +105,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(
@@ -117,11 +122,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;
@@ -129,17 +136,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.
@@ -148,27 +156,21 @@
    * @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.
-   * @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
    * @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)
-      throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
-          RestApiException, ConfigInvalidException, NoSuchProjectException {
+  public Result cherryPick(Change change, PatchSet patch, CherryPickInput input, BranchNameKey dest)
+      throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
+          ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        batchUpdateFactory,
         change,
         change.getProject(),
         patch.commitId(),
         input,
         dest,
+        TimeUtil.nowTs(),
         null,
         null,
         null);
@@ -178,7 +180,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
@@ -189,23 +190,21 @@
    * @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.
-   * @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
    * @throws ConfigInvalidException Can't find account to notify.
    * @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)
-      throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
-          RestApiException, ConfigInvalidException, NoSuchProjectException {
+      throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
+          ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        batchUpdateFactory, sourceChange, project, sourceCommit, input, dest, null, null, null);
+        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null);
   }
 
   /**
@@ -213,41 +212,42 @@
    * 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 topic Topic name for the change created.
+   * @param timestamp the current timestamp.
    * @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.
    * @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.
-   * @throws IntegrationException Merge conflict or trees are identical after cherry pick.
+   *     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 UpdateException Problem updating the database using batchUpdateFactory.
    * @throws RestApiException Error such as invalid SHA1
    * @throws ConfigInvalidException Can't find account to notify.
    * @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,
-      @Nullable String topic,
+      Timestamp timestamp,
       @Nullable Change.Id revertedChange,
-      @Nullable ObjectId changeIdForNewChange)
-      throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
-          RestApiException, ConfigInvalidException, NoSuchProjectException {
+      @Nullable ObjectId changeIdForNewChange,
+      @Nullable Change.Id idForNewChange)
+      throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
+          ConfigInvalidException, NoSuchProjectException {
 
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
@@ -278,8 +278,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
@@ -288,10 +287,8 @@
       String commitMessage = ChangeIdUtil.insertId(message, generatedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
-      ProjectState projectState = projectCache.checkedGet(dest.project());
-      if (projectState == null) {
-        throw new NoSuchProjectException(dest.project());
-      }
+      ProjectState projectState =
+          projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
       try {
         MergeUtil mergeUtil;
         if (input.allowConflicts) {
@@ -311,7 +308,7 @@
                 commitMessage,
                 revWalk,
                 input.parent - 1,
-                false,
+                input.allowEmpty,
                 input.allowConflicts);
 
         Change.Key changeKey;
@@ -333,39 +330,63 @@
                   + " 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;
+          String newTopic = null;
+          if (input.topic != null) {
+            newTopic = Strings.emptyToNull(input.topic.trim());
+          }
+          if (newTopic == null
+              && sourceChange != null
+              && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+            newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
+          }
           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(),
+                    newTopic);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
-            String newTopic = topic;
-            if (topic == null
-                && sourceChange != null
-                && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-              newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
-            }
             changeId =
                 createNewChange(
                     bu,
                     cherryPickCommit,
                     dest.branch(),
                     newTopic,
+                    project,
                     sourceChange,
                     sourceCommit,
                     input,
-                    revertedChange);
+                    revertedChange,
+                    idForNewChange);
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
         }
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
-        throw new IntegrationException("Cherry pick failed: " + e.getMessage());
+        throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage());
       }
     }
   }
@@ -422,13 +443,24 @@
   }
 
   private Change.Id insertPatchSet(
-      BatchUpdate bu, Repository git, ChangeNotes destNotes, CodeReviewCommit cherryPickCommit)
+      BatchUpdate bu,
+      Repository git,
+      ChangeNotes destNotes,
+      CodeReviewCommit cherryPickCommit,
+      PatchSet.Id sourcePatchSetId,
+      String topic)
       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() + ".");
+    inserter.setTopic(topic);
     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,15 +469,18 @@
       CodeReviewCommit cherryPickCommit,
       String refName,
       String topic,
+      Project.NameKey project,
       @Nullable Change sourceChange,
       @Nullable ObjectId sourceCommit,
       CherryPickInput input,
-      @Nullable Change.Id revertOf)
-      throws IOException {
-    Change.Id changeId = Change.id(seq.nextChangeId());
+      @Nullable Change.Id revertOf,
+      @Nullable Change.Id idForNewChange)
+      throws IOException, InvalidChangeOperationException {
+    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(
@@ -453,6 +488,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());
@@ -467,6 +503,24 @@
       ccs.remove(user.get().getAccountId());
       ins.setReviewersAndCcs(reviewers, ccs);
     }
+    // If there is a base, and the base is not merged, the groups will be overridden by the base's
+    // groups.
+    ins.setGroups(GroupCollector.getDefaultGroups(cherryPickCommit.getId()));
+    if (input.base != null) {
+      List<ChangeData> changes =
+          queryProvider.get().setLimit(2).byBranchCommitOpen(project.get(), refName, input.base);
+      if (changes.size() > 1) {
+        throw new InvalidChangeOperationException(
+            "Several changes with key "
+                + input.base
+                + " reside on the same branch. "
+                + "Cannot cherry-pick on target branch.");
+      }
+      if (changes.size() == 1) {
+        Change change = changes.get(0).change();
+        ins.setGroups(changeNotesFactory.createChecked(change).getCurrentPatchSet().groups());
+      }
+    }
     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..f51257f 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -19,11 +19,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.CherryPickChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 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.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -33,10 +33,6 @@
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
 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 +41,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 +50,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 +63,7 @@
   }
 
   @Override
-  public Response<CherryPickChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
+  public Response<ChangeInfo> apply(CommitResource rsrc, CherryPickInput input)
       throws IOException, UpdateException, RestApiException, PermissionBackendException,
           ConfigInvalidException, NoSuchProjectException {
     String destination = Strings.nullToEmpty(input.destination).trim();
@@ -94,22 +86,17 @@
     try {
       CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
-              updateFactory,
               null,
               projectName,
               rsrc.getCommit(),
               input,
               BranchNameKey.create(rsrc.getProjectState().getNameKey(), refName));
-      CherryPickChangeInfo changeInfo =
-          json.noOptions()
-              .format(projectName, cherryPickResult.changeId(), CherryPickChangeInfo::new);
+      ChangeInfo changeInfo = json.noOptions().format(projectName, cherryPickResult.changeId());
       changeInfo.containsGitConflicts =
           !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
       return Response.ok(changeInfo);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
-    } catch (IntegrationException e) {
-      throw new ResourceConflictException(e.getMessage());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 9d65940..9e792d0 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;
@@ -28,6 +29,8 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
+import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -41,6 +44,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;
@@ -54,6 +58,8 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -70,8 +76,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.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -88,7 +92,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;
@@ -105,8 +108,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;
@@ -128,6 +133,7 @@
 
   @Inject
   CreateChange(
+      BatchUpdate.Factory updateFactory,
       @AnonymousCowardName String anonymousCowardName,
       GitRepositoryManager gitManager,
       Sequences seq,
@@ -140,13 +146,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;
@@ -168,8 +173,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()) {
@@ -185,11 +189,10 @@
     Project.NameKey project = projectResource.getNameKey();
     contributorAgreements.check(project, user.get());
 
-    checkRequiredPermissions(project, input.branch);
+    checkRequiredPermissions(project, input.branch, input.author);
 
-    Change newChange = createNewChange(input, me, projectState, updateFactory);
-    ChangeJson json = jsonFactory.noOptions();
-    return Response.created(json.format(newChange));
+    ChangeInfo newChange = createNewChange(input, me, projectState, updateFactory);
+    return Response.created(newChange);
   }
 
   /**
@@ -273,66 +276,95 @@
         throw new BadRequestException("Submit type: " + submitType + " is not supported");
       }
     }
+
+    if (input.author != null
+        && (Strings.isNullOrEmpty(input.author.email)
+            || Strings.isNullOrEmpty(input.author.name))) {
+      throw new BadRequestException("Author must specify name and email");
+    }
   }
 
-  private void checkRequiredPermissions(Project.NameKey project, String refName)
+  private void checkRequiredPermissions(
+      Project.NameKey project, String refName, @Nullable AccountInput author)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    PermissionBackend.ForRef forRef = permissionBackend.currentUser().project(project).ref(refName);
     try {
-      permissionBackend.currentUser().project(project).ref(refName).check(RefPermission.READ);
+      forRef.check(RefPermission.READ);
     } catch (AuthException e) {
       throw new ResourceNotFoundException(String.format("ref %s not found", refName), e);
     }
-
-    permissionBackend
-        .currentUser()
-        .project(project)
-        .ref(refName)
-        .check(RefPermission.CREATE_CHANGE);
+    forRef.check(RefPermission.CREATE_CHANGE);
+    if (author != null) {
+      forRef.check(RefPermission.FORGE_AUTHOR);
+    }
   }
 
-  private Change createNewChange(
+  private ChangeInfo createNewChange(
       ChangeInput input,
       IdentifiedUser me,
       ProjectState projectState,
       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)) {
+        CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(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);
 
       Timestamp now = TimeUtil.nowTs();
-      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+
+      PersonIdent committer = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent author =
+          input.author == null
+              ? committer
+              : new PersonIdent(input.author.name, input.author.email, now, serverTimeZone);
+
       String commitMessage = getCommitMessage(input.subject, me);
 
-      RevCommit c;
+      CodeReviewCommit c;
       if (input.merge != null) {
         // create a merge commit
-        c = newMergeCommit(git, oi, rw, projectState, mergeTip, input.merge, author, commitMessage);
+        c =
+            newMergeCommit(
+                git, oi, rw, projectState, mergeTip, input.merge, author, committer, commitMessage);
+        if (!c.getFilesWithGitConflicts().isEmpty()) {
+          logger.atFine().log(
+              "merge commit has conflicts in the following files: %s",
+              c.getFilesWithGitConflicts());
+        }
       } else {
         // create an empty commit
-        c = newCommit(oi, rw, author, mergeTip, commitMessage);
+        c = newCommit(oi, rw, author, committer, mergeTip, commitMessage);
       }
 
       Change.Id changeId = Change.id(seq.nextChangeId());
       ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
-      ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
+      ins.setMessage(messageForNewChange(ins.getPatchSetId(), c));
       ins.setTopic(input.topic);
       ins.setPrivate(input.isPrivate);
-      ins.setWorkInProgress(input.workInProgress);
+      ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
       ins.setGroups(groups);
       try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
         bu.setRepository(git, rw, oi);
@@ -342,8 +374,10 @@
         bu.insertChange(ins);
         bu.execute();
       }
-      return ins.getChange();
-    } catch (InvalidMergeStrategyException e) {
+      ChangeInfo changeInfo = jsonFactory.noOptions().format(ins.getChange());
+      changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
+      return changeInfo;
+    } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
       throw new BadRequestException(e.getMessage());
     }
   }
@@ -460,13 +494,15 @@
     return commitMessage;
   }
 
-  private static RevCommit newCommit(
+  private static CodeReviewCommit newCommit(
       ObjectInserter oi,
-      RevWalk rw,
+      CodeReviewRevWalk rw,
       PersonIdent authorIdent,
+      PersonIdent committerIdent,
       RevCommit mergeTip,
       String commitMessage)
       throws IOException {
+    logger.atFine().log("Creating empty commit");
     CommitBuilder commit = new CommitBuilder();
     if (mergeTip == null) {
       commit.setTreeId(emptyTreeId(oi));
@@ -475,27 +511,38 @@
       commit.setParentId(mergeTip);
     }
     commit.setAuthor(authorIdent);
-    commit.setCommitter(authorIdent);
+    commit.setCommitter(committerIdent);
     commit.setMessage(commitMessage);
     return rw.parseCommit(insert(oi, commit));
   }
 
-  private RevCommit newMergeCommit(
+  private CodeReviewCommit newMergeCommit(
       Repository repo,
       ObjectInserter oi,
-      RevWalk rw,
+      CodeReviewRevWalk rw,
       ProjectState projectState,
       RevCommit mergeTip,
       MergeInput merge,
       PersonIdent authorIdent,
+      PersonIdent committerIdent,
       String commitMessage)
       throws RestApiException, IOException {
+    logger.atFine().log(
+        "Creating merge commit: source = %s, strategy = %s, allowConflicts = %s",
+        merge.source, merge.strategy, merge.allowConflicts);
+
     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);
     }
 
@@ -503,6 +550,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(
@@ -511,19 +559,31 @@
           mergeTip,
           sourceCommit,
           mergeStrategy,
+          merge.allowConflicts,
           authorIdent,
+          committerIdent,
           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);
     }
   }
 
+  private static String messageForNewChange(PatchSet.Id patchSetId, CodeReviewCommit commit) {
+    StringBuilder stringBuilder =
+        new StringBuilder(String.format("Uploaded patch set %s.", patchSetId.get()));
+
+    if (!commit.getFilesWithGitConflicts().isEmpty()) {
+      stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
+      commit.getFilesWithGitConflicts().stream()
+          .sorted()
+          .forEach(filePath -> stringBuilder.append("* ").append(filePath).append("\n"));
+    }
+
+    return stringBuilder.toString();
+  }
+
   private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit) throws IOException {
     ObjectId id = inserter.insert(commit);
     inserter.flush();
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 a159486..8ac2140 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -22,6 +24,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
+import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.MergeInput;
@@ -32,6 +35,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;
@@ -43,6 +47,8 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -54,8 +60,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;
@@ -72,12 +76,11 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 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;
@@ -92,6 +95,7 @@
 
   @Inject
   CreateMergePatchSet(
+      BatchUpdate.Factory updateFactory,
       GitRepositoryManager gitManager,
       CommitsCollection commits,
       @GerritPersonIdent PersonIdent myIdent,
@@ -99,12 +103,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();
@@ -119,15 +122,15 @@
   }
 
   @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());
 
     rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
 
-    ProjectState projectState = projectCache.checkedGet(rsrc.getProject());
+    ProjectState projectState =
+        projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject()));
     projectState.checkStatePermitsWrite();
 
     MergeInput merge = in.merge;
@@ -143,7 +146,7 @@
     try (Repository git = gitManager.openRepository(project);
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader)) {
+        CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(reader)) {
 
       RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, merge.source);
       if (!commits.canRead(projectState, git, sourceCommit)) {
@@ -164,7 +167,7 @@
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
-      RevCommit newCommit =
+      CodeReviewCommit newCommit =
           createMergeCommit(
               in,
               projectState,
@@ -184,7 +187,8 @@
         bu.setRepository(git, rw, oi);
         bu.setNotify(NotifyResolver.Result.none());
         psInserter
-            .setMessage("Uploaded patch set " + nextPsId.get() + ".")
+            .setMessage(messageForChange(nextPsId, newCommit))
+            .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty())
             .setCheckAddPatchSetPermission(false);
         if (groups != null) {
           psInserter.setGroups(groups);
@@ -194,8 +198,11 @@
       }
 
       ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
-      return Response.ok(json.format(psInserter.getChange()));
-    } catch (InvalidMergeStrategyException e) {
+      ChangeInfo changeInfo = json.format(psInserter.getChange());
+      changeInfo.containsGitConflicts =
+          !newCommit.getFilesWithGitConflicts().isEmpty() ? true : null;
+      return Response.ok(changeInfo);
+    } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
       throw new BadRequestException(e.getMessage());
     }
   }
@@ -215,13 +222,13 @@
     return psUtil.current(change);
   }
 
-  private RevCommit createMergeCommit(
+  private CodeReviewCommit createMergeCommit(
       MergePatchSetInput in,
       ProjectState projectState,
       BranchNameKey dest,
       Repository git,
       ObjectInserter oi,
-      RevWalk rw,
+      CodeReviewRevWalk rw,
       RevCommit currentPsCommit,
       RevCommit sourceCommit,
       PersonIdent author,
@@ -260,6 +267,28 @@
             mergeUtilFactory.create(projectState).mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
-        oi, git.getConfig(), mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
+        oi,
+        git.getConfig(),
+        mergeTip,
+        sourceCommit,
+        mergeStrategy,
+        in.merge.allowConflicts,
+        author,
+        commitMsg,
+        rw);
+  }
+
+  private static String messageForChange(PatchSet.Id patchSetId, CodeReviewCommit commit) {
+    StringBuilder stringBuilder =
+        new StringBuilder(String.format("Uploaded patch set %s.", patchSetId.get()));
+
+    if (!commit.getFilesWithGitConflicts().isEmpty()) {
+      stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
+      commit.getFilesWithGitConflicts().stream()
+          .sorted()
+          .forEach(filePath -> stringBuilder.append("* ").append(filePath).append("\n"));
+    }
+
+    return stringBuilder.toString();
   }
 }
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..4c39763 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
@@ -32,6 +33,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 +53,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 +64,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 +80,7 @@
 
   @Inject
   DeleteVote(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
@@ -89,7 +90,7 @@
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
@@ -102,8 +103,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();
@@ -130,7 +130,9 @@
       bu.addOp(
           change.getId(),
           new Op(
-              projectCache.checkedGet(r.getChange().getProject()),
+              projectCache
+                  .get(r.getChange().getProject())
+                  .orElseThrow(illegalState(r.getChange().getProject())),
               r.getReviewerUser().state(),
               rsrc.getLabel(),
               input));
diff --git a/java/com/google/gerrit/server/restapi/change/DownloadContent.java b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
index 4a4a680..74c0acc 100644
--- a/java/com/google/gerrit/server/restapi/change/DownloadContent.java
+++ b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -47,6 +49,9 @@
     RevisionResource rev = rsrc.getRevision();
     return Response.ok(
         fileContentUtil.downloadContent(
-            projectCache.checkedGet(rev.getProject()), rev.getPatchSet().commitId(), path, parent));
+            projectCache.get(rev.getProject()).orElseThrow(illegalState(rev.getProject())),
+            rev.getPatchSet().commitId(),
+            path,
+            parent));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetArchive.java b/java/com/google/gerrit/server/restapi/change/GetArchive.java
index 4ebcbdd..7ab1432 100644
--- a/java/com/google/gerrit/server/restapi/change/GetArchive.java
+++ b/java/com/google/gerrit/server/restapi/change/GetArchive.java
@@ -17,12 +17,13 @@
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.ArchiveFormatInternal;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
@@ -38,9 +39,12 @@
 public class GetArchive implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
   private final AllowedFormats allowedFormats;
+  @Nullable private String format;
 
   @Option(name = "--format")
-  private String format;
+  public void setFormat(String format) {
+    this.format = format;
+  }
 
   @Inject
   GetArchive(GitRepositoryManager repoManager, AllowedFormats allowedFormats) {
@@ -54,17 +58,17 @@
     if (Strings.isNullOrEmpty(format)) {
       throw new BadRequestException("format is not specified");
     }
-    final ArchiveFormat f = allowedFormats.extensions.get("." + format);
+    ArchiveFormatInternal f = allowedFormats.extensions.get("." + format);
     if (f == null) {
       throw new BadRequestException("unknown archive format");
     }
-    if (f == ArchiveFormat.ZIP) {
+    if (f == ArchiveFormatInternal.ZIP) {
       throw new MethodNotAllowedException("zip format is disabled");
     }
     boolean close = true;
-    final Repository repo = repoManager.openRepository(rsrc.getProject());
+    Repository repo = repoManager.openRepository(rsrc.getProject());
     try {
-      final RevCommit commit;
+      RevCommit commit;
       String name;
       try (RevWalk rw = new RevWalk(repo)) {
         commit = rw.parseCommit(rsrc.getPatchSet().commitId());
@@ -103,7 +107,7 @@
     }
   }
 
-  private static String name(ArchiveFormat format, RevWalk rw, RevCommit commit)
+  private static String name(ArchiveFormatInternal format, RevWalk rw, RevCommit commit)
       throws IOException {
     return String.format(
         "%s%s", abbreviateName(commit, rw.getObjectReader()), format.getDefaultSuffix());
diff --git a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
new file mode 100644
index 0000000..08a963b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.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.restapi.change;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.common.AttentionSetEntry;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import java.util.Set;
+
+/** Reads the list of users currently in the attention set. */
+@Singleton
+public class GetAttentionSet implements RestReadView<ChangeResource> {
+
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  GetAttentionSet(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<Set<AttentionSetEntry>> apply(ChangeResource changeResource)
+      throws PermissionBackendException {
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+    ImmutableSet<AttentionSetEntry> response =
+        // This filtering should match ChangeJson.
+        additionsOnly(changeResource.getNotes().getAttentionSet()).stream()
+            .map(
+                a ->
+                    new AttentionSetEntry(
+                        accountLoader.get(a.account()), Timestamp.from(a.timestamp()), a.reason()))
+            .collect(toImmutableSet());
+    accountLoader.fill();
+    return Response.ok(response);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetContent.java b/java/com/google/gerrit/server/restapi/change/GetContent.java
index bf7c51f..c536946 100644
--- a/java/com/google/gerrit/server/restapi/change/GetContent.java
+++ b/java/com/google/gerrit/server/restapi/change/GetContent.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -79,7 +81,9 @@
     }
     return Response.ok(
         fileContentUtil.getContent(
-            projectCache.checkedGet(rsrc.getRevision().getProject()),
+            projectCache
+                .get(rsrc.getRevision().getProject())
+                .orElseThrow(illegalState(rsrc.getRevision().getProject())),
             rsrc.getRevision().getPatchSet().commitId(),
             path,
             parent));
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 39c5a3b..f4e2ddd 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -14,42 +14,35 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 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 +53,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 +66,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 +104,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 +116,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);
@@ -155,108 +143,20 @@
     }
 
     try {
-      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).orElseThrow(illegalState(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().isEmpty()) {
-        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..6089778
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
@@ -0,0 +1,144 @@
+// 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.gerrit.server.project.ProjectCache.illegalState;
+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()).orElseThrow(illegalState(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, ResourceNotFoundException {
+    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/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index a846d50..ba1a1dc 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
@@ -77,9 +84,10 @@
     return Response.ok(relatedChangesInfo);
   }
 
-  private List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
+  public 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/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index edd6201..e544509 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -14,37 +14,80 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.extensions.common.CommentInfo;
+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.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Map;
 
 @Singleton
-public class ListChangeComments extends ListChangeDrafts {
+public class ListChangeComments implements RestReadView<ChangeResource> {
+  private final ChangeMessagesUtil changeMessagesUtil;
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<CommentJson> commentJson;
+  private final CommentsUtil commentsUtil;
+
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
-      CommentsUtil commentsUtil) {
-    super(changeDataFactory, commentJson, commentsUtil);
+      CommentsUtil commentsUtil,
+      ChangeMessagesUtil changeMessagesUtil) {
+    this.changeDataFactory = changeDataFactory;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   @Override
-  protected Iterable<Comment> listComments(ChangeResource rsrc) {
+  public Response<Map<String, List<CommentInfo>>> apply(ChangeResource rsrc)
+      throws AuthException, PermissionBackendException {
+    return Response.ok(getAsMap(listComments(rsrc), rsrc));
+  }
+
+  public List<CommentInfo> getComments(ChangeResource rsrc) throws PermissionBackendException {
+    return getAsList(listComments(rsrc), rsrc);
+  }
+
+  private Iterable<Comment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     return commentsUtil.publishedByChange(cd.notes());
   }
 
-  @Override
-  protected boolean includeAuthorInfo() {
-    return true;
+  private ImmutableList<CommentInfo> getAsList(Iterable<Comment> comments, ChangeResource rsrc)
+      throws PermissionBackendException {
+    ImmutableList<CommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
+    List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    return commentInfos;
   }
 
-  @Override
-  public boolean requireAuthentication() {
-    return false;
+  private Map<String, List<CommentInfo>> getAsMap(Iterable<Comment> comments, ChangeResource rsrc)
+      throws PermissionBackendException {
+    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
+    List<CommentInfo> commentInfos =
+        commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
+    List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    return commentInfosMap;
+  }
+
+  private CommentFormatter getCommentFormatter() {
+    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index cc35a5e..24e1d40 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -32,9 +32,9 @@
 
 @Singleton
 public class ListChangeDrafts implements RestReadView<ChangeResource> {
-  protected final ChangeData.Factory changeDataFactory;
-  protected final Provider<CommentJson> commentJson;
-  protected final CommentsUtil commentsUtil;
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<CommentJson> commentJson;
+  private final CommentsUtil commentsUtil;
 
   @Inject
   ListChangeDrafts(
@@ -46,23 +46,15 @@
     this.commentsUtil = commentsUtil;
   }
 
-  protected Iterable<Comment> listComments(ChangeResource rsrc) {
+  private Iterable<Comment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     return commentsUtil.draftByChangeAuthor(cd.notes(), rsrc.getUser().getAccountId());
   }
 
-  protected boolean includeAuthorInfo() {
-    return false;
-  }
-
-  public boolean requireAuthentication() {
-    return true;
-  }
-
   @Override
   public Response<Map<String, List<CommentInfo>>> apply(ChangeResource rsrc)
       throws AuthException, PermissionBackendException {
-    if (requireAuthentication() && !rsrc.getUser().isIdentifiedUser()) {
+    if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
     return Response.ok(getCommentFormatter().format(listComments(rsrc)));
@@ -70,17 +62,13 @@
 
   public List<CommentInfo> getComments(ChangeResource rsrc)
       throws AuthException, PermissionBackendException {
-    if (requireAuthentication() && !rsrc.getUser().isIdentifiedUser()) {
+    if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
     return getCommentFormatter().formatAsList(listComments(rsrc));
   }
 
   private CommentFormatter getCommentFormatter() {
-    return commentJson
-        .get()
-        .setFillAccounts(includeAuthorInfo())
-        .setFillPatchSet(true)
-        .newCommentFormatter();
+    return commentJson.get().setFillAccounts(false).setFillPatchSet(true).newCommentFormatter();
   }
 }
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/ListChangeRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
index 719a477..0ed7d60 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 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.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -31,27 +35,35 @@
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   @Inject
   ListChangeRobotComments(
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
-      CommentsUtil commentsUtil) {
+      CommentsUtil commentsUtil,
+      ChangeMessagesUtil changeMessagesUtil) {
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   @Override
   public Response<Map<String, List<RobotCommentInfo>>> apply(ChangeResource rsrc)
       throws AuthException, PermissionBackendException {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return Response.ok(
+    Map<String, List<RobotCommentInfo>> robotCommentsMap =
         commentJson
             .get()
             .setFillAccounts(true)
             .setFillPatchSet(true)
             .newRobotCommentFormatter()
-            .format(commentsUtil.robotCommentsByChange(cd.notes())));
+            .format(commentsUtil.robotCommentsByChange(cd.notes()));
+    List<RobotCommentInfo> commentInfos =
+        robotCommentsMap.values().stream().flatMap(List::stream).collect(toList());
+    List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    return Response.ok(robotCommentsMap);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index bbbe12d..742eaca 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -14,14 +14,19 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.change.CommentJson.RobotCommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -32,34 +37,52 @@
 public class ListRobotComments implements RestReadView<RevisionResource> {
   protected final Provider<CommentJson> commentJson;
   protected final CommentsUtil commentsUtil;
+  protected final ChangeMessagesUtil changeMessagesUtil;
 
   @Inject
-  ListRobotComments(Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+  ListRobotComments(
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil,
+      ChangeMessagesUtil changeMessagesUtil) {
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   @Override
   public Response<Map<String, List<RobotCommentInfo>>> apply(RevisionResource rsrc)
       throws PermissionBackendException {
-    return Response.ok(
-        commentJson
-            .get()
-            .setFillAccounts(true)
-            .newRobotCommentFormatter()
-            .format(listComments(rsrc)));
+    return Response.ok(getAsMap(listComments(rsrc), rsrc));
   }
 
   public ImmutableList<RobotCommentInfo> getComments(RevisionResource rsrc)
       throws PermissionBackendException {
-    return commentJson
-        .get()
-        .setFillAccounts(true)
-        .newRobotCommentFormatter()
-        .formatAsList(listComments(rsrc));
+    return getAsList(listComments(rsrc), rsrc);
+  }
+
+  private ImmutableList<RobotCommentInfo> getAsList(
+      Iterable<RobotComment> comments, RevisionResource rsrc) throws PermissionBackendException {
+    ImmutableList<RobotCommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
+    List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    return commentInfos;
+  }
+
+  private Map<String, List<RobotCommentInfo>> getAsMap(
+      Iterable<RobotComment> comments, RevisionResource rsrc) throws PermissionBackendException {
+    Map<String, List<RobotCommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
+    List<RobotCommentInfo> commentInfos =
+        commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
+    List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    return commentInfosMap;
   }
 
   private Iterable<RobotComment> listComments(RevisionResource rsrc) {
     return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().id());
   }
+
+  private RobotCommentFormatter getCommentFormatter() {
+    return commentJson.get().setFillAccounts(true).newRobotCommentFormatter();
+  }
 }
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..b84b5e3 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 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;
@@ -106,7 +107,8 @@
     try (Repository git = gitManager.openRepository(change.getProject())) {
       ObjectId commit = ps.commitId();
       Ref ref = git.getRefDatabase().exactRef(change.getDest().branch());
-      ProjectState projectState = projectCache.get(change.getProject());
+      ProjectState projectState =
+          projectCache.get(change.getProject()).orElseThrow(illegalState(change.getProject()));
       String strategy = mergeUtilFactory.create(projectState).mergeStrategyName();
       result.strategy = strategy;
       result.mergeable = isMergable(git, change, commit, ref, result.submitType, strategy);
@@ -133,10 +135,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..387d0a8 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.change.AttentionSetEntryResource.ATTENTION_SET_ENTRY_KIND;
 import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_KIND;
 import static com.google.gerrit.server.change.ChangeMessageResource.CHANGE_MESSAGE_KIND;
 import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.DeleteChangeOp;
@@ -38,8 +40,10 @@
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
 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;
@@ -71,6 +75,7 @@
     DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
     DynamicMap.mapOf(binder(), VOTE_KIND);
     DynamicMap.mapOf(binder(), CHANGE_MESSAGE_KIND);
+    DynamicMap.mapOf(binder(), ATTENTION_SET_ENTRY_KIND);
 
     postOnCollection(CHANGE_KIND).to(CreateChange.class);
     get(CHANGE_KIND).to(GetChange.class);
@@ -78,6 +83,10 @@
     get(CHANGE_KIND, "detail").to(GetDetail.class);
     get(CHANGE_KIND, "topic").to(GetTopic.class);
     get(CHANGE_KIND, "in").to(ChangeIncludedIn.class);
+    child(CHANGE_KIND, "attention").to(AttentionSet.class);
+    delete(ATTENTION_SET_ENTRY_KIND).to(RemoveFromAttentionSet.class);
+    post(ATTENTION_SET_ENTRY_KIND, "delete").to(RemoveFromAttentionSet.class);
+    postOnCollection(ATTENTION_SET_ENTRY_KIND).to(AddToAttentionSet.class);
     get(CHANGE_KIND, "assignee").to(GetAssignee.class);
     get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
     put(CHANGE_KIND, "assignee").to(PutAssignee.class);
@@ -96,6 +105,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 +169,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,8 +210,12 @@
     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);
+    factory(SetTopicOp.Factory.class);
+    factory(AddToAttentionSetOp.Factory.class);
+    factory(RemoveFromAttentionSetOp.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..c109cbf 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.server.permissions.ChangePermission.ABANDON;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.base.Strings;
@@ -26,7 +27,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -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,16 +60,12 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.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.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -76,11 +73,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 +90,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 +112,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
@@ -151,7 +147,7 @@
     } catch (AuthException denied) {
       throw new AuthException("move not permitted", denied);
     }
-    projectCache.checkedGet(project).checkStatePermitsWrite();
+    projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
 
     Op op = new Op(input);
     try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.nowTs())) {
@@ -257,11 +253,10 @@
     private void updateApprovals(
         ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project)
         throws IOException {
-      List<PatchSetApproval> approvals = new ArrayList<>();
       for (PatchSetApproval psa :
           approvalsUtil.byPatchSet(
               ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-        ProjectState projectState = projectCache.checkedGet(project);
+        ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
         LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
@@ -272,12 +267,6 @@
 
         // Remove votes from NoteDb.
         update.removeApprovalFor(psa.accountId(), psa.label());
-        approvals.add(
-            PatchSetApproval.builder()
-                .key(PatchSetApproval.key(psId, psa.accountId(), LabelId.create(psa.label())))
-                .value(0)
-                .granted(ctx.getWhen())
-                .build());
       }
     }
   }
@@ -296,10 +285,13 @@
     }
 
     try {
-      if (!projectCache.checkedGet(rsrc.getProject()).statePermitsWrite()) {
+      if (!projectCache
+          .get(rsrc.getProject())
+          .orElseThrow(illegalState(rsrc.getProject()))
+          .statePermitsWrite()) {
         return description;
       }
-    } catch (IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if project state permits write: %s", rsrc.getProject());
       return description;
@@ -309,7 +301,7 @@
       if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (StorageException | IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
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 e0d0a04..7008bb9 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.groupingBy;
@@ -60,12 +61,10 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -73,12 +72,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;
@@ -101,6 +101,8 @@
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffSummary;
@@ -121,16 +123,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;
@@ -142,7 +140,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;
@@ -151,8 +148,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";
@@ -161,9 +157,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;
@@ -178,7 +172,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;
@@ -187,7 +180,7 @@
 
   @Inject
   PostReview(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
@@ -207,7 +200,7 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       PluginSetContext<CommentValidator> commentValidators) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.commentsUtil = commentsUtil;
@@ -222,7 +215,6 @@
     this.reviewerAdder = reviewerAdder;
     this.addReviewersEmail = addReviewersEmail;
     this.notifyResolver = notifyResolver;
-    this.gerritConfig = gerritConfig;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
@@ -231,15 +223,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.
@@ -247,9 +237,12 @@
     if (revision.getEdit().isPresent()) {
       throw new ResourceConflictException("cannot post review on edit");
     }
-    ProjectState projectState = projectCache.checkedGet(revision.getProject());
+    ProjectState projectState =
+        projectCache.get(revision.getProject()).orElseThrow(illegalState(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);
 
@@ -271,6 +264,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();
@@ -283,13 +277,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);
       }
     }
@@ -308,6 +307,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) {
@@ -315,29 +317,21 @@
         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)) {
-              ccOrReviewer = true;
-              break;
-            }
-          }
-        }
-        if (!ccOrReviewer && reviewerResult.result.ccs != null) {
-          for (AccountInfo accountInfo : reviewerResult.result.ccs) {
-            if (Objects.equals(id.get(), accountInfo._accountId)) {
-              ccOrReviewer = true;
-              break;
-            }
-          }
+        if (!ccOrReviewer && reviewerResult.reviewers.contains(id)) {
+          logger.atFine().log("calling user is explicitly added as reviewer or CC");
+          ccOrReviewer = true;
         }
       }
 
@@ -345,6 +339,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);
@@ -366,6 +361,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();
@@ -373,12 +369,12 @@
       }
 
       // Add the review op.
+      logger.atFine().log("posting review");
       bu.addOp(
           revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
 
       // Notify based on ReviewInput, ignoring the notify settings from any AddReviewerInputs.
-      NotifyResolver.Result notify =
-          notifyResolver.resolve(getNotifyHandling(input, output, revision), input.notifyDetails);
+      NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
       bu.setNotify(notify);
 
       bu.execute();
@@ -396,17 +392,6 @@
     return Response.ok(output);
   }
 
-  private NotifyHandling getNotifyHandling(
-      ReviewInput input, ReviewResult output, RevisionResource revision) {
-    if (input.notify != null) {
-      return input.notify;
-    }
-    if ((output.ready != null && output.ready) || !revision.getChange().isWorkInProgress()) {
-      return NotifyHandling.ALL;
-    }
-    return NotifyHandling.NONE;
-  }
-
   private NotifyHandling defaultNotify(Change c, ReviewInput in) {
     boolean workInProgress = c.isWorkInProgress();
     if (in.workInProgress) {
@@ -436,31 +421,35 @@
       Change change,
       List<ReviewerAddition> reviewerAdditions,
       NotifyResolver.Result notify) {
-    List<Account.Id> to = new ArrayList<>();
-    List<Account.Id> cc = new ArrayList<>();
-    List<Address> toByEmail = new ArrayList<>();
-    List<Address> ccByEmail = new ArrayList<>();
-    for (ReviewerAddition addition : reviewerAdditions) {
-      Result reviewAdditionResult = addition.op.getResult();
-      if (addition.state() == ReviewerState.REVIEWER
-          && (!reviewAdditionResult.addedReviewers().isEmpty()
-              || !reviewAdditionResult.addedReviewersByEmail().isEmpty())) {
-        to.addAll(addition.reviewers);
-        toByEmail.addAll(addition.reviewersByEmail);
-      } else if (addition.state() == ReviewerState.CC
-          && (!reviewAdditionResult.addedCCs().isEmpty()
-              || !reviewAdditionResult.addedCCsByEmail().isEmpty())) {
-        cc.addAll(addition.reviewers);
-        ccByEmail.addAll(addition.reviewersByEmail);
+    try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
+      List<Account.Id> to = new ArrayList<>();
+      List<Account.Id> cc = new ArrayList<>();
+      List<Address> toByEmail = new ArrayList<>();
+      List<Address> ccByEmail = new ArrayList<>();
+      for (ReviewerAddition addition : reviewerAdditions) {
+        Result reviewAdditionResult = addition.op.getResult();
+        if (addition.state() == ReviewerState.REVIEWER
+            && (!reviewAdditionResult.addedReviewers().isEmpty()
+                || !reviewAdditionResult.addedReviewersByEmail().isEmpty())) {
+          to.addAll(addition.reviewers);
+          toByEmail.addAll(addition.reviewersByEmail);
+        } else if (addition.state() == ReviewerState.CC
+            && (!reviewAdditionResult.addedCCs().isEmpty()
+                || !reviewAdditionResult.addedCCsByEmail().isEmpty())) {
+          cc.addAll(addition.reviewers);
+          ccByEmail.addAll(addition.reviewersByEmail);
+        }
       }
+      addReviewersEmail.emailReviewersAsync(
+          user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
     }
-    addReviewersEmail.emailReviewers(
-        user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
   }
 
   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));
@@ -469,6 +458,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();
@@ -476,15 +467,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) {
@@ -497,11 +495,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) {
@@ -515,16 +515,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;
       }
@@ -536,10 +540,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;
       }
@@ -580,9 +587,14 @@
         .collect(toList());
   }
 
+  private TraceContext.TraceTimer newTimer(String method) {
+    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
+  }
+
   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();
@@ -635,44 +647,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) {
@@ -876,10 +863,21 @@
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getNotes(), psId);
-      boolean dirty = insertComments(ctx);
-      dirty |= insertRobotComments(ctx);
-      dirty |= updateLabels(projectState, ctx);
-      dirty |= insertMessage(ctx);
+      List<RobotComment> newRobotComments =
+          in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+      boolean dirty = false;
+      try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
+        dirty |= insertComments(ctx, newRobotComments);
+      }
+      try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
+        dirty |= insertRobotComments(ctx, newRobotComments);
+      }
+      try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
+        dirty |= updateLabels(projectState, ctx);
+      }
+      try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
+        dirty |= insertMessage(ctx);
+      }
       return dirty;
     }
 
@@ -904,7 +902,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;
@@ -967,16 +965,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);
@@ -985,30 +989,45 @@
       return !toPublish.isEmpty();
     }
 
-    private void validateComments(Stream<Comment> comments) throws CommentsRejectedException {
+    /**
+     * Validates all comments and the change message in a single call to fulfill the interface
+     * contract of {@link CommentValidator#validateComments(CommentValidationContext,
+     * ImmutableList)}.
+     */
+    private void validateComments(CommentValidationContext ctx, Stream<Comment> comments)
+        throws CommentsRejectedException {
+      String changeMessage = Strings.nullToEmpty(in.message).trim();
       ImmutableList<CommentForValidation> draftsForValidation =
-          comments
-              .map(
-                  comment ->
+          Stream.concat(
+                  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.getApproximateSize())),
+                  Stream.of(
                       CommentForValidation.create(
-                          comment.lineNbr > 0
-                              ? CommentForValidation.CommentType.INLINE_COMMENT
-                              : CommentForValidation.CommentType.FILE_COMMENT,
-                          comment.message))
+                          CommentForValidation.CommentSource.HUMAN,
+                          CommentForValidation.CommentType.CHANGE_MESSAGE,
+                          changeMessage,
+                          changeMessage.length())))
               .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();
@@ -1370,7 +1389,7 @@
       return current;
     }
 
-    private boolean insertMessage(ChangeContext ctx) throws CommentsRejectedException {
+    private boolean insertMessage(ChangeContext ctx) {
       String msg = Strings.nullToEmpty(in.message).trim();
 
       StringBuilder buf = new StringBuilder();
@@ -1383,15 +1402,8 @@
         buf.append(String.format("\n\n(%d comments)", comments.size()));
       }
       if (!msg.isEmpty()) {
-        ImmutableList<CommentValidationFailure> messageValidationFailure =
-            PublishCommentUtil.findInvalidComments(
-                commentValidators,
-                ImmutableList.of(
-                    CommentForValidation.create(
-                        CommentForValidation.CommentType.CHANGE_MESSAGE, msg)));
-        if (!messageValidationFailure.isEmpty()) {
-          throw new CommentsRejectedException(messageValidationFailure);
-        }
+        // Message was already validated when validating comments, since validators need to see
+        // everything in a single call.
         buf.append("\n\n").append(msg);
       } else if (in.ready) {
         buf.append("\n\n" + START_REVIEW_MESSAGE);
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/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
index e6a60d5..ed6c0a5 100644
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.ArchiveFormatInternal;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream;
@@ -87,12 +87,12 @@
     if (Strings.isNullOrEmpty(format)) {
       throw new BadRequestException("format is not specified");
     }
-    ArchiveFormat f = allowedFormats.extensions.get("." + format);
+    ArchiveFormatInternal f = allowedFormats.extensions.get("." + format);
     if (f == null && format.equals("tgz")) {
       // Always allow tgz, even when the allowedFormats doesn't contain it.
       // Then we allow at least one format even if the list of allowed
       // formats is empty.
-      f = ArchiveFormat.TGZ;
+      f = ArchiveFormatInternal.TGZ;
     }
     if (f == null) {
       throw new BadRequestException("unknown archive format");
@@ -109,7 +109,7 @@
     return Response.ok(getBundles(rsrc, f));
   }
 
-  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormat f)
+  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormatInternal f)
       throws RestApiException, UpdateException, IOException, ConfigInvalidException,
           PermissionBackendException {
     IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
@@ -138,10 +138,11 @@
   private static class SubmitPreviewResult extends BinaryResult {
 
     private final MergeOp mergeOp;
-    private final ArchiveFormat archiveFormat;
+    private final ArchiveFormatInternal archiveFormat;
     private final int maxBundleSize;
 
-    private SubmitPreviewResult(MergeOp mergeOp, ArchiveFormat archiveFormat, int maxBundleSize) {
+    private SubmitPreviewResult(
+        MergeOp mergeOp, ArchiveFormatInternal archiveFormat, int maxBundleSize) {
       this.mergeOp = mergeOp;
       this.archiveFormat = archiveFormat;
       this.maxBundleSize = maxBundleSize;
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 cb73747..1ed7fd7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -23,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.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -37,8 +40,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;
@@ -58,8 +59,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;
@@ -71,7 +73,7 @@
 
   @Inject
   PutMessage(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       GitRepositoryManager repositoryManager,
       Provider<CurrentUser> userProvider,
       PatchSetInserter.Factory psInserterFactory,
@@ -80,7 +82,7 @@
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
       ProjectCache projectCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
     this.psInserterFactory = psInserterFactory;
@@ -92,8 +94,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());
@@ -108,7 +109,10 @@
 
     ensureCanEditCommitMessage(resource.getNotes());
     ChangeUtil.ensureChangeIdIsCorrect(
-        projectCache.checkedGet(resource.getProject()).is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
+        projectCache
+            .get(resource.getProject())
+            .orElseThrow(illegalState(resource.getProject()))
+            .is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
         resource.getChange().getKey().get(),
         sanitizedCommitMessage);
 
@@ -171,7 +175,7 @@
   }
 
   private void ensureCanEditCommitMessage(ChangeNotes changeNotes)
-      throws AuthException, PermissionBackendException, IOException, ResourceConflictException {
+      throws AuthException, PermissionBackendException, ResourceConflictException {
     if (!userProvider.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
@@ -183,7 +187,10 @@
           .user(userProvider.get())
           .change(changeNotes)
           .check(ChangePermission.ADD_PATCH_SET);
-      projectCache.checkedGet(changeNotes.getProjectName()).checkStatePermitsWrite();
+      projectCache
+          .get(changeNotes.getProjectName())
+          .orElseThrow(illegalState(changeNotes.getProjectName()))
+          .checkStatePermitsWrite();
     } catch (AuthException denied) {
       throw new AuthException("modifying commit message not permitted", denied);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index cfeb884..325b80c 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -15,47 +15,36 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.api.changes.TopicInput;
 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;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.extensions.events.TopicEdited;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 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.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> {
-  private final ChangeMessagesUtil cmUtil;
-  private final TopicEdited topicEdited;
+public class PutTopic
+    implements RestModifyView<ChangeResource, TopicInput>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
+  private final SetTopicOp.Factory topicOpFactory;
 
   @Inject
-  PutTopic(ChangeMessagesUtil cmUtil, RetryHelper retryHelper, TopicEdited topicEdited) {
-    super(retryHelper);
-    this.cmUtil = cmUtil;
-    this.topicEdited = topicEdited;
+  PutTopic(BatchUpdate.Factory updateFactory, SetTopicOp.Factory topicOpFactory) {
+    this.updateFactory = updateFactory;
+    this.topicOpFactory = topicOpFactory;
   }
 
   @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);
 
@@ -71,58 +60,18 @@
       sanitizedInput.topic = sanitizedInput.topic.trim();
     }
 
-    Op op = new Op(sanitizedInput);
+    SetTopicOp op = topicOpFactory.create(sanitizedInput);
     try (BatchUpdate u =
         updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
-    return Strings.isNullOrEmpty(op.newTopicName) ? Response.none() : Response.ok(op.newTopicName);
-  }
 
-  private class Op implements BatchUpdateOp {
-    private final TopicInput input;
-
-    private Change change;
-    private String oldTopicName;
-    private String newTopicName;
-
-    Op(TopicInput input) {
-      this.input = input;
+    if (Strings.isNullOrEmpty(sanitizedInput.topic)) {
+      return Response.none();
     }
 
-    @Override
-    public boolean updateChange(ChangeContext ctx) {
-      change = ctx.getChange();
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-      newTopicName = Strings.nullToEmpty(input.topic);
-      oldTopicName = Strings.nullToEmpty(change.getTopic());
-      if (oldTopicName.equals(newTopicName)) {
-        return false;
-      }
-      String summary;
-      if (oldTopicName.isEmpty()) {
-        summary = "Topic set to " + newTopicName;
-      } else if (newTopicName.isEmpty()) {
-        summary = "Topic " + oldTopicName + " removed";
-      } else {
-        summary = String.format("Topic changed from %s to %s", oldTopicName, newTopicName);
-      }
-      change.setTopic(Strings.emptyToNull(newTopicName));
-      update.setTopic(change.getTopic());
-
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
-      cmUtil.addChangeMessage(update, cmsg);
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (change != null) {
-        topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
-      }
-    }
+    return Response.ok(sanitizedInput.topic);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index bf4d197..00138b5 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -41,6 +41,7 @@
 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;
 
@@ -49,10 +50,15 @@
 
   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 final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
   private EnumSet<ListChangesOption> options;
+  private Integer limit;
+  private Integer start;
+  private Boolean noLimit;
+  private Boolean skipVisibility;
 
   @Option(
       name = "--query",
@@ -67,7 +73,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")
@@ -86,12 +92,12 @@
       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;
   }
 
   @Option(name = "--skip-visibility", usage = "Skip visibility check, only for administrators")
@@ -99,25 +105,25 @@
     if (on) {
       CurrentUser user = userProvider.get();
       permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      imp.enforceVisibility(false);
     }
+    skipVisibility = 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,
+      Provider<ChangeQueryProcessor> queryProcessorProvider,
       Provider<CurrentUser> userProvider,
       PermissionBackend permissionBackend) {
     this.json = json;
     this.qb = qb;
-    this.imp = qp;
+    this.queryProcessorProvider = queryProcessorProvider;
     this.userProvider = userProvider;
     this.permissionBackend = permissionBackend;
 
@@ -151,9 +157,25 @@
   }
 
   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);
+    }
+    if (skipVisibility != null) {
+      queryProcessor.enforceVisibility(!skipVisibility);
+    }
+    dynamicBeans.forEach((p, b) -> queryProcessor.setDynamicBean(p, b));
+
     if (queries == null || queries.isEmpty()) {
       queries = Collections.singletonList("status:open");
     } else if (queries.size() > 10) {
@@ -163,9 +185,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..75ba4c1 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -47,8 +49,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 +63,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 +81,7 @@
 
   @Inject
   public Rebase(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
       RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
@@ -88,7 +89,7 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.repoManager = repoManager;
     this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
@@ -99,14 +100,16 @@
   }
 
   @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());
 
     rsrc.permissions().check(ChangePermission.REBASE);
-    projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
 
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
@@ -219,10 +222,13 @@
     }
 
     try {
-      if (!projectCache.checkedGet(rsrc.getProject()).statePermitsWrite()) {
+      if (!projectCache
+          .get(rsrc.getProject())
+          .orElseThrow(illegalState(rsrc.getProject()))
+          .statePermitsWrite()) {
         return description;
       }
-    } catch (IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if project state permits write: %s", rsrc.getProject());
       return description;
@@ -232,7 +238,7 @@
       if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (StorageException | IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
@@ -260,28 +266,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..1d550f1 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 =
@@ -137,7 +146,7 @@
 
   private Collection<PatchSetData> walkAncestors(
       ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
-      throws PermissionBackendException, IOException {
+      throws PermissionBackendException {
     LinkedHashSet<PatchSetData> result = new LinkedHashSet<>();
     Deque<PatchSetData> pending = new ArrayDeque<>();
     pending.add(start);
@@ -157,7 +166,7 @@
       PatchSetData start,
       List<PatchSetData> otherPatchSetsOfStart,
       Iterable<PatchSetData> ancestors)
-      throws PermissionBackendException, IOException {
+      throws PermissionBackendException {
     Set<Change.Id> alreadyEmittedChanges = new HashSet<>();
     addAllChangeIds(alreadyEmittedChanges, ancestors);
 
@@ -184,7 +193,7 @@
       Set<Change.Id> alreadyEmittedChanges,
       ListMultimap<PatchSetData, PatchSetData> children,
       List<PatchSetData> start)
-      throws PermissionBackendException, IOException {
+      throws PermissionBackendException {
     if (start.isEmpty()) {
       return ImmutableList.of();
     }
@@ -226,15 +235,14 @@
     return result;
   }
 
-  private boolean isVisible(PatchSetData psd) throws PermissionBackendException, IOException {
+  private boolean isVisible(PatchSetData psd) throws PermissionBackendException {
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
     try {
       perm.change(psd.data()).check(ChangePermission.READ);
     } catch (AuthException e) {
       return false;
     }
-    ProjectState state = projectCache.checkedGet(psd.data().project());
-    return state != null && state.statePermitsRead();
+    return projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
new file mode 100644
index 0000000..ccf375a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -0,0 +1,70 @@
+// 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.restapi.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+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.server.change.AttentionSetEntryResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Removes a single user from the attention set. */
+public class RemoveFromAttentionSet
+    implements RestModifyView<AttentionSetEntryResource, RemoveFromAttentionSetInput> {
+  private final BatchUpdate.Factory updateFactory;
+  private final RemoveFromAttentionSetOp.Factory opFactory;
+
+  @Inject
+  RemoveFromAttentionSet(
+      BatchUpdate.Factory updateFactory, RemoveFromAttentionSetOp.Factory opFactory) {
+    this.updateFactory = updateFactory;
+    this.opFactory = opFactory;
+  }
+
+  @Override
+  public Response<Object> apply(
+      AttentionSetEntryResource attentionResource, RemoveFromAttentionSetInput input)
+      throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
+          UpdateException {
+    if (input == null) {
+      throw new BadRequestException("input may not be null");
+    }
+    input.reason = Strings.nullToEmpty(input.reason).trim();
+    if (input.reason.isEmpty()) {
+      throw new BadRequestException("missing field: reason");
+    }
+    ChangeResource changeResource = attentionResource.getChangeResource();
+    try (BatchUpdate bu =
+        updateFactory.create(
+            changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+      RemoveFromAttentionSetOp op =
+          opFactory.create(attentionResource.getAccountId(), input.reason);
+      bu.addOp(changeResource.getId(), op);
+      bu.execute();
+    }
+    return Response.none();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 679d4f8..a72192e 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
@@ -26,6 +28,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;
@@ -39,12 +42,11 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 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;
@@ -52,10 +54,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 +68,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,14 +85,16 @@
   }
 
   @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());
 
     rsrc.permissions().check(ChangePermission.RESTORE);
-    projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
 
     Op op = new Op(input);
     try (BatchUpdate u =
@@ -167,10 +172,10 @@
     }
 
     try {
-      if (!projectCache.checkedGet(rsrc.getProject()).statePermitsWrite()) {
+      if (!projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsRead).orElse(false)) {
         return description;
       }
-    } catch (IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if project state permits write: %s", rsrc.getProject());
       return description;
@@ -180,7 +185,7 @@
       if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
         return description;
       }
-    } catch (StorageException | IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if the current patch set of change %s is locked", change.getId());
       return description;
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index e196abc..fd4a13e 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -14,128 +14,74 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.permissions.ChangePermission.REVERT;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 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.exceptions.StorageException;
 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.project.ProjectState;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.CommitMessageUtil;
 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,71 +91,24 @@
 
     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 {
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
+    rsrc.permissions().check(REVERT);
+    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 = CommitMessageUtil.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
@@ -217,8 +116,9 @@
     Change change = rsrc.getChange();
     boolean projectStatePermitsWrite = false;
     try {
-      projectStatePermitsWrite = projectCache.checkedGet(rsrc.getProject()).statePermitsWrite();
-    } catch (IOException e) {
+      projectStatePermitsWrite =
+          projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log(
           "Failed to check if project state permits write: %s", rsrc.getProject());
     }
@@ -227,55 +127,12 @@
         .setTitle("Revert the change")
         .setVisible(
             and(
-                change.isMerged() && projectStatePermitsWrite,
-                permissionBackend
-                    .user(rsrc.getUser())
-                    .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;
-    }
+                and(
+                    change.isMerged() && projectStatePermitsWrite,
+                    permissionBackend
+                        .user(rsrc.getUser())
+                        .ref(change.getDest())
+                        .testCond(CREATE_CHANGE)),
+                permissionBackend.user(rsrc.getUser()).change(rsrc.getNotes()).testCond(REVERT)));
   }
 }
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..88db66e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -0,0 +1,637 @@
+// 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.ChangePermission.REVERT;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Iterables;
+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.RevisionResource;
+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.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CherryPickChange.Result;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.CommitMessageUtil;
+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.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+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 final ChangeResource.Factory changeResourceFactory;
+  private final GetRelated getRelated;
+
+  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,
+      ChangeResource.Factory changeResourceFactory,
+      GetRelated getRelated) {
+    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;
+    this.changeResourceFactory = changeResourceFactory;
+    this.getRelated = getRelated;
+    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) {
+      topic = Strings.emptyToNull(topic.trim());
+    }
+    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(REVERT);
+      permissionBackend.currentUser().change(changeData).check(ChangePermission.READ);
+      projectCache
+          .get(change.getProject())
+          .orElseThrow(illegalState(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, PermissionBackendException {
+
+    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,
+          PermissionBackendException {
+
+    String initialMessage = revertInput.message;
+    while (sortedChangesInProjectAndBranch.hasNext()) {
+      ChangeNotes changeNotes = sortedChangesInProjectAndBranch.next().data().notes();
+      if (cherryPickInput.base == null) {
+        // If no base was provided, the first change will be used to find a common base.
+        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);
+      } else {
+        createCherryPickedRevert(revertInput, project, changeNotes, timestamp);
+      }
+    }
+  }
+
+  private void createCherryPickedRevert(
+      RevertInput revertInput,
+      Project.NameKey project,
+      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 = CommitMessageUtil.generateChangeId();
+    Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
+    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, generatedChangeId, cherryPickRevertChangeId, 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;
+    cherryPickInput.topic = revertInput.topic;
+    cherryPickInput.allowEmpty = 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.
+   *
+   * <p>If there is only one change, we will base the revert on that change. If all changes are
+   * related, we will base on the first commit of this submission in the topological order.
+   *
+   * <p>If none of those special cases applies, the only case left is the case where we have at
+   * least 2 independent changes in the same project + branch (and possibly other dependent
+   * changes). In this case, 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, it means that this merge commit was created when this submission was
+   * submitted. It also means that this merge commit is a descendant of all of the changes in this
+   * submission and project + branch. Therefore, we return this merge commit.
+   *
+   * <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).
+   *
+   * <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, PermissionBackendException {
+    // If there is only one change in that project and branch, just base the revert on that one
+    // change.
+    if (commitIds.size() == 1) {
+      return Iterables.getOnlyElement(commitIds);
+    }
+    // If all changes are related, just return the first commit of this submission in the
+    // topological sorting.
+    if (getRelated.getRelated(getRevisionResource(changeNotes)).stream()
+        .map(changes -> ObjectId.fromString(changes.commit.commit))
+        .collect(Collectors.toSet())
+        .containsAll(commitIds)) {
+      return changeNotes.getCurrentPatchSet().commitId();
+    }
+    // There are independent changes in this submission and repository + branch.
+    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();
+      revWalk.markStart(revWalk.parseCommit(startCommit));
+      markChangesParentsUninteresting(commitIds, revWalk);
+      Iterator<RevCommit> revWalkIterator = revWalk.iterator();
+      while (revWalkIterator.hasNext()) {
+        RevCommit revCommit = revWalkIterator.next();
+        if (commitIds.contains(revCommit.getId())) {
+          return changeNotes.getCurrentPatchSet().commitId();
+        }
+        if (Arrays.stream(revCommit.getParents())
+            .anyMatch(parent -> commitIds.contains(parent.getId()))) {
+          // Found a merge commit that at least one parent is in this submission. we should only
+          // reach here if both conditions apply:
+          // 1. There is more than one change in that project + branch in this submission.
+          // 2. Not all changes in that project + branch are related in this submission.
+          // Therefore, there are at least 2 unrelated changes in this project + branch that got
+          // submitted together,
+          // and since we found a merge commit with one of those as parents, this merge commit is
+          // the first common descendant of all those changes.
+          return revCommit.getId();
+        }
+      }
+      // 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()));
+    }
+  }
+
+  private RevisionResource getRevisionResource(ChangeNotes changeNotes) {
+    return new RevisionResource(
+        changeResourceFactory.create(changeNotes, user.get()), psUtil.current(changeNotes));
+  }
+
+  // The parents are not interesting since there is no reason to base the reverts on any of the
+  // parents or their ancestors.
+  private void markChangesParentsUninteresting(Set<ObjectId> commitIds, RevWalk revWalk)
+      throws IOException {
+    for (ObjectId id : commitIds) {
+      RevCommit revCommit = revWalk.parseCommit(id);
+      for (int i = 0; i < revCommit.getParentCount(); i++) {
+        revWalk.markUninteresting(revCommit.getParent(i));
+      }
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    boolean projectStatePermitsWrite = false;
+    try {
+      projectStatePermitsWrite =
+          projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to check if project state permits write: %s", rsrc.getProject());
+    }
+    return new UiAction.Description()
+        .setLabel("Revert submission")
+        .setTitle(
+            "Revert this change and all changes that have been submitted together with this change")
+        .setVisible(
+            and(
+                and(
+                    change.isMerged()
+                        && change.getSubmissionId() != null
+                        && isChangePartOfSubmission(change.getSubmissionId())
+                        && projectStatePermitsWrite,
+                    permissionBackend
+                        .user(rsrc.getUser())
+                        .ref(change.getDest())
+                        .testCond(CREATE_CHANGE)),
+                permissionBackend.user(rsrc.getUser()).change(rsrc.getNotes()).testCond(REVERT)));
+  }
+
+  /**
+   * @param submissionId the submission id of the change.
+   * @return True if the submission has more than one change, false otherwise.
+   */
+  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 ObjectId computedChangeId;
+    private final Change.Id cherryPickRevertChangeId;
+    private final Timestamp timestamp;
+
+    CreateCherryPickOp(
+        ObjectId revCommitId,
+        ObjectId computedChangeId,
+        Change.Id cherryPickRevertChangeId,
+        Timestamp timestamp) {
+      this.revCommitId = revCommitId;
+      this.computedChangeId = computedChangeId;
+      this.cherryPickRevertChangeId = cherryPickRevertChangeId;
+      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)),
+              timestamp,
+              change.getId(),
+              computedChangeId,
+              cherryPickRevertChangeId);
+      // 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()));
+      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/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 7ca989c..69b82ba 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -34,6 +34,7 @@
 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.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -105,13 +106,16 @@
     }
   }
 
-  private boolean visible(ChangeResource change) throws PermissionBackendException, IOException {
+  private boolean visible(ChangeResource change) throws PermissionBackendException {
     try {
       permissionBackend
           .user(change.getUser())
           .change(change.getNotes())
           .check(ChangePermission.READ);
-      return projectCache.checkedGet(change.getProject()).statePermitsRead();
+      return projectCache
+          .get(change.getProject())
+          .map(ProjectState::statePermitsRead)
+          .orElse(false);
     } catch (AuthException e) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 288806c..c118766 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,15 +68,15 @@
       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();
     }
   }
 
   @Override
   public Description getDescription(ChangeResource rsrc) {
     return new Description()
-        .setLabel("Start Review")
-        .setTitle("Set Ready For Review")
+        .setLabel("Mark as Active")
+        .setTitle("Switch change state from WIP to Active (ready for review)")
         .setVisible(
             and(
                 rsrc.getChange().isNew() && rsrc.getChange().isWorkInProgress(),
diff --git a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java b/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
new file mode 100644
index 0000000..9eff5c1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 com.google.common.base.Strings;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.extensions.api.changes.TopicInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.extensions.events.TopicEdited;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SetTopicOp implements BatchUpdateOp {
+  public interface Factory {
+    SetTopicOp create(TopicInput input);
+  }
+
+  private final TopicInput input;
+  private final TopicEdited topicEdited;
+  private final ChangeMessagesUtil cmUtil;
+
+  private Change change;
+  private String oldTopicName;
+  private String newTopicName;
+
+  @Inject
+  public SetTopicOp(
+      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Assisted TopicInput input) {
+    this.input = input;
+    this.topicEdited = topicEdited;
+    this.cmUtil = cmUtil;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws BadRequestException {
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    newTopicName = Strings.nullToEmpty(input.topic);
+    oldTopicName = Strings.nullToEmpty(change.getTopic());
+    if (oldTopicName.equals(newTopicName)) {
+      return false;
+    }
+
+    String summary;
+    if (oldTopicName.isEmpty()) {
+      summary = "Topic set to " + newTopicName;
+    } else if (newTopicName.isEmpty()) {
+      summary = "Topic " + oldTopicName + " removed";
+    } else {
+      summary = String.format("Topic changed from %s to %s", oldTopicName, newTopicName);
+    }
+    change.setTopic(Strings.emptyToNull(newTopicName));
+    try {
+      update.setTopic(change.getTopic());
+    } catch (ValidationException ex) {
+      throw new BadRequestException(ex.getMessage());
+    }
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
+    cmUtil.addChangeMessage(update, cmsg);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (change != null) {
+      topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
+    }
+  }
+}
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 2c7a83a..e77bfe7 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.joining;
 
 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;
@@ -55,6 +55,7 @@
 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.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.ChangeSet;
@@ -182,7 +183,10 @@
       rsrc.permissions().check(ChangePermission.SUBMIT);
       submitter = rsrc.getUser().asIdentifiedUser();
     }
-    projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
 
     return mergeChange(rsrc, submitter, input);
   }
@@ -190,7 +194,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 +212,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(updatedChange));
       }
 
-      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()));
     }
   }
 
@@ -299,10 +296,13 @@
     }
 
     try {
-      if (!projectCache.checkedGet(resource.getProject()).statePermitsWrite()) {
+      if (!projectCache
+          .get(resource.getProject())
+          .map(ProjectState::statePermitsWrite)
+          .orElse(false)) {
         return null; // submit not visible
       }
-    } catch (IOException e) {
+    } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Error checking if change is submittable");
       throw new StorageException("Could not determine problems for the change", e);
     }
@@ -471,12 +471,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/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index d247b5b..26c7297 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
@@ -96,7 +98,7 @@
             reviewerState,
             rsrc.getNotes(),
             this,
-            projectCache.checkedGet(rsrc.getProject()),
+            projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject())),
             getVisibility(rsrc),
             excludeGroups));
   }
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index bae2e52..e0398c7 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
 import com.google.gerrit.server.rules.PrologRule;
@@ -75,10 +74,9 @@
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
 
-    ProjectState projectState = projectCache.get(rsrc.getProject());
-    if (projectState == null) {
-      throw new BadRequestException("project not found");
-    }
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(() -> new BadRequestException("project not found " + rsrc.getProject()));
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitRecord record =
         prologRule.evaluate(
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/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 44c71b3..83b0262 100644
--- a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -20,32 +20,28 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.StoredPreferences;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.config.DefaultPreferencesCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
 
 @Singleton
 public class GetDiffPreferences implements RestReadView<ConfigResource> {
 
-  private final AllUsersName allUsersName;
-  private final GitRepositoryManager gitManager;
+  private final DefaultPreferencesCache defaultPreferenceCache;
 
   @Inject
-  GetDiffPreferences(GitRepositoryManager gitManager, AllUsersName allUsersName) {
-    this.allUsersName = allUsersName;
-    this.gitManager = gitManager;
+  GetDiffPreferences(DefaultPreferencesCache defaultPreferenceCache) {
+    this.defaultPreferenceCache = defaultPreferenceCache;
   }
 
   @Override
   public Response<DiffPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
-    try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Response.ok(StoredPreferences.readDefaultDiffPreferences(allUsersName, git));
-    }
+    return Response.ok(
+        StoredPreferences.parseDiffPreferences(
+            defaultPreferenceCache.get().asConfig(), null, null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
index a5ab967..95fc10e 100644
--- a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
@@ -20,31 +20,27 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.StoredPreferences;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.config.DefaultPreferencesCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
 
 @Singleton
 public class GetEditPreferences implements RestReadView<ConfigResource> {
-  private final AllUsersName allUsersName;
-  private final GitRepositoryManager gitManager;
+  private final DefaultPreferencesCache defaultPreferenceCache;
 
   @Inject
-  GetEditPreferences(GitRepositoryManager gitManager, AllUsersName allUsersName) {
-    this.allUsersName = allUsersName;
-    this.gitManager = gitManager;
+  GetEditPreferences(DefaultPreferencesCache defaultPreferenceCache) {
+    this.defaultPreferenceCache = defaultPreferenceCache;
   }
 
   @Override
   public Response<EditPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
-    try (Repository git = gitManager.openRepository(allUsersName)) {
-      return Response.ok(StoredPreferences.readDefaultEditPreferences(allUsersName, git));
-    }
+    return Response.ok(
+        StoredPreferences.parseEditPreferences(
+            defaultPreferenceCache.get().asConfig(), null, null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetPreferences.java b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
index 8da9134..8a28d55 100644
--- a/java/com/google/gerrit/server/restapi/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -18,31 +18,27 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.StoredPreferences;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.config.DefaultPreferencesCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
 
 @Singleton
 public class GetPreferences implements RestReadView<ConfigResource> {
-  private final GitRepositoryManager gitMgr;
-  private final AllUsersName allUsersName;
+  private final DefaultPreferencesCache defaultPreferenceCache;
 
   @Inject
-  public GetPreferences(GitRepositoryManager gitMgr, AllUsersName allUsersName) {
-    this.gitMgr = gitMgr;
-    this.allUsersName = allUsersName;
+  public GetPreferences(DefaultPreferencesCache defaultPreferenceCache) {
+    this.defaultPreferenceCache = defaultPreferenceCache;
   }
 
   @Override
   public Response<GeneralPreferencesInfo> apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
-    try (Repository git = gitMgr.openRepository(allUsersName)) {
-      return Response.ok(StoredPreferences.readDefaultGeneralPreferences(allUsersName, git));
-    }
+    return Response.ok(
+        StoredPreferences.parseGeneralPreferences(
+            defaultPreferenceCache.get().asConfig(), null, null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 2d504c7..c83bf42 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountsInfo;
 import com.google.gerrit.extensions.common.AuthInfo;
 import com.google.gerrit.extensions.common.ChangeConfigInfo;
@@ -42,7 +43,8 @@
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 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.ArchiveFormatInternal;
+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;
@@ -73,6 +75,7 @@
 public class GetServerInfo implements RestReadView<ConfigResource> {
   private final Config config;
   private final AccountVisibilityProvider accountVisibilityProvider;
+  private final AccountDefaultDisplayName accountDefaultDisplayName;
   private final AuthConfig authConfig;
   private final Realm realm;
   private final PluginMapContext<DownloadScheme> downloadSchemes;
@@ -95,6 +98,7 @@
   public GetServerInfo(
       @GerritServerConfig Config config,
       AccountVisibilityProvider accountVisibilityProvider,
+      AccountDefaultDisplayName accountDefaultDisplayName,
       AuthConfig authConfig,
       Realm realm,
       PluginMapContext<DownloadScheme> downloadSchemes,
@@ -114,6 +118,7 @@
       SitePaths sitePaths) {
     this.config = config;
     this.accountVisibilityProvider = accountVisibilityProvider;
+    this.accountDefaultDisplayName = accountDefaultDisplayName;
     this.authConfig = authConfig;
     this.realm = realm;
     this.downloadSchemes = downloadSchemes;
@@ -155,6 +160,7 @@
   private AccountsInfo getAccountsInfo() {
     AccountsInfo info = new AccountsInfo();
     info.visibility = accountVisibilityProvider.get();
+    info.defaultDisplayName = accountDefaultDisplayName;
     return info;
   }
 
@@ -227,10 +233,13 @@
     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();
+    info.enableAttentionSet =
+        toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", false));
+    info.enableAssignee = toBoolean(this.config.getBoolean("change", null, "enableAssignee", true));
     return info;
   }
 
@@ -245,7 +254,9 @@
           }
         });
     info.archives =
-        archiveFormats.getAllowed().stream().map(ArchiveFormat::getShortName).collect(toList());
+        archiveFormats.getAllowed().stream()
+            .map(ArchiveFormatInternal::getShortName)
+            .collect(toList());
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index 6a3ca42..eac9653 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -41,6 +41,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
 @Singleton
@@ -85,8 +86,8 @@
         Boolean visible = visibilityCache.get(task.projectName);
         if (visible == null) {
           Project.NameKey nameKey = Project.nameKey(task.projectName);
-          ProjectState state = projectCache.get(nameKey);
-          if (state == null || !state.statePermitsRead()) {
+          Optional<ProjectState> state = projectCache.get(nameKey);
+          if (!state.isPresent() || !state.get().statePermitsRead()) {
             visible = false;
           } else {
             try {
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/config/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
index 96654a9..75d2bdb 100644
--- a/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
@@ -42,16 +41,12 @@
 
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
-  private final AccountCache accountCache;
 
   @Inject
   SetDiffPreferences(
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName,
-      AccountCache accountCache) {
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory, AllUsersName allUsersName) {
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
-    this.accountCache = accountCache;
   }
 
   @Override
@@ -67,7 +62,6 @@
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       DiffPreferencesInfo updatedPrefs = StoredPreferences.updateDefaultDiffPreferences(md, input);
-      accountCache.evictAll();
       return Response.ok(updatedPrefs);
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
index 4bb420b..36f817c 100644
--- a/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetEditPreferences.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
@@ -42,16 +41,12 @@
 
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
-  private final AccountCache accountCache;
 
   @Inject
   SetEditPreferences(
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName,
-      AccountCache accountCache) {
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory, AllUsersName allUsersName) {
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
-    this.accountCache = accountCache;
   }
 
   @Override
@@ -67,7 +62,6 @@
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       EditPreferencesInfo updatedPrefs = StoredPreferences.updateDefaultEditPreferences(md, input);
-      accountCache.evictAll();
       return Response.ok(updatedPrefs);
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/config/SetPreferences.java b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
index c88c1119..2b99539 100644
--- a/java/com/google/gerrit/server/restapi/config/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigResource;
@@ -42,16 +41,11 @@
 
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
-  private final AccountCache accountCache;
 
   @Inject
-  SetPreferences(
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllUsersName allUsersName,
-      AccountCache accountCache) {
+  SetPreferences(Provider<MetaDataUpdate.User> metaDataUpdateFactory, AllUsersName allUsersName) {
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
-    this.accountCache = accountCache;
   }
 
   @Override
@@ -64,7 +58,6 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       GeneralPreferencesInfo updatedPrefs =
           StoredPreferences.updateDefaultGeneralPreferences(md, input);
-      accountCache.evictAll();
       return Response.ok(updatedPrefs);
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
index 837d071..409aa9c 100644
--- a/java/com/google/gerrit/server/restapi/config/TasksCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -37,6 +37,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.Optional;
 
 @Singleton
 public class TasksCollection implements ChildCollection<ConfigResource, TaskResource> {
@@ -87,12 +88,12 @@
     Task<?> task = workQueue.getTask(taskId);
     if (task instanceof ProjectTask) {
       Project.NameKey nameKey = ((ProjectTask<?>) task).getProjectNameKey();
-      ProjectState state = projectCache.get(nameKey);
-      if (state == null) {
+      Optional<ProjectState> state = projectCache.get(nameKey);
+      if (!state.isPresent()) {
         throw new ResourceNotFoundException(String.format("project %s not found", nameKey));
       }
 
-      state.checkStatePermitsRead();
+      state.get().checkStatePermitsRead();
 
       try {
         permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
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 7c5e7ed..e5a1478 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;
@@ -140,7 +140,7 @@
     args.setGroupName(name);
     args.uuid = Strings.isNullOrEmpty(input.uuid) ? null : AccountGroup.UUID.parse(input.uuid);
     if (args.uuid != null) {
-      if (!AccountGroup.isInternalGroup(args.uuid)) {
+      if (!args.uuid.isInternalGroup()) {
         throw new BadRequestException(String.format("invalid group UUID '%s'", args.uuid.get()));
       }
       if (groupCache.get(args.uuid).isPresent()) {
@@ -208,7 +208,7 @@
     AccountGroup.UUID uuid =
         MoreObjects.firstNonNull(
             createGroupArgs.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..fd7bc26 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();
     }
 
@@ -92,7 +87,7 @@
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
-    } else if (!(user.isIdentifiedUser())) {
+    } else if (!(user.isIdentifiedUser() || user.isInternalUser())) {
       throw new ResourceNotFoundException(id);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 1802ea6..bcb199f 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 4864fde..da6ff14 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 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.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -28,6 +29,7 @@
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Inject;
 import java.io.IOException;
+import org.eclipse.jgit.errors.NoMergeBaseException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Ref;
@@ -78,7 +80,8 @@
 
   @Override
   public Response<MergeableInfo> apply(BranchResource resource)
-      throws IOException, BadRequestException, ResourceNotFoundException {
+      throws IOException, BadRequestException, ResourceNotFoundException,
+          ResourceConflictException {
     if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
         || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
       throw new BadRequestException("Submit type: " + submitType + " is not supported");
@@ -123,6 +126,12 @@
       }
     } catch (InvalidMergeStrategyException e) {
       throw new BadRequestException(e.getMessage());
+    } catch (NoMergeBaseException e) {
+      // TODO(ekempin) Rather return MergeableInfo with mergeable = false. But then we need a new
+      // field in MergeableInfo to carry the message to the client and the frontend needs to be
+      // adapted to show the message to the user.
+      throw new ResourceConflictException(
+          String.format("Change cannot be merged: %s", e.getMessage()), e);
     }
     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/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index fe48301..a87bbd1 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.entities.Change;
@@ -105,7 +107,10 @@
         throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG, denied);
       }
     }
-    projectCache.checkedGet(rsrc.getNameKey()).checkStatePermitsWrite();
+    projectCache
+        .get(rsrc.getNameKey())
+        .orElseThrow(illegalState(rsrc.getNameKey()))
+        .checkStatePermitsWrite();
 
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
     List<AccessSection> removals = setAccess.getAccessSections(input.remove);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index fd6e024..b901057 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -28,6 +27,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;
@@ -45,7 +45,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -57,8 +56,6 @@
 @Singleton
 public class CreateBranch
     implements RestCollectionCreateView<ProjectResource, BranchResource, BranchInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final Provider<IdentifiedUser> identifiedUser;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
@@ -113,7 +110,7 @@
               + "\"");
     }
 
-    final BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
+    BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -121,83 +118,71 @@
 
       if (ref.startsWith(Constants.R_HEADS)) {
         // Ensure that what we start the branch from is a commit. If we
-        // were given a tag, deference to the commit instead.
+        // were given a tag, dereference to the commit instead.
         //
-        try {
-          object = rw.parseCommit(object);
-        } catch (IncorrectObjectTypeException notCommit) {
-          throw new BadRequestException("\"" + input.revision + "\" not a commit", notCommit);
-        }
+        object = rw.parseCommit(object);
       }
 
       createRefControl.checkCreateRef(identifiedUser, repo, name, object);
 
-      try {
-        final RefUpdate u = repo.updateRef(ref);
-        u.setExpectedOldObjectId(ObjectId.zeroId());
-        u.setNewObjectId(object.copy());
-        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
-        u.setRefLogMessage("created via REST from " + input.revision, false);
-        refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
-        final RefUpdate.Result result = u.update(rw);
-        switch (result) {
-          case FAST_FORWARD:
-          case NEW:
-          case NO_CHANGE:
-            referenceUpdated.fire(
-                name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
-            break;
-          case LOCK_FAILURE:
-            if (repo.getRefDatabase().exactRef(ref) != null) {
-              throw new ResourceConflictException("branch \"" + ref + "\" already exists");
+      RefUpdate u = repo.updateRef(ref);
+      u.setExpectedOldObjectId(ObjectId.zeroId());
+      u.setNewObjectId(object.copy());
+      u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
+      u.setRefLogMessage("created via REST from " + input.revision, false);
+      refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
+      RefUpdate.Result result = u.update(rw);
+      switch (result) {
+        case FAST_FORWARD:
+        case NEW:
+        case NO_CHANGE:
+          referenceUpdated.fire(
+              name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+          break;
+        case LOCK_FAILURE:
+          if (repo.getRefDatabase().exactRef(ref) != null) {
+            throw new ResourceConflictException("branch \"" + ref + "\" already exists");
+          }
+          String refPrefix = RefUtil.getRefPrefix(ref);
+          while (!Constants.R_HEADS.equals(refPrefix)) {
+            if (repo.getRefDatabase().exactRef(refPrefix) != null) {
+              throw new ResourceConflictException(
+                  "Cannot create branch \""
+                      + ref
+                      + "\" since it conflicts with branch \""
+                      + refPrefix
+                      + "\".");
             }
-            String refPrefix = RefUtil.getRefPrefix(ref);
-            while (!Constants.R_HEADS.equals(refPrefix)) {
-              if (repo.getRefDatabase().exactRef(refPrefix) != null) {
-                throw new ResourceConflictException(
-                    "Cannot create branch \""
-                        + ref
-                        + "\" since it conflicts with branch \""
-                        + refPrefix
-                        + "\".");
-              }
-              refPrefix = RefUtil.getRefPrefix(refPrefix);
-            }
-            // fall through
-            // $FALL-THROUGH$
-          case FORCED:
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            {
-              throw new IOException(result.name());
-            }
-        }
-
-        BranchInfo info = new BranchInfo();
-        info.ref = ref;
-        info.revision = revid.getName();
-
-        if (isConfigRef(name.branch())) {
-          // Never allow to delete the meta config branch.
-          info.canDelete = null;
-        } else {
-          info.canDelete =
-              permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
-                      && rsrc.getProjectState().statePermitsWrite()
-                  ? true
-                  : null;
-        }
-        return Response.created(info);
-      } catch (IOException err) {
-        logger.atSevere().withCause(err).log("Cannot create branch \"%s\"", name);
-        throw err;
+            refPrefix = RefUtil.getRefPrefix(refPrefix);
+          }
+          throw new LockFailureException(String.format("Failed to create %s", ref), u);
+        case FORCED:
+        case IO_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new IOException(String.format("Failed to create %s: %s", ref, result.name()));
       }
+
+      BranchInfo info = new BranchInfo();
+      info.ref = ref;
+      info.revision = revid.getName();
+
+      if (isConfigRef(name.branch())) {
+        // Never allow to delete the meta config branch.
+        info.canDelete = null;
+      } else {
+        info.canDelete =
+            permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
+                    && rsrc.getProjectState().statePermitsWrite()
+                ? true
+                : null;
+      }
+      return Response.created(info);
     } catch (RefUtil.InvalidRevisionException e) {
       throw new BadRequestException("invalid revision \"" + input.revision + "\"", e);
     }
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..a85ad39
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -0,0 +1,210 @@
+// 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.copyValues != null) {
+      labelType.setCopyValues(input.copyValues);
+    }
+
+    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 537d45a..abd47e9 100644
--- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -42,6 +42,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/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index a9a9403..5a3dbcd 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.READ;
 import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.collect.ImmutableBiMap;
@@ -118,10 +119,8 @@
   }
 
   public ProjectAccessInfo apply(Project.NameKey nameKey) throws Exception {
-    ProjectState state = projectCache.checkedGet(nameKey);
-    if (state == null) {
-      throw new ResourceNotFoundException(nameKey.get());
-    }
+    ProjectState state =
+        projectCache.get(nameKey).orElseThrow(() -> new ResourceNotFoundException(nameKey.get()));
     return apply(new ProjectResource(state, user.get())).value();
   }
 
@@ -135,7 +134,8 @@
 
     Project.NameKey projectName = rsrc.getNameKey();
     ProjectAccessInfo info = new ProjectAccessInfo();
-    ProjectState projectState = projectCache.checkedGet(projectName);
+    ProjectState projectState =
+        projectCache.get(projectName).orElseThrow(illegalState(projectName));
     PermissionBackend.ForProject perm = permissionBackend.currentUser().project(projectName);
 
     ProjectConfig config;
@@ -154,12 +154,12 @@
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        projectState = projectCache.checkedGet(projectName);
+        projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
       } else if (config.getRevision() != null
           && !config.getRevision().equals(projectState.getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        projectState = projectCache.checkedGet(projectName);
+        projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
       }
     } catch (ConfigInvalidException e) {
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/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 6384282..c56e8c6 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -75,7 +75,6 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.SortedMap;
 import java.util.SortedSet;
@@ -610,7 +609,8 @@
   private Stream<ProjectState> filter(PermissionBackend.WithUser perm) throws BadRequestException {
     return StreamSupport.stream(scan().spliterator(), false)
         .map(projectCache::get)
-        .filter(Objects::nonNull)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
         .filter(p -> permissionCheck(p, perm));
   }
 
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/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 7d6f7ef..f92624f 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -43,6 +43,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 
 @Singleton
 public class ProjectsCollection
@@ -135,23 +136,23 @@
 
   @Nullable
   private ProjectResource _parse(String id, boolean checkAccess)
-      throws IOException, PermissionBackendException, ResourceConflictException {
+      throws PermissionBackendException, ResourceConflictException {
     id = ProjectUtil.sanitizeProjectName(id);
 
     Project.NameKey nameKey = Project.nameKey(id);
-    ProjectState state = projectCache.checkedGet(nameKey);
-    if (state == null) {
+    Optional<ProjectState> state = projectCache.get(nameKey);
+    if (!state.isPresent()) {
       return null;
     }
 
-    logger.atFine().log("Project %s has state %s", nameKey, state.getProject().getState());
+    logger.atFine().log("Project %s has state %s", nameKey, state.get().getProject().getState());
 
     if (checkAccess) {
       // Hidden projects(permitsRead = false) should only be accessible by the project owners.
       // WRITE_CONFIG is checked here because it's only allowed to project owners (ACCESS may also
       // be allowed for other users). Allowing project owners to access here will help them to view
       // and update the config of hidden projects easily.
-      if (state.statePermitsRead()) {
+      if (state.get().statePermitsRead()) {
         try {
           permissionBackend.currentUser().project(nameKey).check(ProjectPermission.ACCESS);
         } catch (AuthException e) {
@@ -161,11 +162,11 @@
         try {
           permissionBackend.currentUser().project(nameKey).check(ProjectPermission.WRITE_CONFIG);
         } catch (AuthException e) {
-          state.checkStatePermitsRead();
+          state.get().checkStatePermitsRead();
         }
       }
     }
-    return new ProjectResource(state, user.get());
+    return new ProjectResource(state.get(), user.get());
   }
 
   @Override
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/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index ea29fb3..5d5e779 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.Project;
@@ -34,9 +35,9 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.PluginPermissionsUtil;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
-import com.google.gerrit.server.restapi.config.ListCapabilities;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,18 +52,18 @@
   private final GroupResolver groupResolver;
   private final AllProjectsName allProjects;
   private final Provider<SetParent> setParent;
-  private final ListCapabilities listCapabilities;
+  private final PluginPermissionsUtil pluginPermissionsUtil;
 
   @Inject
   private SetAccessUtil(
       GroupResolver groupResolver,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
-      ListCapabilities listCapabilities) {
+      PluginPermissionsUtil pluginPermissionsUtil) {
     this.groupResolver = groupResolver;
     this.allProjects = allProjects;
     this.setParent = setParent;
-    this.listCapabilities = listCapabilities;
+    this.pluginPermissionsUtil = pluginPermissionsUtil;
   }
 
   List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
@@ -150,12 +151,18 @@
           throw new BadRequestException("invalid section name");
         }
         RefPattern.validate(name);
+
+        // Check all permissions for soundness
+        for (Permission p : section.getPermissions()) {
+          if (!isPermission(p.getName())) {
+            throw new BadRequestException("Unknown permission: " + p.getName());
+          }
+        }
       } else {
         // Check all permissions for soundness
         for (Permission p : section.getPermissions()) {
           if (!isCapability(p.getName())) {
-            throw new BadRequestException(
-                "Cannot add non-global capability " + p.getName() + " to global capabilities");
+            throw new BadRequestException("Unknown global capability: " + p.getName());
           }
         }
       }
@@ -240,11 +247,28 @@
     }
   }
 
+  private boolean isPermission(String name) {
+    if (Permission.isPermission(name)) {
+      if (Permission.isLabel(name) || Permission.isLabelAs(name)) {
+        String labelName = Permission.extractLabel(name);
+        try {
+          LabelType.checkName(labelName);
+        } catch (IllegalArgumentException e) {
+          return false;
+        }
+      }
+      return true;
+    }
+    Set<String> pluginPermissions =
+        pluginPermissionsUtil.collectPluginProjectPermissions().keySet();
+    return pluginPermissions.contains(name);
+  }
+
   private boolean isCapability(String name) {
     if (GlobalCapability.isGlobalCapability(name)) {
       return true;
     }
-    Set<String> pluginCapabilities = listCapabilities.collectPluginCapabilities().keySet();
+    Set<String> pluginCapabilities = pluginPermissionsUtil.collectPluginCapabilities().keySet();
     return pluginCapabilities.contains(name);
   }
 }
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..0a35865
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -0,0 +1,227 @@
+// 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.copyValues != null) {
+      labelType.setCopyValues(input.copyValues);
+      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/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index a610dd4..42790aa 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -156,10 +156,14 @@
 
     newParent = Strings.emptyToNull(newParent);
     if (newParent != null) {
-      ProjectState parent = cache.get(Project.nameKey(newParent));
-      if (parent == null) {
-        throw new UnprocessableEntityException("parent project " + newParent + " not found");
-      }
+      Project.NameKey newParentNameKey = Project.nameKey(newParent);
+      ProjectState parent =
+          cache
+              .get(newParentNameKey)
+              .orElseThrow(
+                  () ->
+                      new UnprocessableEntityException(
+                          "parent project " + newParentNameKey + " not found"));
 
       if (parent.getName().equals(project.get())) {
         throw new ResourceConflictException("cannot set parent to self");
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 32aec59..799d706 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.rules;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelFunction;
@@ -63,11 +64,12 @@
 
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
-    ProjectState projectState = projectCache.get(cd.project());
+    ProjectState projectState =
+        projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
 
     // In case at least one project has a rules.pl file, we let Prolog handle it.
     // The Prolog rules engine will also handle the labels for us.
-    if (projectState == null || projectState.hasPrologRules()) {
+    if (projectState.hasPrologRules()) {
       return Optional.empty();
     }
 
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/PrologRule.java b/java/com/google/gerrit/server/rules/PrologRule.java
index bf1d545..1861ee7 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/PrologRule.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.rules;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.server.project.ProjectCache;
@@ -36,9 +38,10 @@
 
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
-    ProjectState projectState = projectCache.get(cd.project());
+    ProjectState projectState =
+        projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
     // We only want to run the Prolog engine if we have at least one rules.pl file to use.
-    if ((projectState == null || !projectState.hasPrologRules())) {
+    if (!projectState.hasPrologRules()) {
       return Optional.empty();
     }
 
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 72dc46a..5f1268b 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.rules;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.createRuleError;
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultRuleError;
 import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultTypeError;
@@ -115,7 +116,7 @@
     this.cd = cd;
     this.opts = options;
 
-    this.projectState = projectCache.get(cd.project());
+    this.projectState = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
   }
 
   private static Term toListTerm(List<Term> terms) {
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/AclUtil.java b/java/com/google/gerrit/server/schema/AclUtil.java
index f0aafef..f6c3aad 100644
--- a/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/java/com/google/gerrit/server/schema/AclUtil.java
@@ -105,4 +105,15 @@
   public static PermissionRule rule(ProjectConfig config, GroupReference group) {
     return new PermissionRule(config.resolve(group));
   }
+
+  public static void remove(
+      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+    Permission p = section.getPermission(permission, true);
+    for (GroupReference group : groupList) {
+      if (group != null) {
+        PermissionRule r = rule(config, group);
+        p.remove(r);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index c15efba..cfa5825 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -174,10 +174,12 @@
       AccessSection heads, LabelType codeReviewLabel, ProjectConfig config) {
     AccessSection refsFor = config.getAccessSection("refs/for/*", true);
     AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
+    AccessSection all = config.getAccessSection("refs/*", true);
 
     grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
+    grant(config, all, Permission.REVERT, registered);
     grant(config, magic, Permission.PUSH, registered);
     grant(config, magic, Permission.PUSH_MERGE, registered);
   }
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index cee0862..0df7907 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -26,7 +26,6 @@
         "//lib/commons:dbcp",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/schema/GrantRevertPermission.java b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
new file mode 100644
index 0000000..2f890d5
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
@@ -0,0 +1,96 @@
+// 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.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+import static com.google.gerrit.server.schema.AclUtil.remove;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class adds the "revert" permission to all hosts that call this method with the relevant
+ * projectName. This class should be called with AllProjects as the project, by all hosts before
+ * enabling the "revert" permission.
+ */
+public class GrantRevertPermission {
+
+  private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SystemGroupBackend systemGroupBackend;
+  private final PersonIdent serverUser;
+
+  @Inject
+  public GrantRevertPermission(
+      GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
+    this.systemGroupBackend = systemGroupBackend;
+    this.serverUser = serverUser;
+  }
+
+  public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    try (Repository repo = repoManager.openRepository(projectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
+
+      Permission permissionOnRefsHeads = heads.getPermission(Permission.REVERT);
+
+      if (permissionOnRefsHeads != null) {
+        if (permissionOnRefsHeads.getRule(registeredUsers) == null
+            || permissionOnRefsHeads.getRules().size() > 1) {
+          // If admins already changed the permission, don't do anything.
+          return;
+        }
+        // permission already exists in refs/heads/*, delete it for Registered Users.
+        remove(projectConfig, heads, Permission.REVERT, registeredUsers);
+      }
+
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
+      Permission permissionOnRefsStar = all.getPermission(Permission.REVERT);
+      if (permissionOnRefsStar != null && permissionOnRefsStar.getRule(registeredUsers) != null) {
+        // permission already exists in refs/*, don't do anything.
+        return;
+      }
+      // If the permission doesn't exist of refs/* for Registered Users, grant it.
+      grant(projectConfig, all, Permission.REVERT, registeredUsers);
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
index b6a7a1c..c3c8f5e 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /**
  * Schema upgrade implementation.
@@ -32,13 +36,24 @@
     final GitRepositoryManager repoManager;
     final AllProjectsName allProjects;
     final AllUsersName allUsers;
+    final ProjectConfig.Factory projectConfigFactory;
+    final SystemGroupBackend systemGroupBackend;
+    final PersonIdent serverUser;
 
     @Inject
     Arguments(
-        GitRepositoryManager repoManager, AllProjectsName allProjects, AllUsersName allUsers) {
+        GitRepositoryManager repoManager,
+        AllProjectsName allProjects,
+        AllUsersName allUsers,
+        ProjectConfig.Factory projectConfigFactory,
+        SystemGroupBackend systemGroupBackend,
+        @GerritPersonIdent PersonIdent serverUser) {
       this.repoManager = repoManager;
       this.allProjects = allProjects;
       this.allUsers = allUsers;
+      this.projectConfigFactory = projectConfigFactory;
+      this.systemGroupBackend = systemGroupBackend;
+      this.serverUser = serverUser;
     }
   }
 
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
index 02250f2..97c9f3a 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -28,7 +28,7 @@
 public class NoteDbSchemaVersions {
   static final ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> ALL =
       // List all supported NoteDb schema versions here.
-      Stream.of(Schema_180.class, Schema_181.class)
+      Stream.of(Schema_180.class, Schema_181.class, Schema_182.class, Schema_183.class)
           .collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
 
   public static final int FIRST = ALL.firstKey();
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/schema/Schema_182.java b/java/com/google/gerrit/server/schema/Schema_182.java
new file mode 100644
index 0000000..a61a175
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_182.java
@@ -0,0 +1,35 @@
+// 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.schema;
+
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+
+/**
+ * Schema 182 for Gerrit metadata.
+ *
+ * <p>Upgrading to this schema version cleans the zombie draft comment refs in NoteDb
+ */
+public class Schema_182 implements NoteDbSchemaVersion {
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+    AllUsersName allUsers = args.allUsers;
+    GitRepositoryManager gitRepoManager = args.repoManager;
+    DeleteZombieCommentsRefs cleanup =
+        new DeleteZombieCommentsRefs(allUsers, gitRepoManager, 100, ui::message);
+    cleanup.execute();
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/Schema_183.java b/java/com/google/gerrit/server/schema/Schema_183.java
new file mode 100644
index 0000000..056c698
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_183.java
@@ -0,0 +1,31 @@
+// 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.schema;
+
+/**
+ * Schema 183 for Gerrit metadata.
+ *
+ * <p>Upgrading to this schema version adds a new permission to the Gerrit permission system. The
+ * new permission "Revert" will be default to all Registered Users.
+ */
+public class Schema_183 implements NoteDbSchemaVersion {
+
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+    new GrantRevertPermission(
+            args.repoManager, args.projectConfigFactory, args.systemGroupBackend, args.serverUser)
+        .execute(args.allProjects);
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index a6424b9..8b159bc 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -55,6 +55,7 @@
           "[access \"refs/*\"]",
           "  read = group Administrators",
           "  read = group Anonymous Users",
+          "  revert = group Registered Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
           "[access \"refs/for/refs/*\"]",
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index 8b7b2cd..b66006a 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -45,8 +45,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
@@ -90,7 +89,7 @@
 
     @Override
     protected void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException, MethodNotAllowedException {
+        throws IntegrationConflictException, IOException, MethodNotAllowedException {
       // If there is only one parent, a cherry-pick can be done by taking the
       // delta relative to that one parent and redoing that on the current merge
       // tip.
@@ -181,7 +180,7 @@
     }
 
     @Override
-    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+    public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
       if (args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)) {
         // One or more dependencies were not met. The status was already marked
         // on the commit so we have nothing further to perform at this time.
@@ -217,8 +216,7 @@
   }
 
   static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge) {
     return args.mergeUtil.canCherryPick(args.mergeSorter, args.repo, mergeTip, args.rw, toMerge);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/FastForwardOnly.java b/java/com/google/gerrit/server/submit/FastForwardOnly.java
index 5a471ac..176b063 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOnly.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOnly.java
@@ -26,17 +26,17 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
     if (!newTipCommit.equals(args.mergeTip.getInitialTip())) {
       ops.add(new FastForwardOp(args, newTipCommit));
-    }
-    while (!sorted.isEmpty()) {
-      ops.add(new NotFastForwardOp(sorted.remove(0)));
+    } else {
+      for (CodeReviewCommit c : toMerge) {
+        ops.add(new NotFastForwardOp(c));
+      }
     }
     return ops;
   }
@@ -53,8 +53,7 @@
   }
 
   static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge) {
     return args.mergeUtil.canFastForward(args.mergeSorter, mergeTip, args.rw, toMerge);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/FastForwardOp.java b/java/com/google/gerrit/server/submit/FastForwardOp.java
index c83e113..3fe4d8c 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOp.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOp.java
@@ -26,7 +26,7 @@
   }
 
   @Override
-  protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
+  protected void updateRepoImpl(RepoContext ctx) {
     if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
         && toMerge.getParentCount() > 0
         && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
diff --git a/java/com/google/gerrit/server/submit/IntegrationConflictException.java b/java/com/google/gerrit/server/submit/IntegrationConflictException.java
new file mode 100644
index 0000000..bfc02bb
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/IntegrationConflictException.java
@@ -0,0 +1,36 @@
+// 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.submit;
+
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+
+/**
+ * Exception to be thrown if integrating (aka merging) a change into the destination branch is not
+ * possible due to conflicts.
+ *
+ * <p>Throwing this exception results in a {@code 409 Conflict} response to the calling user. The
+ * exception message is returned as error message to the user.
+ */
+public class IntegrationConflictException extends ResourceConflictException {
+  private static final long serialVersionUID = 1L;
+
+  public IntegrationConflictException(String msg) {
+    super(msg);
+  }
+
+  public IntegrationConflictException(String msg, Throwable why) {
+    super(msg, why);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/IntegrationException.java b/java/com/google/gerrit/server/submit/IntegrationException.java
deleted file mode 100644
index 5028b76..0000000
--- a/java/com/google/gerrit/server/submit/IntegrationException.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.submit;
-
-/** Indicates an integration operation (see {@link MergeOp}) failed. */
-public class IntegrationException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public IntegrationException(String msg) {
-    super(msg);
-  }
-
-  public IntegrationException(Throwable why) {
-    super(why);
-  }
-
-  public IntegrationException(String msg, Throwable why) {
-    super(msg, why);
-  }
-}
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index ec6b35a..b8b8b55 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -180,12 +180,10 @@
   }
 
   private boolean isVisible(ChangeSet changeSet, ChangeData cd, CurrentUser user)
-      throws PermissionBackendException, IOException {
-    ProjectState projectState = projectCache.checkedGet(cd.project());
-    boolean visible =
-        changeSet.ids().contains(cd.getId())
-            && (projectState != null)
-            && projectState.statePermitsRead();
+      throws PermissionBackendException {
+    boolean statePermitsRead =
+        projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false);
+    boolean visible = statePermitsRead && changeSet.ids().contains(cd.getId());
     if (!visible) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/submit/MergeAlways.java b/java/com/google/gerrit/server/submit/MergeAlways.java
index 9aab854..c3f186a 100644
--- a/java/com/google/gerrit/server/submit/MergeAlways.java
+++ b/java/com/google/gerrit/server/submit/MergeAlways.java
@@ -25,8 +25,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
@@ -43,8 +42,7 @@
   }
 
   static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge) {
     return args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeIfNecessary.java b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
index d49e7fe..82499b3 100644
--- a/java/com/google/gerrit/server/submit/MergeIfNecessary.java
+++ b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
@@ -25,8 +25,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
 
@@ -48,8 +47,7 @@
   }
 
   static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge) {
     return args.mergeUtil.canFastForward(args.mergeSorter, mergeTip, args.rw, toMerge)
         || args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
   }
diff --git a/java/com/google/gerrit/server/submit/MergeOneOp.java b/java/com/google/gerrit/server/submit/MergeOneOp.java
index 4555a32..f1b93e1 100644
--- a/java/com/google/gerrit/server/submit/MergeOneOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOneOp.java
@@ -28,7 +28,7 @@
   }
 
   @Override
-  public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+  public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
     PersonIdent caller =
         ctx.getIdentifiedUser()
             .newCommitterIdent(args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index adb75a4..f96b0c5 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,21 +448,28 @@
     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)) {
+        TraceContext.open()
+            .addTag(RequestId.Type.SUBMISSION_ID, new RequestId(submissionId.toString()))) {
       openRepoManager();
 
       logger.atFine().log("Beginning integration of %s", change);
       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 +488,34 @@
         }
 
         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);
+                  }
+                  integrateIntoHistory(cs);
+                  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 +535,6 @@
     }
   }
 
-  public Optional<String> getTraceId() {
-    return Optional.ofNullable(traceId);
-  }
-
   private void openRepoManager() {
     if (orm != null) {
       orm.close();
@@ -591,8 +581,7 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs)
-      throws IntegrationException, RestApiException, UpdateException {
+  private void integrateIntoHistory(ChangeSet cs) throws RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logger.atFine().log("Beginning merge attempt on %s", cs);
     Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
@@ -601,7 +590,7 @@
     try {
       cbb = cs.changesByBranch();
     } catch (StorageException e) {
-      throw new IntegrationException("Error reading changes to submit", e);
+      throw new StorageException("Error reading changes to submit", e);
     }
     Set<BranchNameKey> branches = cbb.keySet();
 
@@ -632,8 +621,10 @@
       }
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
-    } catch (IOException | SubmoduleException e) {
-      throw new IntegrationException(e);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    } catch (SubmoduleConflictException e) {
+      throw new IntegrationConflictException(e.getMessage(), e);
     } catch (UpdateException e) {
       if (e.getCause() instanceof LockFailureException) {
         // Lock failures are a special case: RetryHelper depends on this specific causal chain in
@@ -644,20 +635,17 @@
         throw e;
       }
 
-      // BatchUpdate may have inadvertently wrapped an IntegrationException
+      // BatchUpdate may have inadvertently wrapped an IntegrationConflictException
       // thrown by some legacy SubmitStrategyOp code that intended the error
       // message to be user-visible. Copy the message from the wrapped
       // exception.
       //
       // If you happen across one of these, the correct fix is to convert the
-      // inner IntegrationException to a ResourceConflictException.
-      String msg;
-      if (e.getCause() instanceof IntegrationException) {
-        msg = e.getCause().getMessage();
-      } else {
-        msg = genericMergeError(cs);
+      // inner IntegrationConflictException to a ResourceConflictException.
+      if (e.getCause() instanceof IntegrationConflictException) {
+        throw (IntegrationConflictException) e.getCause();
       }
-      throw new IntegrationException(msg, e);
+      throw new StorageException(genericMergeError(cs), e);
     }
   }
 
@@ -671,7 +659,7 @@
 
   private List<SubmitStrategy> getSubmitStrategies(
       Map<BranchNameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
-      throws IntegrationException, NoSuchProjectException, IOException {
+      throws IntegrationConflictException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
     Set<BranchNameKey> allBranches = submoduleOp.getBranchesInOrder();
     Set<CodeReviewCommit> allCommits =
@@ -717,8 +705,7 @@
     return strategies;
   }
 
-  private Set<RevCommit> getAlreadyAccepted(OpenRepo or, CodeReviewCommit branchTip)
-      throws IntegrationException {
+  private Set<RevCommit> getAlreadyAccepted(OpenRepo or, CodeReviewCommit branchTip) {
     Set<RevCommit> alreadyAccepted = new HashSet<>();
 
     if (branchTip != null) {
@@ -737,7 +724,7 @@
         }
       }
     } catch (IOException e) {
-      throw new IntegrationException("Failed to determine already accepted commits.", e);
+      throw new StorageException("Failed to determine already accepted commits.", e);
     }
 
     logger.atFine().log("Found %d existing heads: %s", alreadyAccepted.size(), alreadyAccepted);
@@ -752,8 +739,7 @@
     abstract Set<CodeReviewCommit> commits();
   }
 
-  private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted)
-      throws IntegrationException {
+  private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted) {
     logger.atFine().log("Validating %d changes", submitted.size());
     Set<CodeReviewCommit> toSubmit = new LinkedHashSet<>(submitted.size());
     SetMultimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
@@ -868,8 +854,7 @@
     return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
   }
 
-  private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds)
-      throws IntegrationException {
+  private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds) {
     try {
       List<String> refNames = new ArrayList<>(cds.size());
       for (ChangeData cd : cds) {
@@ -889,7 +874,7 @@
       }
       return revisions;
     } catch (IOException | StorageException e) {
-      throw new IntegrationException("Failed to validate changes", e);
+      throw new StorageException("Failed to validate changes", e);
     }
   }
 
@@ -898,14 +883,14 @@
     return str.isOk() ? str.type : null;
   }
 
-  private OpenRepo openRepo(Project.NameKey project) throws IntegrationException {
+  private OpenRepo openRepo(Project.NameKey project) {
     try {
       return orm.getRepo(project);
     } catch (NoSuchProjectException e) {
       logger.atWarning().log("Project %s no longer exists, abandoning open changes.", project);
       abandonAllOpenChangeForDeletedProject(project);
     } catch (IOException e) {
-      throw new IntegrationException("Error opening project " + project, e);
+      throw new StorageException("Error opening project " + project, e);
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index c2577e7..b32c712 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.Maps;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -42,10 +44,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;
@@ -84,7 +85,7 @@
       branches = Maps.newHashMapWithExpectedSize(1);
     }
 
-    OpenBranch getBranch(BranchNameKey branch) throws IntegrationException {
+    OpenBranch getBranch(BranchNameKey branch) throws IntegrationConflictException {
       OpenBranch ob = branches.get(branch);
       if (ob == null) {
         ob = new OpenBranch(this, branch);
@@ -130,25 +131,23 @@
   }
 
   public static class OpenBranch {
-    final RefUpdate update;
     final CodeReviewCommit oldTip;
     MergeTip mergeTip;
 
-    OpenBranch(OpenRepo or, BranchNameKey name) throws IntegrationException {
+    OpenBranch(OpenRepo or, BranchNameKey name) throws IntegrationConflictException {
       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(
+          throw new IntegrationConflictException(
               "The destination branch " + name + " does not exist anymore.");
         }
       } catch (IOException e) {
-        throw new IntegrationException("Cannot open branch " + name, e);
+        throw new StorageException("Cannot open branch " + name, e);
       }
     }
   }
@@ -188,10 +187,7 @@
       return openRepos.get(project);
     }
 
-    ProjectState projectState = projectCache.get(project);
-    if (projectState == null) {
-      throw new NoSuchProjectException(project);
-    }
+    ProjectState projectState = projectCache.get(project).orElseThrow(noSuchProject(project));
     try {
       OpenRepo or = new OpenRepo(repoManager.openRepository(project), projectState);
       openRepos.put(project, or);
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index bcebc7f..93c78a8 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,19 +107,10 @@
         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());
-
-        if (projectState.statePermitsRead()) {
+        if (projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
           try {
             permissionBackend.user(user).change(cd).check(ChangePermission.READ);
             visible = true;
@@ -149,7 +149,7 @@
    */
   private ChangeSet topicClosure(
       ChangeSet changeSet, CurrentUser user, Set<String> topicsSeen, Set<String> visibleTopicsSeen)
-      throws PermissionBackendException, IOException {
+      throws PermissionBackendException {
     List<ChangeData> visibleChanges = new ArrayList<>();
     List<ChangeData> nonVisibleChanges = new ArrayList<>();
 
@@ -208,15 +208,28 @@
     return queryProvider.get().byTopicOpen(topic);
   }
 
-  private boolean canRead(CurrentUser user, ChangeData cd)
-      throws PermissionBackendException, IOException {
-    ProjectState projectState = projectCache.checkedGet(cd.project());
-    if (projectState == null || !projectState.statePermitsRead()) {
+  private boolean canRead(CurrentUser user, ChangeData cd) throws PermissionBackendException {
+    if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
       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/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 65e18ad..33c3584 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -54,13 +55,12 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted;
     try {
       sorted = args.rebaseSorter.sort(toMerge);
     } catch (IOException | StorageException e) {
-      throw new IntegrationException("Commit sorting failed", e);
+      throw new StorageException("Commit sorting failed", e);
     }
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
@@ -118,7 +118,7 @@
 
     @Override
     public void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
+        throws InvalidChangeOperationException, RestApiException, IOException,
             PermissionBackendException {
       if (args.mergeUtil.canFastForward(
           args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
@@ -193,7 +193,7 @@
           rebaseOp.updateRepo(ctx);
         } catch (MergeConflictException | NoSuchChangeException e) {
           toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-          throw new IntegrationException(
+          throw new IntegrationConflictException(
               "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
         }
         newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
@@ -215,7 +215,7 @@
 
     @Override
     public PatchSet updateChangeImpl(ChangeContext ctx)
-        throws NoSuchChangeException, ResourceConflictException, IOException {
+        throws NoSuchChangeException, ResourceConflictException, IOException, BadRequestException {
       if (newCommit == null) {
         checkState(!rebaseAlways, "RebaseAlways must never fast forward");
         // otherwise, took the fast-forward option, nothing to do.
@@ -260,7 +260,7 @@
     }
 
     @Override
-    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+    public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
       // There are multiple parents, so this is a merge commit. We don't want
       // to rebase the merge as clients can't easily rebase their history with
       // that merge present and replaced by an equivalent merge with a different
@@ -306,8 +306,7 @@
       SubmitDryRun.Arguments args,
       Repository repo,
       CodeReviewCommit mergeTip,
-      CodeReviewCommit toMerge)
-      throws IntegrationException {
+      CodeReviewCommit toMerge) {
     // Test for merge instead of cherry pick to avoid false negatives
     // on commit chains.
     return args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index ff1a1f0..bcd7923 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.submit;
 
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableSet;
@@ -21,6 +22,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -116,7 +118,7 @@
       ObjectId tip,
       ObjectId toMerge,
       Set<RevCommit> alreadyAccepted)
-      throws IntegrationException, NoSuchProjectException, IOException {
+      throws NoSuchProjectException, IOException {
     CodeReviewCommit tipCommit = rw.parseCommit(tip);
     CodeReviewCommit toMergeCommit = rw.parseCommit(toMerge);
     RevFlag canMerge = rw.newFlag("CAN_MERGE");
@@ -151,15 +153,11 @@
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         logger.atSevere().log(errorMsg);
-        throw new IntegrationException(errorMsg);
+        throw new StorageException(errorMsg);
     }
   }
 
   private ProjectState getProject(BranchNameKey branch) throws NoSuchProjectException {
-    ProjectState p = projectCache.get(branch.project());
-    if (p == null) {
-      throw new NoSuchProjectException(branch.project());
-    }
-    return p;
+    return projectCache.get(branch.project()).orElseThrow(noSuchProject(branch.project()));
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 4c68e1b..4010ad7 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableMap;
 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 +44,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 +95,7 @@
           RevFlag canMergeFlag,
           Set<RevCommit> alreadyAccepted,
           Set<CodeReviewCommit> incoming,
-          RequestId submissionId,
+          SubmissionId submissionId,
           SubmitInput submitInput,
           SubmoduleOp submoduleOp,
           boolean dryrun);
@@ -125,7 +126,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 +165,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,
@@ -200,9 +201,7 @@
       this.dryrun = dryrun;
 
       this.project =
-          requireNonNull(
-              projectCache.get(destBranch.project()),
-              () -> String.format("project not found: %s", destBranch.project()));
+          projectCache.get(destBranch.project()).orElseThrow(illegalState(destBranch.project()));
       this.mergeSorter =
           new MergeSorter(caller, rw, alreadyAccepted, canMergeFlag, queryProvider, incoming);
       this.rebaseSorter =
@@ -250,12 +249,8 @@
    * @param toMerge the set of submitted commits that should be merged using this submit strategy.
    *     Implementations are responsible for ordering of commits, and will not modify the input in
    *     place.
-   * @throws IntegrationException if an error occurred initializing the operations (as opposed to an
-   *     error during execution, which will be reported only when the batch update executes the
-   *     operations).
    */
-  public final void addOps(BatchUpdate bu, Set<CodeReviewCommit> toMerge)
-      throws IntegrationException {
+  public final void addOps(BatchUpdate bu, Set<CodeReviewCommit> toMerge) {
     List<SubmitStrategyOp> ops = buildOps(toMerge);
     Set<CodeReviewCommit> added = Sets.newHashSetWithExpectedSize(ops.size());
 
@@ -290,6 +285,5 @@
     }
   }
 
-  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
-      throws IntegrationException;
+  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge);
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index cba572bc..1cc78ff 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -16,13 +16,14 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.exceptions.StorageException;
 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,11 +53,10 @@
       IdentifiedUser caller,
       MergeTip mergeTip,
       CommitStatus commitStatus,
-      RequestId submissionId,
+      SubmissionId submissionId,
       SubmitInput submitInput,
       SubmoduleOp submoduleOp,
-      boolean dryrun)
-      throws IntegrationException {
+      boolean dryrun) {
     SubmitStrategy.Arguments args =
         argsFactory.create(
             submitType,
@@ -89,7 +89,7 @@
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         logger.atSevere().log(errorMsg);
-        throw new IntegrationException(errorMsg);
+        throw new StorageException(errorMsg);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
index f8bcfc1..b533bebc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.submit;
 
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
@@ -50,13 +53,9 @@
 
   @Override
   public void afterUpdateRepos() throws ResourceConflictException {
-    try {
-      markCleanMerges();
-      List<Change.Id> alreadyMerged = checkCommitStatus();
-      findUnmergedChanges(alreadyMerged);
-    } catch (IntegrationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    }
+    markCleanMerges();
+    List<Change.Id> alreadyMerged = checkCommitStatus();
+    findUnmergedChanges(alreadyMerged);
   }
 
   @Override
@@ -66,8 +65,7 @@
     }
   }
 
-  private void findUnmergedChanges(List<Change.Id> alreadyMerged)
-      throws ResourceConflictException, IntegrationException {
+  private void findUnmergedChanges(List<Change.Id> alreadyMerged) throws ResourceConflictException {
     for (SubmitStrategy strategy : strategies) {
       if (strategy instanceof CherryPick) {
         // Can't do this sanity check for CherryPick since:
@@ -84,14 +82,12 @@
               args.mergeTip.getInitialTip(),
               args.mergeTip.getCurrentTip(),
               alreadyMerged);
-      for (Change.Id id : unmerged) {
-        commitStatus.problem(id, "internal error: change not reachable from new branch tip");
-      }
+      checkState(unmerged.isEmpty(), "changes not reachable from new branch tip: %s", unmerged);
     }
     commitStatus.maybeFailVerbose();
   }
 
-  private void markCleanMerges() throws IntegrationException {
+  private void markCleanMerges() {
     for (SubmitStrategy strategy : strategies) {
       SubmitStrategy.Arguments args = strategy.args;
       RevCommit initialTip = args.mergeTip.getInitialTip();
@@ -108,11 +104,9 @@
     for (Change.Id id : commitStatus.getChangeIds()) {
       CodeReviewCommit commit = commitStatus.get(id);
       CommitMergeStatus s = commit != null ? commit.getStatusCode() : null;
-      if (s == null) {
-        logger.atSevere().log("change %d: change not processed by merge strategy", id.get());
-        commitStatus.problem(id, "internal error: change not processed by merge strategy");
-        continue;
-      }
+      requireNonNull(
+          s, String.format("change %d: change not processed by merge strategy", id.get()));
+
       if (commit.getStatusMessage().isPresent()) {
         logger.atFine().log(
             "change %d: Status for commit %s is %s. %s",
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 79f062d..a4141be 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
@@ -137,8 +138,7 @@
     args.submoduleOp.addBranchTip(getDest(), tipAfter);
   }
 
-  private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit)
-      throws IntegrationException {
+  private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit) {
     String refName = getDest().branch();
     if (RefNames.REFS_CONFIG.equals(refName)) {
       logger.atFine().log("Loading new configuration from %s", RefNames.REFS_CONFIG);
@@ -146,7 +146,7 @@
         ProjectConfig cfg = args.projectConfigFactory.create(getProject());
         cfg.load(ctx.getRevWalk(), commit);
       } catch (Exception e) {
-        throw new IntegrationException(
+        throw new StorageException(
             "Submit would store invalid"
                 + " project configuration "
                 + commit.name()
@@ -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
@@ -486,7 +486,8 @@
       // per project even if multiple changes to refs/meta/config are submitted.
       if (RefNames.REFS_CONFIG.equals(getDest().branch())) {
         args.projectCache.evict(getProject());
-        ProjectState p = args.projectCache.get(getProject());
+        ProjectState p =
+            args.projectCache.get(getProject()).orElseThrow(illegalState(getProject()));
         try (Repository git = args.repoManager.openRepository(getProject())) {
           git.setGitwebDescription(p.getProject().getDescription());
         } catch (IOException e) {
@@ -540,7 +541,8 @@
    *
    * @param commit
    */
-  protected CodeReviewCommit amendGitlink(CodeReviewCommit commit) throws IntegrationException {
+  protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
+      throws IntegrationConflictException {
     if (!args.submoduleOp.hasSubscription(args.destBranch)) {
       return commit;
     }
@@ -548,9 +550,15 @@
     // Modify the commit with gitlink update
     try {
       return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
-    } catch (SubmoduleException | IOException e) {
-      throw new IntegrationException(
-          "cannot update gitlink for the commit at branch: " + args.destBranch, e);
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("cannot update gitlink for the commit at branch %s", args.destBranch), e);
+    } catch (SubmoduleConflictException e) {
+      throw new IntegrationConflictException(
+          String.format(
+              "cannot update gitlink for the commit at branch %s: %s",
+              args.destBranch, e.getMessage()),
+          e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleConflictException.java b/java/com/google/gerrit/server/submit/SubmoduleConflictException.java
new file mode 100644
index 0000000..0af0131
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubmoduleConflictException.java
@@ -0,0 +1,31 @@
+// 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.submit;
+
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+
+/**
+ * Exception to be thrown if any submodule operation is not possible due to conflicts.
+ *
+ * <p>Throwing this exception results in a {@code 409 Conflict} response to the calling user. The
+ * exception message is returned as error message to the user.
+ */
+public class SubmoduleConflictException extends ResourceConflictException {
+  private static final long serialVersionUID = 1L;
+
+  public SubmoduleConflictException(String msg) {
+    super(msg);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/SubmoduleException.java b/java/com/google/gerrit/server/submit/SubmoduleException.java
deleted file mode 100644
index 2367d0a..0000000
--- a/java/com/google/gerrit/server/submit/SubmoduleException.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.submit;
-
-/**
- * Indicates the gitlink's update cannot be processed at this time.
- *
- * <p>Message should be considered user-visible.
- */
-public class SubmoduleException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  SubmoduleException(String msg) {
-    super(msg, null);
-  }
-
-  SubmoduleException(String msg, Throwable why) {
-    super(msg, why);
-  }
-}
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 8ab99dd..b48076194 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.submit;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toList;
 
@@ -27,6 +28,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -115,7 +117,7 @@
     }
 
     public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
-        throws SubmoduleException {
+        throws SubmoduleConflictException {
       return new SubmoduleOp(
           gitmodulesFactory, serverIdent.get(), cfg, projectCache, updatedBranches, orm);
     }
@@ -165,7 +167,7 @@
       ProjectCache projectCache,
       Set<BranchNameKey> updatedBranches,
       MergeOpRepoManager orm)
-      throws SubmoduleException {
+      throws SubmoduleConflictException {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
     this.projectCache = projectCache;
@@ -198,7 +200,6 @@
    * </ul>
    *
    * @return the ordered set to be stored in {@link #sortedBranches}.
-   * @throws SubmoduleException if an error occurred walking projects.
    */
   // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
   // mutable maps, which makes this whole class difficult to understand.
@@ -214,7 +215,8 @@
   //
   // In addition to improving readability, this approach has the advantage of making (1) and (2)
   // testable using small tests.
-  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps() throws SubmoduleException {
+  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps()
+      throws SubmoduleConflictException {
     if (!enableSuperProjectSubscriptions) {
       logger.atFine().log("Updating superprojects disabled");
       return null;
@@ -243,11 +245,11 @@
       BranchNameKey current,
       LinkedHashSet<BranchNameKey> currentVisited,
       LinkedHashSet<BranchNameKey> allVisited)
-      throws SubmoduleException {
+      throws SubmoduleConflictException {
     logger.atFine().log("Now processing %s", current);
 
     if (currentVisited.contains(current)) {
-      throw new SubmoduleException(
+      throw new SubmoduleConflictException(
           "Branch level circular subscriptions detected:  "
               + printCircularPath(currentVisited, current));
     }
@@ -269,7 +271,7 @@
         affectedBranches.add(sub.getSubmodule());
       }
     } catch (IOException e) {
-      throw new SubmoduleException("Cannot find superprojects for " + current, e);
+      throw new StorageException("Cannot find superprojects for " + current, e);
     }
     currentVisited.remove(current);
     allVisited.add(current);
@@ -362,7 +364,11 @@
     logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     Project.NameKey srcProject = srcBranch.project();
-    for (SubscribeSection s : projectCache.get(srcProject).getSubscribeSections(srcBranch)) {
+    for (SubscribeSection s :
+        projectCache
+            .get(srcProject)
+            .orElseThrow(illegalState(srcProject))
+            .getSubscribeSections(srcBranch)) {
       logger.atFine().log("Checking subscribe section %s", s);
       Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
       for (BranchNameKey targetBranch : branches) {
@@ -391,7 +397,7 @@
     return ret;
   }
 
-  public void updateSuperProjects() throws SubmoduleException {
+  public void updateSuperProjects() throws RestApiException {
     ImmutableSet<Project.NameKey> projects = getProjectsInOrder();
     if (projects == null) {
       return;
@@ -411,19 +417,19 @@
         }
       }
       BatchUpdate.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
-    } catch (RestApiException | UpdateException | IOException | NoSuchProjectException e) {
-      throw new SubmoduleException("Cannot update gitlinks", e);
+    } catch (UpdateException | IOException | NoSuchProjectException e) {
+      throw new StorageException("Cannot update gitlinks", e);
     }
   }
 
   /** Create a separate gitlink commit */
   private CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber)
-      throws IOException, SubmoduleException {
+      throws IOException, SubmoduleConflictException {
     OpenRepo or;
     try {
       or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
-      throw new SubmoduleException("Cannot access superproject", e);
+      throw new StorageException("Cannot access superproject", e);
     }
 
     CodeReviewCommit currentCommit;
@@ -432,7 +438,7 @@
     } else {
       Ref r = or.repo.exactRef(subscriber.branch());
       if (r == null) {
-        throw new SubmoduleException(
+        throw new SubmoduleConflictException(
             "The branch was probably deleted from the subscriber repository");
       }
       currentCommit = or.rw.parseCommit(r.getObjectId());
@@ -488,12 +494,12 @@
 
   /** Amend an existing commit with gitlink updates */
   CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
-      throws IOException, SubmoduleException {
+      throws IOException, SubmoduleConflictException {
     OpenRepo or;
     try {
       or = orm.getRepo(subscriber.project());
     } catch (NoSuchProjectException | IOException e) {
-      throw new SubmoduleException("Cannot access superproject", e);
+      throw new StorageException("Cannot access superproject", e);
     }
 
     StringBuilder msgbuf = new StringBuilder();
@@ -529,13 +535,13 @@
 
   private RevCommit updateSubmodule(
       DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
-      throws SubmoduleException, IOException {
+      throws SubmoduleConflictException, IOException {
     logger.atFine().log("Updating gitlink for %s", s);
     OpenRepo subOr;
     try {
       subOr = orm.getRepo(s.getSubmodule().project());
     } catch (NoSuchProjectException | IOException e) {
-      throw new SubmoduleException("Cannot access submodule", e);
+      throw new StorageException("Cannot access submodule", e);
     }
 
     DirCacheEntry dce = dc.getEntry(s.getPath());
@@ -549,7 +555,7 @@
                 + s.getSubmodule().project().get()
                 + " but entry "
                 + "doesn't have gitlink file mode.";
-        throw new SubmoduleException(errMsg);
+        throw new SubmoduleConflictException(errMsg);
       }
       // Parse the current gitlink entry commit in the subproject repo. This is used to add a
       // shortlog for this submodule to the commit message in the superproject.
@@ -612,8 +618,7 @@
       SubmoduleSubscription s,
       OpenRepo subOr,
       RevCommit newCommit,
-      RevCommit oldCommit)
-      throws SubmoduleException {
+      RevCommit oldCommit) {
     msgbuf.append("* Update ");
     msgbuf.append(s.getPath());
     msgbuf.append(" from branch '");
@@ -654,7 +659,7 @@
         msgbuf.append(message);
       }
     } catch (IOException e) {
-      throw new SubmoduleException(
+      throw new StorageException(
           "Could not perform a revwalk to create superproject commit message", e);
     }
   }
@@ -671,7 +676,7 @@
     return dc;
   }
 
-  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
+  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
     for (Project.NameKey project : branchesByProject.keySet()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
@@ -687,9 +692,9 @@
       Project.NameKey project,
       LinkedHashSet<Project.NameKey> current,
       LinkedHashSet<Project.NameKey> projects)
-      throws SubmoduleException {
+      throws SubmoduleConflictException {
     if (current.contains(project)) {
-      throw new SubmoduleException(
+      throw new SubmoduleConflictException(
           "Project level circular subscriptions detected:  " + printCircularPath(current, project));
     }
 
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index ce16706..166e88d 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;
@@ -49,11 +51,13 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 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 +132,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 +185,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 +260,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;
     }
@@ -461,7 +490,11 @@
       logDebug("Executing updateRepo on %d ops", ops.size());
       RepoContextImpl ctx = new RepoContextImpl();
       for (BatchUpdateOp op : ops.values()) {
-        op.updateRepo(ctx);
+        try (TraceContext.TraceTimer ignored =
+            TraceContext.newTimer(
+                op.getClass().getSimpleName() + "#updateRepo", Metadata.empty())) {
+          op.updateRepo(ctx);
+        }
       }
 
       logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
@@ -564,14 +597,19 @@
           id,
           lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
       for (BatchUpdateOp op : e.getValue()) {
-        dirty |= op.updateChange(ctx);
+        try (TraceContext.TraceTimer ignored =
+            TraceContext.newTimer(
+                op.getClass().getSimpleName() + "#updateChange", Metadata.empty())) {
+          dirty |= op.updateChange(ctx);
+        }
       }
       if (!dirty) {
         logDebug("No ops reported dirty, short-circuiting");
         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);
@@ -602,11 +640,17 @@
   private void executePostOps() throws Exception {
     ContextImpl ctx = new ContextImpl();
     for (BatchUpdateOp op : ops.values()) {
-      op.postUpdate(ctx);
+      try (TraceContext.TraceTimer ignored =
+          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        op.postUpdate(ctx);
+      }
     }
 
     for (RepoOnlyOp op : repoOnlyOps) {
-      op.postUpdate(ctx);
+      try (TraceContext.TraceTimer ignored =
+          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        op.postUpdate(ctx);
+      }
     }
   }
 
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..2db625b 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,22 @@
                 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 (cause = %s)",
+                        actionName, cause);
+                    opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
+                    metrics.autoRetryCount.increment(actionType, actionName, cause);
                     return true;
                   }
 
@@ -342,40 +493,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 (cause = %s)", actionName, cause);
+                  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 +574,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 +596,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 bce1209..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 56c3eec..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/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
new file mode 100644
index 0000000..ad2c98c
--- /dev/null
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -0,0 +1,33 @@
+// 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.util;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import java.util.Collection;
+
+/** Common helpers for dealing with attention set data structures. */
+public class AttentionSetUtil {
+  /** Returns only updates where the user was added. */
+  public static ImmutableSet<AttentionSetUpdate> additionsOnly(
+      Collection<AttentionSetUpdate> updates) {
+    return updates.stream()
+        .filter(u -> u.operation() == Operation.ADD)
+        .collect(ImmutableSet.toImmutableSet());
+  }
+
+  private AttentionSetUtil() {}
+}
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 689c567..a5b88b4 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -33,7 +33,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/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 491bcb8..4a1489739 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -55,13 +54,13 @@
   }
 
   public void addChange(String id, Map<Change.Id, ChangeResource> changes)
-      throws UnloggedFailure, PermissionBackendException, IOException {
+      throws UnloggedFailure, PermissionBackendException {
     addChange(id, changes, null);
   }
 
   public void addChange(
       String id, Map<Change.Id, ChangeResource> changes, @Nullable ProjectState projectState)
-      throws UnloggedFailure, PermissionBackendException, IOException {
+      throws UnloggedFailure, PermissionBackendException {
     addChange(id, changes, projectState, true);
   }
 
@@ -70,7 +69,7 @@
       Map<Change.Id, ChangeResource> changes,
       @Nullable ProjectState projectState,
       boolean useIndex)
-      throws UnloggedFailure, PermissionBackendException, IOException {
+      throws UnloggedFailure, PermissionBackendException {
     List<ChangeNotes> matched = useIndex ? changeFinder.find(id) : changeFromNotesFactory(id);
     List<ChangeNotes> toAdd = new ArrayList<>(changes.size());
     boolean canMaintainServer;
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 6c0f3af..6997d96 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -18,7 +18,9 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Splitter;
 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;
@@ -37,9 +39,9 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 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;
@@ -197,8 +199,15 @@
             continue;
           }
 
+          List<String> parts = Splitter.on(' ').splitToList(line);
+          if (parts.size() > 2) {
+            throw new IllegalArgumentException(
+                "Invalid peer key file format, only <key [comment]> lines supported");
+          }
           try {
-            byte[] bin = Base64.decodeBase64(line.getBytes(ISO_8859_1));
+            byte[] bin =
+                BaseEncoding.base64()
+                    .decode(new String(parts.get(0).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/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index a1c057f..c14ebd8 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -69,6 +69,7 @@
 import org.apache.mina.transport.socket.SocketSessionConfig;
 import org.apache.sshd.common.BaseBuilder;
 import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.NamedResource;
 import org.apache.sshd.common.cipher.Cipher;
 import org.apache.sshd.common.compression.BuiltinCompressions;
 import org.apache.sshd.common.compression.Compression;
@@ -83,7 +84,7 @@
 import org.apache.sshd.common.io.mina.MinaServiceFactoryFactory;
 import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory;
-import org.apache.sshd.common.kex.KeyExchange;
+import org.apache.sshd.common.kex.KeyExchangeFactory;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.mac.Mac;
 import org.apache.sshd.common.random.Random;
@@ -97,7 +98,7 @@
 import org.apache.sshd.common.util.security.SecurityUtils;
 import org.apache.sshd.server.ServerBuilder;
 import org.apache.sshd.server.SshServer;
-import org.apache.sshd.server.auth.UserAuth;
+import org.apache.sshd.server.auth.UserAuthFactory;
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
 import org.apache.sshd.server.auth.gss.UserAuthGSSFactory;
 import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
@@ -486,11 +487,9 @@
     return r.toString();
   }
 
-  @SuppressWarnings("unchecked")
   private void initKeyExchanges(Config cfg) {
-    List<NamedFactory<KeyExchange>> a = ServerBuilder.setUpDefaultKeyExchanges(true);
-    setKeyExchangeFactories(
-        filter(cfg, "kex", (NamedFactory<KeyExchange>[]) a.toArray(new NamedFactory<?>[a.size()])));
+    List<KeyExchangeFactory> a = ServerBuilder.setUpDefaultKeyExchanges(true);
+    setKeyExchangeFactories(filter(cfg, "kex", a.toArray(new KeyExchangeFactory[a.size()])));
   }
 
   private void initProviderBouncyCastle(Config cfg) {
@@ -602,17 +601,16 @@
   }
 
   @SafeVarargs
-  private static <T> List<NamedFactory<T>> filter(
-      final Config cfg, String key, NamedFactory<T>... avail) {
-    final ArrayList<NamedFactory<T>> def = new ArrayList<>();
-    for (NamedFactory<T> n : avail) {
+  private static <T extends NamedResource> List<T> filter(Config cfg, String key, T... avail) {
+    List<T> def = new ArrayList<>();
+    for (T n : avail) {
       if (n == null) {
         break;
       }
       def.add(n);
     }
 
-    final String[] want = cfg.getStringList("sshd", null, key);
+    String[] want = cfg.getStringList("sshd", null, key);
     if (want == null || want.length == 0) {
       return def;
     }
@@ -631,9 +629,9 @@
         def.clear();
       }
 
-      final NamedFactory<T> n = find(name, avail);
+      T n = find(name, avail);
       if (n == null) {
-        final StringBuilder msg = new StringBuilder();
+        StringBuilder msg = new StringBuilder();
         msg.append("sshd.").append(key).append(" = ").append(name).append(" unsupported; only ");
         for (int i = 0; i < avail.length; i++) {
           if (avail[i] == null) {
@@ -659,8 +657,8 @@
   }
 
   @SafeVarargs
-  private static <T> NamedFactory<T> find(String name, NamedFactory<T>... avail) {
-    for (NamedFactory<T> n : avail) {
+  private static <T extends NamedResource> T find(String name, T... avail) {
+    for (T n : avail) {
       if (n != null && name.equals(n.getName())) {
         return n;
       }
@@ -669,8 +667,7 @@
   }
 
   private void initSignatures() {
-    setSignatureFactories(
-        NamedFactory.setUpBuiltinFactories(false, ServerBuilder.DEFAULT_SIGNATURE_PREFERENCE));
+    setSignatureFactories(ServerBuilder.setUpDefaultSignatureFactories(false));
   }
 
   private void initCompression(boolean enableCompression) {
@@ -717,7 +714,7 @@
       final GSSAuthenticator kerberosAuthenticator,
       String kerberosKeytab,
       String kerberosPrincipal) {
-    List<NamedFactory<UserAuth>> authFactories = new ArrayList<>();
+    List<UserAuthFactory> authFactories = new ArrayList<>();
     if (kerberosKeytab != null) {
       authFactories.add(UserAuthGSSFactory.INSTANCE);
       logger.atInfo().log("Enabling kerberos with keytab %s", kerberosKeytab);
diff --git a/java/com/google/gerrit/sshd/SshLogJsonLayout.java b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
index 5ece5af..488a4c5 100644
--- a/java/com/google/gerrit/sshd/SshLogJsonLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
@@ -25,17 +25,11 @@
 
 import com.google.gerrit.util.logging.JsonLayout;
 import com.google.gerrit.util.logging.JsonLogEntry;
-import java.time.format.DateTimeFormatter;
 import org.apache.log4j.spi.LoggingEvent;
 
 public class SshLogJsonLayout extends JsonLayout {
 
   @Override
-  public DateTimeFormatter createDateTimeFormatter() {
-    return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss,SSS Z");
-  }
-
-  @Override
   public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
     return new SshJsonLogEntry(event);
   }
@@ -65,7 +59,7 @@
     public String bytesTotal;
 
     public SshJsonLogEntry(LoggingEvent event) {
-      this.timestamp = formatDate(event.getTimeStamp());
+      this.timestamp = timestampFormatter.format(event.getTimeStamp());
       this.session = getMdcString(event, P_SESSION);
       this.thread = event.getThreadName();
       this.user = getMdcString(event, P_USER_NAME);
diff --git a/java/com/google/gerrit/sshd/SshLogLayout.java b/java/com/google/gerrit/sshd/SshLogLayout.java
index c676be9..1dda068 100644
--- a/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -23,27 +23,16 @@
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
-import java.text.SimpleDateFormat;
-import java.util.Calendar;
-import java.util.TimeZone;
+import com.google.gerrit.util.logging.LogTimestampFormatter;
 import org.apache.log4j.Layout;
 import org.apache.log4j.spi.LoggingEvent;
 import org.eclipse.jgit.util.QuotedString;
 
 public final class SshLogLayout extends Layout {
-
-  private final Calendar calendar;
-  private long lastTimeMillis;
-  private final char[] lastTimeString = new char[20];
-  private final SimpleDateFormat tzFormat;
-  private char[] timeZone;
+  protected final LogTimestampFormatter timestampFormatter;
 
   public SshLogLayout() {
-    final TimeZone tz = TimeZone.getDefault();
-    calendar = Calendar.getInstance(tz);
-
-    tzFormat = new SimpleDateFormat("Z");
-    tzFormat.setTimeZone(tz);
+    timestampFormatter = new LogTimestampFormatter();
   }
 
   @Override
@@ -51,7 +40,7 @@
     final StringBuffer buf = new StringBuffer(128);
 
     buf.append('[');
-    formatDate(event.getTimeStamp(), buf);
+    buf.append(timestampFormatter.format(event.getTimeStamp()));
     buf.append(']');
 
     req(P_SESSION, buf, event);
@@ -77,41 +66,6 @@
     return buf.toString();
   }
 
-  private void formatDate(long now, StringBuffer sbuf) {
-    final int millis = (int) (now % 1000);
-    final long rounded = now - millis;
-    if (rounded != lastTimeMillis) {
-      synchronized (calendar) {
-        final int start = sbuf.length();
-        calendar.setTimeInMillis(rounded);
-        sbuf.append(calendar.get(Calendar.YEAR));
-        sbuf.append('-');
-        sbuf.append(toTwoDigits(calendar.get(Calendar.MONTH) + 1));
-        sbuf.append('-');
-        sbuf.append(toTwoDigits(calendar.get(Calendar.DAY_OF_MONTH)));
-        sbuf.append(' ');
-        sbuf.append(toTwoDigits(calendar.get(Calendar.HOUR_OF_DAY)));
-        sbuf.append(':');
-        sbuf.append(toTwoDigits(calendar.get(Calendar.MINUTE)));
-        sbuf.append(':');
-        sbuf.append(toTwoDigits(calendar.get(Calendar.SECOND)));
-        sbuf.append(',');
-        sbuf.getChars(start, sbuf.length(), lastTimeString, 0);
-        lastTimeMillis = rounded;
-        timeZone = tzFormat.format(calendar.getTime()).toCharArray();
-      }
-    } else {
-      sbuf.append(lastTimeString);
-    }
-    sbuf.append(String.format("%03d", millis));
-    sbuf.append(' ');
-    sbuf.append(timeZone);
-  }
-
-  private String toTwoDigits(int input) {
-    return String.format("%02d", input);
-  }
-
   private void req(String key, StringBuffer buf, LoggingEvent event) {
     Object val = event.getMDC(key);
     buf.append(' ');
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index 6176628..abbd81d 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;
@@ -29,7 +30,6 @@
 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.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -37,7 +37,6 @@
 import org.apache.sshd.common.session.helpers.AbstractSession;
 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 {
@@ -57,7 +56,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);
@@ -95,8 +94,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 b243748..5fd2297 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -120,7 +120,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/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 905ba9c..cfd17f4 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -82,6 +82,19 @@
       command(git, "upload-pack").to(Upload.class);
       command("git-upload-archive").to(Commands.key(git, "upload-archive"));
       command(git, "upload-archive").to(UploadArchive.class);
+      if (slaveMode) {
+        command("git-receive-pack").to(ReceiveSlaveMode.class);
+        command("gerrit-receive-pack").to(ReceiveSlaveMode.class);
+        command(git, "receive-pack").to(ReceiveSlaveMode.class);
+      } else {
+        command("git-receive-pack").to(Commands.key(git, "receive-pack"));
+        command("gerrit-receive-pack").to(Commands.key(git, "receive-pack"));
+        command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack"));
+      }
+    }
+
+    if (!slaveMode) {
+      command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
     }
     command("suexec").to(SuExec.class);
     listener().to(ShowCaches.StartupListener.class);
@@ -91,24 +104,13 @@
     command(gerrit, CreateProjectCommand.class);
     command(gerrit, SetHeadCommand.class);
 
-    if (slaveMode) {
-      command("git-receive-pack").to(ReceiveSlaveMode.class);
-      command("gerrit-receive-pack").to(ReceiveSlaveMode.class);
-      command(git, "receive-pack").to(ReceiveSlaveMode.class);
-    } else {
-      if (sshEnabled()) {
-        command("git-receive-pack").to(Commands.key(git, "receive-pack"));
-        command("gerrit-receive-pack").to(Commands.key(git, "receive-pack"));
-        command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack"));
-      }
-      command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
-    }
     command(gerrit, Receive.class);
 
     command(gerrit, RenameGroupCommand.class);
     command(gerrit, ReviewCommand.class);
     command(gerrit, SetProjectCommand.class);
     command(gerrit, SetReviewersCommand.class);
+    command(gerrit, SetTopicCommand.class);
 
     command(gerrit, SetMembersCommand.class);
     command(gerrit, CreateBranchCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index 041c85c..1fb0e13 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import org.kohsuke.args4j.Argument;
@@ -44,7 +43,7 @@
   void addChange(String token) {
     try {
       changeArgumentParser.addChange(token, changes, null, false);
-    } catch (UnloggedFailure | StorageException | PermissionBackendException | IOException e) {
+    } catch (UnloggedFailure | StorageException | PermissionBackendException e) {
       writeError("warning", e.getMessage());
     }
   }
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index b8fc55a0..6eb045b 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;
@@ -90,14 +90,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/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index f804c2d..4ebf15e 100644
--- a/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -32,6 +31,7 @@
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class PatchSetParser {
@@ -126,12 +126,11 @@
     if (projectState != null) {
       return notesFactory.create(projectState.getNameKey(), changeId);
     }
-    try {
-      ChangeNotes notes = changeFinder.findOne(changeId);
-      return notesFactory.create(notes.getProjectName(), changeId);
-    } catch (NoSuchChangeException e) {
-      throw error("\"" + changeId + "\" no such change", e);
+    Optional<ChangeNotes> notes = changeFinder.findOne(changeId);
+    if (!notes.isPresent()) {
+      throw error("\"" + changeId + "\" no such change");
     }
+    return notesFactory.create(notes.get().getProjectName(), changeId);
   }
 
   private static boolean inProject(Change change, ProjectState projectState) {
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 61e529f..541bf52 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -326,8 +326,8 @@
 
     ProjectState allProjectsState;
     try {
-      allProjectsState = projectCache.checkedGet(allProjects);
-    } catch (IOException e) {
+      allProjectsState = projectCache.getAllProjects();
+    } catch (Exception e) {
       throw die("missing " + allProjects.get(), e);
     }
 
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/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index bfdbff9..d23f7fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.entities.Project;
@@ -37,6 +38,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
@@ -118,7 +120,7 @@
 
     for (Project.NameKey nameKey : childProjects) {
       final String name = nameKey.get();
-      ProjectState project = projectCache.get(nameKey);
+      ProjectState project = projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
       try {
         setParent.apply(new ProjectResource(project, user), parentInput(newParentKey.get()));
       } catch (AuthException e) {
@@ -178,10 +180,10 @@
   }
 
   private Set<Project.NameKey> getAllParents(Project.NameKey projectName) {
-    ProjectState ps = projectCache.get(projectName);
-    if (ps == null) {
+    Optional<ProjectState> ps = projectCache.get(projectName);
+    if (!ps.isPresent()) {
       return Collections.emptySet();
     }
-    return ps.parents().transform(ProjectState::getNameKey).toSet();
+    return ps.get().parents().transform(ProjectState::getNameKey).toSet();
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 0e6fa65..95627e1 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -73,7 +72,7 @@
   void addChange(String token) {
     try {
       changeArgumentParser.addChange(token, changes, projectState);
-    } catch (IOException | UnloggedFailure e) {
+    } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
     } catch (StorageException e) {
       throw new IllegalArgumentException("database is down", e);
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
new file mode 100644
index 0000000..70700f1
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -0,0 +1,96 @@
+// 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.sshd.commands;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.TopicInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.change.SetTopicOp;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.sshd.ChangeArgumentParser;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(name = "set-topic", description = "Set the topic for one or more changes")
+public class SetTopicCommand extends SshCommand {
+  private final BatchUpdate.Factory updateFactory;
+  private final ChangeArgumentParser changeArgumentParser;
+  private final SetTopicOp.Factory topicOpFactory;
+
+  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
+
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
+      metaVar = "CHANGE",
+      usage = "changes to index")
+  void addChange(String token) {
+    try {
+      changeArgumentParser.addChange(token, changes, null, true);
+    } catch (UnloggedFailure | StorageException | PermissionBackendException e) {
+      writeError("warning", e.getMessage());
+    }
+  }
+
+  @Option(
+      name = "--topic",
+      aliases = "-t",
+      usage = "applies a topic to the given changes",
+      metaVar = "TOPIC")
+  private String topic;
+
+  @Inject
+  SetTopicCommand(
+      BatchUpdate.Factory updateFactory,
+      ChangeArgumentParser changeArgumentParser,
+      SetTopicOp.Factory topicOpFactory) {
+    this.updateFactory = updateFactory;
+    this.changeArgumentParser = changeArgumentParser;
+    this.topicOpFactory = topicOpFactory;
+  }
+
+  @Override
+  public void run() throws Exception {
+    TopicInput input = new TopicInput();
+    if (topic != null) {
+      input.topic = topic.trim();
+    }
+
+    if (input.topic != null && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+      throw new BadRequestException(
+          String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
+    }
+
+    for (ChangeResource r : changes.values()) {
+      SetTopicOp op = topicOpFactory.create(input);
+      try (BatchUpdate u =
+          updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
+        u.addOp(r.getId(), op);
+        u.execute();
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 63a0dda..ba84179 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -198,7 +198,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/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index c25a1a8..67dc5a5 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.ArchiveFormatInternal;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -174,7 +174,7 @@
       // Parse Git arguments
       readArguments();
 
-      ArchiveFormat f = allowedFormats.getExtensions().get("." + options.format);
+      ArchiveFormatInternal f = allowedFormats.getExtensions().get("." + options.format);
       if (f == null) {
         throw new Failure(3, "fatal: upload-archive not permitted for format " + options.format);
       }
@@ -222,8 +222,8 @@
     }
   }
 
-  private Map<String, Object> getFormatOptions(ArchiveFormat f) {
-    if (f == ArchiveFormat.ZIP) {
+  private Map<String, Object> getFormatOptions(ArchiveFormatInternal f) {
+    if (f == ArchiveFormatInternal.ZIP) {
       int value =
           Arrays.asList(
                   options.level0,
@@ -245,8 +245,8 @@
   }
 
   private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException {
-    ProjectState projectState = projectCache.get(projectName);
-    requireNonNull(projectState, () -> String.format("Failed to load project %s", projectName));
+    ProjectState projectState =
+        projectCache.get(projectName).orElseThrow(illegalState(projectName));
 
     if (!projectState.statePermitsRead()) {
       return false;
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index c610d07..16c15ad 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/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index 0178c72..ab3348b 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -61,18 +60,6 @@
     throw new UnsupportedOperationException();
   }
 
-  @Override
-  public synchronized void evict(@Nullable Account.Id accountId) {
-    if (byId != null) {
-      byId.remove(accountId);
-    }
-  }
-
-  @Override
-  public synchronized void evictAll() {
-    byId.clear();
-  }
-
   public synchronized void put(Account account) {
     AccountState state = newState(account);
     byId.put(account.id(), state);
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/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index abb5955..8800463 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRuntime;
@@ -179,7 +180,7 @@
     // support Path-based Configs, only FileBasedConfig.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-    bind(GerritOptions.class).toInstance(new GerritOptions(false, false, false));
+    bind(GerritOptions.class).toInstance(new GerritOptions(false, false, ""));
 
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
@@ -189,6 +190,7 @@
     install(new InMemorySchemaModule());
     install(NoSshKeyCache.module());
     install(new GerritInstanceNameModule());
+    install(new GerritInstanceIdModule());
     install(
         new CanonicalWebUrlModule() {
           @Override
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..5ce6d13 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,39 @@
     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 {
+    addRobotComment(targetChangeId, robotCommentInput, "robot comment test");
+  }
+
+  public void addRobotComment(
+      String targetChangeId, RobotCommentInput robotCommentInput, String message) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
+    reviewInput.message = message;
+    gApi.changes().id(targetChangeId).current().review(reviewInput);
+  }
 }
diff --git a/java/com/google/gerrit/truth/MapSubject.java b/java/com/google/gerrit/truth/MapSubject.java
new file mode 100644
index 0000000..8217920
--- /dev/null
+++ b/java/com/google/gerrit/truth/MapSubject.java
@@ -0,0 +1,56 @@
+/*
+ * 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.truth;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import java.util.Map;
+
+/**
+ * A Truth subject for maps providing additional methods simplifying tests but missing on Truth's
+ * {@link com.google.common.truth.MapSubject}.
+ */
+public class MapSubject extends com.google.common.truth.MapSubject {
+
+  private final Map<?, ?> map;
+
+  public static MapSubject assertThatMap(Map<?, ?> map) {
+    return assertAbout(mapEntries()).that(map);
+  }
+
+  public static Subject.Factory<MapSubject, Map<?, ?>> mapEntries() {
+    return MapSubject::new;
+  }
+
+  private MapSubject(FailureMetadata failureMetadata, Map<?, ?> map) {
+    super(failureMetadata, map);
+    this.map = map;
+  }
+
+  public IterableSubject keys() {
+    isNotNull();
+    return check("keys()").that(map.keySet());
+  }
+
+  public IterableSubject values() {
+    isNotNull();
+    return check("values()").that(map.values());
+  }
+}
diff --git a/java/com/google/gerrit/util/logging/JsonLayout.java b/java/com/google/gerrit/util/logging/JsonLayout.java
index 8f797ec..b8d6431 100644
--- a/java/com/google/gerrit/util/logging/JsonLayout.java
+++ b/java/com/google/gerrit/util/logging/JsonLayout.java
@@ -17,30 +17,18 @@
 import com.google.gson.FieldNamingPolicy;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.OffsetDateTime;
-import java.time.ZoneId;
-import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
 import org.apache.log4j.Layout;
 import org.apache.log4j.spi.LoggingEvent;
 
 public abstract class JsonLayout extends Layout {
-  private final DateTimeFormatter dateFormatter;
   private final Gson gson;
-  private final ZoneOffset timeOffset;
+  protected final LogTimestampFormatter timestampFormatter;
 
   public JsonLayout() {
-    dateFormatter = createDateTimeFormatter();
-    timeOffset = OffsetDateTime.now().getOffset();
-
+    timestampFormatter = new LogTimestampFormatter();
     gson = newGson();
   }
 
-  public abstract DateTimeFormatter createDateTimeFormatter();
-
   public abstract JsonLogEntry toJsonLogEntry(LoggingEvent event);
 
   @Override
@@ -56,12 +44,6 @@
     return gb.create();
   }
 
-  public String formatDate(long now) {
-    return ZonedDateTime.of(
-            LocalDateTime.ofInstant(Instant.ofEpochMilli(now), timeOffset), ZoneId.systemDefault())
-        .format(dateFormatter);
-  }
-
   @Override
   public void activateOptions() {}
 
diff --git a/java/com/google/gerrit/util/logging/LogTimestampFormatter.java b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
new file mode 100644
index 0000000..9637b8b
--- /dev/null
+++ b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
@@ -0,0 +1,49 @@
+// 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.util.logging;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/** Formatter for timestamps used in log entries. */
+public class LogTimestampFormatter {
+  public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+
+  private final DateTimeFormatter dateFormatter;
+  private final ZoneOffset timeOffset;
+
+  public LogTimestampFormatter() {
+    dateFormatter = DateTimeFormatter.ofPattern(TIMESTAMP_FORMAT);
+    timeOffset = OffsetDateTime.now(ZoneId.systemDefault()).getOffset();
+  }
+
+  /**
+   * Formats time given in milliseconds since UNIX epoch to ISO 8601 format.
+   *
+   * @param epochMilli milliseconds since UNIX epoch
+   * @return ISO 8601-formatted timestamp
+   */
+  public String format(long epochMilli) {
+    return ZonedDateTime.of(
+            LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), timeOffset),
+            ZoneId.systemDefault())
+        .format(dateFormatter);
+  }
+}
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 4fe230e..0f8c1f4 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.BlindTrustManager;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
@@ -25,7 +26,6 @@
 import java.util.List;
 import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
-import org.apache.commons.codec.binary.Base64;
 
 /**
  * SMTP Client with authentication support and optional SSL processing and verification. {@link
@@ -132,7 +132,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);
@@ -185,6 +185,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/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 1a09aa1..ff9bac9 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -20,9 +20,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -254,130 +252,6 @@
   }
 
   @Test
-  public void accountEvictionIfUserBranchIsReset() throws Exception {
-    Account.Id accountId = Account.id(1);
-    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
-    Repository allUsersRepo = repoManager.createRepository(allUsers);
-    Ref userBranch = createRef(allUsersRepo, RefNames.refsUsers(accountId));
-
-    AccountCache accountCache = mock(AccountCache.class);
-    AccountIndexer accountIndexer = mock(AccountIndexer.class);
-
-    // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(2)));
-
-    try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null, null, null, null)
-            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
-      updateRef(nonUserBranch);
-      updateRef(allUsersRepo, userBranch);
-    }
-
-    verify(accountCache, only()).evict(accountId);
-    verify(accountIndexer, only()).index(accountId);
-  }
-
-  @Test
-  public void accountEvictionIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = Account.id(1);
-    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
-    Repository allUsersRepo = repoManager.createRepository(allUsers);
-
-    AccountCache accountCache = mock(AccountCache.class);
-    AccountIndexer accountIndexer = mock(AccountIndexer.class);
-
-    try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null, null, null, null)
-            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
-      // Non-user branch because it's not in All-Users.
-      createRef(RefNames.refsUsers(Account.id(2)));
-
-      createRef(allUsersRepo, RefNames.refsUsers(accountId));
-    }
-
-    verify(accountCache, only()).evict(accountId);
-    verify(accountIndexer, only()).index(accountId);
-  }
-
-  @Test
-  public void accountEvictionIfExternalIdsBranchIsReset() throws Exception {
-    Account.Id accountId = Account.id(1);
-    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
-    Repository allUsersRepo = repoManager.createRepository(allUsers);
-    Ref externalIds = createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-    createRef(allUsersRepo, RefNames.refsUsers(accountId));
-
-    Account.Id accountId2 = Account.id(2);
-
-    AccountCache accountCache = mock(AccountCache.class);
-    AccountIndexer accountIndexer = mock(AccountIndexer.class);
-
-    // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
-
-    try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null, null, null, null)
-            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
-      updateRef(nonUserBranch);
-      updateRef(allUsersRepo, externalIds);
-      createRef(allUsersRepo, RefNames.refsUsers(accountId2));
-    }
-
-    verify(accountCache).evict(accountId);
-    verify(accountCache).evict(accountId2);
-    verify(accountIndexer).index(accountId);
-    verify(accountIndexer).index(accountId2);
-    verifyNoMoreInteractions(accountCache, accountIndexer);
-  }
-
-  @Test
-  public void accountEvictionIfExternalIdsBranchIsDeleted() throws Exception {
-    Account.Id accountId = Account.id(1);
-    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
-    Repository allUsersRepo = repoManager.createRepository(allUsers);
-    createRef(allUsersRepo, RefNames.refsUsers(accountId));
-
-    Account.Id accountId2 = Account.id(2);
-
-    AccountCache accountCache = mock(AccountCache.class);
-    AccountIndexer accountIndexer = mock(AccountIndexer.class);
-
-    // Non-user branch because it's not in All-Users.
-    Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
-
-    try (ProjectResetter resetProject =
-        builder(null, accountCache, accountIndexer, null, null, null, null)
-            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
-      updateRef(nonUserBranch);
-      createRef(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
-      createRef(allUsersRepo, RefNames.refsUsers(accountId2));
-    }
-
-    verify(accountCache).evict(accountId);
-    verify(accountCache).evict(accountId2);
-    verify(accountIndexer).index(accountId);
-    verify(accountIndexer).index(accountId2);
-    verifyNoMoreInteractions(accountCache, accountIndexer);
-  }
-
-  @Test
-  public void accountEvictionFromAccountCreatorIfUserBranchIsDeleted() throws Exception {
-    Account.Id accountId = Account.id(1);
-    Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
-    Repository allUsersRepo = repoManager.createRepository(allUsers);
-
-    AccountCreator accountCreator = mock(AccountCreator.class);
-
-    try (ProjectResetter resetProject =
-        builder(accountCreator, null, null, null, null, null, null)
-            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
-      createRef(allUsersRepo, RefNames.refsUsers(accountId));
-    }
-
-    verify(accountCreator, only()).evict(ImmutableSet.of(accountId));
-  }
-
-  @Test
   public void groupEviction() throws Exception {
     AccountGroup.UUID uuid1 = AccountGroup.uuid("abcd1");
     AccountGroup.UUID uuid2 = AccountGroup.uuid("abcd2");
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
deleted file mode 100644
index d5ac2f7..0000000
--- a/javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.annotation;
-
-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 {
-  @Test
-  @GerritConfig(name = "section.name", value = "value")
-  public void testOne() {
-    assertThat(cfg.getString("section", null, "name")).isEqualTo("value");
-  }
-
-  @Test
-  @GerritConfig(name = "section.subsection.name", value = "value")
-  public void testOneWithSubsection() {
-    assertThat(cfg.getString("section", "subsection", "name")).isEqualTo("value");
-  }
-
-  @Test
-  @GerritConfig(name = "section.name", value = "value")
-  @GerritConfig(name = "section1.name", value = "value1")
-  @GerritConfig(name = "section.subsection.name", value = "value")
-  @GerritConfig(name = "section.subsection1.name", value = "value1")
-  public void testMultiple() {
-    assertThat(cfg.getString("section", null, "name")).isEqualTo("value");
-    assertThat(cfg.getString("section1", null, "name")).isEqualTo("value1");
-    assertThat(cfg.getString("section", "subsection", "name")).isEqualTo("value");
-    assertThat(cfg.getString("section", "subsection1", "name")).isEqualTo("value1");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "section.name",
-      values = {"value-1", "value-2"})
-  public void testList() {
-    assertThat(cfg.getStringList("section", null, "name"))
-        .asList()
-        .containsExactly("value-1", "value-2");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "section.subsection.name",
-      values = {"value-1", "value-2"})
-  public void testListWithSubsection() {
-    assertThat(cfg.getStringList("section", "subsection", "name"))
-        .asList()
-        .containsExactly("value-1", "value-2");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "section.name",
-      value = "value-1",
-      values = {"value-2", "value-3"})
-  public void valueHasPrecedenceOverValues() {
-    assertThat(cfg.getStringList("section", null, "name")).asList().containsExactly("value-1");
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
deleted file mode 100644
index 44d9e46..0000000
--- a/javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
+++ /dev/null
@@ -1,91 +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.annotation;
-
-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;
-
-public class UseGlobalPluginConfigAnnotationTest extends AbstractDaemonTest {
-  private Config cfg() {
-    return pluginConfig.getGlobalPluginConfig("test");
-  }
-
-  @Test
-  @UseLocalDisk
-  @GlobalPluginConfig(pluginName = "test", name = "section.name", value = "value")
-  public void testOne() {
-    assertThat(cfg().getString("section", null, "name")).isEqualTo("value");
-  }
-
-  @Test
-  @UseLocalDisk
-  @GlobalPluginConfig(pluginName = "test", name = "section.subsection.name", value = "value")
-  public void testOneWithSubsection() {
-    assertThat(cfg().getString("section", "subsection", "name")).isEqualTo("value");
-  }
-
-  @Test
-  @UseLocalDisk
-  @GlobalPluginConfig(pluginName = "test", name = "section.name", value = "value")
-  @GlobalPluginConfig(pluginName = "test", name = "section1.name", value = "value1")
-  @GlobalPluginConfig(pluginName = "test", name = "section.subsection.name", value = "value")
-  @GlobalPluginConfig(pluginName = "test", name = "section.subsection1.name", value = "value1")
-  public void testMultiple() {
-    assertThat(cfg().getString("section", null, "name")).isEqualTo("value");
-    assertThat(cfg().getString("section1", null, "name")).isEqualTo("value1");
-    assertThat(cfg().getString("section", "subsection", "name")).isEqualTo("value");
-    assertThat(cfg().getString("section", "subsection1", "name")).isEqualTo("value1");
-  }
-
-  @Test
-  @UseLocalDisk
-  @GlobalPluginConfig(
-      pluginName = "test",
-      name = "section.name",
-      values = {"value-1", "value-2"})
-  public void testList() {
-    assertThat(cfg().getStringList("section", null, "name"))
-        .asList()
-        .containsExactly("value-1", "value-2");
-  }
-
-  @Test
-  @UseLocalDisk
-  @GlobalPluginConfig(
-      pluginName = "test",
-      name = "section.subsection.name",
-      values = {"value-1", "value-2"})
-  public void testListWithSubsection() {
-    assertThat(cfg().getStringList("section", "subsection", "name"))
-        .asList()
-        .containsExactly("value-1", "value-2");
-  }
-
-  @Test
-  @UseLocalDisk
-  @GlobalPluginConfig(
-      pluginName = "test",
-      name = "section.name",
-      value = "value-1",
-      values = {"value-2", "value-3"})
-  public void valueHasPrecedenceOverValues() {
-    assertThat(cfg().getStringList("section", null, "name")).asList().containsExactly("value-1");
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 5719c7f..f9ba8a2 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -36,6 +36,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -50,7 +51,6 @@
 import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.github.rholder.retry.StopStrategies;
-import com.google.common.cache.LoadingCache;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -64,12 +64,12 @@
 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;
@@ -111,7 +111,6 @@
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 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;
@@ -124,6 +123,7 @@
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.httpd.CacheBasedWebSession;
 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;
@@ -138,7 +138,6 @@
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
-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;
@@ -151,7 +150,6 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.name.Named;
 import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -231,13 +229,10 @@
   @Inject private StalenessChecker stalenessChecker;
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
 
   @Inject protected Emails emails;
 
-  @Inject
-  @Named("accounts")
-  private LoadingCache<Account.Id, AccountState> accountsCache;
-
   @Inject private AccountOperations accountOperations;
 
   @Inject protected GroupOperations groupOperations;
@@ -288,9 +283,8 @@
       boolean exclusive,
       String labelName,
       int min,
-      int max)
-      throws IOException {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+      int max) {
+    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
     AccessSection accessSection = cfg.getAccessSection(ref);
     assertThat(accessSection).isNotNull();
 
@@ -938,7 +932,7 @@
       gApi.changes().id(r.getChangeId()).abandon();
       List<Message> messages = sender.getMessages();
       assertThat(messages).hasSize(1);
-      assertThat(messages.get(0).rcpt()).containsExactly(user2.getEmailAddress());
+      assertThat(messages.get(0).rcpt()).containsExactly(user2.getNameEmail());
       accountIndexedCounter.assertNoReindex();
     }
   }
@@ -964,7 +958,7 @@
       List<Message> messages = sender.getMessages();
       assertThat(messages).hasSize(1);
       Message message = messages.get(0);
-      assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+      assertThat(message.rcpt()).containsExactly(user.getNameEmail());
       assertMailReplyTo(message, admin.email());
       accountIndexedCounter.assertNoReindex();
     }
@@ -984,7 +978,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
-    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(message.rcpt()).containsExactly(user.getNameEmail());
     assertMailReplyTo(message, admin.email());
 
     sender.clear();
@@ -1005,7 +999,7 @@
     List<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
-    assertThat(message2.rcpt()).containsExactly(user.getEmailAddress(), user2.getEmailAddress());
+    assertThat(message2.rcpt()).containsExactly(user.getNameEmail(), user2.getNameEmail());
     assertMailReplyTo(message, admin.email());
 
     sender.clear();
@@ -1037,7 +1031,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
-    assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(message.rcpt()).containsExactly(user.getNameEmail());
     assertMailReplyTo(message, admin.email());
 
     sender.clear();
@@ -1050,7 +1044,7 @@
     List<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
-    assertThat(message2.rcpt()).containsExactly(user.getEmailAddress(), user2.getEmailAddress());
+    assertThat(message2.rcpt()).containsExactly(user.getNameEmail(), user2.getNameEmail());
     assertMailReplyTo(message2, admin.email());
 
     sender.clear();
@@ -1087,7 +1081,7 @@
     String email = "preferred@example.com";
     String name = "Foo";
     String username = name("foo");
-    TestAccount foo = accountCreator.create(username, email, name);
+    TestAccount foo = accountCreator.create(username, email, name, null);
     String secondaryEmail = "secondary@example.com";
     EmailInput input = newEmailInput(secondaryEmail);
     gApi.accounts().id(foo.id().get()).addEmail(input);
@@ -1112,7 +1106,7 @@
   public void detailOfOtherAccountDoesntIncludeSecondaryEmailsWithoutModifyAccount()
       throws Exception {
     String email = "preferred@example.com";
-    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
     String secondaryEmail = "secondary@example.com";
     EmailInput input = newEmailInput(secondaryEmail);
     gApi.accounts().id(foo.id().get()).addEmail(input);
@@ -1125,7 +1119,7 @@
   @Test
   public void detailOfOtherAccountIncludeSecondaryEmailsWithModifyAccount() throws Exception {
     String email = "preferred@example.com";
-    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
     String secondaryEmail = "secondary@example.com";
     EmailInput input = newEmailInput(secondaryEmail);
     gApi.accounts().id(foo.id().get()).addEmail(input);
@@ -1137,7 +1131,7 @@
   @Test
   public void getOwnEmails() throws Exception {
     String email = "preferred@example.com";
-    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
 
     requestScopeOperations.setApiUser(foo.id());
     assertThat(getEmails()).containsExactly(email);
@@ -1154,7 +1148,7 @@
   @Test
   public void cannotGetEmailsOfOtherAccountWithoutModifyAccount() throws Exception {
     String email = "preferred2@example.com";
-    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
 
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
@@ -1166,7 +1160,7 @@
   public void getEmailsOfOtherAccount() throws Exception {
     String email = "preferred3@example.com";
     String secondaryEmail = "secondary3@example.com";
-    TestAccount foo = accountCreator.create(name("foo"), email, "Foo");
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
     EmailInput input = newEmailInput(secondaryEmail);
     gApi.accounts().id(foo.id().get()).addEmail(input);
 
@@ -1257,7 +1251,7 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(new Address(email));
+    assertThat(m.rcpt()).containsExactly(Address.create(email));
   }
 
   @Test
@@ -2082,7 +2076,7 @@
 
       assertThat(sender.getMessages()).hasSize(1);
       Message message = sender.getMessages().get(0);
-      assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+      assertThat(message.rcpt()).containsExactly(user.getNameEmail());
       assertThat(message.body()).contains("new SSH keys have been added");
 
       // Delete key
@@ -2094,7 +2088,7 @@
 
       assertThat(sender.getMessages()).hasSize(1);
       message = sender.getMessages().get(0);
-      assertThat(message.rcpt()).containsExactly(user.getEmailAddress());
+      assertThat(message.rcpt()).containsExactly(user.getNameEmail());
       assertThat(message.body()).contains("SSH keys have been deleted");
     }
   }
@@ -2150,7 +2144,7 @@
     // Create an account with a preferred email.
     String username = name("foo");
     String email = username + "@example.com";
-    TestAccount account = accountCreator.create(username, email, "Foo Bar");
+    TestAccount account = accountCreator.create(username, email, "Foo Bar", null);
 
     ConsistencyCheckInput input = new ConsistencyCheckInput();
     input.checkAccounts = new CheckAccountsInput();
@@ -2331,7 +2325,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
@@ -2385,7 +2381,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r ->
                     r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
                         .withBlockStrategy(noSleepBlockStrategy)),
@@ -2443,7 +2441,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
@@ -2516,7 +2516,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
@@ -2578,7 +2580,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);
@@ -2642,15 +2644,11 @@
   }
 
   private void assertStaleAccountAndReindex(Account.Id accountId) throws IOException {
-    // Evict account from cache to be sure that we use the index state for staleness checks. This
-    // 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
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index 72a8264..f78cb9ab 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -86,18 +86,6 @@
   }
 
   @Test
-  public void indexingUpdatesStaleCache() throws Exception {
-    Account.Id accountId = createAccount("foo");
-    loadAccountToCache(accountId);
-    String status = "ooo";
-    updateAccountWithoutCacheOrIndex(accountId, newAccountUpdate().setStatus(status).build());
-    assertThat(accountCache.get(accountId).get().account().status()).isNull();
-
-    accountIndexer.index(accountId);
-    assertThat(accountCache.get(accountId).get().account().status()).isEqualTo(status);
-  }
-
-  @Test
   public void reindexingStaleAccountUpdatesTheIndex() throws Exception {
     Account.Id accountId = createAccount("foo");
     String preferredEmail = "foo@example.com";
@@ -140,7 +128,6 @@
   }
 
   private void reloadAccountToCache(Account.Id accountId) {
-    accountCache.evict(accountId);
     loadAccountToCache(accountId);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 5bc0473..b41a2f3 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.extensions.client.AccountFieldName;
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/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 6717fb7..746e6fe 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -47,7 +47,7 @@
   @Before
   public void setUp() throws Exception {
     String name = name("user42");
-    user42 = accountCreator.create(name, name + "@example.com", "User 42");
+    user42 = accountCreator.create(name, name + "@example.com", "User 42", null);
   }
 
   @Test
@@ -56,7 +56,7 @@
     assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
     assertThat(o.my)
         .containsExactly(
-            new MenuItem("Changes", "#/dashboard/self", null),
+            new MenuItem("Dashboard", "#/dashboard/self", null),
             new MenuItem("Draft Comments", "#/q/has:draft", null),
             new MenuItem("Edits", "#/q/has:edit", null),
             new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
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 6a360c9..d25e6541f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,6 +47,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -74,13 +75,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;
@@ -144,8 +145,10 @@
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -180,6 +183,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -249,6 +253,7 @@
     assertThat(c.owner.email).isNull();
     assertThat(c.owner.username).isNull();
     assertThat(c.owner.avatars).isNull();
+    assertThat(c.submissionId).isNull();
   }
 
   @Test
@@ -294,19 +299,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 +307,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();
@@ -1520,8 +1522,8 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.from().getName()).isEqualTo("Administrator (Code Review)");
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.from().name()).isEqualTo("Administrator (Code Review)");
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("I'd like you to do a code review");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertMailReplyTo(m, admin.email());
@@ -1590,7 +1592,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
@@ -1774,7 +1776,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
@@ -1902,7 +1904,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(new Address(fullname, email));
+    assertThat(m.rcpt()).containsExactly(Address.create(fullname, email));
     assertThat(m.body()).contains("Hello " + fullname + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
@@ -1963,7 +1965,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(new Address(myGroupUserFullname, myGroupUserEmail));
+    assertThat(m.rcpt()).containsExactly(Address.create(myGroupUserFullname, myGroupUserEmail));
     assertThat(m.body()).contains("Hello " + myGroupUserFullname + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
@@ -2165,7 +2167,7 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
   }
 
   @Test
@@ -2199,7 +2201,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());
@@ -2207,7 +2209,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
@@ -2395,7 +2416,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
     assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
     assertThat(msg.body())
         .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
@@ -3049,7 +3070,7 @@
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()), c.updated, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.updated, serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -3058,7 +3079,7 @@
       RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()), c.created, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -3194,14 +3215,61 @@
     mergeInput.source = "dev";
     MergePatchSetInput in = new MergePatchSetInput();
     in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-    gApi.changes().id(changeId).createMergePatchSet(in);
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isNull();
+      assertThat(changeInfo.workInProgress).isNull();
+    }
+    assertThat(wipStateChangedListener.invoked).isFalse();
+
+    // To get the revisions, we must retrieve the change with more change options.
     ChangeInfo changeInfo =
         gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
     assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo(in.subject);
     assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
         .isEqualTo(parent);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
+        .contains(subject);
+  }
+
+  @Test
+  public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch("dev");
+
+    // create a change for master
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String subject = result.getChange().change().getSubject();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result pushResult =
+        pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
+    pushResult.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = null;
+
+    // Ensure subject carries over
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.subject).isEqualTo(subject);
   }
 
   @Test
@@ -3240,6 +3308,142 @@
   }
 
   @Test
+  public void createMergePatchSet_ConflictAllowed() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch("dev");
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isTrue();
+      assertThat(changeInfo.workInProgress).isTrue();
+    }
+    assertThat(wipStateChangedListener.invoked).isTrue();
+    assertThat(wipStateChangedListener.wip).isTrue();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify that the file content in the created patch set is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
+    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< TARGET BRANCH ("
+                + targetSha1
+                + " "
+                + targetSubject
+                + ")\n"
+                + targetContent
+                + "\n"
+                + "=======\n"
+                + sourceContent
+                + "\n"
+                + ">>>>>>> SOURCE BRANCH ("
+                + sourceSha1
+                + " "
+                + sourceSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + fileName
+                + "\n");
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch("dev");
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    mergeInput.strategy = "simple-two-way-in-core";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
   public void createMergePatchSetInheritParent() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch("dev");
@@ -3459,6 +3663,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);
@@ -3619,6 +3824,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);
@@ -3634,11 +3840,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;
@@ -4227,7 +4456,7 @@
     amendChange(r.getChangeId());
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
-    Address address = new Address(fullname, email);
+    Address address = Address.create(fullname, email);
     assertThat(messages.get(0).rcpt()).containsExactly(address);
 
     // Review notification is not sent to users ignoring the change
@@ -4324,7 +4553,7 @@
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(messages.get(0).rcpt()).containsExactly(user.getNameEmail());
   }
 
   @Test
@@ -4434,13 +4663,11 @@
             ListChangesOption.ALL_COMMITS,
             ListChangesOption.ALL_REVISIONS,
             ListChangesOption.CHANGE_ACTIONS,
-            ListChangesOption.CURRENT_ACTIONS,
             ListChangesOption.DETAILED_LABELS,
             ListChangesOption.DOWNLOAD_COMMANDS,
             ListChangesOption.MESSAGES,
             ListChangesOption.SUBMITTABLE,
             ListChangesOption.WEB_LINKS,
-            ListChangesOption.SKIP_MERGEABLE,
             ListChangesOption.SKIP_DIFFSTAT);
 
     PushOneCommit.Result change = createChange();
@@ -4452,6 +4679,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");
   }
@@ -4473,4 +4716,17 @@
   private interface AddReviewerCaller {
     void call(String changeId, String reviewer) throws RestApiException;
   }
+
+  private static class TestWorkInProgressStateChangedListener
+      implements WorkInProgressStateChangedListener {
+    boolean invoked;
+    Boolean wip;
+
+    @Override
+    public void onWorkInProgressStateChanged(Event event) {
+      this.invoked = true;
+      this.wip =
+          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index fcc33c9..5523f9c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.SubmitRecord;
@@ -40,14 +39,9 @@
 
 public class ChangeSubmitRequirementIT extends AbstractDaemonTest {
   private static final SubmitRequirement req =
-      SubmitRequirement.builder()
-          .setType("custom_rule")
-          .setFallbackText("Fallback text")
-          .addCustomValue("key", "value")
-          .build();
+      SubmitRequirement.builder().setType("custom_rule").setFallbackText("Fallback text").build();
   private static final SubmitRequirementInfo reqInfo =
-      new SubmitRequirementInfo(
-          "NOT_READY", "Fallback text", "custom_rule", ImmutableMap.of("key", "value"));
+      new SubmitRequirementInfo("NOT_READY", "Fallback text", "custom_rule");
 
   @Override
   public Module createModule() {
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..be0cc04 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -16,6 +16,9 @@
 
 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.eq;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -24,19 +27,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,6 +52,8 @@
 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;
@@ -51,12 +61,42 @@
 
 /** Tests for comment validation in {@link PostReview}. */
 public class PostReviewIT extends AbstractDaemonTest {
+
   @Inject private CommentValidator mockCommentValidator;
   @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 final Correspondence<CommentForValidation, CommentForValidation>
+      COMMENT_CORRESPONDENCE =
+          Correspondence.from(
+              (left, right) ->
+                  left != null
+                      && right != null
+                      && left.getSource() == right.getSource()
+                      && left.getType() == right.getType()
+                      && left.getText().equals(right.getText()),
+              "matches (ignoring size approximation)");
 
   @Override
   public Module createModule() {
@@ -80,15 +120,11 @@
 
   @Test
   public void validateCommentsInInput_commentOK() throws Exception {
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(
-                    CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
+    PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture()))
         .thenReturn(ImmutableList.of());
 
-    PushOneCommit.Result r = createChange();
-
-    ReviewInput input = new ReviewInput();
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
     comment.updated = new Timestamp(0);
     input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment));
@@ -96,20 +132,17 @@
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
     gApi.changes().id(r.getChangeId()).current().review(input);
 
+    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, FILE_COMMENT_FOR_VALIDATION);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
   }
 
   @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)), captor.capture()))
+        .thenReturn(ImmutableList.of(FILE_COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
 
-    ReviewInput input = new ReviewInput();
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
     comment.updated = new Timestamp(0);
     input.comments = ImmutableMap.of(comment.path, ImmutableList.of(comment));
@@ -119,6 +152,7 @@
         assertThrows(
             BadRequestException.class,
             () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, FILE_COMMENT_FOR_VALIDATION);
     assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
     assertThat(
             Iterables.getOnlyElement(
@@ -151,37 +185,29 @@
 
   @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)), captor.capture()))
+        .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();
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     input.drafts = DraftHandling.PUBLISH;
 
     gApi.changes().id(r.getChangeId()).current().review(input);
+    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, INLINE_COMMENT_FOR_VALIDATION);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
   }
 
   @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)), captor.capture()))
+        .thenReturn(ImmutableList.of(INLINE_COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
 
     DraftInput draft =
         testCommentHelper.newDraft(
@@ -189,12 +215,13 @@
     testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draft);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
 
-    ReviewInput input = new ReviewInput();
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     input.drafts = DraftHandling.PUBLISH;
     BadRequestException badRequestException =
         assertThrows(
             BadRequestException.class,
             () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, INLINE_COMMENT_FOR_VALIDATION);
     assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
     assertThat(
             Iterables.getOnlyElement(
@@ -218,33 +245,38 @@
     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();
+    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     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())
-        .containsExactly(
-            CommentForValidation.create(
-                CommentForValidation.CommentType.INLINE_COMMENT, draftInline.message),
-            CommentForValidation.create(
-                CommentForValidation.CommentType.FILE_COMMENT, draftFile.message));
+    assertValidatorCalledWith(
+        CHANGE_MESSAGE_FOR_VALIDATION,
+        CommentForValidation.create(
+            CommentForValidation.CommentSource.HUMAN,
+            CommentForValidation.CommentType.INLINE_COMMENT,
+            draftInline.message,
+            draftInline.message.length()),
+        CommentForValidation.create(
+            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(eq(contextFor(r)), captor.capture()))
+        .thenReturn(ImmutableList.of());
 
     ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     int numMessages = gApi.changes().id(r.getChangeId()).get().messages.size();
     gApi.changes().id(r.getChangeId()).current().review(input);
+    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION);
     assertThat(gApi.changes().id(r.getChangeId()).get().messages).hasSize(numMessages + 1);
     ChangeMessageInfo message =
         Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
@@ -253,13 +285,9 @@
 
   @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(eq(contextFor(r)), captor.capture()))
+        .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)
@@ -268,6 +296,7 @@
         assertThrows(
             BadRequestException.class,
             () -> gApi.changes().id(r.getChangeId()).current().review(input));
+    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION);
     assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
     assertThat(
             Iterables.getOnlyElement(
@@ -284,7 +313,94 @@
     assertThat(message.message).doesNotContain(COMMENT_TEXT);
   }
 
+  @Test
+  @GerritConfig(name = "change.maxComments", value = "7")
+  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);
+    // Counting change messages plus comments we now have 4.
+
+    // reviewInput still has both a user and a robot comment (and deduplication is false). We also
+    // create a draft, and there's the change message, so that in total there would be 8 comments.
+    // The limit is set to 7, so this verifies that all new comments 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: 4 (existing) + 4 (new) > 7");
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+    assertThat(getRobotComments(r.getChangeId())).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "7k")
+  public void validateCumulativeCommentSize() throws Exception {
+    PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(eq(contextFor(r)), any()))
+        .thenReturn(ImmutableList.of());
+
+    // Use large sizes because autogenerated messages already have O(100) bytes.
+    String commentText2000Bytes = new String(new char[2000]).replace("\0", "x");
+    String filePath = r.getChange().currentFilePaths().get(0);
+    ReviewInput reviewInput = new ReviewInput().message(commentText2000Bytes);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = commentText2000Bytes;
+    commentInput.path = filePath;
+    reviewInput.comments = ImmutableMap.of(filePath, ImmutableList.of(commentInput));
+
+    // Use up ~4000 bytes.
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    // Hit the limit when trying that again.
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(reviewInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .contains("Exceeding maximum cumulative size of comments");
+  }
+
+  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());
+  }
+
+  private void assertValidatorCalledWith(CommentForValidation... commentsForValidation) {
+    assertThat(captor.getAllValues()).hasSize(1);
+    assertThat(captor.getValue())
+        .comparingElementsUsing(COMMENT_CORRESPONDENCE)
+        .containsExactly(commentsForValidation);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 6839b96..892455b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -15,16 +15,30 @@
 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 com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 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.TopLevelResource;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.restapi.change.QueryChanges;
@@ -38,7 +52,8 @@
 
 @NoHttpd
 public class QueryChangesIT extends AbstractDaemonTest {
-
+  @Inject private AccountOperations accountOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private Provider<QueryChanges> queryChangesProvider;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -108,6 +123,173 @@
   }
 
   @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);
+    }
+  }
+
+  @Test
+  public void queryByFullNameEmailFormatWithEmptyFullNameWhenEmailMatchesSeveralAccounts()
+      throws Exception {
+    // Create 2 accounts with the same preferred email (both account must have no external ID for
+    // the email because otherwise the account with the external ID takes precedence).
+    String email = "foo.bar@example.com";
+    Account.Id account1 = accountOperations.newAccount().create();
+    accountOperations
+        .account(account1)
+        .forInvalidation()
+        .preferredEmailWithoutExternalId(email)
+        .invalidate();
+    Account.Id account2 = accountOperations.newAccount().create();
+    accountOperations
+        .account(account2)
+        .forInvalidation()
+        .preferredEmailWithoutExternalId(email)
+        .invalidate();
+
+    // Search with "Full Name <email>" format, but without full name. Both created accounts match
+    // the email. In this case Gerrit falls back to match on the full name. Check that this logic
+    // doesn't fail if the full name in the input string is not present.
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("<" + email + ">");
+    assertThat(queryChanges.apply(TopLevelResource.INSTANCE).statusCode()).isEqualTo(SC_OK);
+  }
+
+  @Test
+  public void defaultQueryCannotBeParsedDueToInvalidRegEx() throws Exception {
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("^[A");
+    BadRequestException e =
+        assertThrows(
+            BadRequestException.class, () -> queryChanges.apply(TopLevelResource.INSTANCE));
+    assertThat(e).hasMessageThat().contains("no viable alternative at character '['");
+  }
+
+  @Test
+  public void defaultQueryWithInvalidQuotedRegEx() throws Exception {
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("\"^[A\"");
+    BadRequestException e =
+        assertThrows(
+            BadRequestException.class, () -> queryChanges.apply(TopLevelResource.INSTANCE));
+    assertThat(e).hasMessageThat().isEqualTo("invalid regular expression: [A");
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  @GerritConfig(name = "has-operand-alias.change.unaddressedaliastest", value = "unresolved")
+  public void hasOperandAliasQuery() 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;
+
+    ReviewInput input = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.line = 1;
+    comment.message = "comment";
+    comment.unresolved = true;
+    input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
+    gApi.changes().id(cId2).current().review(input);
+
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("is:open repo:" + project.get());
+    queryChanges.addQuery("has:unaddressedaliastest repo:" + project.get());
+
+    List<List<ChangeInfo>> result =
+        (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE).value();
+    assertThat(result).hasSize(2);
+    assertThat(result.get(0)).hasSize(2);
+    assertThat(result.get(1)).hasSize(1);
+
+    List<Integer> firstResultIds =
+        ImmutableList.of(result.get(0).get(0)._number, result.get(0).get(1)._number);
+    assertThat(firstResultIds).containsExactly(numericId1, numericId2);
+    assertThat(result.get(1).get(0)._number).isEqualTo(numericId2);
+  }
+
+  @Test
   public void skipVisibility_rejectedForNonAdmin() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     final QueryChanges queryChanges = queryChangesProvider.get();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 0607a3c..24d08db 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,23 @@
 
   @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();
-    projectCache.checkedGet(project).getProject().setState(ProjectState.READ_ONLY);
+    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();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+      u.save();
+    }
 
+    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 +435,814 @@
     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
+  public void revertNotAllowedForOwnerWithoutRevertPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.REVERT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result result = createChange();
+    approve(result.getChangeId());
+    gApi.changes().id(result.getChangeId()).current().submit();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.changes().id(result.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains("revert not permitted");
+  }
+
+  @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.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+      u.save();
+    }
+
+    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 revertSubmissionNotAllowedForOwnerWithoutRevertPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.REVERT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result result = createChange();
+    approve(result.getChangeId());
+    gApi.changes().id(result.getChangeId()).current().submit();
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(result.getChangeId()).revertSubmission());
+    assertThat(thrown).hasMessageThat().contains("revert 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
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void revertSubmissionUnrelatedWithAnotherDependantChangeWithDifferentTopic()
+      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());
+
+    // A non-merged change without the same topic that is related to the second change.
+    createChange();
+
+    gApi.changes().id(firstResult.getChangeId()).current().submit();
+
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(secondResult.getChangeId()).revertSubmission();
+
+    List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    Collections.reverse(revertChanges);
+    assertThat(revertChanges.get(0).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    // The parent of the first revert is the merge change of the submission.
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).subject)
+        .contains("Merge \"second change\"");
+    // Next revert would base itself on the previous revert.
+    String sha1FirstRevert = revertChanges.get(0).current().commit(false).commit;
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1FirstRevert);
+
+    assertThat(revertChanges).hasSize(2);
+  }
+
+  @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 +1284,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/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 7e69251..923b66f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -185,6 +185,35 @@
   }
 
   @Test
+  public void stickyOnCopyValues() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .getLabelSections()
+          .get("Code-Review")
+          .setCopyValues(ImmutableList.of((short) -1, (short) 1));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = createChange(changeKind);
+      vote(admin, changeId, -1, 1);
+      vote(user, changeId, -2, -1);
+      vote(user2, changeId, 1, -1);
+
+      updateChange(changeId, changeKind);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, -1, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+      assertVotes(c, user2, 1, 0, changeKind);
+    }
+  }
+
+  @Test
   public void stickyOnTrivialRebase() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
diff --git a/javatests/com/google/gerrit/acceptance/api/config/DefaultConfigCacheIT.java b/javatests/com/google/gerrit/acceptance/api/config/DefaultConfigCacheIT.java
new file mode 100644
index 0000000..532fa42
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/DefaultConfigCacheIT.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.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.server.config.CachedPreferences;
+import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class DefaultConfigCacheIT extends AbstractDaemonTest {
+  @Inject DefaultPreferencesCache defaultPreferencesCache;
+
+  @Test
+  public void invalidatesOldValue() throws Exception {
+    CachedPreferences before = defaultPreferencesCache.get();
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = 123;
+    gApi.config().server().setDefaultDiffPreferences(update);
+    assertThat(before).isNotEqualTo(defaultPreferencesCache.get());
+  }
+
+  @Test
+  public void subsequentCallsReturnSameInstance() {
+    assertThat(defaultPreferencesCache.get()).isSameInstanceAs(defaultPreferencesCache.get());
+  }
+
+  @Test
+  public void canLoadAtSpecificRev() throws Exception {
+    // Set a value to make sure we have custom preferences set
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = 1337;
+    gApi.config().server().setDefaultDiffPreferences(update);
+
+    ObjectId oldRev = currentRev();
+    CachedPreferences before = defaultPreferencesCache.get();
+
+    // Mutate the preferences
+    DiffPreferencesInfo update2 = new DiffPreferencesInfo();
+    update2.lineLength = 815;
+    gApi.config().server().setDefaultDiffPreferences(update2);
+
+    assertThat(oldRev).isNotEqualTo(currentRev());
+    assertThat(defaultPreferencesCache.get()).isNotEqualTo(before);
+    assertThat(defaultPreferencesCache.get(oldRev)).isEqualTo(before);
+  }
+
+  private ObjectId currentRev() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return repo.exactRef(RefNames.REFS_USERS_DEFAULT).getObjectId();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index b53681b..10bd03b 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
@@ -27,6 +28,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 import static java.util.stream.Collectors.toList;
@@ -40,7 +42,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 +49,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;
@@ -72,6 +74,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
+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;
@@ -79,7 +82,10 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gerrit.server.account.GroupsSnapshotReader;
+import com.google.gerrit.server.auth.ldap.FakeLdapGroupBackend;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -94,7 +100,9 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.Module;
 import java.io.IOException;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
@@ -113,6 +121,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -138,6 +147,18 @@
   @Inject private Sequences seq;
   @Inject private StalenessChecker stalenessChecker;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private GroupsSnapshotReader groupsSnapshotReader;
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        /** Binding a {@link FakeLdapGroupBackend} to test adding external groups * */
+        DynamicSet.bind(binder(), GroupBackend.class).to(FakeLdapGroupBackend.class);
+      }
+    };
+  }
 
   @After
   public void consistencyCheck() throws Exception {
@@ -187,6 +208,81 @@
   }
 
   @Test
+  public void addExternalGroups() throws Exception {
+    AccountGroup.UUID group1 = groupOperations.newGroup().create();
+    AccountGroup.UUID group2 = groupOperations.newGroup().create();
+    String g1RefName = RefNames.refsGroups(group1);
+    String g2RefName = RefNames.refsGroups(group2);
+
+    gApi.groups().id(group1.get()).addGroups("ldap:external_g1");
+    gApi.groups().id(group2.get()).addGroups("ldap:external_g2");
+
+    assertThat(groupIncludeCache.allExternalMembers())
+        .containsAtLeastElementsIn(
+            ImmutableList.of(
+                AccountGroup.UUID.parse("ldap:external_g1"),
+                AccountGroup.UUID.parse("ldap:external_g2")));
+
+    assertThat(groupIncludeCache.parentGroupsOf(AccountGroup.UUID.parse("ldap:external_g1")))
+        .containsExactly(group1);
+    assertThat(groupIncludeCache.parentGroupsOf(AccountGroup.UUID.parse("ldap:external_g2")))
+        .containsExactly(group2);
+
+    GroupsSnapshotReader.Snapshot snapshot = groupsSnapshotReader.getSnapshot();
+
+    gApi.groups().id(group1.get()).removeGroups("ldap:external_g1");
+
+    GroupsSnapshotReader.Snapshot newSnapshot = groupsSnapshotReader.getSnapshot();
+
+    /** Make sure groups snapshots are consistent */
+    ObjectId g1ObjectId = getObjectIdFromSnapshot(snapshot, g1RefName);
+    ObjectId g2ObjectId = getObjectIdFromSnapshot(snapshot, g2RefName);
+    assertThat(snapshot.hash()).isNotEqualTo(newSnapshot.hash());
+    assertThat(g1ObjectId).isNotEqualTo(getObjectIdFromSnapshot(newSnapshot, g1RefName));
+    assertThat(g2ObjectId).isEqualTo(getObjectIdFromSnapshot(newSnapshot, g2RefName));
+    assertThat(snapshot.groupsRefs().stream().map(Ref::getName).collect(toList()))
+        .containsAtLeastElementsIn(ImmutableList.of(g1RefName, g2RefName));
+    assertThat(newSnapshot.groupsRefs().stream().map(Ref::getName).collect(toList()))
+        .containsAtLeastElementsIn(ImmutableList.of(g1RefName, g2RefName));
+
+    /** GroupIncludeCache should return ldap:external_g2 only */
+    assertThat(groupIncludeCache.allExternalMembers())
+        .contains(AccountGroup.UUID.parse("ldap:external_g2"));
+
+    /** Testing groups.getExternalGroups() with the old Snapshot */
+    assertThat(groups.getExternalGroups(snapshot.groupsRefs()))
+        .containsAtLeastElementsIn(
+            ImmutableList.of(
+                AccountGroup.UUID.parse("ldap:external_g1"),
+                AccountGroup.UUID.parse("ldap:external_g2")));
+  }
+
+  private ObjectId getObjectIdFromSnapshot(GroupsSnapshotReader.Snapshot snapshot, String refName) {
+    return snapshot.groupsRefs().stream()
+        .filter(r -> r.getName().equals(refName))
+        .map(Ref::getObjectId)
+        .collect(onlyElement());
+  }
+
+  @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();
@@ -345,6 +441,13 @@
   }
 
   @Test
+  public void createGroupNameIsTrimmed() throws Exception {
+    String newGroupName = name("newGroup");
+    GroupInfo g = gApi.groups().create(" " + newGroupName + " ").get();
+    assertGroupInfo(group(newGroupName), g);
+  }
+
+  @Test
   public void createDuplicateInternalGroupCaseSensitiveName_Conflict() throws Exception {
     String dupGroupName = name("dupGroup");
     gApi.groups().create(dupGroupName);
@@ -821,7 +924,8 @@
     List<String> expectedGroups =
         groups.getAllGroupReferences().map(GroupReference::getName).sorted().collect(toList());
     assertThat(expectedGroups.size()).isAtLeast(2);
-    assertThat(gApi.groups().list().getAsMap().keySet())
+    assertThatMap(gApi.groups().list().getAsMap())
+        .keys()
         .containsExactlyElementsIn(expectedGroups)
         .inOrder();
   }
@@ -868,20 +972,19 @@
     gApi.groups().create(in);
 
     requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.groups().list().getAsMap()).doesNotContainKey(newGroupName);
+    assertThatMap(gApi.groups().list().getAsMap()).keys().doesNotContain(newGroupName);
 
     requestScopeOperations.setApiUser(admin.id());
     gApi.groups().id(newGroupName).addMembers(user.username());
 
     requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.groups().list().getAsMap()).containsKey(newGroupName);
+    assertThatMap(gApi.groups().list().getAsMap()).keys().contains(newGroupName);
   }
 
   @Test
   public void suggestGroup() throws Exception {
     Map<String, GroupInfo> groups = gApi.groups().list().withSuggest("adm").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly("Administrators");
     assertBadRequest(gApi.groups().list().withSuggest("adm").withSubstring("foo"));
     assertBadRequest(gApi.groups().list().withSuggest("adm").withRegex("foo.*"));
     assertBadRequest(gApi.groups().list().withSuggest("adm").withUser("user"));
@@ -898,19 +1001,15 @@
     // Choose a substring which isn't part of any group or test method within this class.
     String substring = "efghijk";
     Map<String, GroupInfo> groups = gApi.groups().list().withSubstring(substring).getAsMap();
-    assertThat(groups).containsKey(group);
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly(group);
 
     groups = gApi.groups().list().withSubstring("abcdefghi").getAsMap();
-    assertThat(groups).containsKey(group);
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly(group);
 
     String otherGroup = name("Abcdefghijklmnop2");
     gApi.groups().create(otherGroup);
     groups = gApi.groups().list().withSubstring(substring).getAsMap();
-    assertThat(groups).hasSize(2);
-    assertThat(groups).containsKey(group);
-    assertThat(groups).containsKey(otherGroup);
+    assertThatMap(groups).keys().containsExactly(group, otherGroup);
 
     groups = gApi.groups().list().withSubstring("non-existing-substring").getAsMap();
     assertThat(groups).isEmpty();
@@ -919,15 +1018,13 @@
   @Test
   public void withRegex() throws Exception {
     Map<String, GroupInfo> groups = gApi.groups().list().withRegex("Admin.*").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly("Administrators");
 
     groups = gApi.groups().list().withRegex("admin.*").getAsMap();
     assertThat(groups).isEmpty();
 
     groups = gApi.groups().list().withRegex(".*istrators").getAsMap();
-    assertThat(groups).containsKey("Administrators");
-    assertThat(groups).hasSize(1);
+    assertThatMap(groups).keys().containsExactly("Administrators");
 
     assertBadRequest(gApi.groups().list().withRegex(".*istrators").withSubstring("s"));
   }
@@ -936,8 +1033,7 @@
   public void allGroupInfoFieldsSetCorrectly() throws Exception {
     InternalGroup adminGroup = adminGroup();
     Map<String, GroupInfo> groups = gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
-    assertThat(groups).hasSize(1);
-    assertThat(groups).containsKey("Administrators");
+    assertThatMap(groups).keys().containsExactly("Administrators");
     assertGroupInfo(adminGroup, Iterables.getOnlyElement(groups.values()));
   }
 
@@ -1312,7 +1408,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);
@@ -1341,17 +1437,6 @@
   }
 
   @Test
-  public void groupNamesWithLeadingAndTrailingWhitespace() throws Exception {
-    for (String leading : ImmutableList.of("", " ", "  ")) {
-      for (String trailing : ImmutableList.of("", " ", "  ")) {
-        String name = leading + name("group") + trailing;
-        GroupInfo g = gApi.groups().create(name).get();
-        assertThat(g.name).isEqualTo(name);
-      }
-    }
-  }
-
-  @Test
   @Sandboxed
   public void groupsOfUserCanBeListedInSlaveMode() throws Exception {
     GroupInput groupInput = new GroupInput();
@@ -1453,11 +1538,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 67da084..6838f8d 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/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index b8c1818..3fc6e44 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -62,7 +62,7 @@
     AccountGroup.UUID privilegedGroupUuid =
         groupOperations.newGroup().name(name("privilegedGroup")).create();
 
-    privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
+    privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden", null);
     groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
 
     projectOperations
@@ -226,7 +226,6 @@
                 privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
             TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
             TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
index 27dd16a..4163e17 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -53,14 +53,14 @@
     PushOneCommit.Result r = createChange("refs/for/master");
     String branch = r.getChange().change().getDest().branch();
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectResultInfo checkResult =
         gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
     assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
   }
 
@@ -121,7 +121,7 @@
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectResultInfo checkResult =
@@ -132,7 +132,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
   }
 
@@ -144,7 +144,7 @@
     RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
     serverSideTestRepo.branch(branch).update(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
@@ -156,7 +156,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -170,7 +170,7 @@
 
     serverSideTestRepo.commit(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
@@ -179,7 +179,7 @@
     CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
     assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     input.autoCloseableChangesCheck.maxCommits = 2;
@@ -190,7 +190,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -204,7 +204,7 @@
 
     serverSideTestRepo.commit(amendedCommit);
 
-    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    ChangeInfo info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
@@ -213,7 +213,7 @@
     CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
     assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     input.autoCloseableChangesCheck.skipCommits = 1;
@@ -224,7 +224,7 @@
                 .collect(toSet()))
         .containsExactly(r.getChange().getId().get());
 
-    info = gApi.changes().id(r.getChange().getId().get()).info();
+    info = change(r).info();
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 04625c5..e67770c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -169,6 +169,21 @@
     assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
   }
 
+  @Test
+  public void cherryPickCommitWithSetTopic() throws Exception {
+    String branch = "foo";
+    RevCommit revCommit = createChange().getCommit();
+    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+    CherryPickInput input = new CherryPickInput();
+    input.destination = branch;
+    input.topic = "topic";
+    String changeId =
+        gApi.projects().name(project.get()).commit(revCommit.name()).cherryPick(input).get().id;
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.topic).isEqualTo(input.topic);
+  }
+
   private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
     return gApi.projects().name(project.get()).commit(id.name()).includedIn();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 7f00930b..8dc76dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -33,10 +33,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.RestResponse;
+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;
@@ -46,6 +46,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;
@@ -73,13 +74,11 @@
 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;
 import org.junit.Test;
 
-@NoHttpd
 public class ProjectIT extends AbstractDaemonTest {
   private static final String BUGZILLA = "bugzilla";
   private static final String BUGZILLA_LINK = "http://bugzilla.example.com/?id=$2";
@@ -715,6 +714,139 @@
   }
 
   @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());
@@ -740,23 +872,17 @@
     // 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()));
-    }
+    projectOperations
+        .project(allProjects)
+        .forInvalidation()
+        .addProjectConfigUpdater(
+            cfg ->
+                cfg.setStringList(
+                    "label",
+                    "Code-Review",
+                    "value",
+                    ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad")))
+        .invalidate();
 
     // Verify that project info can be retrieved and that the label value "+1 LGTM" appears only
     // once.
@@ -766,6 +892,29 @@
         .containsExactly("+1", "LGTM", " 0", "No Value", "-1", "Looks Bad");
   }
 
+  @Test
+  public void getProjectThatHasInvalidProjectConfig() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forInvalidation()
+        .makeProjectConfigInvalid()
+        .invalidate();
+
+    // We must test this via the REST API since ExceptionHook is not invoked from the Java API.
+    RestResponse r = adminRestSession.get("/projects/" + allProjects.get());
+    r.assertConflict();
+    assertThat(r.getEntityContent())
+        .contains(
+            String.format(
+                "Invalid config file %s in project %s in branch %s in commit %s: "
+                    + "Bad entry delimiter\n"
+                    + "Please contact the project owner.",
+                ProjectConfig.PROJECT_CONFIG,
+                allProjects.get(),
+                RefNames.REFS_CONFIG,
+                projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).name()));
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     return new CommentLinkInfoImpl(name, match, link, null /*html*/, null /*enabled*/);
   }
@@ -774,6 +923,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 c1162ff..a53f298 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 String updatedCommitMessage() {
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 32941ff..74f9134 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;
@@ -72,7 +73,6 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-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.FileInfo;
@@ -303,13 +303,7 @@
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
-        assertThrows(
-            AuthException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChange().getId().get())
-                    .current()
-                    .review(ReviewInput.approve()));
+        assertThrows(AuthException.class, () -> change(r).current().review(ReviewInput.approve()));
     assertThat(thrown).hasMessageThat().contains("is restricted");
   }
 
@@ -323,7 +317,7 @@
     ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
 
     assertThat(orig.get().messages).hasSize(1);
-    CherryPickChangeInfo changeInfo = orig.revision(r.getCommit().name()).cherryPickAsInfo(in);
+    ChangeInfo changeInfo = orig.revision(r.getCommit().name()).cherryPickAsInfo(in);
     assertThat(changeInfo.containsGitConflicts).isNull();
     assertThat(changeInfo.workInProgress).isNull();
     ChangeApi cherry = gApi.changes().id(changeInfo._number);
@@ -336,6 +330,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();
   }
@@ -406,6 +403,52 @@
   }
 
   @Test
+  public void cherryPickWithSetTopic() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.topic = "topic";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().topic).isEqualTo("topic");
+  }
+
+  @Test
+  public void cherryPickNewPatchsetWithSetTopic() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().topic).isNull();
+    in.topic = "topic";
+    cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().topic).isEqualTo("topic");
+  }
+
+  @Test
+  public void cherryPickNewPatchsetWithNoTopic() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.topic = "topic";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().topic).isEqualTo("topic");
+
+    in.topic = null;
+    cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    // confirm that the topic doesn't change when not specified.
+    assertThat(cherry.get().topic).isEqualTo("topic");
+  }
+
+  @Test
   public void cherryPickWorkInProgressChange() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%wip");
     CherryPickInput in = new CherryPickInput();
@@ -415,23 +458,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.");
   }
@@ -499,6 +542,14 @@
             ResourceConflictException.class,
             () -> orig.revision(r.getCommit().name()).cherryPick(in));
     assertThat(thrown).hasMessageThat().contains("Cherry pick failed: identical tree");
+
+    in.allowEmpty = true;
+    ChangeInfo cherryPickChange = orig.revision(r.getCommit().name()).cherryPick(in).get();
+    assertThat(cherryPickChange.cherryPickOfChange).isEqualTo(r.getChange().change().getChangeId());
+
+    // An empty commit is created
+    assertThat(cherryPickChange.insertions).isEqualTo(0);
+    assertThat(cherryPickChange.deletions).isEqualTo(0);
   }
 
   @Test
@@ -557,7 +608,7 @@
     PushOneCommit.Result r = push.to("refs/for/master%topic=someTopic");
 
     // Verify before the cherry-pick that the change has exactly 1 message.
-    ChangeApi changeApi = gApi.changes().id(r.getChange().getId().get());
+    ChangeApi changeApi = change(r);
     assertThat(changeApi.get().messages).hasSize(1);
 
     // Cherry-pick the change to the other branch, that should fail with a conflict.
@@ -572,8 +623,7 @@
 
     // Cherry-pick with auto merge should succeed.
     in.allowConflicts = true;
-    CherryPickChangeInfo cherryPickChange =
-        changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
+    ChangeInfo cherryPickChange = changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
     assertThat(cherryPickChange.containsGitConflicts).isTrue();
     assertThat(cherryPickChange.workInProgress).isTrue();
 
@@ -624,6 +674,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
@@ -1011,6 +1097,57 @@
   }
 
   @Test
+  public void getRelatedCherryPicks() throws Exception {
+    PushOneCommit.Result r1 = createChange(SUBJECT, "a.txt", "a");
+    PushOneCommit.Result r2 = createChange(SUBJECT, "b.txt", "b");
+
+    String branch = "foo";
+    // Create target branch to cherry-pick to.
+    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+
+    CherryPickInput input = new CherryPickInput();
+    input.message = "message";
+    input.destination = branch;
+    ChangeInfo firstCherryPickResult =
+        gApi.changes().id(r1.getChangeId()).current().cherryPickAsInfo(input);
+
+    input.base = gApi.changes().id(firstCherryPickResult.changeId).current().commit(false).commit;
+    ChangeInfo secondCherryPickResult =
+        gApi.changes().id(r2.getChangeId()).current().cherryPickAsInfo(input);
+    assertThat(gApi.changes().id(firstCherryPickResult.changeId).current().related().changes)
+        .hasSize(2);
+    assertThat(gApi.changes().id(secondCherryPickResult.changeId).current().related().changes)
+        .hasSize(2);
+  }
+
+  @Test
+  public void cherryPickOnMergedChangeIsNotRelated() throws Exception {
+    PushOneCommit.Result r1 = createChange(SUBJECT, "a.txt", "a");
+    PushOneCommit.Result r2 = createChange(SUBJECT, "b.txt", "b");
+
+    String branch = "foo";
+    // Create target branch to cherry-pick to.
+    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+
+    CherryPickInput input = new CherryPickInput();
+    input.message = "message";
+    input.destination = branch;
+    ChangeInfo firstCherryPickResult =
+        gApi.changes().id(r1.getChangeId()).current().cherryPickAsInfo(input);
+
+    gApi.changes().id(firstCherryPickResult.id).current().review(ReviewInput.approve());
+    gApi.changes().id(firstCherryPickResult.id).current().submit();
+
+    input.base = gApi.changes().id(firstCherryPickResult.changeId).current().commit(false).commit;
+    ChangeInfo secondCherryPickResult =
+        gApi.changes().id(r2.getChangeId()).current().cherryPickAsInfo(input);
+    assertThat(gApi.changes().id(firstCherryPickResult.changeId).current().related().changes)
+        .hasSize(0);
+    assertThat(gApi.changes().id(secondCherryPickResult.changeId).current().related().changes)
+        .hasSize(0);
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -1047,6 +1184,24 @@
   }
 
   @Test
+  public void setReviewedFlagWithMultiplePatchSets() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+
+    gApi.changes().id(r1.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);
+
+    /** Amending the change will result in the file being un-reviewed in the latest patchset */
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+
+    assertThat(gApi.changes().id(r2.getChangeId()).current().reviewed()).isEmpty();
+
+    gApi.changes().id(r2.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);
+
+    assertThat(Iterables.getOnlyElement(gApi.changes().id(r2.getChangeId()).current().reviewed()))
+        .isEqualTo(PushOneCommit.FILE_NAME);
+  }
+
+  @Test
   public void setUnsetReviewedFlagByFileApi() throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -1062,6 +1217,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 +1437,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 +1500,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 486b157..0c98d32 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.acceptance.api.revision;
 
+import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+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 +28,18 @@
 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.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 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.ChangeMessageInfo;
+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,25 +50,36 @@
 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.gerrit.testing.TestTimeUtil;
+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 java.util.concurrent.TimeUnit;
 import org.junit.Before;
 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 GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
+
   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_NAME3 = "file_without_newline_at_end.txt";
   private static final String FILE_CONTENT =
       "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
           + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
 
   private String changeId;
+  private String commitId;
   private FixReplacementInfo fixReplacementInfo;
   private FixSuggestionInfo fixSuggestionInfo;
   private RobotCommentInput withFixRobotCommentInput;
@@ -71,13 +91,16 @@
             admin.newIdent(),
             testRepo,
             "Provide files which can be used for fixes",
-            ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
     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 +114,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 +126,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();
 
@@ -122,10 +145,94 @@
     assertRobotComment(comment2, in2, false);
   }
 
+  @UseClockStep
+  @Test
+  public void addedRobotCommentsAreLinkedToChangeMessages() throws Exception {
+    TestTimeUtil.resetWithClockStep(0, TimeUnit.SECONDS);
+    createChange();
+    /* Advancing the time after creating the change so that the first robot comment is not in the same timestamp as with the change creation */
+    TestTimeUtil.incrementClock(5, TimeUnit.SECONDS);
+
+    RobotCommentInput c1 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    RobotCommentInput c2 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    RobotCommentInput c3 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    /* Give the robot comments identifiable names for testing */
+    c1.message = "robot comment 1";
+    c2.message = "robot comment 2";
+    c3.message = "robot comment 3";
+
+    testCommentHelper.addRobotComment(changeId, c1, "robot message 1");
+    TestTimeUtil.incrementClock(5, TimeUnit.SECONDS);
+
+    testCommentHelper.addRobotComment(changeId, c2, "robot message 2");
+    TestTimeUtil.incrementClock(5, TimeUnit.SECONDS);
+
+    testCommentHelper.addRobotComment(changeId, c3, "robot message 3");
+    TestTimeUtil.incrementClock(5, TimeUnit.SECONDS);
+
+    Map<String, List<RobotCommentInfo>> robotComments = gApi.changes().id(changeId).robotComments();
+    List<RobotCommentInfo> robotCommentsList =
+        robotComments.values().stream().flatMap(List::stream).collect(toList());
+
+    List<ChangeMessageInfo> allMessages =
+        gApi.changes().id(changeId).get(MESSAGES).messages.stream().collect(toList());
+
+    assertThat(allMessages.stream().map(cm -> cm.message).collect(toList()))
+        .containsExactly(
+            "Uploaded patch set 1.",
+            "Patch Set 1:\n\n(1 comment)\n\nrobot message 1",
+            "Patch Set 1:\n\n(1 comment)\n\nrobot message 2",
+            "Patch Set 1:\n\n(1 comment)\n\nrobot message 3");
+
+    assertThat(robotCommentsList.stream().map(c -> c.message).collect(toList()))
+        .containsExactly("robot comment 1", "robot comment 2", "robot comment 3");
+
+    String message1ChangeId =
+        allMessages.stream()
+            .filter(c -> c.message.contains("robot message 1"))
+            .collect(onlyElement())
+            .id;
+    String message2ChangeId =
+        allMessages.stream()
+            .filter(c -> c.message.contains("robot message 2"))
+            .collect(onlyElement())
+            .id;
+    String message3ChangeId =
+        allMessages.stream()
+            .filter(c -> c.message.contains("robot message 3"))
+            .collect(onlyElement())
+            .id;
+
+    String comment1MessageId =
+        robotCommentsList.stream()
+            .filter(c -> c.message.equals("robot comment 1"))
+            .collect(onlyElement())
+            .changeMessageId;
+    String comment2MessageId =
+        robotCommentsList.stream()
+            .filter(c -> c.message.equals("robot comment 2"))
+            .collect(onlyElement())
+            .changeMessageId;
+    String comment3MessageId =
+        robotCommentsList.stream()
+            .filter(c -> c.message.equals("robot comment 3"))
+            .collect(onlyElement())
+            .changeMessageId;
+
+    /**
+     * Upload PS message, robot message 1 & robot comment 1 all have the same timestamp. The robot
+     * comment is matched to robot message 1 because the PS upload message is auto-generated and is
+     * ignored in matching
+     */
+    assertThat(message1ChangeId).isEqualTo(comment1MessageId);
+    assertThat(message2ChangeId).isEqualTo(comment2MessageId);
+    assertThat(message3ChangeId).isEqualTo(comment3MessageId);
+  }
+
   @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 +244,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 +257,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 +267,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 +292,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 +319,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 +330,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 +338,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 +351,7 @@
 
   @Test
   public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     assertThatList(robotCommentInfos)
@@ -254,12 +362,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 +379,7 @@
 
   @Test
   public void addedFixReplacementCanBeRetrieved() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     assertThatList(robotCommentInfos)
@@ -281,12 +390,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 +408,7 @@
 
   @Test
   public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -311,12 +421,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 +438,7 @@
 
   @Test
   public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -340,12 +451,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 +467,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 +494,8 @@
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown).hasMessageThat().contains("overlap");
   }
 
@@ -403,7 +516,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 +540,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 +567,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 +575,7 @@
 
   @Test
   public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -475,12 +588,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 +610,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 +633,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 +665,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 +695,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 +730,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 +764,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 +786,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 +816,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 +843,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 +859,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 +885,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 +920,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);
@@ -825,7 +943,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);
@@ -843,7 +961,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);
@@ -866,7 +984,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);
@@ -884,7 +1002,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);
@@ -907,7 +1025,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);
@@ -930,7 +1048,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);
@@ -949,7 +1067,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()));
@@ -961,25 +1080,241 @@
     }
   }
 
-  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
+  public void getFixPreviewForCommitMsg() throws Exception {
+    String footer = "Change-Id: " + changeId;
+    updateCommitMessage(
+        changeId,
+        "Commit title\n\nCommit message line 1\nLine 2\nLine 3\nLast line\n"
+            + "\n"
+            + footer
+            + "\n");
+    FixReplacementInfo commitMsgReplacement = new FixReplacementInfo();
+    commitMsgReplacement.path = Patch.COMMIT_MSG;
+    // The test assumes that the first 5 lines is a header.
+    // Line 10 has content "Line 2"
+    commitMsgReplacement.range = createRange(10, 0, 11, 0);
+    commitMsgReplacement.replacement = "New content\n";
+
+    FixSuggestionInfo commitMsgSuggestionInfo = createFixSuggestionInfo(commitMsgReplacement);
+    RobotCommentInput commitMsgRobotCommentInput =
+        TestCommentHelper.createRobotCommentInput(Patch.COMMIT_MSG, commitMsgSuggestionInfo);
+    testCommentHelper.addRobotComment(changeId, commitMsgRobotCommentInput);
+
+    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(1);
+    assertThat(fixPreview).containsKey(Patch.COMMIT_MSG);
+
+    DiffInfo diff = fixPreview.get(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+    assertThat(diff).metaB().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaB().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+
+    assertThat(diff).content().element(0).commonLines().hasSize(9);
+    // Header has a dynamic content, do not check it
+    assertThat(diff).content().element(0).commonLines().element(6).isEqualTo("Commit title");
+    assertThat(diff).content().element(0).commonLines().element(7).isEqualTo("");
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .element(8)
+        .isEqualTo("Commit message line 1");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("New content");
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Last line", "", footer, "");
+  }
+
+  private void updateCommitMessage(String changeId, String newCommitMessage) throws Exception {
+    gApi.changes().id(changeId).edit().create();
+    gApi.changes().id(changeId).edit().modifyCommitMessage(newCommitMessage);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeId).edit().publish(publishInput);
+  }
+
+  @Test
+  public void getFixPreviewForNonExistingFile() throws Exception {
+    FixReplacementInfo replacement = new FixReplacementInfo();
+    replacement.path = "a_non_existent_file.txt";
+    replacement.range = createRange(1, 0, 2, 0);
+    replacement.replacement = "Modified content\n";
+
+    FixSuggestionInfo fixSuggestion = createFixSuggestionInfo(replacement);
+    RobotCommentInput commentInput =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME2, fixSuggestion);
+    testCommentHelper.addRobotComment(changeId, commentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    assertThrows(
+        ResourceNotFoundException.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();
+  }
+
+  @Test
+  public void getFixPreviewAddNewLineAtEnd() throws Exception {
+    FixReplacementInfo replacement = new FixReplacementInfo();
+    replacement.path = FILE_NAME3;
+    replacement.range = createRange(2, 8, 2, 8);
+    replacement.replacement = "\n";
+
+    FixSuggestionInfo fixSuggestion = createFixSuggestionInfo(replacement);
+    RobotCommentInput commentInput =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME3, fixSuggestion);
+    testCommentHelper.addRobotComment(changeId, commentInput);
+
+    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(1);
+    assertThat(fixPreview).containsKey(FILE_NAME3);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME3);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(2);
+    // Original file doesn't have EOL marker at the end of file.
+    // Due to the additional EOL mark diff has one additional line
+    // This behavior is in line with ordinary get diff API.
+    assertThat(diff).metaB().totalLineCount().isEqualTo(3);
+
+    assertThat(diff).content().hasSize(2);
+    assertThat(diff).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("2nd line");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
   }
 
   private static FixSuggestionInfo createFixSuggestionInfo(
@@ -1009,15 +1344,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/config/GerritInstanceIdIT.java b/javatests/com/google/gerrit/acceptance/config/GerritInstanceIdIT.java
new file mode 100644
index 0000000..0dd6a83
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/config/GerritInstanceIdIT.java
@@ -0,0 +1,34 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.junit.Test;
+
+public class GerritInstanceIdIT extends AbstractDaemonTest {
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+  public void shouldReturnInstanceIdWhenDefined() {
+    assertThat(instanceId).isEqualTo("testInstanceId");
+  }
+
+  @Test
+  public void shouldReturnNullWhenNotDefined() {
+    assertThat(instanceId).isNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java b/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
new file mode 100644
index 0000000..ac10e96
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
@@ -0,0 +1,111 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.GerritInstanceId;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.events.EventListener;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.Scopes;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+
+@TestPlugin(
+    name = "instance-id-from-plugin",
+    sysModule = "com.google.gerrit.acceptance.config.InstanceIdFromPluginIT$Module")
+public class InstanceIdFromPluginIT extends LightweightPluginDaemonTest {
+
+  public static class Module extends AbstractModule {
+
+    @Override
+    protected void configure() {
+      bind(InstanceIdLoader.class).in(Scopes.SINGLETON);
+      bind(TestEventListener.class).in(Scopes.SINGLETON);
+      DynamicSet.bind(binder(), EventListener.class).to(TestEventListener.class);
+    }
+  }
+
+  public static class InstanceIdLoader {
+    public final String gerritInstanceId;
+
+    @Inject
+    InstanceIdLoader(@Nullable @GerritInstanceId String gerritInstanceId) {
+      this.gerritInstanceId = gerritInstanceId;
+    }
+  }
+
+  public static class TestEventListener implements EventListener {
+    private final List<Event> events = new ArrayList<>();
+
+    @Override
+    public void onEvent(Event event) {
+      events.add(event);
+    }
+
+    public List<Event> getEvents() {
+      return events;
+    }
+  }
+
+  public static class TestEvent extends Event {
+
+    protected TestEvent(String instanceId) {
+      super("test");
+      this.instanceId = instanceId;
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+  public void shouldReturnInstanceIdWhenDefined() {
+    assertThat(getInstanceIdLoader().gerritInstanceId).isEqualTo("testInstanceId");
+  }
+
+  @Test
+  public void shouldReturnNullWhenNotDefined() {
+    assertThat(getInstanceIdLoader().gerritInstanceId).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+  public void shouldPreserveEventInstanceIdWhenDefined() throws PermissionBackendException {
+    EventDispatcher dispatcher =
+        plugin.getSysInjector().getInstance(new Key<DynamicItem<EventDispatcher>>() {}).get();
+    String eventInstanceId = "eventInstanceId";
+    TestEventListener eventListener = plugin.getSysInjector().getInstance(TestEventListener.class);
+    TestEvent testEvent = new TestEvent(eventInstanceId);
+
+    dispatcher.postEvent(testEvent);
+    List<Event> receivedEvents = eventListener.getEvents();
+    assertThat(receivedEvents).hasSize(1);
+    assertThat(receivedEvents.get(0).instanceId).isEqualTo(eventInstanceId);
+  }
+
+  private InstanceIdLoader getInstanceIdLoader() {
+    return plugin.getSysInjector().getInstance(InstanceIdLoader.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/config/UseGerritConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/config/UseGerritConfigAnnotationTest.java
new file mode 100644
index 0000000..f72aa74
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/config/UseGerritConfigAnnotationTest.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.junit.Test;
+
+public class UseGerritConfigAnnotationTest extends AbstractDaemonTest {
+  @Test
+  @GerritConfig(name = "section.name", value = "value")
+  public void testOne() {
+    assertThat(cfg.getString("section", null, "name")).isEqualTo("value");
+  }
+
+  @Test
+  @GerritConfig(name = "section.subsection.name", value = "value")
+  public void testOneWithSubsection() {
+    assertThat(cfg.getString("section", "subsection", "name")).isEqualTo("value");
+  }
+
+  @Test
+  @GerritConfig(name = "section.name", value = "value")
+  @GerritConfig(name = "section1.name", value = "value1")
+  @GerritConfig(name = "section.subsection.name", value = "value")
+  @GerritConfig(name = "section.subsection1.name", value = "value1")
+  public void testMultiple() {
+    assertThat(cfg.getString("section", null, "name")).isEqualTo("value");
+    assertThat(cfg.getString("section1", null, "name")).isEqualTo("value1");
+    assertThat(cfg.getString("section", "subsection", "name")).isEqualTo("value");
+    assertThat(cfg.getString("section", "subsection1", "name")).isEqualTo("value1");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "section.name",
+      values = {"value-1", "value-2"})
+  public void testList() {
+    assertThat(cfg.getStringList("section", null, "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "section.subsection.name",
+      values = {"value-1", "value-2"})
+  public void testListWithSubsection() {
+    assertThat(cfg.getStringList("section", "subsection", "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "section.name",
+      value = "value-1",
+      values = {"value-2", "value-3"})
+  public void valueHasPrecedenceOverValues() {
+    assertThat(cfg.getStringList("section", null, "name")).asList().containsExactly("value-1");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/config/UseGlobalPluginConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/config/UseGlobalPluginConfigAnnotationTest.java
new file mode 100644
index 0000000..cfad6f2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/config/UseGlobalPluginConfigAnnotationTest.java
@@ -0,0 +1,90 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class UseGlobalPluginConfigAnnotationTest extends AbstractDaemonTest {
+  private Config cfg() {
+    return pluginConfig.getGlobalPluginConfig("test");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(pluginName = "test", name = "section.name", value = "value")
+  public void testOne() {
+    assertThat(cfg().getString("section", null, "name")).isEqualTo("value");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(pluginName = "test", name = "section.subsection.name", value = "value")
+  public void testOneWithSubsection() {
+    assertThat(cfg().getString("section", "subsection", "name")).isEqualTo("value");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(pluginName = "test", name = "section.name", value = "value")
+  @GlobalPluginConfig(pluginName = "test", name = "section1.name", value = "value1")
+  @GlobalPluginConfig(pluginName = "test", name = "section.subsection.name", value = "value")
+  @GlobalPluginConfig(pluginName = "test", name = "section.subsection1.name", value = "value1")
+  public void testMultiple() {
+    assertThat(cfg().getString("section", null, "name")).isEqualTo("value");
+    assertThat(cfg().getString("section1", null, "name")).isEqualTo("value1");
+    assertThat(cfg().getString("section", "subsection", "name")).isEqualTo("value");
+    assertThat(cfg().getString("section", "subsection1", "name")).isEqualTo("value1");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(
+      pluginName = "test",
+      name = "section.name",
+      values = {"value-1", "value-2"})
+  public void testList() {
+    assertThat(cfg().getStringList("section", null, "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(
+      pluginName = "test",
+      name = "section.subsection.name",
+      values = {"value-1", "value-2"})
+  public void testListWithSubsection() {
+    assertThat(cfg().getStringList("section", "subsection", "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(
+      pluginName = "test",
+      name = "section.name",
+      value = "value-1",
+      values = {"value-2", "value-3"})
+  public void valueHasPrecedenceOverValues() {
+    assertThat(cfg().getStringList("section", null, "name")).asList().containsExactly("value-1");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 73542ca..d7cc338 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -46,6 +46,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;
@@ -61,12 +62,12 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 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;
@@ -95,6 +96,15 @@
   private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
   private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
   private static final byte[] CONTENT_NEW2 = CONTENT_NEW2_STR.getBytes(UTF_8);
+  private static final String CONTENT_BINARY_ENCODED_NEW =
+      "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==";
+  private static final byte[] CONTENT_BINARY_DECODED_NEW = "Hello, World!".getBytes(UTF_8);
+  private static final String CONTENT_BINARY_ENCODED_NEW2 =
+      "data:text/plain;base64,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
+  private static final byte[] CONTENT_BINARY_DECODED_NEW2 =
+      "Uploading to an edit worked!".getBytes(UTF_8);
+  private static final String CONTENT_BINARY_ENCODED_NEW3 =
+      "data:text/plain,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -108,7 +118,7 @@
     changeId = newChange(admin.newIdent());
     ps = getCurrentPatchSet(changeId);
     assertThat(ps).isNotNull();
-    amendChange(admin.newIdent(), changeId);
+    addNewPatchSet(changeId);
     changeId2 = newChange2(admin.newIdent());
   }
 
@@ -133,7 +143,7 @@
   @Test
   public void deleteEditOfOlderPatchSet() throws Exception {
     createArbitraryEditFor(changeId2);
-    amendChange(admin.newIdent(), changeId2);
+    addNewPatchSet(changeId2);
 
     gApi.changes().id(changeId2).edit().delete();
     assertThat(getEdit(changeId2)).isAbsent();
@@ -236,7 +246,7 @@
     PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    amendChange(admin.newIdent(), changeId2);
+    addNewPatchSet(changeId2);
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
@@ -255,7 +265,7 @@
     PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-    amendChange(admin.newIdent(), changeId2);
+    addNewPatchSet(changeId2);
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
 
     Optional<EditInfo> originalEdit = getEdit(changeId2);
@@ -311,6 +321,16 @@
   }
 
   @Test
+  public void updateCommitMessageByEditingMagicCommitMsgFileWithoutContent() throws Exception {
+    createEmptyEditFor(changeId);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).edit().modifyFile(Patch.COMMIT_MSG, (RawInput) null));
+    assertThat(ex).hasMessageThat().isEqualTo("either content or binary_content is required");
+  }
+
+  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void updateRootCommitMessage() throws Exception {
     // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
@@ -469,9 +489,89 @@
   }
 
   @Test
-  public void retrieveFilesInEdit() throws Exception {
-    createEmptyEditFor(changeId);
-    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2));
+  public void editIsDiffedAgainstPatchSetParentByDefault() throws Exception {
+    // Create a patch set. The previous patch set contained FILE_NAME.
+    addNewPatchSetWithModifiedFile(
+        changeId2, "file_in_latest_patch_set.txt", "Content of a file in latest patch set.");
+
+    // Create an empty edit on top of that patch set.
+    createEmptyEditFor(changeId2);
+
+    Optional<EditInfo> edit =
+        gApi.changes()
+            .id(changeId2)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+
+    assertThat(edit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, FILE_NAME, "file_in_latest_patch_set.txt");
+  }
+
+  @Test
+  public void editCanBeDiffedAgainstCurrentPatchSet() throws Exception {
+    // Create a patch set.
+    addNewPatchSetWithModifiedFile(
+        changeId2, "file_in_latest_patch_set.txt", "Content of a file in latest patch set.");
+    String currentPatchSetId = gApi.changes().id(changeId2).get().currentRevision;
+
+    // Create an edit on top of that patch set and add a new file.
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .modifyFile(
+            "file_in_change_edit.txt",
+            RawInputUtil.create("Content of the file added to the current change edit."));
+
+    // Diff the edit against the patch set.
+    Optional<EditInfo> edit =
+        gApi.changes()
+            .id(changeId2)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .withBase(currentPatchSetId)
+            .get();
+
+    assertThat(edit).value().files().keys().containsExactly(COMMIT_MSG, "file_in_change_edit.txt");
+  }
+
+  @Test
+  public void editCanBeDiffedAgainstEarlierPatchSet() throws Exception {
+    // Create two patch sets.
+    addNewPatchSetWithModifiedFile(
+        changeId2, "file_in_old_patch_set.txt", "Content of file in older patch set.");
+    String previousPatchSetId = gApi.changes().id(changeId2).get().currentRevision;
+    addNewPatchSetWithModifiedFile(
+        changeId2, "file_in_latest_patch_set.txt", "Content of a file in latest patch set.");
+
+    // Create an edit and add a new file.
+    gApi.changes()
+        .id(changeId2)
+        .edit()
+        .modifyFile(
+            "file_in_change_edit.txt",
+            RawInputUtil.create("Content of the file added to the current change edit."));
+
+    // Diff the edit against the previous patch set.
+    Optional<EditInfo> edit =
+        gApi.changes()
+            .id(changeId2)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .withBase(previousPatchSetId)
+            .get();
+
+    assertThat(edit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, "file_in_latest_patch_set.txt", "file_in_change_edit.txt");
   }
 
   @Test
@@ -565,7 +665,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);
@@ -577,13 +677,40 @@
   @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 createAndUploadBinaryInChangeEditOneRequestRest() throws Exception {
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW2;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW2);
+  }
+
+  @Test
+  public void invalidBase64UploadBinaryInChangeEditOneRequestRest() throws Exception {
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW3;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertBadRequest();
+  }
+
+  @Test
+  public void changeEditNoContentProvidedRest() throws Exception {
+    createEmptyEditFor(changeId);
+
+    FileContentInput in = new FileContentInput();
+    in.binary_content = null;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertBadRequest();
+  }
+
+  @Test
   public void emptyPutRequest() throws Exception {
     createEmptyEditFor(changeId);
     adminRestSession.put(urlEditFile(changeId, FILE_NAME)).assertNoContent();
@@ -598,7 +725,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));
@@ -622,10 +749,34 @@
   @Test
   public void addNewFile() throws Exception {
     createEmptyEditFor(changeId);
-    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2));
+    Optional<EditInfo> originalEdit =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(originalEdit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2);
+
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
-    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2, FILE_NAME3));
+    Optional<EditInfo> adjustedEdit =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(adjustedEdit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2, FILE_NAME3);
   }
 
   @Test
@@ -838,16 +989,16 @@
     return push.to("refs/for/master").getChangeId();
   }
 
-  private String amendChange(PersonIdent ident, String changeId) throws Exception {
+  private void addNewPatchSet(String changeId) throws Exception {
+    addNewPatchSetWithModifiedFile(changeId, FILE_NAME2, new String(CONTENT_NEW2, UTF_8));
+  }
+
+  private void addNewPatchSetWithModifiedFile(String changeId, String filePath, String fileContent)
+      throws Exception {
     PushOneCommit push =
         pushFactory.create(
-            ident,
-            testRepo,
-            PushOneCommit.SUBJECT,
-            FILE_NAME2,
-            new String(CONTENT_NEW2, UTF_8),
-            changeId);
-    return push.to("refs/for/master").getChangeId();
+            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, filePath, fileContent, changeId);
+    push.to("refs/for/master");
   }
 
   private String newChange2(PersonIdent ident) throws Exception {
@@ -866,19 +1017,6 @@
     assertThat(fileContent).value().bytes().isEqualTo(expectedFileBytes);
   }
 
-  private void assertFiles(String changeId, List<String> expected) throws Exception {
-    Optional<EditInfo> info =
-        gApi.changes()
-            .id(changeId)
-            .edit()
-            .detail()
-            .withOption(ChangeEditDetailOption.LIST_FILES)
-            .get();
-    assertThat(info).isPresent();
-    assertThat(info.get().files).isNotNull();
-    assertThat(info.get().files.keySet()).containsExactlyElementsIn(expected);
-  }
-
   private String urlEdit(String changeId) {
     return "/changes/" + changeId + "/edit";
   }
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 ea82013..171cb04 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -52,13 +53,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;
@@ -103,6 +104,7 @@
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -114,8 +116,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 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;
@@ -438,7 +440,7 @@
     r.assertErrorStatus("change " + url + " closed");
 
     // Check change message that was added on auto-close
-    ChangeInfo change = gApi.changes().id(r.getChange().getId().get()).get();
+    ChangeInfo change = change(r).get();
     assertThat(Iterables.getLast(change.messages).message)
         .isEqualTo("Change has been successfully pushed.");
   }
@@ -478,22 +480,16 @@
     r.assertErrorStatus("change " + url + " closed");
 
     // Check that new commit was added as patch set
-    ChangeInfo change = gApi.changes().id(r.getChange().getId().get()).get();
+    ChangeInfo change = change(r).get();
     assertThat(change.revisions).hasSize(2);
     assertThat(change.currentRevision).isEqualTo(c.name());
   }
 
   @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 +510,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)");
@@ -530,7 +519,7 @@
   @Test
   public void pushForMasterWithNotify() throws Exception {
     // create a user that watches the project
-    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3");
+    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = project.get();
@@ -559,7 +548,7 @@
     r.assertOkStatus();
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
 
     sender.clear();
     r = pushTo(pushSpec + ",notify=" + NotifyHandling.ALL);
@@ -567,7 +556,7 @@
     assertThat(sender.getMessages()).hasSize(1);
     m = sender.getMessages().get(0);
     assertThat(m.rcpt())
-        .containsExactly(user.getEmailAddress(), user2.getEmailAddress(), user3.getEmailAddress());
+        .containsExactly(user.getNameEmail(), user2.getNameEmail(), user3.getNameEmail());
 
     sender.clear();
     r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + user3.email());
@@ -605,16 +594,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 +621,9 @@
     String nonExistingEmail = "non.existing@example.com";
     r =
         pushTo(
-            "refs/for/master/"
+            "refs/for/master%topic="
                 + topic
-                + "%cc="
+                + ",cc="
                 + admin.email()
                 + ",cc="
                 + nonExistingEmail
@@ -684,18 +673,18 @@
   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);
 
     // add several reviewers
     TestAccount user2 =
-        accountCreator.create("another-user", "another.user@example.com", "Another User");
+        accountCreator.create("another-user", "another.user@example.com", "Another User", null);
     r =
         pushTo(
-            "refs/for/master/"
+            "refs/for/master%topic="
                 + topic
-                + "%r="
+                + ",r="
                 + admin.email()
                 + ",r="
                 + user.email()
@@ -709,9 +698,9 @@
     String nonExistingEmail = "non.existing@example.com";
     r =
         pushTo(
-            "refs/for/master/"
+            "refs/for/master%topic="
                 + topic
-                + "%r="
+                + ",r="
                 + admin.email()
                 + ",r="
                 + nonExistingEmail
@@ -954,7 +943,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);
@@ -978,7 +967,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 =
@@ -989,7 +978,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);
@@ -1009,7 +998,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();
@@ -1030,7 +1019,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);
@@ -1048,7 +1037,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");
@@ -1066,7 +1055,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");
@@ -1087,7 +1076,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.");
   }
@@ -1105,7 +1094,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");
@@ -1142,7 +1131,7 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).rcpt())
-        .containsExactly(user.getEmailAddress(), user2.getEmailAddress());
+        .containsExactly(user.getNameEmail(), user2.getNameEmail());
   }
 
   @Test
@@ -1167,7 +1156,7 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).rcpt())
-        .containsExactly(user.getEmailAddress(), user2.getEmailAddress());
+        .containsExactly(user.getNameEmail(), user2.getNameEmail());
   }
 
   /**
@@ -1192,7 +1181,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):
@@ -1240,7 +1229,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");
@@ -1270,13 +1259,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");
   }
 
@@ -1309,7 +1298,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();
@@ -1864,7 +1853,7 @@
 
   @Test
   public void pushWithEmailInFooter() throws Exception {
-    pushWithReviewerInFooter(user.getEmailAddress().toString(), user);
+    pushWithReviewerInFooter(user.getNameEmail().toString(), user);
   }
 
   @Test
@@ -1874,7 +1863,8 @@
 
   @Test
   public void pushWithEmailInFooterNotFound() throws Exception {
-    pushWithReviewerInFooter(new Address("No Body", "notarealuser@example.com").toString(), null);
+    pushWithReviewerInFooter(
+        Address.create("No Body", "notarealuser@example.com").toString(), null);
   }
 
   @Test
@@ -2048,6 +2038,70 @@
   }
 
   @Test
+  public void publishedCommentsAssignedToChangeMessages() throws Exception {
+    TestTimeUtil.resetWithClockStep(0, TimeUnit.SECONDS);
+    PushOneCommit.Result r = createChange(); // creating the change with patch set 1
+    TestTimeUtil.incrementClock(5, TimeUnit.SECONDS);
+
+    /** Create and publish a comment on PS2. Increment the clock step */
+    String rev1 = r.getCommit().name();
+    addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment_PS2."));
+    r = amendChange(r.getChangeId(), "refs/for/master%publish-comments");
+    assertThat(getPublishedComments(r.getChangeId())).isNotEmpty();
+    TestTimeUtil.incrementClock(5, TimeUnit.SECONDS);
+
+    /** Create and publish a comment on PS3 */
+    String rev2 = r.getCommit().name();
+    addDraft(r.getChangeId(), rev2, newDraft(FILE_NAME, 1, "comment_PS3."));
+    amendChange(r.getChangeId(), "refs/for/master%publish-comments");
+
+    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    List<ChangeMessageInfo> allMessages = getMessages(r.getChangeId());
+
+    assertThat(allMessages.stream().map(m -> m.message).collect(toList()))
+        .containsExactly(
+            "Uploaded patch set 1.",
+            "Uploaded patch set 2.",
+            "Patch Set 2:\n\n(1 comment)",
+            "Uploaded patch set 3.",
+            "Patch Set 3:\n\n(1 comment)")
+        .inOrder();
+
+    /**
+     * Note that the following 3 items have the same timestamp: comment "comment_PS2", message
+     * "Uploaded patch set 2.", and message "Patch Set 2:\n\n(1 comment)". The comment will not be
+     * matched with the upload change message because it is auto-generated. Same goes for patch set
+     * 3.
+     */
+    String commentPs2MessageId =
+        comments.stream()
+            .filter(c -> c.message.equals("comment_PS2."))
+            .collect(onlyElement())
+            .changeMessageId;
+
+    String commentPs3MessageId =
+        comments.stream()
+            .filter(c -> c.message.equals("comment_PS3."))
+            .collect(onlyElement())
+            .changeMessageId;
+
+    String message2Id =
+        allMessages.stream()
+            .filter(m -> m.message.equals("Patch Set 2:\n\n(1 comment)"))
+            .collect(onlyElement())
+            .id;
+
+    String message3Id =
+        allMessages.stream()
+            .filter(m -> m.message.equals("Patch Set 3:\n\n(1 comment)"))
+            .collect(onlyElement())
+            .id;
+
+    assertThat(commentPs2MessageId).isEqualTo(message2Id);
+    assertThat(commentPs3MessageId).isEqualTo(message3Id);
+  }
+
+  @Test
   public void publishCommentsOnPushPublishesDraftsOnAllRevisions() throws Exception {
     PushOneCommit.Result r = createChange();
     String rev1 = r.getCommit().name();
@@ -2068,36 +2122,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
@@ -2110,8 +2185,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
@@ -2129,16 +2203,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
@@ -2163,7 +2243,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
@@ -2650,6 +2730,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/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 7cfb0f2..dcee118 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -518,7 +518,7 @@
       TestAccount expected, @Nullable RecipientType expectedRecipientType) {
     String expectedEmail = expected.email();
     String expectedFullName = expected.fullName();
-    Address expectedAddress = new Address(expectedFullName, expectedEmail);
+    Address expectedAddress = Address.create(expectedFullName, expectedEmail);
     assertThat(sender.getMessages()).hasSize(2);
     Message message = sender.getMessages().get(0);
     assertThat(message.body().contains("review")).isTrue();
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
index 2f677e2..6f7a4c3 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -182,7 +182,8 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      TestAccount foo =
+          accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo", null);
       String userRef = RefNames.refsUsers(foo.id());
       accountIndexedCounter.clear();
 
@@ -253,11 +254,13 @@
           .contains(
               String.format(
                   "invalid account configuration: commit '%s' has an invalid '%s' file for account"
-                      + " '%s': Invalid config file %s in commit %s",
+                      + " '%s': Invalid config file %s in project %s in branch %s in commit %s",
                   r.getCommit().name(),
                   AccountProperties.ACCOUNT_CONFIG,
                   admin.id(),
                   AccountProperties.ACCOUNT_CONFIG,
+                  allUsers.get(),
+                  userRef,
                   r.getCommit().name()));
     }
   }
@@ -442,7 +445,7 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way");
+      TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way", null);
       requestScopeOperations.setApiUser(oooUser.id());
 
       // Must clone as oooUser to ensure the push is allowed.
@@ -479,7 +482,8 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      String userRef = RefNames.refsUsers(admin.id());
+      fetch(allUsersRepo, userRef + ":userRef");
       allUsersRepo.reset("userRef");
 
       PushOneCommit.Result r =
@@ -495,11 +499,13 @@
       r.assertMessage(
           String.format(
               "commit '%s' has an invalid '%s' file for account '%s':"
-                  + " Invalid config file %s in commit %s",
+                  + " Invalid config file %s in project %s in branch %s in commit %s",
               r.getCommit().name(),
               AccountProperties.ACCOUNT_CONFIG,
               admin.id(),
               AccountProperties.ACCOUNT_CONFIG,
+              allUsers.get(),
+              userRef,
               r.getCommit().name()));
       accountIndexedCounter.assertNoReindex();
     }
@@ -539,7 +545,8 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      TestAccount foo =
+          accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo", null);
       String userRef = RefNames.refsUsers(foo.id());
 
       String noEmail = "no.email";
@@ -584,7 +591,8 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      TestAccount foo =
+          accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo", null);
       String userRef = RefNames.refsUsers(foo.id());
       accountIndexedCounter.clear();
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index ea9f48e..1083377 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;
@@ -1074,7 +1071,7 @@
 
       PersonIdent committer = serverIdent.get();
       PersonIdent author =
-          noteUtil.newIdent(getAccount(admin.id()), committer.getWhen(), committer);
+          noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
       tr.branch(RefNames.changeMetaRef(cd3.getId()))
           .commit()
           .author(author)
@@ -1364,15 +1361,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);
     }
   }
@@ -1383,8 +1383,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)
@@ -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/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index 876e342..d7952e4 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 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.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -206,4 +207,22 @@
       r4.assertErrorStatus(UPDATE_NONFASTFORWARD.name());
     }
   }
+
+  @Test
+  @GerritConfig(name = "change.maxFiles", value = "0")
+  public void dontEnforceFileCountForDirectPushes() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "change", "c.txt", "content");
+    PushOneCommit.Result result = push.to("refs/heads/master");
+    result.assertOkStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "change.maxFiles", value = "0")
+  public void enforceFileCountLimitOnPushesForReview() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "change", "c.txt", "content");
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertErrorStatus("Exceeding maximum number of files per change");
+  }
 }
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/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 03202f2..c0feda9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -32,6 +32,7 @@
   public static void assertAccountInfo(TestAccount testAccount, AccountInfo accountInfo) {
     assertThat(accountInfo._accountId).isEqualTo(testAccount.id().get());
     assertThat(accountInfo.name).isEqualTo(testAccount.fullName());
+    assertThat(accountInfo.displayName).isEqualTo(testAccount.displayName());
     assertThat(accountInfo.email).isEqualTo(testAccount.email());
     assertThat(accountInfo.inactive).isNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 6e16435..53e871f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -132,14 +132,14 @@
     AuthException thrown =
         assertThrows(
             AuthException.class, () -> gApi.accounts().id(admin.id().get()).getExternalIds());
-    assertThat(thrown).hasMessageThat().contains("access database not permitted");
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
-  public void getExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
+  public void getExternalIdsOfOtherUserWithModifyAccount() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
         .update();
 
     Collection<ExternalId> expectedIds = getAccountState(admin.id()).externalIds();
@@ -193,7 +193,7 @@
                 gApi.accounts()
                     .id(admin.id().get())
                     .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList())));
-    assertThat(thrown).hasMessageThat().contains("access database not permitted");
+    assertThat(thrown).hasMessageThat().contains("modify account not permitted");
   }
 
   @Test
@@ -213,10 +213,10 @@
   }
 
   @Test
-  public void deleteExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
+  public void deleteExternalIdsOfOtherUserWithModifyAccount() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
         .update();
 
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
@@ -464,7 +464,7 @@
   public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
         .update();
     requestScopeOperations.resetCurrentApiUser();
 
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/auth/AuthenticationCheckIT.java b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
index b6ef5a3..9298b43 100644
--- a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.rest.auth;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
@@ -24,6 +26,7 @@
   public void authCheck_loggedInUser_returnsOk() throws Exception {
     RestResponse r = adminRestSession.get("/auth-check");
     r.assertNoContent();
+    // Jetty strips Content-Length when status is NO_CONTENT
   }
 
   @Test
@@ -31,5 +34,6 @@
     RestSession anonymous = new RestSession(server, null);
     RestResponse r = anonymous.get("/auth-check");
     r.assertForbidden();
+    assertThat(r.getHeader("Content-Length")).isEqualTo("0");
   }
 }
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..574e919 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -63,6 +63,8 @@
           RestCall.get("/changes/%s/comments"),
           RestCall.get("/changes/%s/robotcomments"),
           RestCall.get("/changes/%s/drafts"),
+          RestCall.get("/changes/%s/attention"),
+          RestCall.post("/changes/%s/attention"),
           RestCall.get("/changes/%s/assignee"),
           RestCall.get("/changes/%s/past_assignees"),
           RestCall.put("/changes/%s/assignee"),
@@ -84,6 +86,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"),
@@ -266,6 +269,11 @@
           // Delete content of a file in an existing change edit.
           RestCall.delete("/changes/%s/edit/%s"));
 
+  private static final ImmutableList<RestCall> ATTENTION_SET_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.post("/changes/%s/attention/%s/delete"),
+          RestCall.delete("/changes/%s/attention/%s"));
+
   private static final String FILENAME = "test.txt";
 
   @Test
@@ -476,6 +484,14 @@
     RestApiCallHelper.execute(adminRestSession, CHANGE_EDIT_ENDPOINTS, changeId, FILENAME);
   }
 
+  @Test
+  public void attentionSetEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    RestApiCallHelper.execute(
+        adminRestSession, ATTENTION_SET_ENDPOINTS, changeId, user.id().toString());
+  }
+
   private static Comment.Range createRange(
       int startLine, int startCharacter, int endLine, int endCharacter) {
     Comment.Range range = new Comment.Range();
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 b822750..72db9b3 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;
@@ -202,12 +202,14 @@
                   "Failed to submit 3 changes due to the following problems:\n"
                       + "Change "
                       + change2.getChange().getId()
-                      + ": internal error: "
-                      + "change not processed by merge strategy\n"
+                      + ": Project policy "
+                      + "requires all submissions to be a fast-forward. Please "
+                      + "rebase the change locally and upload again for review.\n"
                       + "Change "
                       + change3.getChange().getId()
-                      + ": internal error: "
-                      + "change not processed by merge strategy\n"
+                      + ": Project policy "
+                      + "requires all submissions to be a fast-forward. Please "
+                      + "rebase the change locally and upload again for review.\n"
                       + "Change "
                       + change4.getChange().getId()
                       + ": Project policy "
@@ -613,11 +615,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)
@@ -781,8 +783,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());
@@ -930,7 +932,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.
@@ -947,7 +949,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.
@@ -1197,7 +1199,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 =
@@ -1467,6 +1471,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..c6a2819 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -63,18 +64,42 @@
     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);
+    assertThatMap(actions1).keys().containsAtLeast("revert", "revert_submission");
+    Map<String, ActionInfo> actions2 = getChangeActions(changeId2);
+    assertThatMap(actions2).keys().containsAtLeast("revert", "revert_submission");
+  }
+
+  @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     Map<String, ActionInfo> actions = getActions(changeId);
-    assertThat(actions).hasSize(3);
-    assertThat(actions).containsKey("cherrypick");
-    assertThat(actions).containsKey("rebase");
-    assertThat(actions).containsKey("description");
+    assertThatMap(actions).keys().containsExactly("cherrypick", "rebase", "description");
   }
 
   @Test
@@ -478,11 +503,7 @@
   }
 
   private void commonActionsAssertions(Map<String, ActionInfo> actions) {
-    assertThat(actions).hasSize(4);
-    assertThat(actions).containsKey("cherrypick");
-    assertThat(actions).containsKey("submit");
-    assertThat(actions).containsKey("description");
-    assertThat(actions).containsKey("rebase");
+    assertThatMap(actions).keys().containsExactly("cherrypick", "submit", "description", "rebase");
   }
 
   private PushOneCommit.Result createChangeWithTopic() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 0f5def6..2d47dd8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -61,7 +61,7 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
   }
 
   @Test
@@ -188,11 +188,11 @@
   }
 
   private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).getAssignee();
+    return change(r).getAssignee();
   }
 
   private List<AccountInfo> getPastAssignees(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).getPastAssignees();
+    return change(r).getPastAssignees();
   }
 
   private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r, ReviewerState state)
@@ -203,10 +203,10 @@
   private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer) throws Exception {
     AssigneeInput input = new AssigneeInput();
     input.assignee = identifieer;
-    return gApi.changes().id(r.getChange().getId().get()).setAssignee(input);
+    return change(r).setAssignee(input);
   }
 
   private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
-    return gApi.changes().id(r.getChange().getId().get()).deleteAssignee();
+    return change(r).deleteAssignee();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
new file mode 100644
index 0000000..caa8832
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -0,0 +1,136 @@
+// 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.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseClockStep(clockStepUnit = TimeUnit.MINUTES)
+public class AttentionSetIT extends AbstractDaemonTest {
+  /** Simulates a fake clock. Uses second granularity. */
+  private static class FakeClock implements LongSupplier {
+    Instant now = Instant.now();
+
+    @Override
+    public long getAsLong() {
+      return TimeUnit.SECONDS.toMillis(now.getEpochSecond());
+    }
+
+    Instant now() {
+      return Instant.ofEpochSecond(now.getEpochSecond());
+    }
+
+    void advance(Duration duration) {
+      now = now.plus(duration);
+    }
+  }
+
+  private FakeClock fakeClock = new FakeClock();
+
+  @Before
+  public void setUp() {
+    TimeUtil.setCurrentMillisSupplier(fakeClock);
+  }
+
+  @Test
+  public void emptyAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void addUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    int accountId =
+        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "first"))._accountId;
+    assertThat(accountId).isEqualTo(user.id().get());
+    AttentionSetUpdate expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), user.id(), AttentionSetUpdate.Operation.ADD, "first");
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Second add is ignored.
+    accountId =
+        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "second"))._accountId;
+    assertThat(accountId).isEqualTo(user.id().get());
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+  }
+
+  @Test
+  public void addMultipleUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Instant timestamp1 = fakeClock.now();
+    int accountId1 =
+        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "user"))._accountId;
+    assertThat(accountId1).isEqualTo(user.id().get());
+    fakeClock.advance(Duration.ofSeconds(42));
+    Instant timestamp2 = fakeClock.now();
+    int accountId2 =
+        change(r)
+            .addToAttentionSet(new AddToAttentionSetInput(admin.id().toString(), "admin"))
+            ._accountId;
+    assertThat(accountId2).isEqualTo(admin.id().get());
+
+    AttentionSetUpdate expectedAttentionSetUpdate1 =
+        AttentionSetUpdate.createFromRead(
+            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "user");
+    AttentionSetUpdate expectedAttentionSetUpdate2 =
+        AttentionSetUpdate.createFromRead(
+            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "admin");
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(expectedAttentionSetUpdate1, expectedAttentionSetUpdate2);
+  }
+
+  @Test
+  public void removeUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "added"));
+    fakeClock.advance(Duration.ofSeconds(42));
+    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("removed"));
+    AttentionSetUpdate expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed");
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Second removal is ignored.
+    fakeClock.advance(Duration.ofSeconds(42));
+    change(r)
+        .attention(user.id().toString())
+        .remove(new RemoveFromAttentionSetInput("removed again"));
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+  }
+
+  @Test
+  public void removeUnrelatedUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("foo"));
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 8cfcbab..264ced6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
@@ -55,11 +54,9 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
 @UseClockStep
 @UseTimezone(timezone = "US/Eastern")
-@RunWith(ConfigSuite.class)
 public class ChangeMessagesIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index ac00e38..843ecc6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -209,7 +209,7 @@
       List<Message> messages = sender.getMessages();
       assertThat(messages).hasSize(1);
       assertThat(messages.get(0).rcpt())
-          .containsExactly(Address.parse(addInput.reviewer), user.getEmailAddress());
+          .containsExactly(Address.parse(addInput.reviewer), user.getNameEmail());
       sender.clear();
     }
   }
@@ -368,6 +368,6 @@
   }
 
   private static String toRfcAddressString(AccountInfo info) {
-    return (new Address(info.name, info.email)).toString();
+    return (Address.create(info.name, info.email)).toString();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index fbe6533..94357b9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -150,7 +150,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains(admin.fullName() + " has uploaded this change for review.");
   }
 
@@ -188,13 +188,14 @@
     Message m = messages.get(0);
     List<Address> expectedAddresses = new ArrayList<>(firstUsers.size());
     for (TestAccount u : firstUsers) {
-      expectedAddresses.add(u.getEmailAddress());
+      expectedAddresses.add(u.getNameEmail());
     }
     assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
 
     // CC a group that overlaps with some existing reviewers and CCed accounts.
     TestAccount reviewer =
-        accountCreator.create(name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer");
+        accountCreator.create(
+            name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer", null);
     result = addReviewer(changeId, reviewer.username());
     assertThat(result.error).isNull();
     sender.clear();
@@ -216,9 +217,9 @@
     m = messages.get(0);
     expectedAddresses = new ArrayList<>(4);
     for (int i = 0; i < 3; i++) {
-      expectedAddresses.add(users.get(users.size() - i - 1).getEmailAddress());
+      expectedAddresses.add(users.get(users.size() - i - 1).getNameEmail());
     }
-    expectedAddresses.add(reviewer.getEmailAddress());
+    expectedAddresses.add(reviewer.getNameEmail());
     assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
   }
 
@@ -398,13 +399,13 @@
     assertThat(messages).hasSize(2);
 
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress(), observer.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
     assertThat(m.body()).contains(admin.fullName() + " has posted comments on this change.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
 
     m = messages.get(1);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress(), observer.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
     assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
   }
@@ -546,11 +547,11 @@
   public void addOverlappingGroups() throws Exception {
     String emailPrefix = "addOverlappingGroups-";
     TestAccount user1 =
-        accountCreator.create(name("user1"), emailPrefix + "user1@example.com", "User1");
+        accountCreator.create(name("user1"), emailPrefix + "user1@example.com", "User1", null);
     TestAccount user2 =
-        accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2");
+        accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2", null);
     TestAccount user3 =
-        accountCreator.create(name("user3"), emailPrefix + "user3@example.com", "User3");
+        accountCreator.create(name("user3"), emailPrefix + "user3@example.com", "User3", null);
     String group1 = groupOperations.newGroup().name("group1").create().get();
     String group2 = groupOperations.newGroup().name("group2").create().get();
     gApi.groups().id(group1).addMembers(user1.username(), user2.username());
@@ -635,7 +636,7 @@
     sender.clear();
     gApi.changes().id(r.getChangeId()).current().review(reviewInput);
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.getEmailAddress());
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.getNameEmail());
   }
 
   @Test
@@ -652,7 +653,7 @@
     sender.clear();
     gApi.changes().id(r.getChangeId()).addReviewer(addReviewer);
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.getEmailAddress());
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.getNameEmail());
   }
 
   @Test
@@ -829,7 +830,7 @@
   }
 
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
-    AccountInfo userInfo = new AccountInfo(user.fullName(), user.getEmailAddress().getEmail());
+    AccountInfo userInfo = new AccountInfo(user.fullName(), user.getNameEmail().email());
     userInfo._accountId = user.id().get();
     userInfo.username = user.username();
     assertThat(gApi.changes().id(changeId).get().reviewers)
@@ -912,7 +913,7 @@
     for (int i = 0; i < n; i++) {
       result.add(
           accountCreator.create(
-              name("u" + i), emailPrefix + "-" + i + "@example.com", "Full Name " + i));
+              name("u" + i), emailPrefix + "-" + i + "@example.com", "Full Name " + i, null));
     }
     return result;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index a1167ed..a0ebf02 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -18,12 +18,16 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 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.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
@@ -32,20 +36,27 @@
 import com.google.gerrit.acceptance.UseSystemTime;
 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.Change;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 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.changes.RevisionApi;
+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;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 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;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -53,6 +64,7 @@
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -140,6 +152,11 @@
     ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
     assertThat(info.revisions.get(info.currentRevision).commit.message)
         .contains("Change-Id: " + info.changeId);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(info._number).messages();
+    assertThat(messages).hasSize(1);
+    assertThat(Iterables.getOnlyElement(messages).message).isEqualTo("Uploaded patch set 1.");
   }
 
   @Test
@@ -199,7 +216,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains(admin.fullName() + " has uploaded this change for review.");
 
     // check that watcher is not notified if notify=NONE
@@ -254,6 +271,56 @@
   }
 
   @Test
+  public void createDefaultAuthor() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    ChangeInfo info = assertCreateSucceeds(input);
+    GitPerson person = gApi.changes().id(info.id).current().commit(false).author;
+    assertThat(person).email().isEqualTo(admin.email());
+  }
+
+  @Test
+  public void createAuthorOverrideBadRequest() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.name = "name";
+    assertCreateFails(input, BadRequestException.class, "email");
+    input.author.name = null;
+    input.author.email = "gerritlessjane@invalid";
+    assertCreateFails(input, BadRequestException.class, "email");
+  }
+
+  @Test
+  public void createAuthorOverride() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.email = "gerritlessjane@invalid";
+    // This is an email address that doesn't exist as account on the Gerrit server.
+    input.author.name = "Gerritless Jane";
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    RevisionApi rApi = gApi.changes().id(info.id).current();
+    GitPerson author = rApi.commit(false).author;
+    assertThat(author).email().isEqualTo(input.author.email);
+    assertThat(author).name().isEqualTo(input.author.name);
+    GitPerson committer = rApi.commit(false).committer;
+    assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
+  }
+
+  @Test
+  public void createAuthorPermission() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.name = "Jane";
+    input.author.email = "jane@invalid";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertCreateFails(input, AuthException.class, "forge author");
+  }
+
+  @Test
   public void createNewWorkInProgressChange() throws Exception {
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.workInProgress = true;
@@ -266,7 +333,19 @@
         changeInTwoBranches("foo", "foo.txt", "bar", "bar.txt");
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.baseCommit = setup.get("master").getCommit().getId().name();
-    assertCreateSucceeds(input);
+    ChangeInfo result = assertCreateSucceeds(input);
+    assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(input.baseCommit);
+  }
+
+  @Test
+  public void createChangeWithParentChange() throws Exception {
+    Result change = createChange();
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.baseChange = change.getChangeId();
+    ChangeInfo result = assertCreateSucceeds(input);
+    assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(change.getCommit().getId().name());
   }
 
   @Test
@@ -350,7 +429,7 @@
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()), c.created, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
@@ -363,7 +442,28 @@
   public void createMergeChange() throws Exception {
     changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
     ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
-    assertCreateSucceeds(in);
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(change._number).messages();
+    assertThat(messages).hasSize(1);
+    assertThat(Iterables.getOnlyElement(messages).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void createMergeChangeAuthor() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    in.author = new AccountInput();
+    in.author.name = "Gerritless Jane";
+    in.author.email = "gerritlessjane@invalid";
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    RevisionApi rApi = gApi.changes().id(change.id).current();
+    GitPerson author = rApi.commit(false).author;
+    assertThat(author).email().isEqualTo(in.author.email);
+    GitPerson committer = rApi.commit(false).committer;
+    assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
   }
 
   @Test
@@ -381,6 +481,87 @@
   }
 
   @Test
+  public void createMergeChange_ConflictsAllowed() throws Exception {
+    String fileName = "shared.txt";
+    String sourceBranch = "sourceBranch";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetBranch = "targetBranch";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    changeInTwoBranches(
+        sourceBranch,
+        sourceSubject,
+        fileName,
+        sourceContent,
+        targetBranch,
+        targetSubject,
+        fileName,
+        targetContent);
+    ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, "", true);
+    ChangeInfo change = assertCreateSucceedsWithConflicts(in);
+
+    // Verify that the file content in the created change is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin = gApi.changes().id(change._number).current().file(fileName).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String sourceSha1 = abbreviateName(projectOperations.project(project).getHead(sourceBranch), 6);
+    String targetSha1 = abbreviateName(projectOperations.project(project).getHead(targetBranch), 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< TARGET BRANCH ("
+                + targetSha1
+                + " "
+                + targetSubject
+                + ")\n"
+                + targetContent
+                + "\n"
+                + "=======\n"
+                + sourceContent
+                + "\n"
+                + ">>>>>>> SOURCE BRANCH ("
+                + sourceSha1
+                + " "
+                + sourceSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(change._number).messages();
+    assertThat(messages).hasSize(1);
+    assertThat(Iterables.getOnlyElement(messages).message)
+        .isEqualTo(
+            "Uploaded patch set 1.\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + fileName
+                + "\n");
+  }
+
+  @Test
+  public void createMergeChange_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+    String fileName = "shared.txt";
+    String sourceBranch = "sourceBranch";
+    String targetBranch = "targetBranch";
+    changeInTwoBranches(
+        sourceBranch,
+        "source change",
+        fileName,
+        "source content",
+        targetBranch,
+        "target change",
+        fileName,
+        "target content");
+    String mergeStrategy = "simple-two-way-in-core";
+    ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, mergeStrategy, true);
+    assertCreateFails(
+        in,
+        BadRequestException.class,
+        "merge with conflicts is not supported with merge strategy: " + mergeStrategy);
+  }
+
+  @Test
   public void createMergeChangeFailsWithConflictIfThereAreTooManyCommonPredecessors()
       throws Exception {
     // Create an initial commit in master.
@@ -606,6 +787,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();
@@ -635,6 +912,26 @@
     }
     assertThat(out.revisions).hasSize(1);
     assertThat(out.submitted).isNull();
+    assertThat(out.containsGitConflicts).isNull();
+    assertThat(in.status).isEqualTo(ChangeStatus.NEW);
+    return out;
+  }
+
+  private ChangeInfo assertCreateSucceedsWithConflicts(ChangeInput in) throws Exception {
+    ChangeInfo out = gApi.changes().createAsInfo(in);
+    assertThat(out.project).isEqualTo(in.project);
+    assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
+    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
+    assertThat(out.topic).isEqualTo(in.topic);
+    assertThat(out.status).isEqualTo(in.status);
+    if (in.isPrivate) {
+      assertThat(out.isPrivate).isTrue();
+    } else {
+      assertThat(out.isPrivate).isNull();
+    }
+    assertThat(out.submitted).isNull();
+    assertThat(out.containsGitConflicts).isTrue();
+    assertThat(out.workInProgress).isTrue();
     assertThat(in.status).isEqualTo(ChangeStatus.NEW);
     return out;
   }
@@ -667,6 +964,11 @@
   }
 
   private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef, String strategy) {
+    return newMergeChangeInput(targetBranch, sourceRef, strategy, false);
+  }
+
+  private ChangeInput newMergeChangeInput(
+      String targetBranch, String sourceRef, String strategy, boolean allowConflicts) {
     // create a merge change from branchA to master in gerrit
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -679,6 +981,7 @@
     if (!Strings.isNullOrEmpty(strategy)) {
       in.merge.strategy = strategy;
     }
+    in.merge.allowConflicts = allowConflicts;
     return in;
   }
 
@@ -694,6 +997,34 @@
    */
   private Map<String, Result> changeInTwoBranches(
       String branchA, String fileA, String branchB, String fileB) throws Exception {
+    return changeInTwoBranches(
+        branchA, "change A", fileA, "A content", branchB, "change B", fileB, "B content");
+  }
+
+  /**
+   * Create an empty commit in master, two new branches with one commit each.
+   *
+   * @param branchA name of first branch to create
+   * @param subjectA commit message subject for the change on branchA
+   * @param fileA name of file to commit to branchA
+   * @param contentA file content to commit to branchA
+   * @param branchB name of second branch to create
+   * @param subjectB commit message subject for the change on branchB
+   * @param fileB name of file to commit to branchB
+   * @param contentB file content to commit to branchB
+   * @return A {@code Map} of branchName => commit result.
+   * @throws Exception
+   */
+  private Map<String, Result> changeInTwoBranches(
+      String branchA,
+      String subjectA,
+      String fileA,
+      String contentA,
+      String branchB,
+      String subjectB,
+      String fileB,
+      String contentB)
+      throws Exception {
     // create a initial commit in master
     Result initialCommit =
         pushFactory
@@ -708,13 +1039,13 @@
     // create a commit in branchA
     Result changeA =
         pushFactory
-            .create(user.newIdent(), testRepo, "change A", fileA, "A content")
+            .create(user.newIdent(), testRepo, subjectA, fileA, contentA)
             .to("refs/heads/" + branchA);
     changeA.assertOkStatus();
 
     // create a commit in branchB
     PushOneCommit commitB =
-        pushFactory.create(user.newIdent(), testRepo, "change B", fileB, "B content");
+        pushFactory.create(user.newIdent(), testRepo, subjectB, fileB, contentB);
     commitB.setParent(initialCommit.getCommit());
     Result changeB = commitB.to("refs/heads/" + branchB);
     changeB.assertOkStatus();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 37fa2ce..25e5647 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -73,7 +73,7 @@
     List<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     FakeEmailSender.Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
     assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
     assertThat(msg.body())
         .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
new file mode 100644
index 0000000..15e6360
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
@@ -0,0 +1,154 @@
+// 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.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.client.ArchiveFormat;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.git.ObjectIds;
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.HashMap;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GetArchiveIT extends AbstractDaemonTest {
+  private static final String DIRECTORY_NAME = "foo";
+  private static final String FILE_NAME = DIRECTORY_NAME + "/bar.txt";
+  private static final String FILE_CONTENT = "some content";
+
+  private String changeId;
+  private RevCommit commit;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "My Change", FILE_NAME, FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    changeId = result.getChangeId();
+    commit = result.getCommit();
+  }
+
+  @Test
+  public void formatNotSpecified() throws Exception {
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).current().getArchive(null));
+    assertThat(ex).hasMessageThat().isEqualTo("format is not specified");
+  }
+
+  @Test
+  public void unknownFormat() throws Exception {
+    // Test this by a REST call, since the Java API doesn't allow to specify an unknown format.
+    RestResponse res =
+        adminRestSession.get(
+            String.format(
+                "/changes/%s/revisions/current/archive?format=%s", changeId, "unknownFormat"));
+    res.assertBadRequest();
+    assertThat(res.getEntityContent()).isEqualTo("unknown archive format");
+  }
+
+  @Test
+  public void zipFormatIsDisabled() throws Exception {
+    MethodNotAllowedException ex =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.changes().id(changeId).current().getArchive(ArchiveFormat.ZIP));
+    assertThat(ex).hasMessageThat().isEqualTo("zip format is disabled");
+  }
+
+  @Test
+  public void getTarArchive() throws Exception {
+    BinaryResult res = gApi.changes().id(changeId).current().getArchive(ArchiveFormat.TAR);
+    assertThat(res.getAttachmentName())
+        .isEqualTo(commit.abbreviate(ObjectIds.ABBREV_STR_LEN).name() + ".tar");
+    assertThat(res.getContentType()).isEqualTo("application/x-tar");
+    assertThat(res.canGzip()).isFalse();
+
+    byte[] archiveBytes = getBinaryContent(res);
+    try (ByteArrayInputStream in = new ByteArrayInputStream(archiveBytes)) {
+      HashMap<String, String> archiveEntries = getTarContent(in);
+      assertThat(archiveEntries)
+          .containsExactly(DIRECTORY_NAME + "/", null, FILE_NAME, FILE_CONTENT);
+    }
+  }
+
+  @Test
+  public void getTgzArchive() throws Exception {
+    BinaryResult res = gApi.changes().id(changeId).current().getArchive(ArchiveFormat.TGZ);
+    assertThat(res.getAttachmentName())
+        .isEqualTo(commit.abbreviate(ObjectIds.ABBREV_STR_LEN).name() + ".tar.gz");
+    assertThat(res.getContentType()).isEqualTo("application/x-gzip");
+    assertThat(res.canGzip()).isFalse();
+
+    byte[] archiveBytes = getBinaryContent(res);
+    try (ByteArrayInputStream in = new ByteArrayInputStream(archiveBytes);
+        GzipCompressorInputStream gzipIn = new GzipCompressorInputStream(in)) {
+      HashMap<String, String> archiveEntries = getTarContent(gzipIn);
+      assertThat(archiveEntries)
+          .containsExactly(DIRECTORY_NAME + "/", null, FILE_NAME, FILE_CONTENT);
+    }
+  }
+
+  private HashMap<String, String> getTarContent(InputStream in) throws Exception {
+    HashMap<String, String> archiveEntries = new HashMap<>();
+    int bufferSize = 100;
+    try (TarArchiveInputStream tarIn = new TarArchiveInputStream(in)) {
+      TarArchiveEntry entry;
+      while ((entry = tarIn.getNextTarEntry()) != null) {
+        if (entry.isDirectory()) {
+          archiveEntries.put(entry.getName(), null);
+        } else {
+          byte data[] = new byte[bufferSize];
+          try (ByteArrayOutputStream out = new ByteArrayOutputStream();
+              BufferedOutputStream bufferedOut = new BufferedOutputStream(out, bufferSize)) {
+            int count;
+            while ((count = tarIn.read(data, 0, bufferSize)) != -1) {
+              bufferedOut.write(data, 0, count);
+            }
+            bufferedOut.flush();
+            archiveEntries.put(entry.getName(), out.toString());
+          }
+        }
+      }
+    }
+    return archiveEntries;
+  }
+
+  private byte[] getBinaryContent(BinaryResult res) throws Exception {
+    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+      res.writeTo(out);
+      return out.toByteArray();
+    } finally {
+      res.close();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index c57a035..0099fe6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -226,7 +226,7 @@
     HashtagsInput input = new HashtagsInput();
     input.add = Sets.newHashSet("tag3", "tag4");
     input.remove = Sets.newHashSet("tag1");
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
     assertThatGet(r).containsExactly("tag2", "tag3", "tag4");
     assertMessage(r, "Hashtags added: tag3, tag4\nHashtag removed: tag1");
 
@@ -235,7 +235,7 @@
     input = new HashtagsInput();
     input.add = Sets.newHashSet("tag3", "tag4");
     input.remove = Sets.newHashSet("tag3");
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
     assertThatGet(r).containsExactly("tag1", "tag2", "tag4");
     assertMessage(r, "Hashtag removed: tag3");
   }
@@ -271,19 +271,19 @@
   }
 
   private IterableSubject assertThatGet(PushOneCommit.Result r) throws Exception {
-    return assertThat(gApi.changes().id(r.getChange().getId().get()).getHashtags());
+    return assertThat(change(r).getHashtags());
   }
 
   private void addHashtags(PushOneCommit.Result r, String... toAdd) throws Exception {
     HashtagsInput input = new HashtagsInput();
     input.add = Sets.newHashSet(toAdd);
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
   }
 
   private void removeHashtags(PushOneCommit.Result r, String... toRemove) throws Exception {
     HashtagsInput input = new HashtagsInput();
     input.remove = Sets.newHashSet(toRemove);
-    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
+    change(r).setHashtags(input);
   }
 
   private void assertMessage(PushOneCommit.Result r, String expectedMessage) throws Exception {
@@ -299,8 +299,7 @@
   }
 
   private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
-    ChangeMessageInfo lastMessage =
-        Iterables.getLast(gApi.changes().id(r.getChange().getId().get()).get().messages, null);
+    ChangeMessageInfo lastMessage = Iterables.getLast(change(r).get().messages, null);
     assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
     return lastMessage;
   }
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/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index 388c4f4..d742fad 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -27,11 +27,11 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -129,8 +129,7 @@
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (Registration registration =
         extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
-      ResourceConflictException thrown =
-          assertThrows(ResourceConflictException.class, () -> submitWithRebase());
+      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause).hasMessageThat().isEqualTo("boom");
@@ -146,8 +145,7 @@
             .newRegistration()
             .add(modifier1, "modifier-1")
             .add(modifier2, "modifier-2")) {
-      ResourceConflictException thrown =
-          assertThrows(ResourceConflictException.class, () -> submitWithRebase());
+      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause)
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..551a349 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()))
@@ -586,7 +570,7 @@
 
   private TestAccount createAccountWithSecondaryEmail(String name, String secondaryEmail)
       throws Exception {
-    TestAccount foo = accountCreator.create(name(name), "foo.primary@example.com", "Foo");
+    TestAccount foo = accountCreator.create(name(name), "foo.primary@example.com", "Foo", null);
     EmailInput input = new EmailInput();
     input.email = secondaryEmail;
     input.noConfirmation = true;
@@ -617,17 +601,15 @@
   }
 
   private TestAccount user(String name, String fullName, String emailName) throws Exception {
-    return accountCreator.create(name(name), name(emailName) + "@example.com", fullName);
+    return accountCreator.create(name(name), name(emailName) + "@example.com", fullName, null);
   }
 
   private TestAccount user(String name, String fullName) throws Exception {
     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/change/TopicIT.java b/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
index 1d928a2..003580a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/TopicIT.java
@@ -58,4 +58,13 @@
 
     assertThat(response.getEntityContent()).isEqualTo(")]}'\n\"t opic\"");
   }
+
+  @Test
+  public void illegalTopics() throws Exception {
+    Result result = createChange();
+    char illegalChar = '"';
+    String endpoint = "/changes/" + result.getChangeId() + "/topic";
+    RestResponse response = adminRestSession.put(endpoint, "topic" + illegalChar);
+    response.assertBadRequest();
+  }
 }
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..cef66654 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -18,13 +18,14 @@
 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;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -41,6 +42,7 @@
   @Test
   // accounts
   @GerritConfig(name = "accounts.visibility", value = "VISIBLE_GROUP")
+  @GerritConfig(name = "accounts.defaultDisplayName", value = "FIRST_NAME")
 
   // auth
   @GerritConfig(name = "auth.type", value = "HTTP")
@@ -61,6 +63,8 @@
   @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  @GerritConfig(name = "change.enableAttentionSet", value = "true")
+  @GerritConfig(name = "change.enableAssignee", value = "true")
 
   // download
   @GerritConfig(
@@ -82,6 +86,7 @@
 
     // accounts
     assertThat(i.accounts.visibility).isEqualTo(AccountVisibility.VISIBLE_GROUP);
+    assertThat(i.accounts.defaultDisplayName).isEqualTo(AccountDefaultDisplayName.FIRST_NAME);
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
@@ -102,6 +107,8 @@
     assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
     assertThat(i.change.disablePrivateChanges).isTrue();
+    assertThat(i.change.enableAttentionSet).isTrue();
+    assertThat(i.change.enableAssignee).isTrue();
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
@@ -169,7 +176,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 +209,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..0514e03 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -17,9 +17,12 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -30,6 +33,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -44,13 +48,18 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.GrantRevertPermission;
 import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.Map;
@@ -75,6 +84,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private GrantRevertPermission grantRevertPermission;
 
   private Project.NameKey newProjectName;
 
@@ -84,6 +94,101 @@
   }
 
   @Test
+  public void grantRevertPermission() throws Exception {
+    String ref = "refs/*";
+    String groupId = "global:Registered-Users";
+
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+    assertThat(info.local.containsKey(ref)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(ref);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
+    String refsHeads = "refs/heads/*";
+    String refsStar = "refs/*";
+    String groupId = "global:Registered-Users";
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
+      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+
+    // Revert permission is removed on refs/heads/*.
+    assertThat(info.local.containsKey(refsHeads)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
+
+    // new permission is added on refs/* with Registered-Users.
+    assertThat(info.local.containsKey(refsStar)).isTrue();
+    accessSectionInfo = info.local.get(refsStar);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
+      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+      grant(projectConfig, heads, Permission.REVERT, otherGroup);
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    ProjectAccessInfo expected = pApi().access();
+
+    grantRevertPermission.execute(newProjectName);
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo actual = pApi().access();
+    // Permissions don't change
+    assertThat(expected.local).isEqualTo(actual.local);
+  }
+
+  @Test
+  public void grantRevertPermissionOnlyWorksOnce() throws Exception {
+    grantRevertPermission.execute(newProjectName);
+    grantRevertPermission.execute(newProjectName);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
+
+      Permission permission = all.getPermission(Permission.REVERT);
+      assertThat(permission.getRules()).hasSize(1);
+    }
+  }
+
+  @Test
   public void getDefaultInheritance() throws Exception {
     String inheritedName = pApi().access().inheritsFrom.name;
     assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
@@ -143,6 +248,68 @@
   }
 
   @Test
+  public void addAccessSectionForPluginPermission() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new PluginProjectPermissionDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Project Permission";
+                  }
+                },
+                "fooPermission")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(
+          "plugin-" + ExtensionRegistry.PLUGIN_NAME + "-fooPermission", foo);
+
+      accessInput.add.put(REFS_HEADS, accessSectionInfo);
+      ProjectAccessInfo updatedAccessSectionInfo = pApi().access(accessInput);
+      assertThat(updatedAccessSectionInfo.local).isEqualTo(accessInput.add);
+
+      assertThat(pApi().access().local).isEqualTo(accessInput.add);
+    }
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: Invalid Permission");
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("label-Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: label-Invalid Permission");
+  }
+
+  @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
@@ -366,7 +533,8 @@
 
     accessInput.add.put(REFS_ALL, accessSection);
     ProjectAccessInfo result = pApi().access(accessInput);
-    assertThat(result.groups.keySet())
+    assertThatMap(result.groups)
+        .keys()
         .containsExactly(
             SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
 
@@ -379,7 +547,8 @@
 
     // Get call returns groups too.
     ProjectAccessInfo loggedInResult = pApi().access();
-    assertThat(loggedInResult.groups.keySet())
+    assertThatMap(loggedInResult.groups)
+        .keys()
         .containsExactly(
             SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
 
@@ -392,7 +561,8 @@
     // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
     requestScopeOperations.setApiUserAnonymous();
     ProjectAccessInfo anonResult = pApi().access();
-    assertThat(anonResult.groups.keySet())
+    assertThatMap(anonResult.groups)
+        .keys()
         .containsExactly(
             SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
   }
@@ -446,16 +616,82 @@
 
     ProjectAccessInfo updatedAccessSectionInfo =
         gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedAccessSectionInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
+    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
         .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
   }
 
   @Test
+  public void addPluginGlobalCapability() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new CapabilityDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Global Capability";
+                  }
+                },
+                "fooCapability")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(ExtensionRegistry.PLUGIN_NAME + "-fooCapability", foo);
+
+      accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+      ProjectAccessInfo updatedAccessSectionInfo =
+          gApi.projects().name(allProjects.get()).access(accessInput);
+      assertThatMap(
+              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+          .keys()
+          .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+    }
+  }
+
+  @Test
+  public void addPermissionAsGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put(Permission.PUSH, push);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown global capability: " + Permission.PUSH);
+  }
+
+  @Test
+  public void addInvalidGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Global Capability", permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo("Unknown global capability: Invalid Global Capability");
+  }
+
+  @Test
   public void addGlobalCapabilityForNonRootProject() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
@@ -507,12 +743,8 @@
 
     ProjectAccessInfo updatedProjectAccessInfo =
         gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedProjectAccessInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
         .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
 
     // Remove
@@ -520,12 +752,8 @@
     accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(
-            updatedProjectAccessInfo
-                .local
-                .get(AccessSection.GLOBAL_CAPABILITIES)
-                .permissions
-                .keySet())
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
         .containsNoneIn(accessSectionInfo.permissions.keySet());
   }
 
@@ -604,7 +832,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();
@@ -617,14 +845,12 @@
 
     // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
     Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThat(local).isNotNull();
-    assertThat(local).containsKey(RefNames.REFS_GROUPS + "*");
+    assertThatMap(local).keys().contains(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);
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
     Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThat(rules.values()).containsExactly(pri);
+    assertThatMap(rules).values().containsExactly(pri);
 
     // Revoke the permission
     accessInput.add.clear();
@@ -633,12 +859,47 @@
 
     // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
     Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
-    assertThat(local2).isNotNull();
-    assertThat(local2).containsKey(RefNames.REFS_GROUPS + "*");
+    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
     Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
-    assertThat(permissions2).hasSize(1);
     // READ is the default permission and should be preserved by the syncer
-    assertThat(permissions2.keySet()).containsExactly(Permission.READ);
+    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
+  }
+
+  @Test
+  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
+      throws Exception {
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Grant CREATE_GROUP to Administrators
+    accessInput = newProjectAccessInput();
+    accessSection = newAccessSectionInfo();
+    createGroup = newPermissionInfo();
+    createGroup.rules.put(adminGroupUuid().get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThatMap(rules)
+        .keys()
+        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
+    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
+    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index ca187a3..54ae5af 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/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 85d383e..b01a07b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -23,7 +23,9 @@
 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.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -38,8 +40,20 @@
 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.RestApiException;
+import com.google.gerrit.server.events.RefReceivedEvent;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
@@ -47,6 +61,7 @@
 public class CreateBranchIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private BranchNameKey testBranch;
 
@@ -82,7 +97,63 @@
   @Test
   public void branchAlreadyExists_Conflict() throws Exception {
     assertCreateSucceeds(testBranch);
-    assertCreateFails(testBranch, ResourceConflictException.class);
+    assertCreateFails(
+        testBranch,
+        ResourceConflictException.class,
+        "branch \"" + testBranch.branch() + "\" already exists");
+  }
+
+  @Test
+  public void createBranch_LockFailure() throws Exception {
+    // check that the branch doesn't exist yet
+    assertThrows(ResourceNotFoundException.class, () -> branch(testBranch).get());
+
+    // Register a validation listener that creates the branch to simulate a concurrent request that
+    // creates the same branch.
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new RefOperationValidationListener() {
+                  @Override
+                  public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+                      throws ValidationException {
+                    try (Repository repo = repoManager.openRepository(project)) {
+                      RefUpdate u = repo.updateRef(testBranch.branch());
+                      u.setExpectedOldObjectId(ObjectId.zeroId());
+                      u.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
+                      RefUpdate.Result result = u.update();
+                      if (result != RefUpdate.Result.NEW) {
+                        throw new ValidationException(
+                            "Concurrent creation of branch failed: " + result);
+                      }
+                      return ImmutableList.of();
+                    } catch (IOException e) {
+                      throw new ValidationException("Concurrent creation of branch failed.", e);
+                    }
+                  }
+                })) {
+      // Creating the branch is expected to fail, since it is created by the validation listener
+      // right before the ref update to create the new branch is done.
+      assertCreateFails(
+          testBranch,
+          ResourceConflictException.class,
+          "branch \"" + testBranch.branch() + "\" already exists");
+    }
+  }
+
+  @Test
+  public void conflictingBranchAlreadyExists_Conflict() throws Exception {
+    assertCreateSucceeds(testBranch);
+    BranchNameKey testBranch2 = BranchNameKey.create(project, testBranch.branch() + "/foo/bar");
+    assertCreateFails(
+        testBranch2,
+        ResourceConflictException.class,
+        "Cannot create branch \""
+            + testBranch2.branch()
+            + "\" since it conflicts with branch \""
+            + testBranch.branch()
+            + "\"");
   }
 
   @Test
@@ -119,6 +190,23 @@
   }
 
   @Test
+  public void createMetaConfigBranch() throws Exception {
+    // Since the refs/meta/config branch exists by default, we must delete it before we can test
+    // creating it. Since deleting the refs/meta/config branch is not allowed through the API, we
+    // delete it directly in the remote repository.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      repo.delete(RefNames.REFS_CONFIG);
+    }
+
+    // Create refs/meta/config branch.
+    BranchInfo created =
+        branch(BranchNameKey.create(project, RefNames.REFS_CONFIG)).create(new BranchInput()).get();
+    assertThat(created.ref).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(created.canDelete).isNull();
+  }
+
+  @Test
   public void createUserBranch_Conflict() throws Exception {
     projectOperations
         .project(allUsers)
@@ -258,6 +346,54 @@
         "invalid revision \"invalid\trevision\"");
   }
 
+  @Test
+  public void cannotCreateBranchInMagicBranchNamespace() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, MagicBranch.NEW_CHANGE + "foo"),
+        BadRequestException.class,
+        "not allowed to create branches under \"" + MagicBranch.NEW_CHANGE + "\"");
+  }
+
+  @Test
+  public void cannotCreateBranchWithInvalidName() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, RefNames.REFS_HEADS),
+        BadRequestException.class,
+        "invalid branch name \"" + RefNames.REFS_HEADS + "\"");
+  }
+
+  @Test
+  public void createBranchLeadingSlashesAreRemoved() throws Exception {
+    BranchNameKey expectedNameKey = BranchNameKey.create(project, "test");
+
+    // check that the branch doesn't exist yet
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).branch(expectedNameKey.branch()).get());
+
+    // create the branch, but include leading slashes in the branch name,
+    // when creating the branch ensure that the branch name in the URL matches the branch name in
+    // the input (if there is a mismatch the creation request is rejected)
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = "////" + expectedNameKey.shortName();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    // verify that the branch was created without the leading slashes in the name
+    assertThat(gApi.projects().name(project.get()).branch(expectedNameKey.branch()).get().ref)
+        .isEqualTo(expectedNameKey.branch());
+  }
+
+  @Test
+  public void branchNameInInputMustMatchBranchNameInUrl() throws Exception {
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = "foo";
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).branch("bar").create(branchInput));
+    assertThat(ex).hasMessageThat().isEqualTo("ref must match URL");
+  }
+
   private void blockCreateReference() throws Exception {
     projectOperations
         .project(project)
@@ -302,9 +438,4 @@
       assertThat(thrown).hasMessageThat().contains(errMsg);
     }
   }
-
-  private void assertCreateFails(BranchNameKey branch, Class<? extends RestApiException> errType)
-      throws Exception {
-    assertCreateFails(branch, errType, null);
-  }
 }
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..e5587a9
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -0,0 +1,623 @@
+// 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.copyValues).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 createWithCopyValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyValues = ImmutableList.of((short) -1, (short) 1);
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+  }
+
+  @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..874f07a 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;
@@ -91,9 +91,9 @@
     // for more extensive coverage of the LabelTypeInfo.
     assertThat(p.labels).hasSize(1);
 
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertProjectInfo(projectState.get().getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
   }
 
@@ -167,9 +167,9 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertProjectInfo(projectState.get().getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
     assertThat(readProjectConfig(newProjectName))
         .hasValue("[access]\n\tinheritFrom = All-Projects\n[submit]\n\taction = inherit\n");
@@ -180,9 +180,9 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + ".git").get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertProjectInfo(projectState.get().getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
   }
 
@@ -191,9 +191,9 @@
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + "/").get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertProjectInfo(projectState.get().getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
   }
 
@@ -202,9 +202,9 @@
     String newProjectName = name("newProject/newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertProjectInfo(projectState.getProject(), p);
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertProjectInfo(projectState.get().getProject(), p);
     assertHead(newProjectName, "refs/heads/master");
   }
 
@@ -221,7 +221,7 @@
     in.requireChangeId = InheritableBoolean.TRUE;
     ProjectInfo p = gApi.projects().create(in).get();
     assertThat(p.name).isEqualTo(newProjectName);
-    Project project = projectCache.get(Project.nameKey(newProjectName)).getProject();
+    Project project = projectCache.get(Project.nameKey(newProjectName)).get().getProject();
     assertProjectInfo(project, p);
     assertThat(project.getDescription()).isEqualTo(in.description);
     assertThat(project.getConfiguredSubmitType()).isEqualTo(in.submitType);
@@ -247,7 +247,7 @@
     in.name = childName;
     in.parent = parentName;
     gApi.projects().create(in);
-    Project project = projectCache.get(Project.nameKey(childName)).getProject();
+    Project project = projectCache.get(Project.nameKey(childName)).get().getProject();
     assertThat(project.getParentName()).isEqualTo(in.parent);
   }
 
@@ -275,12 +275,13 @@
                 .getId()
                 .get())); // by ID
     gApi.projects().create(in);
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
     Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
     expectedOwnerIds.add(SystemGroupBackend.ANONYMOUS_USERS);
     expectedOwnerIds.add(SystemGroupBackend.REGISTERED_USERS);
     expectedOwnerIds.add(groupUuid("Administrators"));
-    assertProjectOwners(expectedOwnerIds, projectState);
+    assertThat(projectState).isPresent();
+    assertProjectOwners(expectedOwnerIds, projectState.get());
   }
 
   @Test
@@ -367,7 +368,7 @@
 
   @Test
   public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
-    Project parent = projectCache.get(allProjects).getProject();
+    Project parent = projectCache.get(allProjects).get().getProject();
     parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
     projectOperations
         .allProjectsForUpdate()
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/GetChildProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
index 8911163..d45c0f2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
@@ -49,7 +49,7 @@
     Project.NameKey child = projectOperations.newProject().create();
     ProjectInfo childInfo = gApi.projects().name(allProjects.get()).child(child.get()).get();
 
-    assertProjectInfo(projectCache.get(child).getProject(), childInfo);
+    assertProjectInfo(projectCache.get(child).get().getProject(), childInfo);
   }
 
   @Test
@@ -67,7 +67,7 @@
 
     ProjectInfo grandChildInfo =
         gApi.projects().name(allProjects.get()).child(grandChild.get()).get(true);
-    assertProjectInfo(projectCache.get(grandChild).getProject(), grandChildInfo);
+    assertProjectInfo(projectCache.get(grandChild).get().getProject(), grandChildInfo);
   }
 
   private void assertChildNotFound(Project.NameKey parent, String child) throws Exception {
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..940fae5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -0,0 +1,157 @@
+// 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.copyValues).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.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+      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.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+    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..65e352b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.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.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.copyValues).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..ef08079
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -0,0 +1,271 @@
+// 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.copyValues).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.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+      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.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+    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/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index 76c30a9..c3891cf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -50,13 +50,13 @@
             admin.newIdent(), testRepo, "Create Project Level Config", configName, cfg.toText());
     push.to(RefNames.REFS_CONFIG);
 
-    ProjectState state = projectCache.get(project);
+    ProjectState state = projectCache.get(project).get();
     assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
   }
 
   @Test
   public void nonExistingConfig() {
-    ProjectState state = projectCache.get(project);
+    ProjectState state = projectCache.get(project).get();
     assertThat(state.getConfig("test.config").get().toText()).isEqualTo("");
   }
 
@@ -99,7 +99,7 @@
         .to(RefNames.REFS_CONFIG)
         .assertOkStatus();
 
-    ProjectState state = projectCache.get(childProject);
+    ProjectState state = projectCache.get(childProject).get();
 
     Config expectedCfg = new Config();
     expectedCfg.setString("s1", null, "k1", "childValue1");
@@ -158,7 +158,7 @@
         .to(RefNames.REFS_CONFIG)
         .assertOkStatus();
 
-    ProjectState state = projectCache.get(childProject);
+    ProjectState state = projectCache.get(childProject).get();
 
     Config expectedCfg = new Config();
     expectedCfg.setStringList("s1", null, "k1", Arrays.asList("childValue1", "parentValue1"));
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..b08c72b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -0,0 +1,933 @@
+// 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 setCopyValues() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyValues = ImmutableList.of((short) -1, (short) 1);
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues)
+        .containsExactly((short) -1, (short) 1)
+        .inOrder();
+  }
+
+  @Test
+  public void unsetCopyValues() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNotEmpty();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyValues = ImmutableList.of();
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyValues).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).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 7ac803e..ecd4025 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -85,6 +86,7 @@
   @Inject private Provider<ChangesCollection> changes;
   @Inject private Provider<PostReview> postReview;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private CommentsUtil commentsUtil;
 
   private final Integer[] lines = {0, 1};
 
@@ -446,6 +448,82 @@
   }
 
   @Test
+  public void putDraft_idMismatch() throws Exception {
+    String file = "file";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraft(file, Side.REVISION, 0, "foo");
+    CommentInfo commentInfo = addDraft(changeId, revId, comment);
+    DraftInput draftInput = newDraft(file, Side.REVISION, 0, "bar");
+    draftInput.id = "anything_but_" + commentInfo.id;
+    BadRequestException e =
+        assertThrows(
+            BadRequestException.class,
+            () -> updateDraft(changeId, revId, draftInput, commentInfo.id));
+    assertThat(e).hasMessageThat().contains("id must match URL");
+  }
+
+  @Test
+  public void putDraft_negativeLine() throws Exception {
+    String file = "file";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraft(file, Side.REVISION, -666, "foo");
+    BadRequestException e =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(e).hasMessageThat().contains("line must be >= 0");
+  }
+
+  @Test
+  public void putDraft_invalidRange() throws Exception {
+    String file = "file";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput draftInput = newDraft(file, Side.REVISION, createLineRange(2, 3), "bar");
+    draftInput.line = 666;
+    BadRequestException e =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, draftInput));
+    assertThat(e)
+        .hasMessageThat()
+        .contains("range endLine must be on the same line as the comment");
+  }
+
+  @Test
+  public void putDraft_updatePath() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraft("file_foo", Side.REVISION, 0, "foo");
+    CommentInfo commentInfo = addDraft(changeId, revId, comment);
+    assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_foo");
+    DraftInput draftInput = newDraft("file_bar", Side.REVISION, 0, "bar");
+    updateDraft(changeId, revId, draftInput, commentInfo.id);
+    assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_bar");
+  }
+
+  @Test
+  public void putDraft_updateInReplyToAndTag() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput draftInput1 = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    CommentInfo commentInfo = addDraft(changeId, revId, draftInput1);
+    DraftInput draftInput2 = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    String inReplyTo = "in_reply_to";
+    String tag = "täg";
+    draftInput2.inReplyTo = inReplyTo;
+    draftInput2.tag = tag;
+    updateDraft(changeId, revId, draftInput2, commentInfo.id);
+    com.google.gerrit.entities.Comment comment =
+        Iterables.getOnlyElement(commentsUtil.draftByChange(r.getChange().notes()));
+    assertThat(comment.parentUuid).isEqualTo(inReplyTo);
+    assertThat(comment.tag).isEqualTo(tag);
+  }
+
+  @Test
   public void listDrafts() throws Exception {
     String file = "file";
     PushOneCommit.Result r = createChange();
@@ -520,7 +598,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));
@@ -605,6 +683,15 @@
   }
 
   @Test
+  public void listChangeDraftsAnonymousThrowsAuthException() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUserAnonymous();
+    assertThrows(AuthException.class, () -> gApi.changes().id(changeId).draftsAsList());
+  }
+
+  @Test
   public void listChangeComments() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -638,6 +725,28 @@
   }
 
   @Test
+  public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
+            .to("refs/for/master");
+
+    addComment(r1, "nit: trailing whitespace");
+    addComment(r2, "typo: content");
+
+    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    assertThat(comments.stream().map(c -> c.message).collect(toList()))
+        .containsExactly("nit: trailing whitespace", "typo: content");
+
+    requestScopeOperations.setApiUserAnonymous();
+    comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    assertThat(comments.stream().map(c -> c.message).collect(toList()))
+        .containsExactly("nit: trailing whitespace", "typo: content");
+  }
+
+  @Test
   public void listChangeWithDrafts() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
@@ -677,15 +786,15 @@
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(1, 4, 10), "Is it that bad?"));
+        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "Is it that bad?"));
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, createLineRange(1, 0, 7), "what happened to this?"));
+        newDraft(FILE_NAME, Side.PARENT, createLineRange(0, 7), "what happened to this?"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(1, 4, 15), "better now"));
+        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "better now"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
@@ -905,7 +1014,7 @@
   @Test
   public void deleteCommentCannotBeAppliedByUser() throws Exception {
     PushOneCommit.Result result = createChange();
-    CommentInput targetComment = addComment(result.getChangeId(), "My password: abc123");
+    CommentInput targetComment = addComment(result.getChangeId());
 
     Map<String, List<CommentInfo>> commentsMap =
         getPublishedComments(result.getChangeId(), result.getCommit().name());
@@ -1083,9 +1192,9 @@
         .collect(toList());
   }
 
-  private CommentInput addComment(String changeId, String message) throws Exception {
+  private CommentInput addComment(String changeId) throws Exception {
     ReviewInput input = new ReviewInput();
-    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, message, false);
+    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
     input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
     gApi.changes().id(changeId).current().review(input);
     return comment;
@@ -1240,7 +1349,7 @@
   private static CommentInput newCommentOnParent(
       String path, int parent, int line, String message) {
     CommentInput c = new CommentInput();
-    return populate(c, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
+    return populate(c, path, Side.PARENT, parent, line, message, false);
   }
 
   private DraftInput newDraft(String path, Side side, int line, String message) {
@@ -1255,7 +1364,7 @@
 
   private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
+    return populate(d, path, Side.PARENT, parent, line, message, false);
   }
 
   private static <C extends Comment> C populate(
@@ -1284,11 +1393,11 @@
     return populate(c, path, side, parent, line, null, message, unresolved);
   }
 
-  private static Comment.Range createLineRange(int line, int startChar, int endChar) {
+  private static Comment.Range createLineRange(int startChar, int endChar) {
     Comment.Range range = new Comment.Range();
-    range.startLine = line;
+    range.startLine = 1;
     range.startCharacter = startChar;
-    range.endLine = line;
+    range.endLine = 1;
     range.endCharacter = endChar;
     return range;
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 1e2d1ba..069387c 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;
             }
           });
@@ -828,7 +830,8 @@
 
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
-    PersonIdent author = noteUtil.newIdent(getAccount(admin.id()), committer.getWhen(), committer);
+    PersonIdent author =
+        noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
@@ -862,7 +865,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/event/InstanceIdInEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/InstanceIdInEventIT.java
new file mode 100644
index 0000000..a333c3c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/event/InstanceIdInEventIT.java
@@ -0,0 +1,85 @@
+// 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.event;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.events.EventTypes;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class InstanceIdInEventIT extends AbstractDaemonTest {
+
+  public static class TestDispatcher {
+    private final DynamicItem<EventDispatcher> eventDispatcher;
+
+    @Inject
+    TestDispatcher(DynamicItem<EventDispatcher> eventDispatcher) {
+      this.eventDispatcher = eventDispatcher;
+    }
+
+    public void postEvent(TestEvent event) {
+      try {
+        eventDispatcher.get().postEvent(event);
+      } catch (Exception e) {
+        fail("Exception raised when posting Event " + e.getCause());
+      }
+    }
+  }
+
+  public static class TestEvent extends Event {
+    private static final String TYPE = "test-event-instance-id";
+
+    public TestEvent() {
+      super(TYPE);
+    }
+  }
+
+  @Inject private DynamicItem<EventDispatcher> eventDispatcher;
+  TestDispatcher testDispatcher;
+
+  @Before
+  public void setUp() throws Exception {
+    testDispatcher = new TestDispatcher(eventDispatcher);
+    EventTypes.register(TestEvent.TYPE, TestEvent.class);
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+  public void shouldSetInstanceIdWhenDefined() {
+    testDispatcher.postEvent(new TestEvent());
+
+    ImmutableList<Event> events = eventRecorder.getGenericEvents(TestEvent.TYPE, 1);
+    assertThat(events.get(0).instanceId).isEqualTo("testInstanceId");
+  }
+
+  @Test
+  public void shouldNotSetInstanceIdWhenNotDefined() {
+    testDispatcher.postEvent(new TestEvent());
+
+    ImmutableList<Event> events = eventRecorder.getGenericEvents(TestEvent.TYPE, 1);
+    assertThat(events.get(0).instanceId).isNull();
+  }
+}
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..d68cada 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,96 @@
     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 = "8")
+  public void countComments_limitNumberOfComments() throws Exception {
+    when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+    // Start out with 1 change message.
+    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);
+    // Publishes the 1 draft and adds 2 change messages.
+    amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+
+    for (int i = 0; i < 2; ++i) {
+      // Adds 1 robot comment and 1 change message.
+      testCommentHelper.addRobotComment(
+          changeId,
+          TestCommentHelper.createRobotCommentInput(result.getChange().currentFilePaths().get(0)));
+    }
+    // We now have 1 comment, 2 robot comments, 5 change messages.
+
+    draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    // Publishes the 1 draft and adds 2 change messages. The latter 2 are autogenerated and are not
+    // subject to validation.
+    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");
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "500")
+  public void limitCumulativeCommentSize() 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);
+    String commentText400Bytes = new String(new char[400]).replace("\0", "x");
+    DraftInput draftInline =
+        testCommentHelper.newDraft(filePath, Side.REVISION, 1, commentText400Bytes);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+
+    draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, commentText400Bytes);
+    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 cumulative size 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..89b5f6e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
@@ -0,0 +1,110 @@
+// 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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for applying limits to e.g. number of files per change. */
+public class ReceiveCommitsLimitsIT extends AbstractDaemonTest {
+  @Test
+  @GerritConfig(name = "change.maxFiles", value = "2")
+  public void limitFileCount() throws Exception {
+    // Create the parent.
+    RevCommit parent =
+        commitBuilder()
+            .add("foo.txt", "same old, same old")
+            .add("bar.txt", "bar")
+            .message("blah")
+            .create();
+    testRepo.reset(parent);
+
+    // A commit with 2 files is OK.
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of(
+                "foo.txt", "same old, same old", "bar.txt", "changed file", "baz.txt", "new file"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertOkStatus();
+
+    // A commit with 3 files is rejected.
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of(
+                "foo.txt",
+                "same old, same old",
+                "bar.txt",
+                "changed file",
+                "baz.txt",
+                "new file",
+                "boom.txt",
+                "boom!"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertErrorStatus("Exceeding maximum number of files per change (3 > 2)");
+  }
+
+  @Test
+  @GerritConfig(name = "change.maxFiles", value = "1")
+  public void limitFileCount_merge() throws Exception {
+    // Create the parents.
+    RevCommit commitFoo =
+        commitBuilder().add("foo.txt", "same old, same old").message("blah").create();
+    RevCommit commitBar =
+        testRepo
+            .branch("branch")
+            .commit()
+            .insertChangeId()
+            .add("bar.txt", "bar")
+            .message("blah")
+            .create();
+    testRepo.reset(commitFoo);
+
+    // By convention we diff against the first parent.
+
+    // commitFoo is first -> 1 file changed -> OK
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of("foo.txt", "same old, same old", "bar.txt", "changed file"))
+        .setParents(ImmutableList.of(commitFoo, commitBar))
+        .to("refs/for/master")
+        .assertOkStatus();
+
+    // commitBar is first -> 2 files changed -> rejected
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of("foo.txt", "same old, same old", "bar.txt", "changed file"))
+        .setParents(ImmutableList.of(commitBar, commitFoo))
+        .to("refs/for/master")
+        .assertErrorStatus("Exceeding maximum number of files per change (2 > 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..d767f48 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -33,26 +33,27 @@
 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());
-    b.addTo(user.getEmailAddress()); // Not evaluated
+    b.from(user.getNameEmail());
+    b.addTo(user.getNameEmail()); // Not evaluated
     b.subject("");
     b.dateReceived(Instant.now());
     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;
@@ -88,14 +89,11 @@
   /**
    * Create a plaintext message body with the specified comments.
    *
-   * @param changeMessage
    * @param c1 Comment in reply to first inline comment.
    * @param f1 Comment on file one.
-   * @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(
-      String changeURL, String changeMessage, String c1, String f1, String fc1) {
+  static String newPlaintextBody(String changeURL, String changeMessage, String c1, String f1) {
     return (changeMessage == null ? "" : changeMessage + "\n")
         + "> Foo Bar has posted comments on this change. (  \n"
         + "> "
@@ -122,9 +120,8 @@
         + changeURL
         + "/gerrit-server/test.txt\n"
         + "> \n"
-        + "> Some comment"
+        + "> Some comment\n"
         + "> \n"
-        + (fc1 == null ? "" : fc1 + "\n")
         + "> "
         + changeURL
         + "/gerrit-server/test.txt@2\n"
@@ -137,7 +134,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/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 2dc1e24..d74cd71 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -76,9 +76,9 @@
   @Before
   public void createExtraAccounts() throws Exception {
     extraReviewer =
-        accountCreator.create("extraReviewer", "extraReviewer@example.com", "extraReviewer");
-    extraCcer = accountCreator.create("extraCcer", "extraCcer@example.com", "extraCcer");
-    other = accountCreator.create("other", "other@example.com", "other");
+        accountCreator.create("extraReviewer", "extraReviewer@example.com", "extraReviewer", null);
+    extraCcer = accountCreator.create("extraCcer", "extraCcer@example.com", "extraCcer", null);
+    other = accountCreator.create("other", "other@example.com", "other", null);
   }
 
   @Before
@@ -129,7 +129,7 @@
   @Test
   public void abandonReviewableChangeByOther() throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     abandon(sc.changeId, other);
     assertThat(sender)
         .sent("abandon", sc)
@@ -145,7 +145,7 @@
   @Test
   public void abandonReviewableChangeByOtherCcingSelf() throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     abandon(sc.changeId, other, CC_ON_OWN_COMMENTS);
     assertThat(sender)
         .sent("abandon", sc)
@@ -190,7 +190,7 @@
   @Test
   public void abandonReviewableChangeByOtherCcingSelfNotifyOwner() throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     abandon(sc.changeId, other, CC_ON_OWN_COMMENTS, OWNER);
     assertThat(sender).sent("abandon", sc).to(sc.owner).cc(other).noOneElse();
     assertThat(sender).didNotSend();
@@ -277,7 +277,7 @@
 
   private void addReviewerToReviewableChange(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email());
     // TODO(logan): Should CCs be included?
     assertThat(sender)
@@ -301,7 +301,7 @@
 
   private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), CC_ON_OWN_COMMENTS, null);
     // TODO(logan): Should CCs be included?
     assertThat(sender)
@@ -324,9 +324,9 @@
   }
 
   private void addReviewerToReviewableChangeByOther(Adder adder) throws Exception {
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, other, reviewer.email());
     // TODO(logan): Should CCs be included?
     assertThat(sender)
@@ -349,9 +349,9 @@
   }
 
   private void addReviewerToReviewableChangeByOtherCcingSelf(Adder adder) throws Exception {
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, other, reviewer.email(), CC_ON_OWN_COMMENTS, null);
     // TODO(logan): Should CCs be included?
     assertThat(sender)
@@ -399,7 +399,7 @@
 
   private void addReviewerToWipChange(Adder adder) throws Exception {
     StagedChange sc = stageWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email());
     assertThat(sender).didNotSend();
   }
@@ -417,7 +417,7 @@
   @Test
   public void addReviewerToReviewableWipChangeSingly() throws Exception {
     StagedChange sc = stageReviewableWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
     // TODO(dborowitz): In theory this should match the batch case, but we don't currently pass
     // enough info into AddReviewersEmail#emailReviewers to distinguish the reviewStarted case.
@@ -429,7 +429,7 @@
   @Test
   public void addReviewerToReviewableWipChangeBatch() throws Exception {
     StagedChange sc = stageReviewableWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(batch(), sc.changeId, sc.owner, reviewer.email());
     // For a review-started WIP change, same as in the notify=ALL case. It's not especially
     // important to notify just because a reviewer is added, but we do want to notify in the other
@@ -444,7 +444,7 @@
 
   private void addReviewerToWipChangeNotifyAll(Adder adder) throws Exception {
     StagedChange sc = stageWipChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), NotifyHandling.ALL);
     // TODO(logan): Should CCs be included?
     assertThat(sender)
@@ -468,7 +468,7 @@
 
   private void addReviewerToReviewableChangeNotifyOwnerReviewers(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), OWNER_REVIEWERS);
     // TODO(logan): Should CCs be included?
     assertThat(sender)
@@ -493,7 +493,7 @@
   private void addReviewerToReviewableChangeByOwnerCcingSelfNotifyOwner(Adder adder)
       throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), CC_ON_OWN_COMMENTS, OWNER);
     assertThat(sender).didNotSend();
   }
@@ -511,7 +511,7 @@
   private void addReviewerToReviewableChangeByOwnerCcingSelfNotifyNone(Adder adder)
       throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), CC_ON_OWN_COMMENTS, NONE);
     assertThat(sender).didNotSend();
   }
@@ -695,7 +695,7 @@
 
   @Test
   public void commentOnReviewableChangeByOther() throws Exception {
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     StagedChange sc = stageReviewableChange();
     review(other, sc.changeId, ENABLED);
     assertThat(sender)
@@ -711,7 +711,7 @@
 
   @Test
   public void commentOnReviewableChangeByOtherCcingSelf() throws Exception {
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     StagedChange sc = stageReviewableChange();
     review(other, sc.changeId, CC_ON_OWN_COMMENTS);
     assertThat(sender)
@@ -2353,7 +2353,7 @@
   @Test
   public void changeAssigneeOnReviewableChange() throws Exception {
     StagedChange sc = stageReviewableChange();
-    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
     assign(sc, sc.owner, other);
     sender.clear();
     assign(sc, sc.owner, sc.assignee);
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..e961c67 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;
@@ -107,11 +107,7 @@
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
         newPlaintextBody(
-            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
-            "Test Message",
-            null,
-            null,
-            null);
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1", "Test Message", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailIT.java
index 0d31a96..c296a5c 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailIT.java
@@ -30,10 +30,8 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
 @NoHttpd
-@RunWith(ConfigSuite.class)
 public class MailIT extends AbstractDaemonTest {
   private static final String RECEIVEEMAIL = "receiveemail";
   private static final String HOST = "localhost";
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 5531709..fc44822 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.server.mail;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.clearInvocations;
@@ -21,14 +22,23 @@
 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.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.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.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
@@ -70,7 +80,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
@@ -82,15 +92,14 @@
   public void parseAndPersistChangeMessage() throws Exception {
     String changeId = createChangeWithReview();
     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")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
-    String txt =
-        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -105,15 +114,15 @@
   public void parseAndPersistInlineComment() throws Exception {
     String changeId = createChangeWithReview();
     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")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -125,7 +134,7 @@
     assertThat(Iterables.getLast(messages).tag).isEqualTo("mailMessageId=some id");
 
     // Assert comment
-    comments = gApi.changes().id(changeId).current().commentsAsList();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     assertThat(comments).hasSize(3);
     assertThat(comments.get(2).message).isEqualTo("Some Inline Comment");
     assertThat(comments.get(2).tag).isEqualTo("mailMessageId=some id");
@@ -136,16 +145,15 @@
   public void parseAndPersistFileComment() throws Exception {
     String changeId = createChangeWithReview();
     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")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(
-            getChangeUrl(changeInfo) + "/1", null, null, "Some Comment on File 1", null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, "Some Comment on File 1");
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
@@ -157,7 +165,7 @@
     assertThat(Iterables.getLast(messages).tag).isEqualTo("mailMessageId=some id");
 
     // Assert comment
-    comments = gApi.changes().id(changeId).current().commentsAsList();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     assertThat(comments).hasSize(3);
     assertThat(comments.get(0).message).isEqualTo("Some Comment on File 1");
     assertThat(comments.get(0).inReplyTo).isNull();
@@ -169,19 +177,19 @@
   public void parseAndPersistMessageTwice() throws Exception {
     String changeId = createChangeWithReview();
     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")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     mailProcessor.process(b.build());
-    comments = gApi.changes().id(changeId).current().commentsAsList();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     assertThat(comments).hasSize(3);
 
     // Check that the comment has not been persisted a second time
@@ -197,13 +205,14 @@
     List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
     String ts =
         MailProcessingUtil.rfcDateformatter.format(
-            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
     assertThat(comments).hasSize(2);
 
     // Build Message
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt =
-        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null, null);
+        newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, "Some Inline Comment", null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     // Set account state to inactive
@@ -224,14 +233,14 @@
     assertThat(comments).hasSize(2);
     String ts =
         MailProcessingUtil.rfcDateformatter.format(
-            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
 
     // Build Message
-    String txt =
-        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
     MailMessage.Builder b =
         messageBuilderWithDefaultFields()
-            .from(user.getEmailAddress())
+            .from(user.getNameEmail())
             .textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     sender.clear();
@@ -249,11 +258,10 @@
     String ts = "null"; // Erroneous timestamp to be used in erroneous metadatas
 
     // Build Message
-    String txt =
-        newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null, null);
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
     MailMessage.Builder b =
         messageBuilderWithDefaultFields()
-            .from(user.getEmailAddress())
+            .from(user.getNameEmail())
             .textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     sender.clear();
@@ -269,15 +277,16 @@
   public void validateChangeMessage_rejected() throws Exception {
     String changeId = createChangeWithReview();
     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")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().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);
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
@@ -293,15 +302,16 @@
   public void validateInlineComment_rejected() throws Exception {
     String changeId = createChangeWithReview();
     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")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().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);
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
@@ -317,15 +327,16 @@
   public void validateFileComment_rejected() throws Exception {
     String changeId = createChangeWithReview();
     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")));
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().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);
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
     Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
@@ -337,15 +348,114 @@
     assertThat(message.body()).contains("rejected one or more comments");
   }
 
+  @Test
+  @GerritConfig(name = "change.maxComments", value = "9")
+  public void limitNumberOfComments() throws Exception {
+    // This change has 2 change messages and 2 comments.
+    String changeId = createChangeWithReview();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    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));
+    // Add 1 change message and another 2 comments. Total count is now 7, which is still OK.
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    MailMessage.Builder mailMessage = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            getChangeUrl(changeInfo) + "/1",
+            "1) change message",
+            "2) reply to comment",
+            "3) file comment");
+    mailMessage.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    ImmutableSet<CommentInfo> commentsBefore = getCommentsAndRobotComments(changeId);
+    // Should have 4 comments (and 3 change messages).
+    assertThat(commentsBefore).hasSize(4);
+
+    // The email adds 3 new comments (of which 1 is the change message).
+    mailProcessor.process(mailMessage.build());
+    ImmutableSet<CommentInfo> commentsAfter = getCommentsAndRobotComments(changeId);
+    assertThat(commentsAfter).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "7k")
+  public void limitCumulativeCommentSize() throws Exception {
+    // Use large sizes because autogenerated messages already have O(100) bytes.
+    String commentText2000Bytes = new String(new char[2000]).replace("\0", "x");
+    String changeId = createChangeWithReview();
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = commentText2000Bytes;
+    commentInput.path = FILE_NAME;
+    ReviewInput reviewInput = new ReviewInput().message(commentText2000Bytes);
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(commentInput));
+    // Use up ~4000 bytes.
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Hit the limit when trying that again.
+    MailMessage.Builder mailMessage = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            getChangeUrl(changeInfo) + "/1",
+            "change message: " + commentText2000Bytes,
+            "reply to comment: " + commentText2000Bytes,
+            null);
+    mailMessage.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(mailMessage.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+  }
+
   private String getChangeUrl(ChangeInfo changeInfo) {
     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!")));
   }
+
+  private ImmutableSet<CommentInfo> getCommentsAndRobotComments(String changeId)
+      throws RestApiException {
+    return Streams.concat(
+            gApi.changes().id(changeId).comments().values().stream(),
+            gApi.changes().id(changeId).robotComments().values().stream())
+        .flatMap(Collection::stream)
+        .collect(toImmutableSet());
+  }
 }
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/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 3b7d826..1d5204b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -27,6 +27,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -332,7 +333,7 @@
   public void customLabel_withBranch() throws Exception {
     label.setRefPatterns(Arrays.asList("master"));
     saveLabelConfig();
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
     assertThat(cfg.getLabelSections().get(label.getName()).getRefPatterns()).contains("master");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 29574c4..7a80cbd 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -49,7 +49,7 @@
 
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
+    Address addr = Address.create("Watcher", "watcher@example.com");
     NotifyConfig nc = new NotifyConfig();
     nc.addEmail(addr);
     nc.setName("new-patch-set");
@@ -90,7 +90,7 @@
 
   @Test
   public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
+    Address addr = Address.create("Watcher", "watcher@example.com");
     NotifyConfig nc = new NotifyConfig();
     nc.addEmail(addr);
     nc.setName("team");
@@ -122,7 +122,7 @@
   @Test
   public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
       throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
+    Address addr = Address.create("Watcher", "watcher@example.com");
     NotifyConfig nc = new NotifyConfig();
     nc.addEmail(addr);
     nc.setName("team");
@@ -151,7 +151,7 @@
 
   @Test
   public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
+    Address addr = Address.create("Watcher", "watcher@example.com");
     NotifyConfig nc = new NotifyConfig();
     nc.addEmail(addr);
     nc.setName("team");
@@ -182,7 +182,7 @@
 
   @Test
   public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
-    Address addr = new Address("Watcher", "watcher@example.com");
+    Address addr = Address.create("Watcher", "watcher@example.com");
     NotifyConfig nc = new NotifyConfig();
     nc.addEmail(addr);
     nc.setName("team");
@@ -241,7 +241,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
@@ -273,13 +273,13 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
+    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2", null);
     requestScopeOperations.setApiUser(user2.id());
     watch(watchedProject);
 
@@ -295,7 +295,7 @@
     messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user2.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user2.getNameEmail());
     assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
@@ -322,7 +322,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Change subject: Document multiprimary setup\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
@@ -357,7 +357,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
@@ -385,13 +385,13 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
+    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2", null);
     requestScopeOperations.setApiUser(user2.id());
     watch(anyProject);
 
@@ -407,7 +407,7 @@
     messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user2.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user2.getNameEmail());
     assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
@@ -434,7 +434,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(user.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("Change subject: Document multiprimary setup\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
     sender.clear();
@@ -528,7 +528,7 @@
     // watch project as user that can view all private change
     TestAccount userThatCanViewPrivateChanges =
         accountCreator.create(
-            "user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
+            "user2", "user2@test.com", "User2", null, groupThatCanViewPrivateChanges.name);
     requestScopeOperations.setApiUser(userThatCanViewPrivateChanges.id());
     watch(watchedProject);
 
@@ -547,7 +547,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.getEmailAddress());
+    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.getNameEmail());
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
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/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index 39dbaa7..01b8eae 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.project.ProjectState;
+import java.util.Optional;
 import org.junit.Test;
 
 @UseSsh
@@ -33,8 +35,8 @@
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + newGroupName + " " + newProjectName);
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
   }
 
   @Test
@@ -46,8 +48,8 @@
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + wrongGroupName + " " + newProjectName);
     adminSshSession.assertFailure();
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNull();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isEmpty();
   }
 
   @Test
@@ -62,9 +64,9 @@
             + newProjectName
             + ".git");
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertThat(projectState.getName()).isEqualTo(newProjectName);
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertThat(projectState.get().getName()).isEqualTo(newProjectName);
   }
 
   @Test
@@ -79,8 +81,8 @@
             + newProjectName
             + "/");
     adminSshSession.assertSuccess();
-    ProjectState projectState = projectCache.get(Project.nameKey(newProjectName));
-    assertThat(projectState).isNotNull();
-    assertThat(projectState.getName()).isEqualTo(newProjectName);
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertThat(projectState.get().getName()).isEqualTo(newProjectName);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 012e998..bbe7b81 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -71,12 +71,14 @@
           "receive-pack",
           "rename-group",
           "review",
+          "sequence",
           "set-account",
           "set-head",
           "set-members",
           "set-project",
           "set-project-parent",
           "set-reviewers",
+          "set-topic",
           "stream-events",
           "test-submit");
 
@@ -100,6 +102,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/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index c7a8eb6..91f815b 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -30,6 +30,7 @@
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.util.stream.Collectors.toList;
@@ -37,9 +38,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -55,6 +59,7 @@
 public class ProjectOperationsImplTest extends AbstractDaemonTest {
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupsOperations;
 
   @Test
   public void defaultName() throws Exception {
@@ -83,6 +88,20 @@
   }
 
   @Test
+  public void permissionOnly() throws Exception {
+    Project.NameKey key = projectOperations.newProject().permissionOnly(true).create();
+    String head = gApi.projects().name(key.get()).head();
+    assertThat(head).isEqualTo(RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void createWithOwners() throws Exception {
+    AccountGroup.UUID uuid = groupsOperations.newGroup().create();
+    Project.NameKey key = projectOperations.newProject().addOwner(uuid).create();
+    assertPermissions(key, groupRef(uuid), "refs/*", false, Permission.OWNER);
+  }
+
+  @Test
   public void getProjectConfig() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
@@ -100,14 +119,15 @@
   public void mutatingResultOfGetProjectConfigDoesNotMutateGlobalCachedValue() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
-    ProjectState cachedProjectState1 = projectCache.checkedGet(key);
+    ProjectState cachedProjectState1 = projectCache.get(key).orElseThrow(illegalState(project));
     ProjectConfig cachedProjectConfig1 = cachedProjectState1.getConfig();
     assertThat(cachedProjectConfig1).isNotSameInstanceAs(projectConfig);
     assertThat(cachedProjectConfig1.getProject().getDescription()).isEmpty();
     assertThat(projectConfig.getProject().getDescription()).isEmpty();
     projectConfig.getProject().setDescription("my fancy project");
 
-    ProjectConfig cachedProjectConfig2 = projectCache.checkedGet(key).getConfig();
+    ProjectConfig cachedProjectConfig2 =
+        projectCache.get(key).orElseThrow(illegalState(project)).getConfig();
     assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
     assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
   }
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/BUILD b/javatests/com/google/gerrit/entities/BUILD
index b24781a..d93aa60 100644
--- a/javatests/com/google/gerrit/entities/BUILD
+++ b/javatests/com/google/gerrit/entities/BUILD
@@ -9,5 +9,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
     ],
 )
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..2896ea9 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -15,11 +15,18 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.CHANGE_URL_PATTERN;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.DIFF_URL_PATTERN;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.computeChangeRequestsPath;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.experimentData;
 import static com.google.gerrit.httpd.raw.IndexHtmlUtil.staticTemplateData;
 
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
 import org.junit.Test;
 
 public class IndexHtmlUtilTest {
@@ -28,7 +35,12 @@
   public void noPathAndNoCDN() throws Exception {
     assertThat(
             staticTemplateData(
-                "http://example.com/", null, null, new HashMap<>(), IndexHtmlUtilTest::ordain))
+                "http://example.com/",
+                null,
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly("canonicalPath", "", "staticResourcePath", ordain(""));
   }
 
@@ -40,7 +52,8 @@
                 null,
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain))
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly("canonicalPath", "/gerrit", "staticResourcePath", ordain("/gerrit"));
   }
 
@@ -52,7 +65,8 @@
                 "http://my-cdn.com/foo/bar/",
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain))
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly(
             "canonicalPath", "", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
   }
@@ -65,13 +79,78 @@
                 "http://my-cdn.com/foo/bar/",
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain))
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly(
             "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, null))
+        .containsExactly(
+            "canonicalPath", "", "staticResourcePath", ordain(""), "useGoogleFonts", "true");
+  }
+
+  @Test
+  public void usePreloadRest() throws Exception {
+    Map<String, String[]> urlParms = new HashMap<>();
+    assertThat(
+            staticTemplateData(
+                "http://example.com/",
+                null,
+                null,
+                urlParms,
+                IndexHtmlUtilTest::ordain,
+                "/c/project/+/123"))
+        .containsExactly(
+            "canonicalPath", "",
+            "staticResourcePath", ordain(""),
+            "defaultChangeDetailHex", "916314",
+            "defaultDiffDetailHex", "800014",
+            "preloadChangePage", "true",
+            "changeRequestsPath", "changes/project~123");
+  }
+
+  @Test
+  public void computeChangePath() throws Exception {
+    assertThat(computeChangeRequestsPath("/c/project/+/123", CHANGE_URL_PATTERN))
+        .isEqualTo("changes/project~123");
+
+    assertThat(computeChangeRequestsPath("/c/project/+/124/2", CHANGE_URL_PATTERN))
+        .isEqualTo("changes/project~124");
+
+    assertThat(computeChangeRequestsPath("/c/project/src/+/23", CHANGE_URL_PATTERN))
+        .isEqualTo("changes/project%2Fsrc~23");
+
+    assertThat(computeChangeRequestsPath("/q/project/src/+/23", CHANGE_URL_PATTERN))
+        .isEqualTo(null);
+
+    assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", CHANGE_URL_PATTERN))
+        .isEqualTo(null);
+    assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", DIFF_URL_PATTERN))
+        .isEqualTo("changes/Scripts~232");
+  }
+
   private static SanitizedContent ordain(String s) {
     return UnsafeSanitizedContentOrdainer.ordainAsSafe(
         s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
   }
+
+  @Test
+  public void useExperiments() throws Exception {
+    Map<String, String[]> urlParms = new HashMap<>();
+    String[] experiments = new String[] {"foo", "bar", "foo"};
+    Set<String> expected = new HashSet<>();
+    for (String exp : experiments) {
+      expected.add(exp);
+    }
+    urlParms.put("experiment", experiments);
+    Set<String> data = experimentData(urlParms);
+    assertThat(data).isEqualTo(expected);
+  }
 }
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/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index 2968111..66b02ff 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -11,6 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.integration.git;
 
 import static com.google.common.truth.Truth.assertThat;
@@ -21,11 +22,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -93,6 +94,7 @@
     try (ServerContext ctx = startServer()) {
       setUpTestHarness(ctx);
       setUpChange();
+
       for (String f : Arrays.asList("zip", "tar", "tar.gz", "tar.bz2", "tar.xz")) {
         verifyUploadArchive(f);
       }
@@ -136,25 +138,27 @@
     project = Project.nameKey("upload-archive-project-test");
     gApi.projects().create(project.get());
     setUpAuthentication();
+
     sshDestination =
         String.format(
             "ssh://%s@%s:%s/%s",
-            "admin", sshAddress.getHostName(), sshAddress.getPort(), project.get());
-    identityPath = sitePaths.data_dir.resolve(String.format("id_rsa_%s", "admin")).toString();
+            admin.username(), sshAddress.getHostName(), sshAddress.getPort(), project.get());
+    identityPath =
+        sitePaths.data_dir.resolve(String.format("id_rsa_%s", admin.username())).toString();
   }
 
   private void setUpAuthentication() throws Exception {
     execute(
         ImmutableList.<String>builder()
             .add(SSH_KEYGEN_CMD)
-            .add(String.format("id_rsa_%s", "admin"))
+            .add(String.format("id_rsa_%s", admin.username()))
             .build());
     gApi.accounts()
-        .id("admin")
+        .id(admin.username())
         .addSshKey(
             new String(
                 java.nio.file.Files.readAllBytes(
-                    sitePaths.data_dir.resolve(String.format("id_rsa_%s.pub", "admin"))),
+                    sitePaths.data_dir.resolve(String.format("id_rsa_%s.pub", admin.username()))),
                 UTF_8));
   }
 
@@ -192,8 +196,10 @@
     ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
     in.newBranch = true;
     String changeId = gApi.changes().create(in).info().changeId;
+
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(FILE_CONTENT));
     gApi.changes().id(changeId).edit().publish();
+
     commit = gApi.changes().id(changeId).current().commit(false);
   }
 
@@ -203,6 +209,7 @@
     while ((e = o.getNextEntry()) != null) {
       entryNames.add(e.getName());
     }
+
     assertThat(entryNames)
         .containsExactly(
             String.format("%s/", commit.commit), String.format("%s/%s", commit.commit, FILE_NAME))
diff --git a/javatests/com/google/gerrit/integration/ssh/BUILD b/javatests/com/google/gerrit/integration/ssh/BUILD
new file mode 100644
index 0000000..dc8e68c
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/ssh/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = ["PeerKeysAuthIT.java"],
+    group = "peer-keys-auth",
+    labels = ["ssh"],
+)
diff --git a/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
new file mode 100644
index 0000000..a219cc2
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
@@ -0,0 +1,104 @@
+// 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.integration.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.server.PeerDaemonUser;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class PeerKeysAuthIT extends StandaloneSiteTest {
+  private static final String[] SSH_KEYGEN_CMD =
+      new String[] {"ssh-keygen", "-t", "rsa", "-q", "-P", "", "-f", "id_rsa"};
+  private static final String[] SSH_COMMAND =
+      new String[] {
+        "ssh",
+        "-o",
+        "UserKnownHostsFile=/dev/null",
+        "-o",
+        "StrictHostKeyChecking=no",
+        "-o",
+        "IdentitiesOnly=yes",
+        "-i"
+      };
+
+  @Inject private @TestSshServerAddress InetSocketAddress sshAddress;
+
+  @Test
+  public void test() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+      // Generate private/public key for user
+      execute(ImmutableList.<String>builder().add(SSH_KEYGEN_CMD).build());
+
+      String[] parts =
+          new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8)
+              .split(" ");
+
+      // Loose algorithm at index 0, verify the format: "key comment"
+      Files.write(
+          sitePaths.peer_keys, String.format("%s %s", parts[1], parts[2]).getBytes(ISO_8859_1));
+      assertContent(execGerritVersionCommand());
+
+      // Only preserve the key material: no algorithm and no comment
+      Files.write(sitePaths.peer_keys, parts[1].getBytes(ISO_8859_1));
+      assertContent(execGerritVersionCommand());
+
+      // Wipe out the content of the peer keys file
+      Files.delete(sitePaths.peer_keys);
+      assertThrows(IOException.class, () -> execGerritVersionCommand());
+    }
+  }
+
+  private String execGerritVersionCommand() throws Exception {
+    return execute(
+        ImmutableList.<String>builder()
+            .add(SSH_COMMAND)
+            .add(sitePaths.data_dir.resolve("id_rsa").toString())
+            .add("-p " + sshAddress.getPort())
+            .add(PeerDaemonUser.USER_NAME + "@" + sshAddress.getHostName())
+            .add("suexec")
+            .add("--as")
+            .add("admin")
+            .add("--")
+            .add("gerrit")
+            .add("version")
+            .build());
+  }
+
+  private String execute(ImmutableList<String> cmd) throws Exception {
+    return execute(cmd, sitePaths.data_dir.toFile(), ImmutableMap.of());
+  }
+
+  private static void assertContent(String result) {
+    assertThat(result).contains("gerrit version " + Version.getVersion());
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index 3c84420..f3c8671 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -84,7 +84,7 @@
   protected static MailMessage.Builder newMailMessageBuilder() {
     MailMessage.Builder b = MailMessage.builder();
     b.id("id");
-    b.from(new Address("Foo Bar", "foo@bar.com"));
+    b.from(Address.create("Foo Bar", "foo@bar.com"));
     b.dateReceived(Instant.now());
     b.subject("");
     return b;
diff --git a/javatests/com/google/gerrit/mail/AddressTest.java b/javatests/com/google/gerrit/mail/AddressTest.java
index da26123..8addcf8 100644
--- a/javatests/com/google/gerrit/mail/AddressTest.java
+++ b/javatests/com/google/gerrit/mail/AddressTest.java
@@ -23,57 +23,57 @@
   @Test
   public void parse_NameEmail1() {
     final Address a = Address.parse("A U Thor <author@example.com>");
-    assertThat(a.getName()).isEqualTo("A U Thor");
-    assertThat(a.getEmail()).isEqualTo("author@example.com");
+    assertThat(a.name()).isEqualTo("A U Thor");
+    assertThat(a.email()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_NameEmail2() {
     final Address a = Address.parse("A <a@b>");
-    assertThat(a.getName()).isEqualTo("A");
-    assertThat(a.getEmail()).isEqualTo("a@b");
+    assertThat(a.name()).isEqualTo("A");
+    assertThat(a.email()).isEqualTo("a@b");
   }
 
   @Test
   public void parse_NameEmail3() {
     final Address a = Address.parse("<a@b>");
-    assertThat(a.getName()).isNull();
-    assertThat(a.getEmail()).isEqualTo("a@b");
+    assertThat(a.name()).isNull();
+    assertThat(a.email()).isEqualTo("a@b");
   }
 
   @Test
   public void parse_NameEmail4() {
     final Address a = Address.parse("A U Thor<author@example.com>");
-    assertThat(a.getName()).isEqualTo("A U Thor");
-    assertThat(a.getEmail()).isEqualTo("author@example.com");
+    assertThat(a.name()).isEqualTo("A U Thor");
+    assertThat(a.email()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_NameEmail5() {
     final Address a = Address.parse("A U Thor  <author@example.com>");
-    assertThat(a.getName()).isEqualTo("A U Thor");
-    assertThat(a.getEmail()).isEqualTo("author@example.com");
+    assertThat(a.name()).isEqualTo("A U Thor");
+    assertThat(a.email()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_Email1() {
     final Address a = Address.parse("author@example.com");
-    assertThat(a.getName()).isNull();
-    assertThat(a.getEmail()).isEqualTo("author@example.com");
+    assertThat(a.name()).isNull();
+    assertThat(a.email()).isEqualTo("author@example.com");
   }
 
   @Test
   public void parse_Email2() {
     final Address a = Address.parse("a@b");
-    assertThat(a.getName()).isNull();
-    assertThat(a.getEmail()).isEqualTo("a@b");
+    assertThat(a.name()).isNull();
+    assertThat(a.email()).isEqualTo("a@b");
   }
 
   @Test
   public void parse_NewTLD() {
     Address a = Address.parse("A U Thor <author@example.systems>");
-    assertThat(a.getName()).isEqualTo("A U Thor");
-    assertThat(a.getEmail()).isEqualTo("author@example.systems");
+    assertThat(a.name()).isEqualTo("A U Thor");
+    assertThat(a.email()).isEqualTo("author@example.systems");
   }
 
   @Test
@@ -148,6 +148,6 @@
   }
 
   private static String format(String name, String email) {
-    return new Address(name, email).toHeaderString();
+    return Address.create(name, email).toHeaderString();
   }
 }
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/mail/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
index 2d2c2ea..296d1a1 100644
--- a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -38,11 +38,11 @@
     b.addAdditionalHeader(
         MailHeader.COMMENT_DATE.fieldWithDelimiter() + "Tue, 25 Oct 2016 02:11:35 -0700");
 
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    Address author = Address.create("Diffy", "test@gerritcodereview.com");
     b.from(author);
 
     MailMetadata meta = MailHeaderParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.author).isEqualTo(author.email());
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
@@ -71,11 +71,11 @@
         .append("Tue, 25 Oct 2016 02:11:35 -0700\r\n");
     b.textContent(stringBuilder.toString());
 
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    Address author = Address.create("Diffy", "test@gerritcodereview.com");
     b.from(author);
 
     MailMetadata meta = MailHeaderParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.author).isEqualTo(author.email());
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
@@ -112,11 +112,11 @@
         .append("</div>");
     b.htmlContent(stringBuilder.toString());
 
-    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    Address author = Address.create("Diffy", "test@gerritcodereview.com");
     b.from(author);
 
     MailMetadata meta = MailHeaderParser.parse(b.build());
-    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.author).isEqualTo(author.email());
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
     assertThat(meta.messageType).isEqualTo("comment");
diff --git a/javatests/com/google/gerrit/mail/data/AttachmentMessage.java b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
index 1d94d68..aea59ba 100644
--- a/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
+++ b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
@@ -77,8 +77,8 @@
     MailMessage.Builder expect = MailMessage.builder();
     expect
         .id("<CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w@mail.gmail.com>")
-        .from(new Address("Patrick Hiesel", "hiesel@google.com"))
-        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .from(Address.create("Patrick Hiesel", "hiesel@google.com"))
+        .addTo(Address.create("Patrick Hiesel", "hiesel@google.com"))
         .textContent("Contains unwanted attachment")
         .htmlContent("<div dir=\"ltr\">Contains unwanted attachment</div>")
         .subject("Test Subject")
diff --git a/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
index aa19537..957ee6e 100644
--- a/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
@@ -53,10 +53,10 @@
     expect
         .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
         .from(
-            new Address(
+            Address.create(
                 "Jonathan Nieder (Gerrit)",
                 "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .addTo(Address.create("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("\uD83D\uDE1B test")
         .dateReceived(
diff --git a/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
index 1d68cc8..e5e2ed8 100644
--- a/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
+++ b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
@@ -91,10 +91,10 @@
     expect
         .id("<001a114cd8be55b4ab053face5cd@google.com>")
         .from(
-            new Address(
+            Address.create(
                 "ekempin (Gerrit)", "noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com"))
-        .addCc(new Address("ekempin", "ekempin@google.com"))
-        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .addCc(Address.create("ekempin", "ekempin@google.com"))
+        .addTo(Address.create("Patrick Hiesel", "hiesel@google.com"))
         .textContent(textContent)
         .htmlContent(unencodedHtmlContent)
         .subject("Change in gerrit[master]: Implement receiver class structure and bindings")
diff --git a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
index 32915e7..e183a37 100644
--- a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
+++ b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
@@ -57,10 +57,10 @@
     expect
         .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
         .from(
-            new Address(
+            Address.create(
                 "Jonathan Nieder (Gerrit)",
                 "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .addTo(Address.create("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("\uD83D\uDE1B test")
         .dateReceived(
diff --git a/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
index 47e813a..ac739c8 100644
--- a/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
@@ -54,10 +54,10 @@
     expect
         .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
         .from(
-            new Address(
+            Address.create(
                 "Jonathan Nieder (Gerrit)",
                 "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .addTo(Address.create("ekempin", "ekempin@google.com"))
         .textContent(textContent)
         .subject("âme vulgaire")
         .dateReceived(
diff --git a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
index c4737e6..3f8e62f 100644
--- a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
+++ b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
@@ -116,13 +116,13 @@
     expect
         .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
         .from(
-            new Address(
+            Address.create(
                 "Jonathan Nieder (Gerrit)",
                 "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
-        .addTo(new Address("ekempin", "ekempin@google.com"))
-        .addCc(new Address("Dave Borowitz", "dborowitz@google.com"))
-        .addCc(new Address("Jonathan Nieder", "jrn@google.com"))
-        .addCc(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .addTo(Address.create("ekempin", "ekempin@google.com"))
+        .addCc(Address.create("Dave Borowitz", "dborowitz@google.com"))
+        .addCc(Address.create("Jonathan Nieder", "jrn@google.com"))
+        .addCc(Address.create("Patrick Hiesel", "hiesel@google.com"))
         .textContent(textContent)
         .subject("Change in gerrit[master]: (Re)enable voting buttons for merged changes")
         .dateReceived(
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index 1445707..5a3a824 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -5,8 +5,6 @@
     name = "pgm_tests",
     srcs = glob(["**/*.java"]),
     deps = [
-        "//java/com/google/gerrit/pgm",
-        "//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 585a272..248c7d1 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -46,7 +46,6 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/jgit",
-        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
@@ -55,6 +54,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",
@@ -74,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/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
new file mode 100644
index 0000000..5e75fe5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.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.account;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Truth;
+import com.google.common.truth.extensions.proto.ProtoTruth;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.config.CachedPreferences;
+import java.sql.Timestamp;
+import java.time.Instant;
+import org.junit.Test;
+
+/**
+ * Test to ensure that we are serializing and deserializing {@link Account} correctly. This is part
+ * of the {@code AccountCache}.
+ */
+public class AccountCacheTest {
+  private static final Account ACCOUNT =
+      Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH)).build();
+  private static final Cache.AccountProto ACCOUNT_PROTO =
+      Cache.AccountProto.newBuilder().setId(1).setRegisteredOn(0).build();
+  private static final CachedAccountDetails.Serializer SERIALIZER =
+      CachedAccountDetails.Serializer.INSTANCE;
+
+  @Test
+  public void account_roundTrip() throws Exception {
+    Account account =
+        Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH))
+            .setFullName("foo bar")
+            .setDisplayName("foo")
+            .setActive(false)
+            .setMetaId("dead..beef")
+            .setStatus("OOO")
+            .setPreferredEmail("foo@bar.tld")
+            .build();
+    CachedAccountDetails original =
+        CachedAccountDetails.create(account, ImmutableMap.of(), CachedPreferences.fromString(""));
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expected =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(
+                Cache.AccountProto.newBuilder()
+                    .setId(1)
+                    .setRegisteredOn(0)
+                    .setFullName("foo bar")
+                    .setDisplayName("foo")
+                    .setInactive(true)
+                    .setMetaId("dead..beef")
+                    .setStatus("OOO")
+                    .setPreferredEmail("foo@bar.tld"))
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
+  }
+
+  @Test
+  public void account_roundTripNullFields() throws Exception {
+    CachedAccountDetails original =
+        CachedAccountDetails.create(ACCOUNT, ImmutableMap.of(), CachedPreferences.fromString(""));
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expected =
+        Cache.AccountDetailsProto.newBuilder().setAccount(ACCOUNT_PROTO).build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
+  }
+
+  @Test
+  public void config_roundTrip() throws Exception {
+    CachedAccountDetails original =
+        CachedAccountDetails.create(
+            ACCOUNT, ImmutableMap.of(), CachedPreferences.fromString("[general]\n\tfoo = bar"));
+
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expected =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(ACCOUNT_PROTO)
+            .setUserPreferences("[general]\n\tfoo = bar")
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
+  }
+
+  @Test
+  public void projectWatch_roundTrip() throws Exception {
+    ProjectWatches.ProjectWatchKey key =
+        ProjectWatches.ProjectWatchKey.create(Project.nameKey("pro/ject"), "*");
+    CachedAccountDetails original =
+        CachedAccountDetails.create(
+            ACCOUNT,
+            ImmutableMap.of(key, ImmutableSet.of(ProjectWatches.NotifyType.ALL_COMMENTS)),
+            CachedPreferences.fromString(""));
+
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expected =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(ACCOUNT_PROTO)
+            .addProjectWatchProto(
+                Cache.ProjectWatchProto.newBuilder()
+                    .setProject("pro/ject")
+                    .setFilter("*")
+                    .addNotifyType("ALL_COMMENTS"))
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
+  }
+
+  @Test
+  public void projectWatch_roundTripNullFilter() throws Exception {
+    ProjectWatches.ProjectWatchKey key =
+        ProjectWatches.ProjectWatchKey.create(Project.nameKey("pro/ject"), null);
+    CachedAccountDetails original =
+        CachedAccountDetails.create(
+            ACCOUNT,
+            ImmutableMap.of(key, ImmutableSet.of(ProjectWatches.NotifyType.ALL_COMMENTS)),
+            CachedPreferences.fromString(""));
+
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expected =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(ACCOUNT_PROTO)
+            .addProjectWatchProto(
+                Cache.ProjectWatchProto.newBuilder()
+                    .setProject("pro/ject")
+                    .addNotifyType("ALL_COMMENTS"))
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
deleted file mode 100644
index a155d7f..0000000
--- a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
+++ /dev/null
@@ -1,32 +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.account;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.entities.AccountGroup;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.Test;
-
-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);
-    assertThat(uuid2).isNotEqualTo(uuid1);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/account/GroupUuidTest.java b/javatests/com/google/gerrit/server/account/GroupUuidTest.java
new file mode 100644
index 0000000..fbf3374
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/GroupUuidTest.java
@@ -0,0 +1,32 @@
+// 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.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.AccountGroup;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.Test;
+
+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);
+    assertThat(uuid2).isNotEqualTo(uuid1);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
index 3443720..066502d 100644
--- a/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
+++ b/javatests/com/google/gerrit/server/account/HashedPasswordTest.java
@@ -53,4 +53,22 @@
     assertThat(hashed.checkPassword("false")).isFalse();
     assertThat(hashed.checkPassword(password)).isTrue();
   }
+
+  @Test
+  public void repeatedPasswordFail() throws Exception {
+    String password = "secret";
+    HashedPassword hashed = HashedPassword.fromPassword(password);
+
+    assertThat(hashed.checkPassword(password + password)).isFalse();
+    assertThat(hashed.checkPassword(password)).isTrue();
+  }
+
+  @Test
+  public void cyclicPasswordTest() throws Exception {
+    String encoded = "bcrypt:4:/KgSxlmbopLXb1eDm35DBA==:98n3gu2pKW9D5mCoZ5kNn9v4HcVFPPJy";
+    HashedPassword hashedPassword = HashedPassword.decode(encoded);
+    String password = "abcdef";
+    assertThat(hashedPassword.checkPassword(password)).isTrue();
+    assertThat(hashedPassword.checkPassword(password + password)).isTrue();
+  }
 }
diff --git a/javatests/com/google/gerrit/server/account/PreferencesTest.java b/javatests/com/google/gerrit/server/account/PreferencesTest.java
deleted file mode 100644
index 9866481..0000000
--- a/javatests/com/google/gerrit/server/account/PreferencesTest.java
+++ /dev/null
@@ -1,50 +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.account;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.EditPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.json.OutputFormat;
-import com.google.gson.Gson;
-import org.junit.Test;
-
-public class PreferencesTest {
-
-  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
-
-  @Test
-  public void generalPreferencesRoundTrip() {
-    GeneralPreferencesInfo original = GeneralPreferencesInfo.defaults();
-    assertThat(GSON.toJson(original))
-        .isEqualTo(GSON.toJson(Preferences.General.fromInfo(original).toInfo()));
-  }
-
-  @Test
-  public void diffPreferencesRoundTrip() {
-    DiffPreferencesInfo original = DiffPreferencesInfo.defaults();
-    assertThat(GSON.toJson(original))
-        .isEqualTo(GSON.toJson(Preferences.Diff.fromInfo(original).toInfo()));
-  }
-
-  @Test
-  public void editPreferencesRoundTrip() {
-    EditPreferencesInfo original = EditPreferencesInfo.defaults();
-    assertThat(GSON.toJson(original))
-        .isEqualTo(GSON.toJson(Preferences.Edit.fromInfo(original).toInfo()));
-  }
-}
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/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 3c7e492..586f1bc 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -41,7 +41,7 @@
                 .setToken("token")
                 .setSecret("secret")
                 .setRaw("raw")
-                .setExpiresAt(12345L)
+                .setExpiresAtMillis(12345L)
                 .setProviderId("provider")
                 .build());
     assertThat(s.deserialize(serialized)).isEqualTo(token);
@@ -56,7 +56,7 @@
             .setToken("token")
             .setSecret("secret")
             .setRaw("raw")
-            .setExpiresAt(12345L)
+            .setExpiresAtMillis(12345L)
             .setProviderId("")
             .build();
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
index ce5f273..fa6a717 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/ExternalGroupsSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/ExternalGroupsSerializerTest.java
new file mode 100644
index 0000000..ba2bfca
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/ExternalGroupsSerializerTest.java
@@ -0,0 +1,53 @@
+// 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.cache.serialize;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.GroupIncludeCacheImpl.ExternalGroupsSerializer;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto;
+import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto.ExternalGroupProto;
+import com.google.protobuf.InvalidProtocolBufferException;
+import org.junit.Test;
+
+public class ExternalGroupsSerializerTest {
+  private static final ImmutableList<AccountGroup.UUID> GROUPS =
+      ImmutableList.of(
+          AccountGroup.UUID.parse("593f90fcf688109f61b0fd4aa47ddf65abb96012"),
+          AccountGroup.UUID.parse("bc9f75584ac0362584a64fb3f0095d905415b153"));
+
+  @Test
+  public void serialize() throws InvalidProtocolBufferException {
+    byte[] serialized = ExternalGroupsSerializer.INSTANCE.serialize(GROUPS);
+
+    assertThat(AllExternalGroupsProto.parseFrom(serialized))
+        .isEqualTo(
+            AllExternalGroupsProto.newBuilder()
+                .addAllExternalGroup(
+                    GROUPS.stream()
+                        .map(g -> ExternalGroupProto.newBuilder().setGroupUuid(g.get()).build())
+                        .collect(toImmutableList()))
+                .build());
+  }
+
+  @Test
+  public void deserialize() {
+    byte[] serialized = ExternalGroupsSerializer.INSTANCE.serialize(GROUPS);
+    assertThat(ExternalGroupsSerializer.INSTANCE.deserialize(serialized)).isEqualTo(GROUPS);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/ArchiveFormatInternalTest.java b/javatests/com/google/gerrit/server/change/ArchiveFormatInternalTest.java
new file mode 100644
index 0000000..003225c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/ArchiveFormatInternalTest.java
@@ -0,0 +1,35 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.client.ArchiveFormat;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+
+public class ArchiveFormatInternalTest {
+  @Test
+  public void internalAndExternalArchiveFormatEnumsMatch() throws Exception {
+    assertThat(getEnumNames(ArchiveFormatInternal.class))
+        .containsExactlyElementsIn(getEnumNames(ArchiveFormat.class));
+  }
+
+  private static List<String> getEnumNames(Class<? extends Enum<?>> e) {
+    return Arrays.stream(e.getEnumConstants()).map(Enum::name).collect(toList());
+  }
+}
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/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index b817b80..47877b6 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -104,7 +104,6 @@
         SubmitRequirement.builder()
             .setType("short_type")
             .setFallbackText("Fallback text may contain special symbols like < > \\ / ; :")
-            .addCustomValue("custom_data", "my value")
             .build();
     r.requirements = Collections.singletonList(sr);
 
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index a713221..a936d28 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, 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,
+            indexes,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            new Config(),
+            null,
+            null));
   }
 
   @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/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
index 9dcb08c..805c542 100644
--- a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -61,8 +61,8 @@
     // Build Message
     MailMessage.Builder b = MailMessage.builder();
     b.id("some id");
-    b.from(new Address("admim@example.com"));
-    b.addTo(new Address("gerrit@my-company.com")); // Not evaluated
+    b.from(Address.create("admim@example.com"));
+    b.addTo(Address.create("gerrit@my-company.com")); // Not evaluated
     b.subject("");
     b.dateReceived(Instant.now());
     b.textContent("I am currently out of office, please leave a code review after the beep.");
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 74f44a1..a383d56 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -85,8 +85,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
+    assertThat(r.name()).isEqualTo(name);
+    assertThat(r.email()).isEqualTo(email);
     verifyAccountCacheGet(user);
   }
 
@@ -99,8 +99,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isNull();
-    assertThat(r.getEmail()).isEqualTo(email);
+    assertThat(r.name()).isNull();
+    assertThat(r.email()).isEqualTo(email);
     verifyAccountCacheGet(user);
   }
 
@@ -113,8 +113,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(name + " (Code Review)");
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyAccountCacheGet(user);
   }
 
@@ -123,8 +123,8 @@
     setFrom("USER");
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(ident.getName());
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyZeroInteractions(accountCache);
   }
 
@@ -138,8 +138,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
+    assertThat(r.name()).isEqualTo(name);
+    assertThat(r.email()).isEqualTo(email);
     verifyAccountCacheGet(user);
   }
 
@@ -153,8 +153,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(name + " (Code Review)");
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyAccountCacheGet(user);
   }
 
@@ -169,8 +169,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
+    assertThat(r.name()).isEqualTo(name);
+    assertThat(r.email()).isEqualTo(email);
     verifyAccountCacheGet(user);
   }
 
@@ -185,8 +185,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(name + " (Code Review)");
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyAccountCacheGet(user);
   }
 
@@ -200,8 +200,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name);
-    assertThat(r.getEmail()).isEqualTo(email);
+    assertThat(r.name()).isEqualTo(name);
+    assertThat(r.email()).isEqualTo(email);
     verifyAccountCacheGet(user);
   }
 
@@ -227,8 +227,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(ident.getName());
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyZeroInteractions(accountCache);
   }
 
@@ -237,8 +237,8 @@
     setFrom("SERVER");
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(ident.getName());
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyZeroInteractions(accountCache);
   }
 
@@ -264,8 +264,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(name + " (Code Review)");
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyAccountCacheGet(user);
   }
 
@@ -278,8 +278,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo("Anonymous Coward (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo("Anonymous Coward (Code Review)");
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyAccountCacheGet(user);
   }
 
@@ -292,8 +292,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(name + " (Code Review)");
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyAccountCacheGet(user);
   }
 
@@ -302,8 +302,8 @@
     setFrom("MIXED");
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    assertThat(r.name()).isEqualTo(ident.getName());
+    assertThat(r.email()).isEqualTo(ident.getEmailAddress());
     verifyZeroInteractions(accountCache);
   }
 
@@ -317,8 +317,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo("A " + name + " B");
-    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    assertThat(r.name()).isEqualTo("A " + name + " B");
+    assertThat(r.email()).isEqualTo("my.server@email.address");
     verifyAccountCacheGet(user);
   }
 
@@ -331,8 +331,8 @@
 
     final Address r = create().from(user);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo("A Anonymous Coward B");
-    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    assertThat(r.name()).isEqualTo("A Anonymous Coward B");
+    assertThat(r.email()).isEqualTo("my.server@email.address");
   }
 
   @Test
@@ -341,8 +341,8 @@
 
     final Address r = create().from(null);
     assertThat(r).isNotNull();
-    assertThat(r.getName()).isEqualTo(ident.getName());
-    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    assertThat(r.name()).isEqualTo(ident.getName());
+    assertThat(r.email()).isEqualTo("my.server@email.address");
   }
 
   private Account.Id user(String name, String email) {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 013939a..8ffcc8b 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -518,7 +518,9 @@
   private RevCommit writeCommit(String body) throws Exception {
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
-        body, noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent), false);
+        body,
+        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        false);
   }
 
   private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
@@ -529,7 +531,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
         initWorkInProgress);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 6ece894..efbaed6 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
@@ -44,6 +45,7 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@@ -55,6 +57,7 @@
 import com.google.protobuf.ByteString;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -64,6 +67,7 @@
 import org.junit.Test;
 
 public class ChangeNotesStateTest {
+
   private static final Change.Id ID = Change.id(123);
   private static final ObjectId SHA =
       ObjectId.fromString("1234567812345678123456781234567812345678");
@@ -95,6 +99,13 @@
     return ChangeNotesState.Builder.empty(ID).metaId(SHA).columns(cols);
   }
 
+  private ChangeNotesStateProto.Builder newProtoBuilder() {
+    return ChangeNotesStateProto.newBuilder()
+        .setMetaId(SHA_BYTES)
+        .setChangeId(ID.get())
+        .setColumns(colsProto);
+  }
+
   @Test
   public void serializeChangeKey() throws Exception {
     assertRoundTrip(
@@ -119,7 +130,7 @@
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
-            .setColumns(colsProto.toBuilder().setCreatedOn(98765L))
+            .setColumns(colsProto.toBuilder().setCreatedOnMillis(98765L))
             .build());
   }
 
@@ -130,7 +141,7 @@
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
-            .setColumns(colsProto.toBuilder().setLastUpdatedOn(98765L))
+            .setColumns(colsProto.toBuilder().setLastUpdatedOnMillis(98765L))
             .build());
   }
 
@@ -402,12 +413,12 @@
                 ReviewerSetEntryProto.newBuilder()
                     .setState("CC")
                     .setAccountId(2001)
-                    .setTimestamp(1212L))
+                    .setTimestampMillis(1212L))
             .addReviewer(
                 ReviewerSetEntryProto.newBuilder()
                     .setState("REVIEWER")
                     .setAccountId(2002)
-                    .setTimestamp(3434L))
+                    .setTimestampMillis(3434L))
             .build());
   }
 
@@ -420,11 +431,11 @@
                     ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
                         .put(
                             ReviewerStateInternal.CC,
-                            new Address("Name1", "email1@example.com"),
+                            Address.create("Name1", "email1@example.com"),
                             new Timestamp(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
-                            new Address("Name2", "email2@example.com"),
+                            Address.create("Name2", "email2@example.com"),
                             new Timestamp(3434L))
                         .build()))
             .build(),
@@ -436,12 +447,12 @@
                 ReviewerByEmailSetEntryProto.newBuilder()
                     .setState("CC")
                     .setAddress("Name1 <email1@example.com>")
-                    .setTimestamp(1212L))
+                    .setTimestampMillis(1212L))
             .addReviewerByEmail(
                 ReviewerByEmailSetEntryProto.newBuilder()
                     .setState("REVIEWER")
                     .setAddress("Name2 <email2@example.com>")
-                    .setTimestamp(3434L))
+                    .setTimestampMillis(3434L))
             .build());
   }
 
@@ -454,7 +465,7 @@
                     ReviewerByEmailSet.fromTable(
                         ImmutableTable.of(
                             ReviewerStateInternal.CC,
-                            new Address("emailonly@example.com"),
+                            Address.create("emailonly@example.com"),
                             new Timestamp(1212L))))
                 .build(),
             ChangeNotesStateProto.newBuilder()
@@ -465,7 +476,7 @@
                     ReviewerByEmailSetEntryProto.newBuilder()
                         .setState("CC")
                         .setAddress("emailonly@example.com")
-                        .setTimestamp(1212L))
+                        .setTimestampMillis(1212L))
                 .build());
 
     // Address doesn't consider the name field in equals, so we have to check it manually.
@@ -473,8 +484,8 @@
     ImmutableSet<Address> ccs = actual.reviewersByEmail().byState(ReviewerStateInternal.CC);
     assertThat(ccs).hasSize(1);
     Address address = Iterables.getOnlyElement(ccs);
-    assertThat(address.getName()).isNull();
-    assertThat(address.getEmail()).isEqualTo("emailonly@example.com");
+    assertThat(address.name()).isNull();
+    assertThat(address.email()).isEqualTo("emailonly@example.com");
   }
 
   @Test
@@ -496,12 +507,12 @@
                 ReviewerSetEntryProto.newBuilder()
                     .setState("CC")
                     .setAccountId(2001)
-                    .setTimestamp(1212L))
+                    .setTimestampMillis(1212L))
             .addPendingReviewer(
                 ReviewerSetEntryProto.newBuilder()
                     .setState("REVIEWER")
                     .setAccountId(2002)
-                    .setTimestamp(3434L))
+                    .setTimestampMillis(3434L))
             .build());
   }
 
@@ -514,11 +525,11 @@
                     ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
                         .put(
                             ReviewerStateInternal.CC,
-                            new Address("Name1", "email1@example.com"),
+                            Address.create("Name1", "email1@example.com"),
                             new Timestamp(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
-                            new Address("Name2", "email2@example.com"),
+                            Address.create("Name2", "email2@example.com"),
                             new Timestamp(3434L))
                         .build()))
             .build(),
@@ -530,12 +541,12 @@
                 ReviewerByEmailSetEntryProto.newBuilder()
                     .setState("CC")
                     .setAddress("Name1 <email1@example.com>")
-                    .setTimestamp(1212L))
+                    .setTimestampMillis(1212L))
             .addPendingReviewerByEmail(
                 ReviewerByEmailSetEntryProto.newBuilder()
                     .setState("REVIEWER")
                     .setAddress("Name2 <email2@example.com>")
-                    .setTimestamp(3434L))
+                    .setTimestampMillis(3434L))
             .build());
   }
 
@@ -575,13 +586,13 @@
             .setColumns(colsProto)
             .addReviewerUpdate(
                 ReviewerStatusUpdateProto.newBuilder()
-                    .setDate(1212L)
+                    .setTimestampMillis(1212L)
                     .setUpdatedBy(1000)
                     .setReviewer(2002)
                     .setState("CC"))
             .addReviewerUpdate(
                 ReviewerStatusUpdateProto.newBuilder()
-                    .setDate(3434L)
+                    .setTimestampMillis(3434L)
                     .setUpdatedBy(1000)
                     .setReviewer(2001)
                     .setState("REVIEWER"))
@@ -589,6 +600,39 @@
   }
 
   @Test
+  public void serializeAttentionSetUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .attentionSet(
+                ImmutableSet.of(
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(23),
+                        Account.id(1000),
+                        AttentionSetUpdate.Operation.ADD,
+                        "reason 1"),
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(42),
+                        Account.id(2000),
+                        AttentionSetUpdate.Operation.REMOVE,
+                        "reason 2")))
+            .build(),
+        newProtoBuilder()
+            .addAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(23_000) // epoch millis
+                    .setAccount(1000)
+                    .setOperation("ADD")
+                    .setReason("reason 1"))
+            .addAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(42_000) // epoch millis
+                    .setAccount(2000)
+                    .setOperation("REMOVE")
+                    .setReason("reason 2"))
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -605,13 +649,13 @@
             .setColumns(colsProto)
             .addAssigneeUpdate(
                 AssigneeStatusUpdateProto.newBuilder()
-                    .setDate(1212L)
+                    .setTimestampMillis(1212L)
                     .setUpdatedBy(1000)
                     .setCurrentAssignee(2001)
                     .setHasCurrentAssignee(true))
             .addAssigneeUpdate(
                 AssigneeStatusUpdateProto.newBuilder()
-                    .setDate(3434L)
+                    .setTimestampMillis(3434L)
                     .setUpdatedBy(1000)
                     .setHasCurrentAssignee(false))
             .build());
@@ -745,6 +789,9 @@
                     "reviewerUpdates",
                     new TypeLiteral<ImmutableList<ReviewerStatusUpdate>>() {}.getType())
                 .put(
+                    "attentionSet",
+                    new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
+                .put(
                     "assigneeUpdates",
                     new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
@@ -776,6 +823,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());
   }
@@ -823,10 +871,10 @@
         .hasFields(
             ImmutableMap.of(
                 "table",
-                    new TypeLiteral<
-                        ImmutableTable<
-                            ReviewerStateInternal, Account.Id, Timestamp>>() {}.getType(),
-                "accounts", new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType()));
+                new TypeLiteral<
+                    ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>>() {}.getType(),
+                "accounts",
+                new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType()));
   }
 
   @Test
@@ -835,9 +883,10 @@
         .hasFields(
             ImmutableMap.of(
                 "table",
-                    new TypeLiteral<
-                        ImmutableTable<ReviewerStateInternal, Address, Timestamp>>() {}.getType(),
-                "users", new TypeLiteral<ImmutableSet<Address>>() {}.getType()));
+                new TypeLiteral<
+                    ImmutableTable<ReviewerStateInternal, Address, Timestamp>>() {}.getType(),
+                "users",
+                new TypeLiteral<ImmutableSet<Address>>() {}.getType()));
   }
 
   @Test
@@ -887,8 +936,7 @@
         .hasAutoValueMethods(
             ImmutableMap.of(
                 "fallbackText", String.class,
-                "type", String.class,
-                "data", new TypeLiteral<ImmutableMap<String, String>>() {}.getType()));
+                "type", String.class));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 145e914..964187c 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -38,6 +38,8 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -45,6 +47,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,12 +55,13 @@
 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.server.validators.ValidationException;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -432,7 +436,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 +471,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 +608,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 +644,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 +673,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
@@ -688,6 +692,95 @@
   }
 
   @Test
+  public void defaultAttentionSetIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSet()).isEmpty();
+  }
+
+  @Test
+  public void addAttentionStatus() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSet()).containsExactly(addTimestamp(attentionSetUpdate, c));
+  }
+
+  @Test
+  public void filterLatestAttentionStatus() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+    update = newUpdate(c, changeOwner);
+    attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSet()).containsExactly(addTimestamp(attentionSetUpdate, c));
+  }
+
+  @Test
+  public void addAttentionStatus_rejectTimestamp() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            Instant.now(), changeOwner.getAccountId(), Operation.ADD, "test");
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate)));
+    assertThat(thrown).hasMessageThat().contains("must not specify timestamp for write");
+  }
+
+  @Test
+  public void addAttentionStatus_rejectMultiplePerUser() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate0 =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 0");
+    AttentionSetUpdate attentionSetUpdate1 =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 1");
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                update.setAttentionSetUpdates(
+                    ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1)));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("must not specify multiple updates for single user");
+  }
+
+  @Test
+  public void addAttentionStatusForMultipleUsers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate0 =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    AttentionSetUpdate attentionSetUpdate1 =
+        AttentionSetUpdate.createForWrite(otherUser.getAccountId(), Operation.ADD, "test");
+
+    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSet())
+        .containsExactly(
+            addTimestamp(attentionSetUpdate0, c), addTimestamp(attentionSetUpdate1, c));
+  }
+
+  @Test
   public void assigneeCommit() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -839,6 +932,10 @@
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isNull();
+
+    // check invalid topic
+    ChangeUpdate failingUpdate = newUpdate(c, changeOwner);
+    assertThrows(ValidationException.class, () -> failingUpdate.setTopic("\""));
   }
 
   @Test
@@ -977,7 +1074,7 @@
     // Finish off by merging the change.
     update = newUpdate(c, changeOwner);
     update.merge(
-        submissionId(c),
+        new SubmissionId(c),
         ImmutableList.of(
             submitRecord(
                 "NOT_READY",
@@ -2823,7 +2920,7 @@
 
   @Test
   public void putReviewerByEmail() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -2836,7 +2933,7 @@
 
   @Test
   public void putAndRemoveReviewerByEmail() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -2853,7 +2950,7 @@
 
   @Test
   public void putRemoveAndAddBackReviewerByEmail() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -2874,8 +2971,8 @@
 
   @Test
   public void putReviewerByEmailAndCcByEmail() throws Exception {
-    Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
-    Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
+    Address adrReviewer = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adrCc = Address.create("Foo Bor", "foo.bar.2@gerritcodereview.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -2896,7 +2993,7 @@
 
   @Test
   public void putReviewerByEmailAndChangeToCc() throws Exception {
-    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -2952,8 +3049,8 @@
 
   @Test
   public void pendingReviewers() throws Exception {
-    Address adr1 = new Address("Foo Bar1", "foo.bar1@gerritcodereview.com");
-    Address adr2 = new Address("Foo Bar2", "foo.bar2@gerritcodereview.com");
+    Address adr1 = Address.create("Foo Bar1", "foo.bar1@gerritcodereview.com");
+    Address adr2 = Address.create("Foo Bar2", "foo.bar2@gerritcodereview.com");
     Account.Id ownerId = changeOwner.getAccount().id();
     Account.Id otherUserId = otherUser.getAccount().id();
 
@@ -3141,7 +3238,12 @@
     return tr.parseBody(commit);
   }
 
-  private RequestId submissionId(Change c) {
-    return new RequestId(c.getId().toString());
+  private AttentionSetUpdate addTimestamp(AttentionSetUpdate attentionSetUpdate, Change c) {
+    Timestamp timestamp = newNotes(c).getChange().getLastUpdatedOn();
+    return AttentionSetUpdate.createFromRead(
+        timestamp.toInstant(),
+        attentionSetUpdate.account(),
+        attentionSetUpdate.operation(),
+        attentionSetUpdate.reason());
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 97781a4..6a090c1 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -21,11 +21,10 @@
 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;
 import java.util.Date;
 import java.util.TimeZone;
@@ -34,9 +33,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
-@RunWith(ConfigSuite.class)
 public class CommitMessageOutputTest extends AbstractChangeNotesTest {
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
@@ -151,7 +148,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 +171,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 +220,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 +231,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());
@@ -391,7 +388,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putReviewerByEmail(
-        new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
+        Address.create("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
     update.commit();
 
     assertBodyEquals(
@@ -404,7 +401,8 @@
   public void ccByEmail() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
+    update.putReviewerByEmail(
+        Address.create("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
     update.commit();
 
     assertBodyEquals(
@@ -427,8 +425,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/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 7f2f5a4..c600844 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -33,6 +33,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.Lists;
@@ -1222,7 +1223,7 @@
   }
 
   private ProjectState getProjectState(Project.NameKey nameKey) throws Exception {
-    return projectCache.checkedGet(nameKey, true);
+    return projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
   }
 
   private ProjectControl internalUser(Project.NameKey localKey) throws Exception {
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 61a2d2f..5e94daa 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -207,7 +207,7 @@
   }
 
   private ProjectState readProjectState() throws Exception {
-    return projectCache.get(project);
+    return projectCache.get(project).get();
   }
 
   private void setUpPermissions() throws Exception {
@@ -215,7 +215,7 @@
     // Anonymous user group has ALLOW READ permission in refs/*.
     // This method is idempotent, so is safe to call on every test setup.
     TestProjectUpdate.Builder u = projectOperations.allProjectsForUpdate();
-    projectCache.checkedGet(allProjects).getConfig().getAccessSectionNames().stream()
+    projectCache.getAllProjects().getConfig().getAccessSectionNames().stream()
         .filter(sec -> sec.startsWith(R_REFS))
         .forEach(sec -> u.remove(permissionKey(Permission.READ).ref(sec)));
     getAdmins().forEach(admin -> u.add(allow(Permission.READ).ref("refs/*").group(admin)));
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index e7f0812..59f2b6d 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -790,7 +790,6 @@
     for (String email : emails) {
       accountManager.link(id, AuthRequest.forEmail(email));
     }
-    accountCache.evict(id);
     accountIndexer.index(id);
   }
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 558658b..1aa0f35 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.AccessSection;
@@ -58,6 +59,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
@@ -84,6 +86,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;
@@ -2097,6 +2100,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());
@@ -2114,7 +2120,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();
 
@@ -2124,6 +2130,29 @@
   }
 
   @Test
+  public void merge() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGE)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
+    RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit mergeCommit =
+        repo.branch("master")
+            .commit()
+            .message("Merge commit")
+            .parent(commit1)
+            .parent(commit2)
+            .insertChangeId()
+            .create();
+    Change mergeChange = insert(repo, newChangeForCommit(repo, mergeCommit));
+
+    assertQuery("status:open is:merge", mergeChange);
+    assertQuery("status:open -is:merge", change2, change1);
+    assertQuery("status:open", mergeChange, change2, change1);
+  }
+
+  @Test
   public void reviewedBy() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
@@ -2576,6 +2605,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;
@@ -2968,6 +3010,41 @@
   }
 
   @Test
+  public void attentionSetIndexed() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "some reason");
+    gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
+
+    assertQuery("attention:" + user.getUserName().get(), change1);
+    assertQuery("-attention:" + userId.toString(), change2);
+  }
+
+  @Test
+  public void attentionSetStored() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+
+    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "reason 1");
+    gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
+    Account.Id user2Id =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    input = new AddToAttentionSetInput(user2Id.toString(), "reason 2");
+    gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
+
+    List<ChangeInfo> result = newQuery("attention:" + user2Id.toString()).get();
+    assertThat(result).hasSize(1);
+    ChangeInfo changeInfo = Iterables.getOnlyElement(result);
+    assertThat(changeInfo.attentionSet.keySet()).containsExactly(userId.get(), user2Id.get());
+    assertThat(changeInfo.attentionSet.get(userId.get()).reason).isEqualTo("reason 1");
+    assertThat(changeInfo.attentionSet.get(user2Id.get()).reason).isEqualTo("reason 2");
+  }
+
+  @Test
   public void assignee() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -3140,6 +3217,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, 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/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
new file mode 100644
index 0000000..9f34377
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.restapi.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.CommentsUtil;
+import java.sql.Timestamp;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ListChangeCommentsTest {
+
+  @Test
+  public void commentsLinkedToChangeMessages() {
+    CommentInfo c1 = getNewCommentInfo("c1", Timestamp.valueOf("2018-01-01 09:01:00"));
+    CommentInfo c2 = getNewCommentInfo("c2", Timestamp.valueOf("2018-01-01 09:01:15"));
+    CommentInfo c3 = getNewCommentInfo("c3", Timestamp.valueOf("2018-01-01 09:01:25"));
+
+    ChangeMessage cm1 =
+        getNewChangeMessage("cm1key", "cm1", Timestamp.valueOf("2018-01-01 00:00:00"));
+    ChangeMessage cm2 =
+        getNewChangeMessage("cm2key", "cm2", Timestamp.valueOf("2018-01-01 09:01:15"));
+    ChangeMessage cm3 =
+        getNewChangeMessage("cm3key", "cm3", Timestamp.valueOf("2018-01-01 09:01:27"));
+
+    assertThat(c1.changeMessageId).isNull();
+    assertThat(c2.changeMessageId).isNull();
+    assertThat(c3.changeMessageId).isNull();
+
+    ImmutableList<CommentInfo> comments = ImmutableList.of(c1, c2, c3);
+    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(cm1, cm2, cm3);
+
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages);
+
+    assertThat(c1.changeMessageId).isEqualTo(changeMessageKey(cm2));
+    assertThat(c2.changeMessageId).isEqualTo(changeMessageKey(cm2));
+    assertThat(c3.changeMessageId).isEqualTo(changeMessageKey(cm3));
+  }
+
+  private static CommentInfo getNewCommentInfo(String message, Timestamp ts) {
+    CommentInfo c = new CommentInfo();
+    c.message = message;
+    c.updated = ts;
+    return c;
+  }
+
+  private static ChangeMessage getNewChangeMessage(String id, String message, Timestamp ts) {
+    ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
+    ChangeMessage cm = new ChangeMessage(key, null, ts, null);
+    cm.setMessage(message);
+    return cm;
+  }
+
+  private static String changeMessageKey(ChangeMessage changeMessage) {
+    return changeMessage.getKey().uuid();
+  }
+}
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/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 059b7f3..beb0e32 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -92,7 +92,9 @@
       allUsersName = new AllUsersName("The-Users");
       repoManager = new InMemoryRepositoryManager();
 
-      args = new NoteDbSchemaVersion.Arguments(repoManager, allProjectsName, allUsersName);
+      args =
+          new NoteDbSchemaVersion.Arguments(
+              repoManager, allProjectsName, allUsersName, null, null, null);
       NoteDbSchemaVersionManager versionManager =
           new NoteDbSchemaVersionManager(allProjectsName, repoManager);
       updater =
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..d3ef4b9 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -47,6 +47,13 @@
 )
 
 java_library(
+    name = "jgit-ssh-jsch",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.ssh.jsch:ssh-jsch"],
+)
+
+java_library(
     name = "jgit-archive",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
@@ -530,7 +537,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/ba-linkify/BUILD b/lib/ba-linkify/BUILD
index 9a8b442..9f0b41d 100644
--- a/lib/ba-linkify/BUILD
+++ b/lib/ba-linkify/BUILD
@@ -1 +1,9 @@
-exports_files(["ba-linkify.js"])
+# Initially, ba-linkify.js was placed in this folder
+# Because BUILD file can't be in the npm package, the .js file was moved to a src/... subfolder.
+# Some plugin can still use ba-linkify, so we add alias to this file, so plugins
+# сan be built without any update.
+alias(
+    name = "ba-linkify.js",
+    actual = "src/ba-linkify.js",
+    visibility = ["//visibility:public"],
+)
diff --git a/lib/ba-linkify/ba-linkify.js b/lib/ba-linkify/ba-linkify.js
deleted file mode 100644
index 32fbea3..0000000
--- a/lib/ba-linkify/ba-linkify.js
+++ /dev/null
@@ -1,239 +0,0 @@
-/**
- * @license
- * Copyright (c) 2009 "Cowboy" Ben Alman
- *
- * Permission is hereby granted, free of charge, to any person
- * obtaining a copy of this software and associated documentation
- * files (the "Software"), to deal in the Software without
- * restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the
- * Software is furnished to do so, subject to the following
- * conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
- * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
- * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
- * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- * OTHER DEALINGS IN THE SOFTWARE.
- */
-/*!
- * JavaScript Linkify - v0.3 - 6/27/2009
- * http://benalman.com/projects/javascript-linkify/
- * 
- * Copyright (c) 2009 "Cowboy" Ben Alman
- * Dual licensed under the MIT and GPL licenses.
- * http://benalman.com/about/license/
- * 
- * Some regexps adapted from http://userscripts.org/scripts/review/7122
- */
-
-// Script: JavaScript Linkify: Process links in text!
-//
-// *Version: 0.3, Last updated: 6/27/2009*
-// 
-// Project Home - http://benalman.com/projects/javascript-linkify/
-// GitHub       - http://github.com/cowboy/javascript-linkify/
-// Source       - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.js
-// (Minified)   - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.min.js (2.8kb)
-// 
-// About: License
-// 
-// Copyright (c) 2009 "Cowboy" Ben Alman,
-// Dual licensed under the MIT and GPL licenses.
-// http://benalman.com/about/license/
-// 
-// About: Examples
-// 
-// This working example, complete with fully commented code, illustrates one way
-// in which this code can be used.
-// 
-// Linkify - http://benalman.com/code/projects/javascript-linkify/examples/linkify/
-// 
-// About: Support and Testing
-// 
-// Information about what browsers this code has been tested in.
-// 
-// Browsers Tested - Internet Explorer 6-8, Firefox 2-3.7, Safari 3-4, Chrome, Opera 9.6-10.
-// 
-// About: Release History
-// 
-// 0.3 - (6/27/2009) Initial release
-
-// Function: linkify
-// 
-// Turn text into linkified html.
-// 
-// Usage:
-// 
-//  > var html = linkify( text [, options ] );
-// 
-// Arguments:
-// 
-//  text - (String) Non-HTML text containing links to be parsed.
-//  options - (Object) An optional object containing linkify parse options.
-// 
-// Options:
-// 
-//  callback (Function) - If specified, this will be called once for each link-
-//    or non-link-chunk with two arguments, text and href. If the chunk is
-//    non-link, href will be omitted. If unspecified, the default linkification
-//    callback is used.
-//  punct_regexp (RegExp) - A RegExp that will be used to trim trailing
-//    punctuation from links, instead of the default. If set to null, trailing
-//    punctuation will not be trimmed.
-// 
-// Returns:
-// 
-//  (String) An HTML string containing links.
-
-window.linkify = (function(){
-  var
-    SCHEME = "[a-z\\d.-]+://",
-    IPV4 = "(?:(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])",
-    HOSTNAME = "(?:(?:[^\\s!@#$%^&*()_=+[\\]{}\\\\|;:'\",.<>/?]+)\\.)+",
-    TLD = "(?:ac|ad|aero|ae|af|ag|ai|al|am|an|ao|aq|arpa|ar|asia|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|biz|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|cat|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|coop|com|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|info|int|in|io|iq|ir|is|it|je|jm|jobs|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mobi|mo|mp|mq|mr|ms|mt|museum|mu|mv|mw|mx|my|mz|name|na|nc|net|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|org|pa|pe|pf|pg|ph|pk|pl|pm|pn|pro|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tel|tf|tg|th|tj|tk|tl|tm|tn|to|tp|travel|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|xn--0zwm56d|xn--11b5bs3a9aj6g|xn--80akhbyknj4f|xn--9t4b11yi5a|xn--deba0ad|xn--g6w251d|xn--hgbk6aj7f53bba|xn--hlcj6aya9esc7a|xn--jxalpdlp|xn--kgbechtv|xn--zckzah|ye|yt|yu|za|zm|zw)",
-    HOST_OR_IP = "(?:" + HOSTNAME + TLD + "|" + IPV4 + ")",
-    PATH = "(?:[;/][^#?<>\\s]*)?",
-    QUERY_FRAG = "(?:\\?[^#<>\\s]*)?(?:#[^<>\\s]*)?",
-    URI1 = "\\b" + SCHEME + "[^<>\\s]+",
-    URI2 = "\\b" + HOST_OR_IP + PATH + QUERY_FRAG + "(?!\\w)",
-    
-    MAILTO = "mailto:",
-    EMAIL = "(?:" + MAILTO + ")?[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@" + HOST_OR_IP + QUERY_FRAG + "(?!\\w)",
-    
-    URI_RE = new RegExp( "(?:" + URI1 + "|" + URI2 + "|" + EMAIL + ")", "ig" ),
-    SCHEME_RE = new RegExp( "^" + SCHEME, "i" ),
-    
-    quotes = {
-      "'": "`",
-      '>': '<',
-      ')': '(',
-      ']': '[',
-      '}': '{',
-      '»': '«',
-      '›': '‹'
-    },
-    
-    default_options = {
-      callback: function( text, href ) {
-        return href ? '<a href="' + href + '" title="' + href + '">' + text + '</a>' : text;
-      },
-      punct_regexp: /(?:[!?.,:;'"]|(?:&|&amp;)(?:lt|gt|quot|apos|raquo|laquo|rsaquo|lsaquo);)$/
-    };
-  
-  return function( txt, options ) {
-    options = options || {};
-    
-    // Temp variables.
-    var arr,
-      i,
-      link,
-      href,
-      
-      // Output HTML.
-      html = '',
-      
-      // Store text / link parts, in order, for re-combination.
-      parts = [],
-      
-      // Used for keeping track of indices in the text.
-      idx_prev,
-      idx_last,
-      idx,
-      link_last,
-      
-      // Used for trimming trailing punctuation and quotes from links.
-      matches_begin,
-      matches_end,
-      quote_begin,
-      quote_end;
-    
-    // Initialize options.
-    for ( i in default_options ) {
-      if ( options[ i ] === undefined ) {
-        options[ i ] = default_options[ i ];
-      }
-    }
-    
-    // Find links.
-    while ( arr = URI_RE.exec( txt ) ) {
-      
-      link = arr[0];
-      idx_last = URI_RE.lastIndex;
-      idx = idx_last - link.length;
-      
-      // Not a link if preceded by certain characters.
-      if ( /[\/:]/.test( txt.charAt( idx - 1 ) ) ) {
-        continue;
-      }
-      
-      // Trim trailing punctuation.
-      do {
-        // If no changes are made, we don't want to loop forever!
-        link_last = link;
-        
-        quote_end = link.substr( -1 )
-        quote_begin = quotes[ quote_end ];
-        
-        // Ending quote character?
-        if ( quote_begin ) {
-          matches_begin = link.match( new RegExp( '\\' + quote_begin + '(?!$)', 'g' ) );
-          matches_end = link.match( new RegExp( '\\' + quote_end, 'g' ) );
-          
-          // If quotes are unbalanced, remove trailing quote character.
-          if ( ( matches_begin ? matches_begin.length : 0 ) < ( matches_end ? matches_end.length : 0 ) ) {
-            link = link.substr( 0, link.length - 1 );
-            idx_last--;
-          }
-        }
-        
-        // Ending non-quote punctuation character?
-        if ( options.punct_regexp ) {
-          link = link.replace( options.punct_regexp, function(a){
-            idx_last -= a.length;
-            return '';
-          });
-        }
-      } while ( link.length && link !== link_last );
-      
-      href = link;
-      
-      // Add appropriate protocol to naked links.
-      if ( !SCHEME_RE.test( href ) ) {
-        href = ( href.indexOf( '@' ) !== -1 ? ( !href.indexOf( MAILTO ) ? '' : MAILTO )
-          : !href.indexOf( 'irc.' ) ? 'irc://'
-          : !href.indexOf( 'ftp.' ) ? 'ftp://'
-          : 'http://' )
-          + href;
-      }
-      
-      // Push preceding non-link text onto the array.
-      if ( idx_prev != idx ) {
-        parts.push([ txt.slice( idx_prev, idx ) ]);
-        idx_prev = idx_last;
-      }
-      
-      // Push massaged link onto the array
-      parts.push([ link, href ]);
-    };
-    
-    // Push remaining non-link text onto the array.
-    parts.push([ txt.substr( idx_prev ) ]);
-    
-    // Process the array items.
-    for ( i = 0; i < parts.length; i++ ) {
-      html += options.callback.apply( window, parts[i] );
-    }
-    
-    // In case of catastrophic failure, return the original text;
-    return html || txt;
-  };
-  
-})();
diff --git a/lib/ba-linkify/src/LICENSE-MIT b/lib/ba-linkify/src/LICENSE-MIT
new file mode 100644
index 0000000..93672f9
--- /dev/null
+++ b/lib/ba-linkify/src/LICENSE-MIT
@@ -0,0 +1,22 @@
+Copyright (c) 2009 "Cowboy" Ben Alman
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/ba-linkify/README.md b/lib/ba-linkify/src/README.md
similarity index 100%
rename from lib/ba-linkify/README.md
rename to lib/ba-linkify/src/README.md
diff --git a/lib/ba-linkify/src/ba-linkify.js b/lib/ba-linkify/src/ba-linkify.js
new file mode 100644
index 0000000..461aff9
--- /dev/null
+++ b/lib/ba-linkify/src/ba-linkify.js
@@ -0,0 +1,245 @@
+/**
+ * @license
+ * Copyright (c) 2009 "Cowboy" Ben Alman
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ */
+ /**
+  * Note(taoalpha):
+  *
+  * To support emails with dots in the name: `foo.bar@test.com`,
+  * the match regex was modified to match `email` first before urls.
+  */
+/*!
+ * JavaScript Linkify - v0.3 - 6/27/2009
+ * http://benalman.com/projects/javascript-linkify/
+ * 
+ * Copyright (c) 2009 "Cowboy" Ben Alman
+ * Dual licensed under the MIT and GPL licenses.
+ * http://benalman.com/about/license/
+ * 
+ * Some regexps adapted from http://userscripts.org/scripts/review/7122
+ */
+
+// Script: JavaScript Linkify: Process links in text!
+//
+// *Version: 0.3, Last updated: 6/27/2009*
+// 
+// Project Home - http://benalman.com/projects/javascript-linkify/
+// GitHub       - http://github.com/cowboy/javascript-linkify/
+// Source       - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.js
+// (Minified)   - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.min.js (2.8kb)
+// 
+// About: License
+// 
+// Copyright (c) 2009 "Cowboy" Ben Alman,
+// Dual licensed under the MIT and GPL licenses.
+// http://benalman.com/about/license/
+// 
+// About: Examples
+// 
+// This working example, complete with fully commented code, illustrates one way
+// in which this code can be used.
+// 
+// Linkify - http://benalman.com/code/projects/javascript-linkify/examples/linkify/
+// 
+// About: Support and Testing
+// 
+// Information about what browsers this code has been tested in.
+// 
+// Browsers Tested - Internet Explorer 6-8, Firefox 2-3.7, Safari 3-4, Chrome, Opera 9.6-10.
+// 
+// About: Release History
+// 
+// 0.3 - (6/27/2009) Initial release
+
+// Function: linkify
+// 
+// Turn text into linkified html.
+// 
+// Usage:
+// 
+//  > var html = linkify( text [, options ] );
+// 
+// Arguments:
+// 
+//  text - (String) Non-HTML text containing links to be parsed.
+//  options - (Object) An optional object containing linkify parse options.
+// 
+// Options:
+// 
+//  callback (Function) - If specified, this will be called once for each link-
+//    or non-link-chunk with two arguments, text and href. If the chunk is
+//    non-link, href will be omitted. If unspecified, the default linkification
+//    callback is used.
+//  punct_regexp (RegExp) - A RegExp that will be used to trim trailing
+//    punctuation from links, instead of the default. If set to null, trailing
+//    punctuation will not be trimmed.
+// 
+// Returns:
+// 
+//  (String) An HTML string containing links.
+
+window.linkify = (function(){
+  var
+    SCHEME = "[a-z\\d.-]+://",
+    IPV4 = "(?:(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])",
+    HOSTNAME = "(?:(?:[^\\s!@#$%^&*()_=+[\\]{}\\\\|;:'\",.<>/?]+)\\.)+",
+    TLD = "(?:ac|ad|aero|ae|af|ag|ai|al|am|an|ao|aq|arpa|ar|asia|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|biz|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|cat|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|coop|com|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|info|int|in|io|iq|ir|is|it|je|jm|jobs|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mobi|mo|mp|mq|mr|ms|mt|museum|mu|mv|mw|mx|my|mz|name|na|nc|net|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|org|pa|pe|pf|pg|ph|pk|pl|pm|pn|pro|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tel|tf|tg|th|tj|tk|tl|tm|tn|to|tp|travel|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|xn--0zwm56d|xn--11b5bs3a9aj6g|xn--80akhbyknj4f|xn--9t4b11yi5a|xn--deba0ad|xn--g6w251d|xn--hgbk6aj7f53bba|xn--hlcj6aya9esc7a|xn--jxalpdlp|xn--kgbechtv|xn--zckzah|ye|yt|yu|za|zm|zw)",
+    HOST_OR_IP = "(?:" + HOSTNAME + TLD + "|" + IPV4 + ")",
+    PATH = "(?:[;/][^#?<>\\s]*)?",
+    QUERY_FRAG = "(?:\\?[^#<>\\s]*)?(?:#[^<>\\s]*)?",
+    URI1 = "\\b" + SCHEME + "[^<>\\s]+",
+    URI2 = "\\b" + HOST_OR_IP + PATH + QUERY_FRAG + "(?!\\w)",
+    
+    MAILTO = "mailto:",
+    EMAIL = "(?:" + MAILTO + ")?[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@" + HOST_OR_IP + QUERY_FRAG + "(?!\\w)",
+    
+    URI_RE = new RegExp( "(?:" + EMAIL + "|" + URI1 + "|" + URI2 + ")", "ig" ),
+    SCHEME_RE = new RegExp( "^" + SCHEME, "i" ),
+    
+    quotes = {
+      "'": "`",
+      '>': '<',
+      ')': '(',
+      ']': '[',
+      '}': '{',
+      '»': '«',
+      '›': '‹'
+    },
+    
+    default_options = {
+      callback: function( text, href ) {
+        return href ? '<a href="' + href + '" title="' + href + '">' + text + '</a>' : text;
+      },
+      punct_regexp: /(?:[!?.,:;'"]|(?:&|&amp;)(?:lt|gt|quot|apos|raquo|laquo|rsaquo|lsaquo);)$/
+    };
+  
+  return function( txt, options ) {
+    options = options || {};
+    
+    // Temp variables.
+    var arr,
+      i,
+      link,
+      href,
+      
+      // Output HTML.
+      html = '',
+      
+      // Store text / link parts, in order, for re-combination.
+      parts = [],
+      
+      // Used for keeping track of indices in the text.
+      idx_prev,
+      idx_last,
+      idx,
+      link_last,
+      
+      // Used for trimming trailing punctuation and quotes from links.
+      matches_begin,
+      matches_end,
+      quote_begin,
+      quote_end;
+    
+    // Initialize options.
+    for ( i in default_options ) {
+      if ( options[ i ] === undefined ) {
+        options[ i ] = default_options[ i ];
+      }
+    }
+    
+    // Find links.
+    while ( arr = URI_RE.exec( txt ) ) {
+      
+      link = arr[0];
+      idx_last = URI_RE.lastIndex;
+      idx = idx_last - link.length;
+      
+      // Not a link if preceded by certain characters.
+      if ( /[\/:]/.test( txt.charAt( idx - 1 ) ) ) {
+        continue;
+      }
+      
+      // Trim trailing punctuation.
+      do {
+        // If no changes are made, we don't want to loop forever!
+        link_last = link;
+        
+        quote_end = link.substr( -1 )
+        quote_begin = quotes[ quote_end ];
+        
+        // Ending quote character?
+        if ( quote_begin ) {
+          matches_begin = link.match( new RegExp( '\\' + quote_begin + '(?!$)', 'g' ) );
+          matches_end = link.match( new RegExp( '\\' + quote_end, 'g' ) );
+          
+          // If quotes are unbalanced, remove trailing quote character.
+          if ( ( matches_begin ? matches_begin.length : 0 ) < ( matches_end ? matches_end.length : 0 ) ) {
+            link = link.substr( 0, link.length - 1 );
+            idx_last--;
+          }
+        }
+        
+        // Ending non-quote punctuation character?
+        if ( options.punct_regexp ) {
+          link = link.replace( options.punct_regexp, function(a){
+            idx_last -= a.length;
+            return '';
+          });
+        }
+      } while ( link.length && link !== link_last );
+      
+      href = link;
+      
+      // Add appropriate protocol to naked links.
+      if ( !SCHEME_RE.test( href ) ) {
+        href = ( href.indexOf( '@' ) !== -1 ? ( !href.indexOf( MAILTO ) ? '' : MAILTO )
+          : !href.indexOf( 'irc.' ) ? 'irc://'
+          : !href.indexOf( 'ftp.' ) ? 'ftp://'
+          : 'http://' )
+          + href;
+      }
+      
+      // Push preceding non-link text onto the array.
+      if ( idx_prev != idx ) {
+        parts.push([ txt.slice( idx_prev, idx ) ]);
+        idx_prev = idx_last;
+      }
+      
+      // Push massaged link onto the array
+      parts.push([ link, href ]);
+    };
+    
+    // Push remaining non-link text onto the array.
+    parts.push([ txt.substr( idx_prev ) ]);
+    
+    // Process the array items.
+    for ( i = 0; i < parts.length; i++ ) {
+      html += options.callback.apply( window, parts[i] );
+    }
+    
+    // In case of catastrophic failure, return the original text;
+    return html || txt;
+  };
+  
+})();
diff --git a/lib/ba-linkify/src/package.json b/lib/ba-linkify/src/package.json
new file mode 100644
index 0000000..813d75f
--- /dev/null
+++ b/lib/ba-linkify/src/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "ba-linkify",
+  "version": "1.0.0",
+  "description": "See README.md",
+  "main": "ba-linkify.js",
+  "license": "MIT",
+  "private": true
+}
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index c4719d5..41d0273 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -4,12 +4,24 @@
 filegroup(
     name = "robotofonts",
     srcs = [
-        "Roboto-Medium.woff",
-        "Roboto-Medium.woff2",
-        "Roboto-Regular.woff",
-        "Roboto-Regular.woff2",
-        "RobotoMono-Regular.woff",
-        "RobotoMono-Regular.woff2",
+        "opensans-latin-400.woff2",
+        "opensans-latin-600.woff2",
+        "opensans-latin-700.woff2",
+        "opensans-latin-ext-400.woff2",
+        "opensans-latin-ext-600.woff2",
+        "opensans-latin-ext-700.woff2",
+        "roboto-latin-400.woff2",
+        "roboto-latin-500.woff2",
+        "roboto-latin-700.woff2",
+        "roboto-latin-ext-400.woff2",
+        "roboto-latin-ext-500.woff2",
+        "roboto-latin-ext-700.woff2",
+        "roboto-mono-latin-400.woff2",
+        "roboto-mono-latin-500.woff2",
+        "roboto-mono-latin-700.woff2",
+        "roboto-mono-latin-ext-400.woff2",
+        "roboto-mono-latin-ext-500.woff2",
+        "roboto-mono-latin-ext-700.woff2",
     ],
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/fonts/Roboto-Medium.woff b/lib/fonts/Roboto-Medium.woff
deleted file mode 100644
index 720bd3e..0000000
--- a/lib/fonts/Roboto-Medium.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/Roboto-Medium.woff2 b/lib/fonts/Roboto-Medium.woff2
deleted file mode 100644
index c003fba..0000000
--- a/lib/fonts/Roboto-Medium.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff b/lib/fonts/Roboto-Regular.woff
deleted file mode 100644
index 03e84eb..0000000
--- a/lib/fonts/Roboto-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff2 b/lib/fonts/Roboto-Regular.woff2
deleted file mode 100644
index 6fa4939..0000000
--- a/lib/fonts/Roboto-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/RobotoMono-Regular.woff b/lib/fonts/RobotoMono-Regular.woff
deleted file mode 100644
index 1ed8af5..0000000
--- a/lib/fonts/RobotoMono-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/RobotoMono-Regular.woff2 b/lib/fonts/RobotoMono-Regular.woff2
deleted file mode 100644
index 1142739..0000000
--- a/lib/fonts/RobotoMono-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/opensans-latin-400.woff2 b/lib/fonts/opensans-latin-400.woff2
new file mode 100644
index 0000000..97338d6
--- /dev/null
+++ b/lib/fonts/opensans-latin-400.woff2
Binary files differ
diff --git a/lib/fonts/opensans-latin-600.woff2 b/lib/fonts/opensans-latin-600.woff2
new file mode 100644
index 0000000..897a088
--- /dev/null
+++ b/lib/fonts/opensans-latin-600.woff2
Binary files differ
diff --git a/lib/fonts/opensans-latin-700.woff2 b/lib/fonts/opensans-latin-700.woff2
new file mode 100644
index 0000000..90f3939
--- /dev/null
+++ b/lib/fonts/opensans-latin-700.woff2
Binary files differ
diff --git a/lib/fonts/opensans-latin-ext-400.woff2 b/lib/fonts/opensans-latin-ext-400.woff2
new file mode 100644
index 0000000..c9470c7
--- /dev/null
+++ b/lib/fonts/opensans-latin-ext-400.woff2
Binary files differ
diff --git a/lib/fonts/opensans-latin-ext-600.woff2 b/lib/fonts/opensans-latin-ext-600.woff2
new file mode 100644
index 0000000..e555f33
--- /dev/null
+++ b/lib/fonts/opensans-latin-ext-600.woff2
Binary files differ
diff --git a/lib/fonts/opensans-latin-ext-700.woff2 b/lib/fonts/opensans-latin-ext-700.woff2
new file mode 100644
index 0000000..a21f919
--- /dev/null
+++ b/lib/fonts/opensans-latin-ext-700.woff2
Binary files differ
diff --git a/lib/fonts/roboto-latin-400.woff2 b/lib/fonts/roboto-latin-400.woff2
new file mode 100644
index 0000000..4fc449a
--- /dev/null
+++ b/lib/fonts/roboto-latin-400.woff2
Binary files differ
diff --git a/lib/fonts/roboto-latin-500.woff2 b/lib/fonts/roboto-latin-500.woff2
new file mode 100644
index 0000000..5ab8a65
--- /dev/null
+++ b/lib/fonts/roboto-latin-500.woff2
Binary files differ
diff --git a/lib/fonts/roboto-latin-700.woff2 b/lib/fonts/roboto-latin-700.woff2
new file mode 100644
index 0000000..1cdd68c
--- /dev/null
+++ b/lib/fonts/roboto-latin-700.woff2
Binary files differ
diff --git a/lib/fonts/roboto-latin-ext-400.woff2 b/lib/fonts/roboto-latin-ext-400.woff2
new file mode 100644
index 0000000..9b257dc
--- /dev/null
+++ b/lib/fonts/roboto-latin-ext-400.woff2
Binary files differ
diff --git a/lib/fonts/roboto-latin-ext-500.woff2 b/lib/fonts/roboto-latin-ext-500.woff2
new file mode 100644
index 0000000..7a7b8b2
--- /dev/null
+++ b/lib/fonts/roboto-latin-ext-500.woff2
Binary files differ
diff --git a/lib/fonts/roboto-latin-ext-700.woff2 b/lib/fonts/roboto-latin-ext-700.woff2
new file mode 100644
index 0000000..5f1a787
--- /dev/null
+++ b/lib/fonts/roboto-latin-ext-700.woff2
Binary files differ
diff --git a/lib/fonts/roboto-mono-latin-400.woff2 b/lib/fonts/roboto-mono-latin-400.woff2
new file mode 100644
index 0000000..0a6a0df
--- /dev/null
+++ b/lib/fonts/roboto-mono-latin-400.woff2
Binary files differ
diff --git a/lib/fonts/roboto-mono-latin-500.woff2 b/lib/fonts/roboto-mono-latin-500.woff2
new file mode 100644
index 0000000..26434e0
--- /dev/null
+++ b/lib/fonts/roboto-mono-latin-500.woff2
Binary files differ
diff --git a/lib/fonts/roboto-mono-latin-700.woff2 b/lib/fonts/roboto-mono-latin-700.woff2
new file mode 100644
index 0000000..b6d0cb1
--- /dev/null
+++ b/lib/fonts/roboto-mono-latin-700.woff2
Binary files differ
diff --git a/lib/fonts/roboto-mono-latin-ext-400.woff2 b/lib/fonts/roboto-mono-latin-ext-400.woff2
new file mode 100644
index 0000000..a8178f9
--- /dev/null
+++ b/lib/fonts/roboto-mono-latin-ext-400.woff2
Binary files differ
diff --git a/lib/fonts/roboto-mono-latin-ext-500.woff2 b/lib/fonts/roboto-mono-latin-ext-500.woff2
new file mode 100644
index 0000000..2560754
--- /dev/null
+++ b/lib/fonts/roboto-mono-latin-ext-500.woff2
Binary files differ
diff --git a/lib/fonts/roboto-mono-latin-ext-700.woff2 b/lib/fonts/roboto-mono-latin-ext-700.woff2
new file mode 100644
index 0000000..02aeffa
--- /dev/null
+++ b/lib/fonts/roboto-mono-latin-ext-700.woff2
Binary files differ
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..4de39cb 100644
--- a/lib/guava.bzl
+++ b/lib/guava.bzl
@@ -1,5 +1,5 @@
-GUAVA_VERSION = "28.1-jre"
+GUAVA_VERSION = "29.0-jre"
 
-GUAVA_BIN_SHA1 = "b0e91dcb6a44ffb6221b5027e12a5cb34b841145"
+GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index 86d455f..4619ac4 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -24,7 +24,6 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = [
-        ":continuation",
         ":http",
         "@jetty-server//jar",
     ],
@@ -35,20 +34,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/lib/js/BUILD b/lib/js/BUILD
index 7478ef3..106aabb 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -1,36 +1,18 @@
-load("//lib/js:bower_components.bzl", "define_bower_components")
 load("//tools/bzl:js.bzl", "bower_component", "js_component")
 
 package(default_visibility = ["//visibility:public"])
 
-# For importing new versions of existing bower packages,
-#
-# 1) edit the versions of 'seed' components in WORKSPACE as desired
-#
-# 2) Run: 'python tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl', to update dependency versions.
-#
-
-# For adding a new component as dependency to a bower_component_bundle
-#
-# 1) add a new bower_archive in WORKSPACE
-#
-# 2) add bower_component(name="my_new_dependency", seed=True) here
-#
-# 3) run bower2bazel (see above.)
-#
-# 4) remove bower_component(name="my_new_dependency", .. ) here
-#
-
-define_bower_components()
-
 js_component(
     name = "highlightjs",
     srcs = ["//lib/highlightjs:highlight.min.js"],
     license = "//lib:LICENSE-highlightjs",
 )
 
+# TODO(dmfilippov) - rename to "highlightjs" after removing js_component
+# license-map.py uses rule name to extract package name; everything after
+# double underscore are removed.
 filegroup(
-    name = "highlightjs_files",
+    name = "highlightjs__files",
     srcs = ["//lib/highlightjs:highlight.min.js"],
     data = ["//lib:LICENSE-highlightjs"],
 )
@@ -41,6 +23,7 @@
     license = "//lib:LICENSE-ba-linkify",
 )
 
+##TODO: remove after plugins migration to npm
 bower_component(
     name = "codemirror-minified",
     license = "//lib:LICENSE-codemirror-minified",
@@ -50,3 +33,4 @@
     name = "resemblejs",
     license = "//lib:LICENSE-resemblejs",
 )
+#End of removal
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
deleted file mode 100644
index 1cd8d52..0000000
--- a/lib/js/bower_archives.bzl
+++ /dev/null
@@ -1,173 +0,0 @@
-# DO NOT EDIT
-# generated with the following command:
-#
-#   tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
-#
-
-load("//tools/bzl:js.bzl", "bower_archive")
-
-def load_bower_archives():
-    bower_archive(
-        name = "accessibility-developer-tools",
-        package = "accessibility-developer-tools",
-        version = "2.12.0",
-        sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49",
-    )
-    bower_archive(
-        name = "async",
-        package = "async",
-        version = "1.5.2",
-        sha1 = "1ec975d3b3834646a7e3d4b7e68118b90ed72508",
-    )
-    bower_archive(
-        name = "chai",
-        package = "chai",
-        version = "3.5.0",
-        sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa",
-    )
-    bower_archive(
-        name = "font-roboto-local",
-        package = "PolymerElements/font-roboto-local",
-        version = "1.1.0",
-        sha1 = "de651abf9b1b2d0935f7b264d48131677196412f",
-    )
-    bower_archive(
-        name = "iron-a11y-announcer",
-        package = "PolymerElements/iron-a11y-announcer",
-        version = "2.1.0",
-        sha1 = "bda12ed6fe7b98a64bf5f70f3e84384053763190",
-    )
-    bower_archive(
-        name = "iron-a11y-keys-behavior",
-        package = "PolymerElements/iron-a11y-keys-behavior",
-        version = "2.1.1",
-        sha1 = "4c8f303479253301e81c63b8ba7bd4cfb62ddf55",
-    )
-    bower_archive(
-        name = "iron-behaviors",
-        package = "PolymerElements/iron-behaviors",
-        version = "2.1.1",
-        sha1 = "d2418e886c3237dcbc8d74a956eec367a95cd068",
-    )
-    bower_archive(
-        name = "iron-checked-element-behavior",
-        package = "PolymerElements/iron-checked-element-behavior",
-        version = "2.1.1",
-        sha1 = "822b6c73e349cf5174e3a17aa9b3d2cb823c37ac",
-    )
-    bower_archive(
-        name = "iron-fit-behavior",
-        package = "PolymerElements/iron-fit-behavior",
-        version = "2.2.1",
-        sha1 = "7b12bc96bf05f04bbb6ad78a16d6c39758263a14",
-    )
-    bower_archive(
-        name = "iron-flex-layout",
-        package = "PolymerElements/iron-flex-layout",
-        version = "2.0.3",
-        sha1 = "c88e9577cabb005ea6d33f35b97d9c39c68f3d9e",
-    )
-    bower_archive(
-        name = "iron-form-element-behavior",
-        package = "PolymerElements/iron-form-element-behavior",
-        version = "2.1.3",
-        sha1 = "634f01cdedd7a616ae025fdcde85c6c5804f6377",
-    )
-    bower_archive(
-        name = "iron-menu-behavior",
-        package = "PolymerElements/iron-menu-behavior",
-        version = "2.1.1",
-        sha1 = "1504997f6eb9aec490b855dadee473cac064f38c",
-    )
-    bower_archive(
-        name = "iron-meta",
-        package = "PolymerElements/iron-meta",
-        version = "2.1.1",
-        sha1 = "7985a9f18b6c32d62f5d3870d58d73ef66613cb9",
-    )
-    bower_archive(
-        name = "iron-resizable-behavior",
-        package = "PolymerElements/iron-resizable-behavior",
-        version = "2.1.1",
-        sha1 = "31e32da6880a983da32da21ee3f483525b24e458",
-    )
-    bower_archive(
-        name = "iron-validatable-behavior",
-        package = "PolymerElements/iron-validatable-behavior",
-        version = "2.1.0",
-        sha1 = "b5dcf3bf4d95b074b74f8170d7122d34ab417daf",
-    )
-    bower_archive(
-        name = "lodash",
-        package = "lodash",
-        version = "3.10.1",
-        sha1 = "2f207a8293c4c554bf6cf071241f7a00dc513d3a",
-    )
-    bower_archive(
-        name = "mocha",
-        package = "mocha",
-        version = "3.5.3",
-        sha1 = "c14f149821e4e96241b20f85134aa757b73038f1",
-    )
-    bower_archive(
-        name = "neon-animation",
-        package = "PolymerElements/neon-animation",
-        version = "2.2.1",
-        sha1 = "865f4252c6306b91609769fefefb4f641361931f",
-    )
-    bower_archive(
-        name = "paper-behaviors",
-        package = "PolymerElements/paper-behaviors",
-        version = "2.1.1",
-        sha1 = "af59936a9015cda4abcfb235f831090a41faa2c4",
-    )
-    bower_archive(
-        name = "paper-icon-button",
-        package = "PolymerElements/paper-icon-button",
-        version = "2.2.1",
-        sha1 = "68f76af3a9379f256a3900a4b68d871898f1fe57",
-    )
-    bower_archive(
-        name = "paper-ripple",
-        package = "PolymerElements/paper-ripple",
-        version = "2.1.1",
-        sha1 = "d402c8165c6a09d17c12a2b421e69ea54e2fc8ef",
-    )
-    bower_archive(
-        name = "paper-styles",
-        package = "PolymerElements/paper-styles",
-        # Basically 2.1.0 but with
-        # https://github.com/PolymerElements/paper-styles/pull/165 applied
-        version = "a6c207e6eee3402fd7a6550e6f9c387ca22ec4c4",
-        sha1 = "6bd17410578b5d4017ccef330393a4b41b1c716e",
-    )
-    bower_archive(
-        name = "shadycss",
-        package = "webcomponents/shadycss",
-        version = "1.9.1",
-        sha1 = "3ef3bd54280ea2d7ce90434620354a2022c8e13d",
-    )
-    bower_archive(
-        name = "sinon-chai",
-        package = "sinon-chai",
-        version = "2.14.0",
-        sha1 = "78f0dc184efe47012a2b1b9a16a4289acf8300dc",
-    )
-    bower_archive(
-        name = "sinonjs",
-        package = "Polymer/sinon.js",
-        version = "1.17.1",
-        sha1 = "a26a6aab7358807de52ba738770f6ac709afd240",
-    )
-    bower_archive(
-        name = "stacky",
-        package = "stacky",
-        version = "1.3.2",
-        sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb",
-    )
-    bower_archive(
-        name = "webcomponentsjs",
-        package = "webcomponents/webcomponentsjs",
-        version = "1.3.3",
-        sha1 = "bbad90bd8301a2f2f5e014e750e0c86351579391",
-    )
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
deleted file mode 100644
index 7fd61c7..0000000
--- a/lib/js/bower_components.bzl
+++ /dev/null
@@ -1,377 +0,0 @@
-# DO NOT EDIT
-# generated with the following command:
-#
-#   tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
-#
-
-load("//tools/bzl:js.bzl", "bower_component")
-
-def define_bower_components():
-    bower_component(
-        name = "accessibility-developer-tools",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "async",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "chai",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "es6-promise",
-        license = "//lib:LICENSE-es6-promise",
-        seed = True,
-    )
-    bower_component(
-        name = "fetch",
-        license = "//lib:LICENSE-fetch",
-        seed = True,
-    )
-    bower_component(
-        name = "font-roboto-local",
-        license = "//lib:LICENSE-polymer",
-    )
-    bower_component(
-        name = "iron-a11y-announcer",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-    )
-    bower_component(
-        name = "iron-a11y-keys-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-    )
-    bower_component(
-        name = "iron-autogrow-textarea",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-behaviors",
-            ":iron-flex-layout",
-            ":iron-validatable-behavior",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-behaviors",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-a11y-keys-behavior",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "iron-checked-element-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-form-element-behavior",
-            ":iron-validatable-behavior",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "iron-dropdown",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-behaviors",
-            ":iron-overlay-behavior",
-            ":neon-animation",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-fit-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-    )
-    bower_component(
-        name = "iron-flex-layout",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-    )
-    bower_component(
-        name = "iron-form-element-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-    )
-    bower_component(
-        name = "iron-icon",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-flex-layout",
-            ":iron-meta",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-iconset-svg",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-meta",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-input",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-a11y-announcer",
-            ":iron-validatable-behavior",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-menu-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-a11y-keys-behavior",
-            ":iron-flex-layout",
-            ":iron-selector",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "iron-meta",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-    )
-    bower_component(
-        name = "iron-overlay-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-a11y-keys-behavior",
-            ":iron-fit-behavior",
-            ":iron-resizable-behavior",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-resizable-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-    )
-    bower_component(
-        name = "iron-selector",
-        license = "//lib:LICENSE-polymer",
-        deps = [":polymer"],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-test-helpers",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-        deps = [":polymer"],
-        seed = True,
-    )
-    bower_component(
-        name = "iron-validatable-behavior",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-meta",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "lodash",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "mocha",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "moment",
-        license = "//lib:LICENSE-moment",
-        seed = True,
-    )
-    bower_component(
-        name = "neon-animation",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-resizable-behavior",
-            ":iron-selector",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "page",
-        license = "//lib:LICENSE-page.js",
-        seed = True,
-    )
-    bower_component(
-        name = "paper-behaviors",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-behaviors",
-            ":iron-checked-element-behavior",
-            ":paper-ripple",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "paper-button",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-flex-layout",
-            ":paper-behaviors",
-            ":paper-styles",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "paper-icon-button",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-icon",
-            ":paper-behaviors",
-            ":paper-styles",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "paper-input",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-a11y-keys-behavior",
-            ":iron-autogrow-textarea",
-            ":iron-behaviors",
-            ":iron-form-element-behavior",
-            ":iron-input",
-            ":paper-styles",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "paper-item",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-behaviors",
-            ":iron-flex-layout",
-            ":paper-styles",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "paper-listbox",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-behaviors",
-            ":iron-menu-behavior",
-            ":paper-styles",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "paper-ripple",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-a11y-keys-behavior",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "paper-styles",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":font-roboto-local",
-            ":iron-flex-layout",
-            ":polymer",
-        ],
-    )
-    bower_component(
-        name = "paper-tabs",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-behaviors",
-            ":iron-flex-layout",
-            ":iron-icon",
-            ":iron-iconset-svg",
-            ":iron-menu-behavior",
-            ":iron-resizable-behavior",
-            ":paper-behaviors",
-            ":paper-icon-button",
-            ":paper-styles",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "paper-toggle-button",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":iron-checked-element-behavior",
-            ":paper-behaviors",
-            ":paper-styles",
-            ":polymer",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "polymer-resin",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":polymer",
-            ":webcomponentsjs",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "polymer",
-        license = "//lib:LICENSE-polymer",
-        deps = [
-            ":shadycss",
-            ":webcomponentsjs",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "shadycss",
-        license = "//lib:LICENSE-shadycss",
-    )
-    bower_component(
-        name = "sinon-chai",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "sinonjs",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "stacky",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-    )
-    bower_component(
-        name = "test-fixture",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-        seed = True,
-    )
-    bower_component(
-        name = "web-component-tester",
-        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
-        deps = [
-            ":accessibility-developer-tools",
-            ":async",
-            ":chai",
-            ":lodash",
-            ":mocha",
-            ":sinon-chai",
-            ":sinonjs",
-            ":stacky",
-            ":test-fixture",
-        ],
-        seed = True,
-    )
-    bower_component(
-        name = "webcomponentsjs",
-        license = "//lib:LICENSE-polymer",
-    )
diff --git a/lib/log/BUILD b/lib/log/BUILD
index fa5bc45..4966723 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -41,17 +41,6 @@
 )
 
 java_library(
-    name = "jsonevent-layout",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jsonevent-layout//jar"],
-    runtime_deps = [
-        ":json-smart",
-        "//lib/commons:lang",
-    ],
-)
-
-java_library(
     name = "json-smart",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 2f98ee3..70e7c1d 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -6,9 +6,8 @@
     visibility = ["//visibility:public"],
     exports = [
         ":eddsa",
-        "@sshd-common//jar",
         "@sshd-mina//jar",
-        "@sshd//jar",
+        "@sshd-osgi//jar",
     ],
     runtime_deps = [":core"],
 )
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index d270853..8369024 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -32,9 +32,8 @@
 nekohtml
 objenesis
 openid-consumer
-sshd
-sshd-common
 sshd-mina
+sshd-osgi
 testcontainers
 testcontainers-elasticsearch
 tukaani-xz
diff --git a/modules/jgit b/modules/jgit
index c9d871f..8f422e9 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit c9d871f15d4daa4ab959b34d2a0759016e415643
+Subproject commit 8f422e9a9add4a7a7d972e8aa1dfa5e15fccdf99
diff --git a/package.json b/package.json
index 56acf61..5b9046d 100644
--- a/package.json
+++ b/package.json
@@ -4,22 +4,27 @@
   "description": "Gerrit Code Review",
   "dependencies": {},
   "devDependencies": {
+    "@bazel/rollup": "^1.1.0",
+    "@bazel/typescript": "^1.0.1",
     "eslint": "^6.6.0",
     "eslint-config-google": "^0.13.0",
     "eslint-plugin-html": "^6.0.0",
-    "eslint-plugin-jsdoc": "^18.4.3",
-    "fried-twinkie": "^0.2.2",
-    "polylint": "^2.10.4",
-    "typescript": "^2.x.x",
+    "eslint-plugin-import": "^2.20.1",
+    "eslint-plugin-jsdoc": "^19.2.0",
+    "eslint-plugin-prettier": "^3.1.3",
+    "polymer-cli": "^1.9.11",
+    "prettier": "2.0.5",
+    "typescript": "^3.7.4",
     "web-component-tester": "^6.5.1"
   },
   "scripts": {
+    "clean": "git clean -fdx && bazel clean --expunge",
     "start": "polygerrit-ui/run-server.sh",
     "test": "WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh",
-    "eslint": "./node_modules/eslint/bin/eslint.js --ext .html,.js polygerrit-ui/app",
-    "eslintfix": "npm run eslint -- --fix",
-    "test-template": "./polygerrit-ui/app/run_template_test.sh",
-    "polylint": "if [[ -z `which bazelisk 2>/dev/null` ]]; then bazel_bin=bazel; else bazel_bin=bazelisk; fi && $bazel_bin test polygerrit-ui/app:polylint_test"
+    "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
+    "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
+    "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
+    "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index 5f9c142..a071bde 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -71,6 +71,7 @@
     "//lib/jackson:jackson-core",
     "//lib:jgit-servlet",
     "//lib:jgit",
+    "//lib:jgit-ssh-jsch",
     "//lib:jsr305",
     "//lib/log:api",
     "//lib/log:log4j",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 59942b1..e211fb1 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 59942b1adf1c949f3633f60ac42f67fae03b3255
+Subproject commit e211fb1bd21043e2574c438a687c8f492d538c97
diff --git a/plugins/delete-project b/plugins/delete-project
index debaa2c..76f5b25 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit debaa2c6d66b5a3c1bc95bc09283344c88407d40
+Subproject commit 76f5b2573343d1b565477678a58641697003248a
diff --git a/plugins/download-commands b/plugins/download-commands
index 1b98be8..e26ed31 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 1b98be8d371e68237182df0f04361017f2be2ea8
+Subproject commit e26ed31aaf070ff884e96b9a09d39c20437de6cb
diff --git a/plugins/gitiles b/plugins/gitiles
index dcac54b..b8ea789 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit dcac54b1afe9275bdd0fc8f3afc2c019b6617ad1
+Subproject commit b8ea789876c0661e1aef9fa3f54a6ac3b8abd62c
diff --git a/plugins/hooks b/plugins/hooks
index 5678f93..7ed555f 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 5678f93fa62df1ec2baedc3a407aff71ca96556d
+Subproject commit 7ed555fe88f4be028acbfd5c245ac78537ac3666
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 20bec50..783f5c6 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 20bec5084c7b90029b8860cbb2fb9a12928f6979
+Subproject commit 783f5c65c7dca522658efe10d57d1ac9ab5f9007
diff --git a/plugins/replication b/plugins/replication
index 17e8c41..cc72dc5 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 17e8c41d5e7b83c419a52a6f7da0ad3ade514f76
+Subproject commit cc72dc5cb3e4198ebd4f487a3fcc04846e1ceb43
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index b9e1e8d..e952b92 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit b9e1e8d61a324ca0592bbb7c80c2802618ef5bc9
+Subproject commit e952b920ecbee5225f1098a02d4a39b19aa7e234
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 4508df9..b594c7f 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 4508df9fe31f9fb70f1392173686ebf80a88de3f
+Subproject commit b594c7f7d6ed77317fa7602dc5d6ff354899bb78
diff --git a/plugins/webhooks b/plugins/webhooks
index 11b6fb8..9fc9c2d 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 11b6fb81b5e4fb0bf3909f862c120d20d4fa9aaf
+Subproject commit 9fc9c2d4e69f7e2701cbcd873977d3312a231a81
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 1c685ab..a029df4 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,40 +1,8 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "bower_component_bundle")
 
 package(default_visibility = ["//visibility:public"])
 
-bower_component_bundle(
-    name = "polygerrit_components.bower_components",
-    deps = [
-        "//lib/js:ba-linkify",
-        "//lib/js:es6-promise",
-        "//lib/js:fetch",
-        # Although highlightjs is inserted separately in the UI zip, it's used
-        # by local development servers (e.g. --polygerrit-dev or run-server.sh).
-        "//lib/js:highlightjs",
-        "//lib/js:iron-a11y-keys-behavior",
-        "//lib/js:iron-autogrow-textarea",
-        "//lib/js:iron-dropdown",
-        "//lib/js:iron-icon",
-        "//lib/js:iron-iconset-svg",
-        "//lib/js:iron-input",
-        "//lib/js:iron-overlay-behavior",
-        "//lib/js:iron-selector",
-        "//lib/js:moment",
-        "//lib/js:page",
-        "//lib/js:paper-button",
-        "//lib/js:paper-input",
-        "//lib/js:paper-item",
-        "//lib/js:paper-listbox",
-        "//lib/js:paper-tabs",
-        "//lib/js:paper-toggle-button",
-        "//lib/js:polymer",
-        "//lib/js:polymer-resin",
-        "//lib/js:shadycss",
-    ],
-)
-
 genrule2(
     name = "fonts",
     srcs = [
@@ -56,7 +24,8 @@
     srcs = ["server.go"],
     data = [
         ":fonts.zip",
-        "//polygerrit-ui/app:test_components.zip",
+        "@ui_dev_npm//:node_modules",
+        "@ui_npm//:node_modules",
     ],
     deps = [
         "@org_golang_x_tools//godoc/vfs/httpfs:go_default_library",
diff --git a/polygerrit-ui/Polymer2.md b/polygerrit-ui/Polymer2.md
index 96bf779..e2a9124 100644
--- a/polygerrit-ui/Polymer2.md
+++ b/polygerrit-ui/Polymer2.md
@@ -1,3 +1,7 @@
+Note: Gerrit has moved to polymer 3 as of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
+
+The change is backward compatible, so no code change needed to support all plugins, but we would highly recommend to start moving to latest polymer 3 for all plugins, check out [Polymer3.md](./Polymer3.md) for more insights.
+
 ## Polymer 2 upgrade
 
 Gerrit is updating to use polymer 2 from polymer 1 by following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade).
diff --git a/polygerrit-ui/Polymer3.md b/polygerrit-ui/Polymer3.md
new file mode 100644
index 0000000..94750d8
--- /dev/null
+++ b/polygerrit-ui/Polymer3.md
@@ -0,0 +1,20 @@
+## Gerrit in Polymer 3
+
+Gerrit has migrated to polymer 3 as of submitted of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
+
+## Polymer 3 vs Polymer 2
+
+The biggest difference between 2 and 3 is the changing of package management from bower to npm and also replaced the html imports with es6 imports so we no longer need templates in separate `html` files for polymer components.
+
+### How that impact plugins
+
+As of now, we still support all syntax in Polymer 2 and most from Polymer 1 with the [legacy layer](https://polymer-library.polymer-project.org/3.0/docs/devguide/legacy-elements). But we do plan to remove those in the future.
+
+So we recommend all plugin owners to start migrating to Polymer 3 for your plugins. You can refer more about polymer 3 from the related resources section.
+
+To get inspirations, check out our [samples here](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples).
+
+### Related resources
+
+- [Polymer 3.0 upgrade guide](https://polymer-library.polymer-project.org/3.0/docs/upgrade)
+-[What's new in Polymer 3.0](https://polymer-library.polymer-project.org/3.0/docs/about_30)
\ No newline at end of file
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 1fbf581..1cd8096 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -2,7 +2,13 @@
 
 Follow the
 [setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
-where applicable.
+where applicable, the most important command is:
+
+```
+git clone --recurse-submodules https://gerrit.googlesource.com/gerrit
+```
+
+The --recurse-submodules option is needed on git clone to ensure that the core plugins, which are included as git submodules, are also cloned.
 
 ## Installing [Bazel](https://bazel.build/)
 
@@ -12,11 +18,28 @@
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
+**Note**: Switch between an old branch with bower_components and a new branch with ui-npm
+packages (or vice versa) can lead to some build errors. To avoid such errors clean up the build
+repository:
+```sh
+rm -rf node_modules/ \
+    polygerrit-ui/node_modules/ \
+    polygerrit-ui/app/node_modules \
+    tools/node_tools/node_modules
+
+bazel clean
+```
+
+If it doesn't help also try to run
+```sh
+bazel clean --expunge
+```
+
 The minimum nodejs version supported is 8.x+
 
 ```sh
 # Debian experimental
-sudo apt-get install nodejs-legacy
+sudo apt-get install nodejs
 sudo apt-get install npm
 
 # OS X with Homebrew
@@ -27,31 +50,44 @@
 All other platforms:
 [download from nodejs.org](https://nodejs.org/en/download/).
 
-Various steps below require installing additional npm packages. The full list of
-dependencies can be installed with:
+or use [nvm - Node Version Manager](https://github.com/nvm-sh/nvm).
+
+### Additional packages
+
+We have several bazel commands to install packages we may need for FE development.
+
+For first time users to get the local server up, `npm start` should be enough and will take care of all of them for you.
 
 ```sh
-npm install
+# Install packages from root-level packages.json
+bazel fetch @npm//:node_modules
+
+# Install packages from polygerrit-ui/app/packages.json
+bazel fetch @ui_npm//:node_modules
+
+# Install packages from polygerrit-ui/packages.json
+bazel fetch @ui_dev_npm//:node_modules
+
+# Install packages from tools/node_tools/packages.json
+bazel fetch @tools_npm//:node_modules
 ```
 
-It may complain about a missing `typescript@2.3.4` peer dependency, which is
-harmless.
+More information for installing and using nodejs rules can be found here https://bazelbuild.github.io/rules_nodejs/install.html
 
-## Running locally against production data
+## Serving files locally
 
 #### Go server
 
-To test the local Polymer frontend against gerrit-review.googlesource.com
-simply execute:
+To test the local Polymer frontend against production data or a local test site execute:
 
 ```sh
 ./polygerrit-ui/run-server.sh
+
+// or
+npm run start
 ```
 
-Then visit http://localhost:8081
-
-This method is based on a
-[simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
+These commands start the [simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
 Mostly it just switches between serving files locally and proxying the real
 server based on the file name. It also does some basic response rewriting, e.g.
 it patches the `config/server/info` response with plugin information provided on
@@ -61,17 +97,27 @@
 ./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
 ```
 
+If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
+
+## Running locally against production data
+
+### Local website
+
+Start [Go server](#go-server) and then visit http://localhost:8081
+
 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
 
@@ -82,9 +128,11 @@
 3. Optionally [populate](https://gerrit.googlesource.com/gerrit/+/master/contrib/populate-fixture-data.py) your test site with some test data.
 
 For running a locally built Gerrit war against your test instance use
-[this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon),
-and add the `--polygerrit-dev` option, if you want to serve the Polymer frontend
-directly from the sources in `polygerrit_ui/app/` instead of from the war:
+[this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon).
+
+If you want to serve the Polymer frontend directly from the sources in `polygerrit_ui/app/` instead of from the war:
+1. Start [Go server](#go-server)
+2. Add the `--dev-cdn` option:
 
 ```sh
 $(bazel info output_base)/external/local_jdk/bin/java \
@@ -92,16 +140,13 @@
     -jar bazel-bin/gerrit.war daemon \
     -d $GERRIT_SITE \
     --console-log \
-    --polygerrit-dev
+    --dev-cdn http://localhost:8081
 ```
 
+*NOTE* You can use any other cdn here, for example: https://cdn.googlesource.com/polygerrit_ui/678.0
+
 ## Running Tests
 
-This step requires the `web-component-tester` npm module.
-
-Note: it may be necessary to add the options `--unsafe-perm=true --allow-root`
-to the `npm install` command to avoid file permission errors.
-
 For daily development you typically only want to run and debug individual tests.
 Run the local [Go proxy server](#go-server) and navigate for example to
 <http://localhost:8081/elements/shared/gr-account-entry/gr-account-entry_test.html>.
@@ -163,7 +208,7 @@
 * To run the linter on all of your local changes:
 
 ```sh
-git diff --name-only master | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
+git diff --name-only HEAD | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
 ```
 
 We also use the `polylint` tool to lint use of Polymer. To install polylint,
@@ -181,40 +226,36 @@
 npm run polylint
 ```
 
-## Template Type Safety
-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.
+## Contributing
 
-A few notes to ensure that these tests pass
-- Any functions with optional parameters will need closure annotations.
-- Any Polymer parameters that are nullable or can be multiple types (other than
-  the one explicitly delared) will need type annotations.
+Our users report bugs / feature requests related to the UI through [Monorail Issues - PolyGerrit](https://bugs.chromium.org/p/gerrit/issues/list?q=component%3APolyGerrit).
 
-These tests require the `typescript` and `fried-twinkie` npm packages.
+If you want to help, feel free to grab one from those `New` issues without
+assignees and send us a change.
 
-To run on all files, execute the following command:
+If you don't know who to assign to review your code change, you can use
+this special account: `gerrit-fe-reviewers@api-project-164060093628.iam.gserviceaccount.com`
+and just assign to that account, it will automatically pick two volunteers
+from the queue we have for FE reviewers.
 
-```sh
-./polygerrit-ui/app/run_template_test.sh
+If you are willing to join the queue and help the community review changes,
+you can create an issue through Monorail and request to join the queue!
+We will review your request and start from there.
+
+## Troubleshotting & Frequently asked questions
+
+1. Local host is blank page and console shows missing files from `polymer-bridges`
+
+Its likely you missed the `polymer-bridges` submodule when you clone the `gerrit` repo.
+
+To fix that, run:
 ```
+// fetch the submodule
+git submodule update --init --recursive
 
-or
+// reset the workspace (please save your local changes before running this command)
+npm run clean
 
-```sh
-npm run test-template
-```
-
-To run on a specific top level directory (ex: change-list)
-```sh
-TEMPLATE_NO_DEFAULT=true ./polygerrit-ui/app/run_template_test.sh //polygerrit-ui/app:template_test_change-list
-```
-
-To run on a specific file (ex: gr-change-list-view), execute the following command:
-```sh
-TEMPLATE_NO_DEFAULT=true ./polygerrit-ui/app/run_template_test.sh //polygerrit-ui/app:template_test_<TOP_LEVEL_DIRECTORY> --test_arg=<VIEW_NAME>
-```
-
-```sh
-TEMPLATE_NO_DEFAULT=true ./polygerrit-ui/app/run_template_test.sh //polygerrit-ui/app:template_test_change-list --test_arg=gr-change-list-view
-```
+// install all dependencies and start the server
+npm start
+```
\ No newline at end of file
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
new file mode 100644
index 0000000..6d9c8f3
--- /dev/null
+++ b/polygerrit-ui/app/.eslintignore
@@ -0,0 +1,2 @@
+**/node_modules
+**/rollup.config.js
diff --git a/polygerrit-ui/app/.eslintrc-bazel.js b/polygerrit-ui/app/.eslintrc-bazel.js
new file mode 100644
index 0000000..977eb45
--- /dev/null
+++ b/polygerrit-ui/app/.eslintrc-bazel.js
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// This file has a special settings for bazel.
+// The settings is required because bazel uses different location
+// for node_modules.
+
+function getBazelSettings() {
+  const runFilesDir = process.env["RUNFILES_DIR"];
+  if (!runFilesDir) {
+    // eslint is executed with 'bazel run ...' to fix the source code. It runs
+    // against real source code, no special paths for node_modules is set.
+    return {};
+  }
+  // eslint is executed with 'bazel test...'. Set path to required node_modules
+  return {
+    "import/resolver": {
+      "node": {
+        "paths": [
+          `${runFilesDir}/ui_npm/node_modules`,
+          `${runFilesDir}/ui_dev_npm/node_modules`
+        ]
+      }
+    }
+  };
+}
+
+module.exports = {
+  "extends": "./.eslintrc.js",
+  "settings": getBazelSettings(),
+};
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
new file mode 100644
index 0000000..42a6564
--- /dev/null
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -0,0 +1,265 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Do not add any bazel-specific properties in this file to keep it clean.
+// Please add such properties to the .eslintrc-bazel.js file
+
+module.exports = {
+  "extends": ["eslint:recommended", "google"],
+  "parserOptions": {
+    "ecmaVersion": 9,
+    "sourceType": "module"
+  },
+  "env": {
+    "browser": true,
+    "es6": true
+  },
+  "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"
+    }],
+    "eol-last": "off",
+    "indent": ["error", 2, {
+      "MemberExpression": 2,
+      "FunctionDeclaration": {"body": 1, "parameters": 2},
+      "FunctionExpression": {"body": 1, "parameters": 2},
+      "CallExpression": {"arguments": 2},
+      "ArrayExpression": 1,
+      "ObjectExpression": 1,
+      "SwitchCase": 1
+    }],
+    "keyword-spacing": ["error", {"after": true, "before": true}],
+    "lines-between-class-members": ["error", "always"],
+    "max-len": [
+      "error",
+      80,
+      2,
+      {
+        "ignoreComments": true,
+        "ignorePattern": "^import .*;$"
+      }
+    ],
+    "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",
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
+        "message": "Remove test.only."
+      },
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
+        "message": "Remove suite.only."
+      }
+    ],
+    // no-undef disables global variable.
+    // "globals" declares allowed global variables.
+    "no-undef": ["error"],
+    "no-useless-escape": "off",
+    "no-var": "error",
+    "operator-linebreak": "off",
+    "object-shorthand": ["error", "always"],
+    "padding-line-between-statements": [
+      "error",
+      {
+        "blankLine": "always",
+        "prev": "class",
+        "next": "*"
+      },
+      {
+        "blankLine": "always",
+        "prev": "*",
+        "next": "class"
+      }
+    ],
+    "prefer-arrow-callback": "error",
+    "prefer-const": "error",
+    "prefer-promise-reject-errors": "error",
+    "prefer-spread": "error",
+    "quote-props": ["error", "consistent-as-needed"],
+    "semi": [2, "always"],
+    "template-curly-spacing": "error",
+
+    "require-jsdoc": 0,
+    "valid-jsdoc": 0,
+    "jsdoc/check-alignment": 2,
+    "jsdoc/check-examples": 0,
+    "jsdoc/check-indentation": 0,
+    "jsdoc/check-param-names": 0,
+    "jsdoc/check-syntax": 0,
+    "jsdoc/check-tag-names": 0,
+    "jsdoc/check-types": 0,
+    "jsdoc/implements-on-classes": 2,
+    "jsdoc/match-description": 0,
+    "jsdoc/newline-after-description": 2,
+    "jsdoc/no-types": 0,
+    "jsdoc/no-undefined-types": 0,
+    "jsdoc/require-description": 0,
+    "jsdoc/require-description-complete-sentence": 0,
+    "jsdoc/require-example": 0,
+    "jsdoc/require-hyphen-before-param-description": 0,
+    "jsdoc/require-jsdoc": 0,
+    "jsdoc/require-param": 0,
+    "jsdoc/require-param-description": 0,
+    "jsdoc/require-param-name": 2,
+    "jsdoc/require-param-type": 2,
+    "jsdoc/require-returns": 0,
+    "jsdoc/require-returns-check": 0,
+    "jsdoc/require-returns-description": 0,
+    "jsdoc/require-returns-type": 2,
+    "jsdoc/valid-types": 2,
+    "jsdoc/require-file-overview": ["error", {
+      "tags": {
+        "license": {
+          "mustExist": true,
+          "preventDuplicates": true
+        }
+      }
+    }],
+    "import/named": 2,
+    "import/no-unresolved": 2,
+    "import/no-self-import": 2,
+    // The no-cycle rule is slow, because it doesn't cache dependencies.
+    // Disable it.
+    "import/no-cycle": 0,
+    "import/no-useless-path-segments": 2,
+    "import/no-unused-modules": 2,
+    "import/no-default-export": 2,
+  },
+
+  // List of allowed globals in all files
+  "globals": {
+    // Polygerrit global variables.
+    // You must not add anything new in this list!
+    // Instead export variables from modules
+    // TODO(dmfilippov): Remove global variables from polygerrit
+    "GrReporting": "readonly",
+    // Global variables from 3rd party libraries.
+    // You should not add anything in this list, always try to import
+    // If import is not possible - you can extend this list
+    "Polymer": "readonly",
+    "ShadyCSS": "readonly",
+    "linkify": "readonly",
+    "security": "readonly",
+  },
+  "overrides": [
+    {
+      "files": ["*.html", "test.js", "test-infra.js"],
+      "rules": {
+        "jsdoc/require-file-overview": "off"
+      },
+    },
+    {
+      "files": ["*.html", "common-test-setup.js"],
+      // Additional global variables allowed in tests
+      "globals": {
+        // Global variables from 3rd party test libraries/frameworks.
+        // You can extend this list if you want to use other global
+        // variables from these libraries and import is not possible
+        "MockInteractions": "readonly",
+        "_": "readonly",
+        "a11ySuite": "readonly",
+        "assert": "readonly",
+        "expect": "readonly",
+        "fixture": "readonly",
+        "flush": "readonly",
+        "flushAsynchronousOperations": "readonly",
+        "setup": "readonly",
+        "sinon": "readonly",
+        "stub": "readonly",
+        "suite": "readonly",
+        "suiteSetup": "readonly",
+        "teardown": "readonly",
+        "test": "readonly",
+      }
+    },
+    {
+      "files": "import-href.js",
+      "globals": {
+        "HTMLImports": "readonly",
+      }
+    },
+    {
+      "files": ["samples/**/*.js", "**/test/plugin.html"],
+      "globals": {
+        // Settings for samples. You can add globals here if you want to use it
+        "Gerrit": "readonly",
+      }
+    },
+    {
+      "files": ["test/functional/**/*.js", "wct.conf.js"],
+      // Settings for functional tests. These scripts are node scripts.
+      // Turn off "no-undef" to allow any global variable
+      "env": {
+        "browser": false,
+        "node": true,
+        "es6": false
+      },
+      "rules": {
+        "no-undef": "off",
+      }
+    },
+    {
+      "files": "test/index.html",
+      "globals": {
+        "WCT": "readonly",
+      }
+    },
+    {
+      "files": ["*_html.js", "gr-icons.js", "*-theme.js", "*-styles.js"],
+      "rules": {
+        "max-len": "off"
+      }
+    },
+    {
+      "files": ["*_html.js"],
+      "rules": {
+        "prettier/prettier": ["error", {
+          "bracketSpacing": false,
+          "singleQuote": true,
+        }]
+      }
+    }
+  ],
+  "plugins": [
+    "html",
+    "jsdoc",
+    "import",
+    "prettier"
+  ],
+  "settings": {
+    "html/report-bad-indent": "error"
+  },
+};
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
deleted file mode 100644
index fb1a5b3..0000000
--- a/polygerrit-ui/app/.eslintrc.json
+++ /dev/null
@@ -1,129 +0,0 @@
-{
-  "extends": ["eslint:recommended", "google"],
-  "parserOptions": {
-    "ecmaVersion": 8
-  },
-  "env": {
-    "browser": true,
-    "es6": true
-  },
-  "globals": {
-    "__dirname": false,
-    "app": false,
-    "page": false,
-    "Polymer": false,
-    "process": false,
-    "require": false,
-    "Gerrit": false,
-    "Promise": false,
-    "assert": false,
-    "test": false,
-    "flushAsynchronousOperations": false
-  },
-  "rules": {
-    "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"
-    }],
-    "eol-last": "off",
-    "indent": ["error", 2, {
-      "MemberExpression": 2,
-      "FunctionDeclaration": {"body": 1, "parameters": 2},
-      "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2},
-      "ArrayExpression": 1,
-      "ObjectExpression": 1,
-      "SwitchCase": 1
-    }],
-    "keyword-spacing": ["error", { "after": true, "before": true }],
-    "lines-between-class-members": ["error", "always"],
-    "max-len": [
-      "error",
-      80,
-      2,
-      {"ignoreComments": true}
-    ],
-    "new-cap": ["error", { "capIsNewExceptions": ["Polymer", "LegacyElementMixin", "GestureEventListeners", "LegacyDataMixin"] }],
-    "no-console": "off",
-    "no-restricted-syntax": [
-      "error",
-      {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
-        "message": "Remove test.only."
-      },
-      {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
-        "message": "Remove suite.only."
-      }
-    ],
-    "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": [
-      "error",
-      {
-        "blankLine": "always",
-        "prev": "class",
-        "next": "*"
-      },
-      {
-        "blankLine": "always",
-        "prev": "*",
-        "next": "class"
-      }
-    ],
-    "prefer-arrow-callback": "error",
-    "prefer-const": "error",
-    "prefer-spread": "error",
-    "quote-props": ["error", "consistent-as-needed"],
-    "semi": [2, "always"],
-    "template-curly-spacing": "error",
-
-    "require-jsdoc": 0,
-    "valid-jsdoc": 0,
-    "jsdoc/check-alignment": 2,
-    "jsdoc/check-examples": 0,
-    "jsdoc/check-indentation": 0,
-    "jsdoc/check-param-names": 0,
-    "jsdoc/check-syntax": 0,
-    "jsdoc/check-tag-names": 0,
-    "jsdoc/check-types": 0,
-    "jsdoc/implements-on-classes": 2,
-    "jsdoc/match-description": 0,
-    "jsdoc/newline-after-description": 2,
-    "jsdoc/no-types": 0,
-    "jsdoc/no-undefined-types": 0,
-    "jsdoc/require-description": 0,
-    "jsdoc/require-description-complete-sentence": 0,
-    "jsdoc/require-example": 0,
-    "jsdoc/require-hyphen-before-param-description": 0,
-    "jsdoc/require-jsdoc": 0,
-    "jsdoc/require-param": 0,
-    "jsdoc/require-param-description": 0,
-    "jsdoc/require-param-name": 2,
-    "jsdoc/require-param-type": 2,
-    "jsdoc/require-returns": 0,
-    "jsdoc/require-returns-check": 0,
-    "jsdoc/require-returns-description": 0,
-    "jsdoc/require-returns-type": 2,
-    "jsdoc/valid-types": 2
-  },
-  "plugins": [
-    "html",
-    "jsdoc"
-  ],
-  "settings": {
-    "html/report-bad-indent": "error"
-  }
-}
diff --git a/polygerrit-ui/app/.gitignore b/polygerrit-ui/app/.gitignore
index 375a75d..c235144 100644
--- a/polygerrit-ui/app/.gitignore
+++ b/polygerrit-ui/app/.gitignore
@@ -1 +1,2 @@
 /plugins/
+/node_modules/
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 5ec0274..5a3f140 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,6 +1,5 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "bower_component_bundle")
-load(":rules.bzl", "polygerrit_bundle")
+load(":rules.bzl", "polygerrit_bundle", "wct_suite")
+load("//tools/js:eslint.bzl", "eslint")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -8,28 +7,18 @@
     name = "polygerrit_ui",
     srcs = glob(
         [
-            "**/*.html",
             "**/*.js",
         ],
         exclude = [
-            "bower_components/**",
+            "node_modules/**",
+            "node_modules_licenses/**",
             "test/**",
             "**/*_test.html",
+            "**/*_test.js",
         ],
     ),
     outs = ["polygerrit_ui.zip"],
-    app = "elements/gr-app.html",
-)
-
-bower_component_bundle(
-    name = "test_components",
-    testonly = True,
-    deps = [
-        "//lib/js:iron-test-helpers",
-        "//lib/js:test-fixture",
-        "//lib/js:web-component-tester",
-        "//polygerrit-ui:polygerrit_components.bower_components",
-    ],
+    entry_point = "elements/gr-app.html",
 )
 
 filegroup(
@@ -40,55 +29,76 @@
             "**/*.js",
         ],
         exclude = [
-            "bower_components/**",
+            "node_modules/**",
+            "node_modules_licenses/**",
         ],
     ),
 )
 
-genrule2(
-    name = "pg_code_zip",
-    srcs = [":pg_code"],
-    outs = ["pg_code.zip"],
-    cmd = " && ".join([
-        ("tar -hcf- $(locations :pg_code) |" +
-         " tar --strip-components=2 -C $$TMP/ -xf-"),
-        "cd $$TMP",
-        "TZ=UTC",
-        "export TZ",
-        "find . -exec touch -t 198001010000 '{}' ';'",
-        "zip -rq $$ROOT/$@ *",
-    ]),
+filegroup(
+    name = "pg_code_without_test",
+    srcs = glob(
+        [
+            "**/*.html",
+            "**/*.js",
+        ],
+        exclude = [
+            "node_modules/**",
+            "node_modules_licenses/**",
+            "**/*_test.html",
+            "test/**",
+            "samples/**",
+            "**/*_test.js",
+        ],
+    ),
 )
 
-sh_test(
-    name = "wct_test",
-    size = "enormous",
-    srcs = ["wct_test.sh"],
-    data = [
-        "test/common-test-setup.html",
+# Workaround for https://github.com/bazelbuild/bazel/issues/1305
+filegroup(
+    name = "test-srcs-fg",
+    srcs = [
+        "test/common-test-setup.js",
         "test/index.html",
-        ":pg_code.zip",
-        ":test_components.zip",
-    ],
-    # Should not run sandboxed.
-    tags = [
-        "local",
-        "manual",
+        ":pg_code",
+        "@ui_dev_npm//:node_modules",
+        "@ui_npm//:node_modules",
     ],
 )
 
-sh_test(
-    name = "lint_test",
-    size = "large",
-    srcs = ["lint_test.sh"],
-    data = [
-        ".eslintrc.json",
-        ":pg_code",
+wct_suite(
+    name = "wct",
+    srcs = [":test-srcs-fg"],
+    split_count = 4,
+)
+
+# Define the eslinter for polygerrit-ui app
+# The eslint macro creates 2 rules: lint_test and lint_bin
+eslint(
+    name = "lint",
+    srcs = [":test-srcs-fg"],
+    config = ".eslintrc-bazel.js",
+    # The .eslintrc-bazel.js extends the .eslintrc.js config, pass it as a dependency
+    data = [".eslintrc.js"],
+    extensions = [
+        ".html",
+        ".js",
     ],
-    # Should not run sandboxed.
-    tags = [
-        "local",
-        "manual",
+    ignore = ".eslintignore",
+    plugins = [
+        "@npm//eslint-config-google",
+        "@npm//eslint-plugin-html",
+        "@npm//eslint-plugin-import",
+        "@npm//eslint-plugin-jsdoc",
+        "@npm//eslint-plugin-prettier",
+    ],
+)
+
+# Workaround for https://github.com/bazelbuild/bazel/issues/1305
+filegroup(
+    name = "polylint-fg",
+    srcs = [
+        ":pg_code_without_test",
+        "@ui_npm//:node_modules",
     ],
 )
 
@@ -96,93 +106,14 @@
     name = "polylint_test",
     size = "large",
     srcs = ["polylint_test.sh"],
+    args = [
+        "$(location @tools_npm//polymer-cli/bin:polymer)",
+        "$(location polymer.json)",
+    ],
     data = [
-        ":pg_code",
-        "//polygerrit-ui:polygerrit_components.bower_components.zip",
-    ],
-    # Should not run sandboxed.
-    tags = [
-        "local",
-        "manual",
-    ],
-)
-
-DIRECTORIES = [
-    "admin",
-    "change",
-    "change-list",
-    "core",
-    "diff",
-    "edit",
-    "plugins",
-    "settings",
-    "shared",
-    "gr-app",
-]
-
-[sh_test(
-    name = "template_test_" + directory,
-    size = "enormous",
-    srcs = ["template_test.sh"],
-    args = [directory],
-    data = [
-        ":pg_code",
-        ":template_test_srcs",
-        "//polygerrit-ui:polygerrit_components.bower_components.zip",
-    ],
-    tags = [
-        # Should not run sandboxed.
-        "local",
-        "manual",
-        "template",
-    ],
-) 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 = [
-        "template_test_srcs/convert_for_template_tests.py",
-        "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",
+        "polymer.json",
+        ":polylint-fg",
+        "@tools_npm//polymer-cli/bin:polymer",
     ],
     # Should not run sandboxed.
     tags = [
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
deleted file mode 100644
index 36e0201..0000000
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
+++ /dev/null
@@ -1,49 +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>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.AsyncForeachBehavior */
-  Gerrit.AsyncForeachBehavior = {
-    /**
-     * @template T
-     * @param {!Array<T>} array
-     * @param {!Function} fn An iteratee function to be passed each element of
-     *     the array in order. Must return a promise, and the following
-     *     iteration will not begin until resolution of the promise returned by
-     *     the previous iteration.
-     *
-     *     An optional second argument to fn is a callback that will halt the
-     *     loop if called.
-     * @return {!Promise<undefined>}
-     */
-    asyncForeach(array, fn) {
-      if (!array.length) { return Promise.resolve(); }
-      let stop = false;
-      const stopCallback = () => { stop = true; };
-      return fn(array[0], stopCallback).then(exit => {
-        if (stop) { return Promise.resolve(); }
-        return this.asyncForeach(array.slice(1), fn);
-      });
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
new file mode 100644
index 0000000..1d384bc
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
@@ -0,0 +1,48 @@
+/**
+ * @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.
+ */
+
+/** @polymerBehavior AsyncForeachBehavior */
+export const AsyncForeachBehavior = {
+  /**
+   * @template T
+   * @param {!Array<T>} array
+   * @param {!Function} fn An iteratee function to be passed each element of
+   *     the array in order. Must return a promise, and the following
+   *     iteration will not begin until resolution of the promise returned by
+   *     the previous iteration.
+   *
+   *     An optional second argument to fn is a callback that will halt the
+   *     loop if called.
+   * @return {!Promise<undefined>}
+   */
+  asyncForeach(array, fn) {
+    if (!array.length) { return Promise.resolve(); }
+    let stop = false;
+    const stopCallback = () => { stop = true; };
+    return fn(array[0], stopCallback).then(exit => {
+      if (stop) { return Promise.resolve(); }
+      return this.asyncForeach(array.slice(1), fn);
+    });
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.AsyncForeachBehavior = AsyncForeachBehavior;
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..1d50cc4 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
@@ -17,40 +17,40 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="async-foreach-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('async-foreach-behavior tests', () => {
-    test('loops over each item', () => {
-      const fn = sinon.stub().returns(Promise.resolve());
-      return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
-          .then(() => {
-            assert.isTrue(fn.calledThrice);
-            assert.equal(fn.getCall(0).args[0], 1);
-            assert.equal(fn.getCall(1).args[0], 2);
-            assert.equal(fn.getCall(2).args[0], 3);
-          });
-    });
-
-    test('halts on stop condition', () => {
-      const stub = sinon.stub();
-      const fn = (e, stop) => {
-        stub(e);
-        stop();
-        return Promise.resolve();
-      };
-      return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
-          .then(() => {
-            assert.isTrue(stub.calledOnce);
-            assert.equal(stub.lastCall.args[0], 1);
-          });
-    });
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../test/common-test-setup.js';
+import {AsyncForeachBehavior} from './async-foreach-behavior.js';
+suite('async-foreach-behavior tests', () => {
+  test('loops over each item', () => {
+    const fn = sinon.stub().returns(Promise.resolve());
+    return AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+        .then(() => {
+          assert.isTrue(fn.calledThrice);
+          assert.equal(fn.getCall(0).args[0], 1);
+          assert.equal(fn.getCall(1).args[0], 2);
+          assert.equal(fn.getCall(2).args[0], 3);
+        });
   });
+
+  test('halts on stop condition', () => {
+    const stub = sinon.stub();
+    const fn = (e, stop) => {
+      stub(e);
+      stop();
+      return Promise.resolve();
+    };
+    return AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+        .then(() => {
+          assert.isTrue(stub.calledOnce);
+          assert.equal(stub.lastCall.args[0], 1);
+        });
+  });
+});
 </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
deleted file mode 100644
index 1748647..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
+++ /dev/null
@@ -1,32 +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>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.BaseUrlBehavior */
-  Gerrit.BaseUrlBehavior = {
-    /** @return {string} */
-    getBaseUrl() {
-      return window.CANONICAL_PATH || '';
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
new file mode 100644
index 0000000..4deb089
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
@@ -0,0 +1,32 @@
+/**
+ * @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.
+ */
+
+/** @polymerBehavior BaseUrlBehavior */
+export const BaseUrlBehavior = {
+  /** @return {string} */
+  getBaseUrl() {
+    return window.CANONICAL_PATH || '';
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.BaseUrlBehavior = BaseUrlBehavior;
+
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..61d7bac 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
@@ -17,19 +17,18 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<script>
-  /** @type {string} */
-  window.CANONICAL_PATH = '/r';
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../test/common-test-setup.js';
+/** @type {string} */
+window.CANONICAL_PATH = '/r';
 </script>
-<link rel="import" href="base-url-behavior.html">
-
 <test-fixture id="basic">
   <template>
     <test-element></test-element>
@@ -44,29 +43,32 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('base-url-behavior tests', () => {
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {BaseUrlBehavior} from './base-url-behavior.js';
+suite('base-url-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [
-          Gerrit.BaseUrlBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-    });
-
-    test('getBaseUrl', () => {
-      assert.deepEqual(element.getBaseUrl(), '/r');
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [
+        BaseUrlBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+  });
+
+  test('getBaseUrl', () => {
+    assert.deepEqual(element.getBaseUrl(), '/r');
+  });
+});
 </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
deleted file mode 100644
index f07a955..0000000
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
+++ /dev/null
@@ -1,63 +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.
--->
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<script>
-(function(window) {
-  'use strict';
-
-  const PROBE_PATH = '/Documentation/index.html';
-  const DOCS_BASE_PATH = '/Documentation';
-
-  let cachedPromise;
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.DocsUrlBehavior */
-  Gerrit.DocsUrlBehavior = [{
-
-    /**
-     * Get the docs base URL from either the server config or by probing.
-     *
-     * @param {Object} config The server config.
-     * @param {!Object} restApi A REST API instance
-     * @return {!Promise<string>} A promise that resolves with the docs base
-     *     URL.
-     */
-    getDocsBaseUrl(config, restApi) {
-      if (!cachedPromise) {
-        cachedPromise = new Promise(resolve => {
-          if (config && config.gerrit && config.gerrit.doc_url) {
-            resolve(config.gerrit.doc_url);
-          } else {
-            restApi.probePath(this.getBaseUrl() + PROBE_PATH).then(ok => {
-              resolve(ok ? (this.getBaseUrl() + DOCS_BASE_PATH) : null);
-            });
-          }
-        });
-      }
-      return cachedPromise;
-    },
-
-    /** For testing only. */
-    _clearDocsBaseUrlCache() {
-      cachedPromise = undefined;
-    },
-  },
-  Gerrit.BaseUrlBehavior,
-  ];
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
new file mode 100644
index 0000000..add1df4
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
+
+const PROBE_PATH = '/Documentation/index.html';
+const DOCS_BASE_PATH = '/Documentation';
+
+let cachedPromise;
+
+/** @polymerBehavior DocsUrlBehavior */
+export const DocsUrlBehavior = [{
+
+  /**
+   * Get the docs base URL from either the server config or by probing.
+   *
+   * @param {Object} config The server config.
+   * @param {!Object} restApi A REST API instance
+   * @return {!Promise<string>} A promise that resolves with the docs base
+   *     URL.
+   */
+  getDocsBaseUrl(config, restApi) {
+    if (!cachedPromise) {
+      cachedPromise = new Promise(resolve => {
+        if (config && config.gerrit && config.gerrit.doc_url) {
+          resolve(config.gerrit.doc_url);
+        } else {
+          restApi.probePath(this.getBaseUrl() + PROBE_PATH).then(ok => {
+            resolve(ok ? (this.getBaseUrl() + DOCS_BASE_PATH) : null);
+          });
+        }
+      });
+    }
+    return cachedPromise;
+  },
+
+  /** For testing only. */
+  _clearDocsBaseUrlCache() {
+    cachedPromise = undefined;
+  },
+},
+BaseUrlBehavior,
+];
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.DocsUrlBehavior = DocsUrlBehavior;
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..0efd80f 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
@@ -15,16 +15,11 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<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"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <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">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -32,70 +27,73 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('docs-url-behavior tests', () => {
-    let element;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {DocsUrlBehavior} from './docs-url-behavior.js';
+suite('docs-url-behavior tests', () => {
+  let element;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'docs-url-behavior-element',
-        behaviors: [Gerrit.DocsUrlBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      element._clearDocsBaseUrlCache();
-    });
-
-    test('null config', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      return element.getDocsBaseUrl(null, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isTrue(
-                mockRestApi.probePath.calledWith('/Documentation/index.html'));
-            assert.equal(docsBaseUrl, '/Documentation');
-          });
-    });
-
-    test('no doc config', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      const config = {gerrit: {}};
-      return element.getDocsBaseUrl(config, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isTrue(
-                mockRestApi.probePath.calledWith('/Documentation/index.html'));
-            assert.equal(docsBaseUrl, '/Documentation');
-          });
-    });
-
-    test('has doc config', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      const config = {gerrit: {doc_url: 'foobar'}};
-      return element.getDocsBaseUrl(config, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isFalse(mockRestApi.probePath.called);
-            assert.equal(docsBaseUrl, 'foobar');
-          });
-    });
-
-    test('no probe', () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(false)),
-      };
-      return element.getDocsBaseUrl(null, mockRestApi)
-          .then(docsBaseUrl => {
-            assert.isTrue(
-                mockRestApi.probePath.calledWith('/Documentation/index.html'));
-            assert.isNotOk(docsBaseUrl);
-          });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'docs-url-behavior-element',
+      behaviors: [DocsUrlBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    element._clearDocsBaseUrlCache();
+  });
+
+  test('null config', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(true)),
+    };
+    return element.getDocsBaseUrl(null, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isTrue(
+              mockRestApi.probePath.calledWith('/Documentation/index.html'));
+          assert.equal(docsBaseUrl, '/Documentation');
+        });
+  });
+
+  test('no doc config', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(true)),
+    };
+    const config = {gerrit: {}};
+    return element.getDocsBaseUrl(config, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isTrue(
+              mockRestApi.probePath.calledWith('/Documentation/index.html'));
+          assert.equal(docsBaseUrl, '/Documentation');
+        });
+  });
+
+  test('has doc config', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(true)),
+    };
+    const config = {gerrit: {doc_url: 'foobar'}};
+    return element.getDocsBaseUrl(config, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isFalse(mockRestApi.probePath.called);
+          assert.equal(docsBaseUrl, 'foobar');
+        });
+  });
+
+  test('no probe', () => {
+    const mockRestApi = {
+      probePath: sinon.stub().returns(Promise.resolve(false)),
+    };
+    return element.getDocsBaseUrl(null, mockRestApi)
+        .then(docsBaseUrl => {
+          assert.isTrue(
+              mockRestApi.probePath.calledWith('/Documentation/index.html'));
+          assert.isNotOk(docsBaseUrl);
+        });
+  });
+});
 </script>
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
deleted file mode 100644
index e0a15cf..0000000
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.html
+++ /dev/null
@@ -1,47 +0,0 @@
-<!--
-@license
-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.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.DomUtilBehavior */
-  Gerrit.DomUtilBehavior = {
-    /**
-     * Are any ancestors of the element (or the element itself) members of the
-     * given class.
-     *
-     * @param {!Element} element
-     * @param {string} className
-     * @param {Element=} opt_stopElement If provided, stop traversing the
-     *     ancestry when the stop element is reached. The stop element's class
-     *     is not checked.
-     * @return {boolean}
-     */
-    descendedFromClass(element, className, opt_stopElement) {
-      let isDescendant = element.classList.contains(className);
-      while (!isDescendant && element.parentElement &&
-          (!opt_stopElement || element.parentElement !== opt_stopElement)) {
-        isDescendant = element.classList.contains(className);
-        element = element.parentElement;
-      }
-      return isDescendant;
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
new file mode 100644
index 0000000..b8d54a4
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * 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.
+ */
+
+export const DomUtilBehavior = {
+  /**
+   * Are any ancestors of the element (or the element itself) members of the
+   * given class.
+   *
+   * @param {!Element} element
+   * @param {string} className
+   * @param {Element=} opt_stopElement If provided, stop traversing the
+   *     ancestry when the stop element is reached. The stop element's class
+   *     is not checked.
+   * @return {boolean}
+   */
+  descendedFromClass(element, className, opt_stopElement) {
+    let isDescendant = element.classList.contains(className);
+    while (!isDescendant && element.parentElement &&
+        (!opt_stopElement || element.parentElement !== opt_stopElement)) {
+      isDescendant = element.classList.contains(className);
+      element = element.parentElement;
+    }
+    return isDescendant;
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.DomUtilBehavior = DomUtilBehavior;
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..1e842c1 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
@@ -17,15 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="dom-util-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="nested-structure">
   <template>
     <test-element></test-element>
@@ -39,33 +37,36 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('dom-util-behavior tests', () => {
-    let element;
-    let divs;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {DomUtilBehavior} from './dom-util-behavior.js';
+suite('dom-util-behavior tests', () => {
+  let element;
+  let divs;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.DomUtilBehavior],
-      });
-    });
-
-    setup(() => {
-      const testDom = fixture('nested-structure');
-      element = testDom[0];
-      divs = testDom[1];
-    });
-
-    test('descendedFromClass', () => {
-      // .c is a child of .a and not vice versa.
-      assert.isTrue(element.descendedFromClass(divs.querySelector('.c'), 'a'));
-      assert.isFalse(element.descendedFromClass(divs.querySelector('.a'), 'c'));
-
-      // Stops at stop element.
-      assert.isFalse(element.descendedFromClass(divs.querySelector('.c'), 'a',
-          divs.querySelector('.b')));
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [DomUtilBehavior],
     });
   });
+
+  setup(() => {
+    const testDom = fixture('nested-structure');
+    element = testDom[0];
+    divs = testDom[1];
+  });
+
+  test('descendedFromClass', () => {
+    // .c is a child of .a and not vice versa.
+    assert.isTrue(element.descendedFromClass(divs.querySelector('.c'), 'a'));
+    assert.isFalse(element.descendedFromClass(divs.querySelector('.a'), 'c'));
+
+    // Stops at stop element.
+    assert.isFalse(element.descendedFromClass(divs.querySelector('.c'), 'a',
+        divs.querySelector('.b')));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html
deleted file mode 100644
index b5afab1..0000000
--- a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html
+++ /dev/null
@@ -1,55 +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.
--->
-
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.FireBehavior */
-  Gerrit.FireBehavior = {
-    /**
-     * Dispatches a custom event with an optional detail value.
-     *
-     * @param {string} type Name of event type.
-     * @param {*=} detail Detail value containing event-specific
-     *   payload.
-     * @param {{ bubbles: (boolean|undefined), cancelable: (boolean|undefined),
-     *     composed: (boolean|undefined) }=}
-     *  options Object specifying options.  These may include:
-     *  `bubbles` (boolean, defaults to `true`),
-     *  `cancelable` (boolean, defaults to false), and
-     *  `composed` (boolean, defaults to true).
-     * @return {!Event} The new event that was fired.
-     * @override
-     */
-    fire(type, detail, options) {
-      options = options || {};
-      detail = (detail === null || detail === undefined) ? {} : detail;
-      const event = new Event(type, {
-        bubbles: options.bubbles === undefined ? true : options.bubbles,
-        cancelable: Boolean(options.cancelable),
-        composed: options.composed === undefined ? true: options.composed,
-      });
-      event.detail = detail;
-      this.dispatchEvent(event);
-      return event;
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
new file mode 100644
index 0000000..88c8835
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
@@ -0,0 +1,55 @@
+/**
+ * @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.
+ */
+
+/** @polymerBehavior Gerrit.FireBehavior */
+export const FireBehavior = {
+  /**
+   * Dispatches a custom event with an optional detail value.
+   *
+   * @param {string} type Name of event type.
+   * @param {*=} detail Detail value containing event-specific
+   *   payload.
+   * @param {{ bubbles: (boolean|undefined), cancelable: (boolean|undefined),
+   *     composed: (boolean|undefined) }=}
+   *  options Object specifying options.  These may include:
+   *  `bubbles` (boolean, defaults to `true`),
+   *  `cancelable` (boolean, defaults to false), and
+   *  `composed` (boolean, defaults to true).
+   * @return {!Event} The new event that was fired.
+   * @override
+   */
+  fire(type, detail, options) {
+    console.warn('\'fire\' is deprecated, please use dispatchEvent instead!');
+    options = options || {};
+    detail = (detail === null || detail === undefined) ? {} : detail;
+    const event = new Event(type, {
+      bubbles: options.bubbles === undefined ? true : options.bubbles,
+      cancelable: Boolean(options.cancelable),
+      composed: options.composed === undefined ? true: options.composed,
+    });
+    event.detail = detail;
+    this.dispatchEvent(event);
+    return event;
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.FireBehavior = FireBehavior;
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
deleted file mode 100644
index fedefc6..0000000
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
+++ /dev/null
@@ -1,150 +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>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.AccessBehavior */
-  Gerrit.AccessBehavior = {
-    properties: {
-      permissionValues: {
-        type: Object,
-        readOnly: true,
-        value: {
-          abandon: {
-            id: 'abandon',
-            name: 'Abandon',
-          },
-          addPatchSet: {
-            id: 'addPatchSet',
-            name: 'Add Patch Set',
-          },
-          create: {
-            id: 'create',
-            name: 'Create Reference',
-          },
-          createTag: {
-            id: 'createTag',
-            name: 'Create Annotated Tag',
-          },
-          createSignedTag: {
-            id: 'createSignedTag',
-            name: 'Create Signed Tag',
-          },
-          delete: {
-            id: 'delete',
-            name: 'Delete Reference',
-          },
-          deleteChanges: {
-            id: 'deleteChanges',
-            name: 'Delete Changes',
-          },
-          deleteOwnChanges: {
-            id: 'deleteOwnChanges',
-            name: 'Delete Own Changes',
-          },
-          editAssignee: {
-            id: 'editAssignee',
-            name: 'Edit Assignee',
-          },
-          editHashtags: {
-            id: 'editHashtags',
-            name: 'Edit Hashtags',
-          },
-          editTopicName: {
-            id: 'editTopicName',
-            name: 'Edit Topic Name',
-          },
-          forgeAuthor: {
-            id: 'forgeAuthor',
-            name: 'Forge Author Identity',
-          },
-          forgeCommitter: {
-            id: 'forgeCommitter',
-            name: 'Forge Committer Identity',
-          },
-          forgeServerAsCommitter: {
-            id: 'forgeServerAsCommitter',
-            name: 'Forge Server Identity',
-          },
-          owner: {
-            id: 'owner',
-            name: 'Owner',
-          },
-          push: {
-            id: 'push',
-            name: 'Push',
-          },
-          pushMerge: {
-            id: 'pushMerge',
-            name: 'Push Merge Commit',
-          },
-          read: {
-            id: 'read',
-            name: 'Read',
-          },
-          rebase: {
-            id: 'rebase',
-            name: 'Rebase',
-          },
-          removeReviewer: {
-            id: 'removeReviewer',
-            name: 'Remove Reviewer',
-          },
-          submit: {
-            id: 'submit',
-            name: 'Submit',
-          },
-          submitAs: {
-            id: 'submitAs',
-            name: 'Submit (On Behalf Of)',
-          },
-          toggleWipState: {
-            id: 'toggleWipState',
-            name: 'Toggle Work In Progress State',
-          },
-          viewPrivateChanges: {
-            id: 'viewPrivateChanges',
-            name: 'View Private Changes',
-          },
-        },
-      },
-    },
-
-    /**
-     * @param {!Object} obj
-     * @return {!Array} returns a sorted array sorted by the id of the original
-     *    object.
-     */
-    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);
-      });
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
new file mode 100644
index 0000000..4028b18
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
@@ -0,0 +1,155 @@
+/**
+ * @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.
+ */
+
+/** @polymerBehavior Gerrit.AccessBehavior */
+export const AccessBehavior = {
+  properties: {
+    permissionValues: {
+      type: Object,
+      readOnly: true,
+      value: {
+        abandon: {
+          id: 'abandon',
+          name: 'Abandon',
+        },
+        addPatchSet: {
+          id: 'addPatchSet',
+          name: 'Add Patch Set',
+        },
+        create: {
+          id: 'create',
+          name: 'Create Reference',
+        },
+        createTag: {
+          id: 'createTag',
+          name: 'Create Annotated Tag',
+        },
+        createSignedTag: {
+          id: 'createSignedTag',
+          name: 'Create Signed Tag',
+        },
+        delete: {
+          id: 'delete',
+          name: 'Delete Reference',
+        },
+        deleteChanges: {
+          id: 'deleteChanges',
+          name: 'Delete Changes',
+        },
+        deleteOwnChanges: {
+          id: 'deleteOwnChanges',
+          name: 'Delete Own Changes',
+        },
+        editAssignee: {
+          id: 'editAssignee',
+          name: 'Edit Assignee',
+        },
+        editHashtags: {
+          id: 'editHashtags',
+          name: 'Edit Hashtags',
+        },
+        editTopicName: {
+          id: 'editTopicName',
+          name: 'Edit Topic Name',
+        },
+        forgeAuthor: {
+          id: 'forgeAuthor',
+          name: 'Forge Author Identity',
+        },
+        forgeCommitter: {
+          id: 'forgeCommitter',
+          name: 'Forge Committer Identity',
+        },
+        forgeServerAsCommitter: {
+          id: 'forgeServerAsCommitter',
+          name: 'Forge Server Identity',
+        },
+        owner: {
+          id: 'owner',
+          name: 'Owner',
+        },
+        push: {
+          id: 'push',
+          name: 'Push',
+        },
+        pushMerge: {
+          id: 'pushMerge',
+          name: 'Push Merge Commit',
+        },
+        read: {
+          id: 'read',
+          name: 'Read',
+        },
+        rebase: {
+          id: 'rebase',
+          name: 'Rebase',
+        },
+        revert: {
+          id: 'revert',
+          name: 'Revert',
+        },
+        removeReviewer: {
+          id: 'removeReviewer',
+          name: 'Remove Reviewer',
+        },
+        submit: {
+          id: 'submit',
+          name: 'Submit',
+        },
+        submitAs: {
+          id: 'submitAs',
+          name: 'Submit (On Behalf Of)',
+        },
+        toggleWipState: {
+          id: 'toggleWipState',
+          name: 'Toggle Work In Progress State',
+        },
+        viewPrivateChanges: {
+          id: 'viewPrivateChanges',
+          name: 'View Private Changes',
+        },
+      },
+    },
+  },
+
+  /**
+   * @param {!Object} obj
+   * @return {!Array} returns a sorted array sorted by the id of the original
+   *    object.
+   */
+  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.
+          a.id.localeCompare(b.id)
+        );
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.AccessBehavior = AccessBehavior;
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..c5f3f94 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
@@ -17,55 +17,56 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-access-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <test-element></test-element>
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-access-behavior tests', () => {
-    let element;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {AccessBehavior} from './gr-access-behavior.js';
+suite('gr-access-behavior tests', () => {
+  let element;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.AccessBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('toSortedArray', () => {
-      const rules = {
-        'global:Project-Owners': {
-          action: 'ALLOW', force: false,
-        },
-        '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-          action: 'ALLOW', force: false,
-        },
-      };
-      const expectedResult = [
-        {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
-          action: 'ALLOW', force: false,
-        }},
-        {id: 'global:Project-Owners', value: {
-          action: 'ALLOW', force: false,
-        }},
-      ];
-      assert.deepEqual(element.toSortedArray(rules), expectedResult);
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [AccessBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  test('toSortedArray', () => {
+    const rules = {
+      'global:Project-Owners': {
+        action: 'ALLOW', force: false,
+      },
+      '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+        action: 'ALLOW', force: false,
+      },
+    };
+    const expectedResult = [
+      {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
+        action: 'ALLOW', force: false,
+      }},
+      {id: 'global:Project-Owners', value: {
+        action: 'ALLOW', force: false,
+      }},
+    ];
+    assert.deepEqual(element.toSortedArray(rules), expectedResult);
+  });
+});
 </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
deleted file mode 100644
index 182d242..0000000
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
+++ /dev/null
@@ -1,205 +0,0 @@
-<!--
-@license
-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.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  const ADMIN_LINKS = [{
-    name: 'Repositories',
-    noBaseUrl: true,
-    url: '/admin/repos',
-    view: 'gr-repo-list',
-    viewableToAll: true,
-  }, {
-    name: 'Groups',
-    section: 'Groups',
-    noBaseUrl: true,
-    url: '/admin/groups',
-    view: 'gr-admin-group-list',
-  }, {
-    name: 'Plugins',
-    capability: 'viewPlugins',
-    section: 'Plugins',
-    noBaseUrl: true,
-    url: '/admin/plugins',
-    view: 'gr-plugin-list',
-  }];
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.AdminNavBehavior */
-  Gerrit.AdminNavBehavior = {
-    /**
-     * @param {!Object} account
-     * @param {!Function} getAccountCapabilities
-     * @param {!Function} getAdminMenuLinks
-     *  Possible aguments in options:
-     *    repoName?: string
-     *    groupId?: string,
-     *    groupName?: string,
-     *    groupIsInternal?: boolean,
-     *    isAdmin?: boolean,
-     *    groupOwner?: boolean,
-     * @param {!Object=} opt_options
-     * @return {Promise<!Object>}
-     */
-    getAdminLinks(account, getAccountCapabilities, getAdminMenuLinks,
-        opt_options) {
-      if (!account) {
-        return Promise.resolve(this._filterLinks(link => link.viewableToAll,
-            getAdminMenuLinks, opt_options));
-      }
-      return getAccountCapabilities()
-          .then(capabilities => {
-            return this._filterLinks(link => {
-              return !link.capability ||
-                  capabilities.hasOwnProperty(link.capability);
-            }, getAdminMenuLinks, opt_options);
-          });
-    },
-
-    /**
-     * @param {!Function} filterFn
-     * @param {!Function} getAdminMenuLinks
-     *  Possible aguments in options:
-     *    repoName?: string
-     *    groupId?: string,
-     *    groupName?: string,
-     *    groupIsInternal?: boolean,
-     *    isAdmin?: boolean,
-     *    groupOwner?: boolean,
-     * @param {!Object|undefined} opt_options
-     * @return {Promise<!Object>}
-     */
-    _filterLinks(filterFn, getAdminMenuLinks, opt_options) {
-      let links = ADMIN_LINKS.slice(0);
-      let expandedSection;
-
-      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 = links.filter(filterFn);
-
-      const filteredLinks = [];
-      const repoName = opt_options && opt_options.repoName;
-      const groupId = opt_options && opt_options.groupId;
-      const groupName = opt_options && opt_options.groupName;
-      const groupIsInternal = opt_options && opt_options.groupIsInternal;
-      const isAdmin = opt_options && opt_options.isAdmin;
-      const groupOwner = opt_options && opt_options.groupOwner;
-
-      // Don't bother to get sub-navigation items if only the top level links
-      // are needed. This is used by the main header dropdown.
-      if (!repoName && !groupId) { return {links, expandedSection}; }
-
-      // Otherwise determine the full set of links and return both the full
-      // set in addition to the subsection that should be displayed if it
-      // exists.
-      for (const link of links) {
-        const linkCopy = Object.assign({}, link);
-        if (linkCopy.name === 'Repositories' && repoName) {
-          linkCopy.subsection = this.getRepoSubsections(repoName);
-          expandedSection = linkCopy.subsection;
-        } else if (linkCopy.name === 'Groups' && groupId && groupName) {
-          linkCopy.subsection = this.getGroupSubsections(groupId, groupName,
-              groupIsInternal, isAdmin, groupOwner);
-          expandedSection = linkCopy.subsection;
-        }
-        filteredLinks.push(linkCopy);
-      }
-      return {links: filteredLinks, expandedSection};
-    },
-
-    getGroupSubsections(groupId, groupName, groupIsInternal, isAdmin,
-        groupOwner) {
-      const subsection = {
-        name: groupName,
-        view: Gerrit.Nav.View.GROUP,
-        url: Gerrit.Nav.getUrlForGroup(groupId),
-        children: [],
-      };
-      if (groupIsInternal) {
-        subsection.children.push({
-          name: 'Members',
-          detailType: Gerrit.Nav.GroupDetailView.MEMBERS,
-          view: Gerrit.Nav.View.GROUP,
-          url: Gerrit.Nav.getUrlForGroupMembers(groupId),
-        });
-      }
-      if (groupIsInternal && (isAdmin || groupOwner)) {
-        subsection.children.push(
-            {
-              name: 'Audit Log',
-              detailType: Gerrit.Nav.GroupDetailView.LOG,
-              view: Gerrit.Nav.View.GROUP,
-              url: Gerrit.Nav.getUrlForGroupLog(groupId),
-            }
-        );
-      }
-      return subsection;
-    },
-
-    getRepoSubsections(repoName) {
-      return {
-        name: repoName,
-        view: Gerrit.Nav.View.REPO,
-        url: Gerrit.Nav.getUrlForRepo(repoName),
-        children: [{
-          name: 'Access',
-          view: Gerrit.Nav.View.REPO,
-          detailType: Gerrit.Nav.RepoDetailView.ACCESS,
-          url: Gerrit.Nav.getUrlForRepoAccess(repoName),
-        },
-        {
-          name: 'Commands',
-          view: Gerrit.Nav.View.REPO,
-          detailType: Gerrit.Nav.RepoDetailView.COMMANDS,
-          url: Gerrit.Nav.getUrlForRepoCommands(repoName),
-        },
-        {
-          name: 'Branches',
-          view: Gerrit.Nav.View.REPO,
-          detailType: Gerrit.Nav.RepoDetailView.BRANCHES,
-          url: Gerrit.Nav.getUrlForRepoBranches(repoName),
-        },
-        {
-          name: 'Tags',
-          view: Gerrit.Nav.View.REPO,
-          detailType: Gerrit.Nav.RepoDetailView.TAGS,
-          url: Gerrit.Nav.getUrlForRepoTags(repoName),
-        },
-        {
-          name: 'Dashboards',
-          view: Gerrit.Nav.View.REPO,
-          detailType: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-          url: Gerrit.Nav.getUrlForRepoDashboards(repoName),
-        }],
-      };
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
new file mode 100644
index 0000000..3c48fbe
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
@@ -0,0 +1,210 @@
+import {GerritNav} from '../../elements/core/gr-navigation/gr-navigation.js';
+
+/**
+ * @license
+ * 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.
+ */
+
+const ADMIN_LINKS = [{
+  name: 'Repositories',
+  noBaseUrl: true,
+  url: '/admin/repos',
+  view: 'gr-repo-list',
+  viewableToAll: true,
+}, {
+  name: 'Groups',
+  section: 'Groups',
+  noBaseUrl: true,
+  url: '/admin/groups',
+  view: 'gr-admin-group-list',
+}, {
+  name: 'Plugins',
+  capability: 'viewPlugins',
+  section: 'Plugins',
+  noBaseUrl: true,
+  url: '/admin/plugins',
+  view: 'gr-plugin-list',
+}];
+
+window.Gerrit = window.Gerrit || {};
+
+/** @polymerBehavior Gerrit.AdminNavBehavior */
+export const AdminNavBehavior = {
+  /**
+   * @param {!Object} account
+   * @param {!Function} getAccountCapabilities
+   * @param {!Function} getAdminMenuLinks
+   *  Possible aguments in options:
+   *    repoName?: string
+   *    groupId?: string,
+   *    groupName?: string,
+   *    groupIsInternal?: boolean,
+   *    isAdmin?: boolean,
+   *    groupOwner?: boolean,
+   * @param {!Object=} opt_options
+   * @return {Promise<!Object>}
+   */
+  getAdminLinks(account, getAccountCapabilities, getAdminMenuLinks,
+      opt_options) {
+    if (!account) {
+      return Promise.resolve(this._filterLinks(link => link.viewableToAll,
+          getAdminMenuLinks, opt_options));
+    }
+    return getAccountCapabilities()
+        .then(capabilities => this._filterLinks(
+            link => !link.capability
+            || capabilities.hasOwnProperty(link.capability),
+            getAdminMenuLinks,
+            opt_options));
+  },
+
+  /**
+   * @param {!Function} filterFn
+   * @param {!Function} getAdminMenuLinks
+   *  Possible aguments in options:
+   *    repoName?: string
+   *    groupId?: string,
+   *    groupName?: string,
+   *    groupIsInternal?: boolean,
+   *    isAdmin?: boolean,
+   *    groupOwner?: boolean,
+   * @param {!Object|undefined} opt_options
+   * @return {Promise<!Object>}
+   */
+  _filterLinks(filterFn, getAdminMenuLinks, opt_options) {
+    let links = ADMIN_LINKS.slice(0);
+    let expandedSection;
+
+    const isExernalLink = link => link.url[0] !== '/';
+
+    // Append top-level links that are defined by plugins.
+    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);
+
+    const filteredLinks = [];
+    const repoName = opt_options && opt_options.repoName;
+    const groupId = opt_options && opt_options.groupId;
+    const groupName = opt_options && opt_options.groupName;
+    const groupIsInternal = opt_options && opt_options.groupIsInternal;
+    const isAdmin = opt_options && opt_options.isAdmin;
+    const groupOwner = opt_options && opt_options.groupOwner;
+
+    // Don't bother to get sub-navigation items if only the top level links
+    // are needed. This is used by the main header dropdown.
+    if (!repoName && !groupId) { return {links, expandedSection}; }
+
+    // Otherwise determine the full set of links and return both the full
+    // set in addition to the subsection that should be displayed if it
+    // exists.
+    for (const link of links) {
+      const linkCopy = Object.assign({}, link);
+      if (linkCopy.name === 'Repositories' && repoName) {
+        linkCopy.subsection = this.getRepoSubsections(repoName);
+        expandedSection = linkCopy.subsection;
+      } else if (linkCopy.name === 'Groups' && groupId && groupName) {
+        linkCopy.subsection = this.getGroupSubsections(groupId, groupName,
+            groupIsInternal, isAdmin, groupOwner);
+        expandedSection = linkCopy.subsection;
+      }
+      filteredLinks.push(linkCopy);
+    }
+    return {links: filteredLinks, expandedSection};
+  },
+
+  getGroupSubsections(groupId, groupName, groupIsInternal, isAdmin,
+      groupOwner) {
+    const subsection = {
+      name: groupName,
+      view: GerritNav.View.GROUP,
+      url: GerritNav.getUrlForGroup(groupId),
+      children: [],
+    };
+    if (groupIsInternal) {
+      subsection.children.push({
+        name: 'Members',
+        detailType: GerritNav.GroupDetailView.MEMBERS,
+        view: GerritNav.View.GROUP,
+        url: GerritNav.getUrlForGroupMembers(groupId),
+      });
+    }
+    if (groupIsInternal && (isAdmin || groupOwner)) {
+      subsection.children.push(
+          {
+            name: 'Audit Log',
+            detailType: GerritNav.GroupDetailView.LOG,
+            view: GerritNav.View.GROUP,
+            url: GerritNav.getUrlForGroupLog(groupId),
+          }
+      );
+    }
+    return subsection;
+  },
+
+  getRepoSubsections(repoName) {
+    return {
+      name: repoName,
+      view: GerritNav.View.REPO,
+      url: GerritNav.getUrlForRepo(repoName),
+      children: [{
+        name: 'Access',
+        view: GerritNav.View.REPO,
+        detailType: GerritNav.RepoDetailView.ACCESS,
+        url: GerritNav.getUrlForRepoAccess(repoName),
+      },
+      {
+        name: 'Commands',
+        view: GerritNav.View.REPO,
+        detailType: GerritNav.RepoDetailView.COMMANDS,
+        url: GerritNav.getUrlForRepoCommands(repoName),
+      },
+      {
+        name: 'Branches',
+        view: GerritNav.View.REPO,
+        detailType: GerritNav.RepoDetailView.BRANCHES,
+        url: GerritNav.getUrlForRepoBranches(repoName),
+      },
+      {
+        name: 'Tags',
+        view: GerritNav.View.REPO,
+        detailType: GerritNav.RepoDetailView.TAGS,
+        url: GerritNav.getUrlForRepoTags(repoName),
+      },
+      {
+        name: 'Dashboards',
+        view: GerritNav.View.REPO,
+        detailType: GerritNav.RepoDetailView.DASHBOARDS,
+        url: GerritNav.getUrlForRepoDashboards(repoName),
+      }],
+    };
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.AdminNavBehavior = AdminNavBehavior;
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..3f58499 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
@@ -17,353 +17,353 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-admin-nav-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <test-element></test-element>
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-admin-nav-behavior tests', () => {
-    let element;
-    let sandbox;
-    let capabilityStub;
-    let menuLinkStub;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {AdminNavBehavior} from './gr-admin-nav-behavior.js';
+suite('gr-admin-nav-behavior tests', () => {
+  let element;
+  let sandbox;
+  let capabilityStub;
+  let menuLinkStub;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [
-          Gerrit.AdminNavBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      capabilityStub = sinon.stub();
-      menuLinkStub = sinon.stub().returns([]);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    const testAdminLinks = (account, options, expected, done) => {
-      element.getAdminLinks(account,
-          capabilityStub,
-          menuLinkStub,
-          options)
-          .then(res => {
-            assert.equal(expected.totalLength, res.links.length);
-            assert.equal(res.links[0].name, 'Repositories');
-            // Repos
-            if (expected.groupListShown) {
-              assert.equal(res.links[1].name, 'Groups');
-            }
-
-            if (expected.pluginListShown) {
-              assert.equal(res.links[2].name, 'Plugins');
-              assert.isNotOk(res.links[2].subsection);
-            }
-
-            if (expected.projectPageShown) {
-              assert.isOk(res.links[0].subsection);
-              assert.equal(res.links[0].subsection.children.length, 5);
-            } else {
-              assert.isNotOk(res.links[0].subsection);
-            }
-            // Groups
-            if (expected.groupPageShown) {
-              assert.isOk(res.links[1].subsection);
-              assert.equal(res.links[1].subsection.children.length,
-                  expected.groupSubpageLength);
-            } else if ( expected.totalLength > 1) {
-              assert.isNotOk(res.links[1].subsection);
-            }
-
-            if (expected.pluginGeneratedLinks) {
-              for (const link of expected.pluginGeneratedLinks) {
-                const linkMatch = res.links.find(l => {
-                  return (l.url === link.url && l.name === link.text);
-                });
-                assert.isTrue(!!linkMatch);
-
-                // External links should open in new tab.
-                if (link.url[0] !== '/') {
-                  assert.equal(linkMatch.target, '_blank');
-                } else {
-                  assert.isNotOk(linkMatch.target);
-                }
-              }
-            }
-
-            // Current section
-            if (expected.projectPageShown || expected.groupPageShown) {
-              assert.isOk(res.expandedSection);
-              assert.isOk(res.expandedSection.children);
-            } else {
-              assert.isNotOk(res.expandedSection);
-            }
-            if (expected.projectPageShown) {
-              assert.equal(res.expandedSection.name, 'my-repo');
-              assert.equal(res.expandedSection.children.length, 5);
-            } else if (expected.groupPageShown) {
-              assert.equal(res.expandedSection.name, 'my-group');
-              assert.equal(res.expandedSection.children.length,
-                  expected.groupSubpageLength);
-            }
-            done();
-          });
-    };
-
-    suite('logged out', () => {
-      let account;
-      let expected;
-
-      setup(() => {
-        expected = {
-          groupListShown: false,
-          groupPageShown: false,
-          pluginListShown: false,
-        };
-      });
-
-      test('without a specific repo or group', done => {
-        let options;
-        expected = Object.assign(expected, {
-          totalLength: 1,
-          projectPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with a repo', done => {
-        const options = {repoName: 'my-repo'};
-        expected = Object.assign(expected, {
-          totalLength: 1,
-          projectPageShown: true,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with plugin generated links', done => {
-        let options;
-        const generatedLinks = [
-          {text: 'internal link text', url: '/internal/link/url'},
-          {text: 'external link text', url: 'http://external/link/url'},
-        ];
-        menuLinkStub.returns(generatedLinks);
-        expected = Object.assign(expected, {
-          totalLength: 3,
-          projectPageShown: false,
-          pluginGeneratedLinks: generatedLinks,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-    suite('no plugin capability logged in', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        expected = {
-          totalLength: 2,
-          pluginListShown: false,
-        };
-        capabilityStub.returns(Promise.resolve({}));
-      });
-
-      test('without a specific project or group', done => {
-        let options;
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupListShown: true,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with a repo', done => {
-        const account = {
-          name: 'test-user',
-        };
-        const options = {repoName: 'my-repo'};
-        expected = Object.assign(expected, {
-          projectPageShown: true,
-          groupListShown: true,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-    suite('view plugin capability logged in', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        capabilityStub.returns(Promise.resolve({viewPlugins: true}));
-        expected = {
-          totalLength: 3,
-          groupListShown: true,
-          pluginListShown: true,
-        };
-      });
-
-      test('without a specific repo or group', done => {
-        let options;
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('with a repo', done => {
-        const options = {repoName: 'my-repo'};
-        expected = Object.assign(expected, {
-          projectPageShown: true,
-          groupPageShown: false,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('admin with internal group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: true,
-          isAdmin: true,
-          groupOwner: false,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 2,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('group owner with internal group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: true,
-          isAdmin: false,
-          groupOwner: true,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 2,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('non owner or admin with internal group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: true,
-          isAdmin: false,
-          groupOwner: false,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 1,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-
-      test('admin with external group', done => {
-        const options = {
-          groupId: 'a15262',
-          groupName: 'my-group',
-          groupIsInternal: false,
-          isAdmin: true,
-          groupOwner: true,
-        };
-        expected = Object.assign(expected, {
-          projectPageShown: false,
-          groupPageShown: true,
-          groupSubpageLength: 0,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-
-    suite('view plugin screen with plugin capability', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        capabilityStub.returns(Promise.resolve({pluginCapability: true}));
-        expected = {};
-      });
-
-      test('with plugin with capabilities', done => {
-        let options;
-        const generatedLinks = [
-          {text: 'without capability', url: '/without'},
-          {text: 'with capability', url: '/with', capability: 'pluginCapability'},
-        ];
-        menuLinkStub.returns(generatedLinks);
-        expected = Object.assign(expected, {
-          totalLength: 4,
-          pluginGeneratedLinks: generatedLinks,
-        });
-        testAdminLinks(account, options, expected, done);
-      });
-    });
-
-
-    suite('view plugin screen without plugin capability', () => {
-      const account = {
-        name: 'test-user',
-      };
-      let expected;
-
-      setup(() => {
-        capabilityStub.returns(Promise.resolve({}));
-        expected = {};
-      });
-
-      test('with plugin with capabilities', done => {
-        let options;
-        const generatedLinks = [
-          {text: 'without capability', url: '/without'},
-          {text: 'with capability',
-            url: '/with',
-            capability: 'pluginCapability'},
-        ];
-        menuLinkStub.returns(generatedLinks);
-        expected = Object.assign(expected, {
-          totalLength: 3,
-          pluginGeneratedLinks: [generatedLinks[0]],
-        });
-        testAdminLinks(account, options, expected, done);
-      });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [
+        AdminNavBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    capabilityStub = sinon.stub();
+    menuLinkStub = sinon.stub().returns([]);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  const testAdminLinks = (account, options, expected, done) => {
+    element.getAdminLinks(account,
+        capabilityStub,
+        menuLinkStub,
+        options)
+        .then(res => {
+          assert.equal(expected.totalLength, res.links.length);
+          assert.equal(res.links[0].name, 'Repositories');
+          // Repos
+          if (expected.groupListShown) {
+            assert.equal(res.links[1].name, 'Groups');
+          }
+
+          if (expected.pluginListShown) {
+            assert.equal(res.links[2].name, 'Plugins');
+            assert.isNotOk(res.links[2].subsection);
+          }
+
+          if (expected.projectPageShown) {
+            assert.isOk(res.links[0].subsection);
+            assert.equal(res.links[0].subsection.children.length, 5);
+          } else {
+            assert.isNotOk(res.links[0].subsection);
+          }
+          // Groups
+          if (expected.groupPageShown) {
+            assert.isOk(res.links[1].subsection);
+            assert.equal(res.links[1].subsection.children.length,
+                expected.groupSubpageLength);
+          } else if ( expected.totalLength > 1) {
+            assert.isNotOk(res.links[1].subsection);
+          }
+
+          if (expected.pluginGeneratedLinks) {
+            for (const link of expected.pluginGeneratedLinks) {
+              const linkMatch = res.links
+                  .find(l => (l.url === link.url && l.name === link.text));
+              assert.isTrue(!!linkMatch);
+
+              // External links should open in new tab.
+              if (link.url[0] !== '/') {
+                assert.equal(linkMatch.target, '_blank');
+              } else {
+                assert.isNotOk(linkMatch.target);
+              }
+            }
+          }
+
+          // Current section
+          if (expected.projectPageShown || expected.groupPageShown) {
+            assert.isOk(res.expandedSection);
+            assert.isOk(res.expandedSection.children);
+          } else {
+            assert.isNotOk(res.expandedSection);
+          }
+          if (expected.projectPageShown) {
+            assert.equal(res.expandedSection.name, 'my-repo');
+            assert.equal(res.expandedSection.children.length, 5);
+          } else if (expected.groupPageShown) {
+            assert.equal(res.expandedSection.name, 'my-group');
+            assert.equal(res.expandedSection.children.length,
+                expected.groupSubpageLength);
+          }
+          done();
+        });
+  };
+
+  suite('logged out', () => {
+    let account;
+    let expected;
+
+    setup(() => {
+      expected = {
+        groupListShown: false,
+        groupPageShown: false,
+        pluginListShown: false,
+      };
+    });
+
+    test('without a specific repo or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: true,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with plugin generated links', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'internal link text', url: '/internal/link/url'},
+        {text: 'external link text', url: 'http://external/link/url'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        projectPageShown: false,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('no plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      expected = {
+        totalLength: 2,
+        pluginListShown: false,
+      };
+      capabilityStub.returns(Promise.resolve({}));
+    });
+
+    test('without a specific project or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const account = {
+        name: 'test-user',
+      };
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({viewPlugins: true}));
+      expected = {
+        totalLength: 3,
+        groupListShown: true,
+        pluginListShown: true,
+      };
+    });
+
+    test('without a specific repo or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('admin with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: true,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('group owner with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('non owner or admin with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 1,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('admin with external group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: false,
+        isAdmin: true,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 0,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin screen with plugin capability', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({pluginCapability: true}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability',
+          url: '/with',
+          capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 4,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin screen without plugin capability', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability',
+          url: '/with',
+          capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        pluginGeneratedLinks: [generatedLinks[0]],
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index f81fef0..0000000
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
+++ /dev/null
@@ -1,81 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.ChangeTableBehavior */
-  Gerrit.ChangeTableBehavior = {
-    properties: {
-      columnNames: {
-        type: Array,
-        value: [
-          'Subject',
-          'Status',
-          'Owner',
-          'Assignee',
-          'Repo',
-          'Branch',
-          'Updated',
-          'Size',
-        ],
-        readOnly: true,
-      },
-    },
-
-    /**
-     * Returns the complement to the given column array
-     *
-     * @param {Array} columns
-     * @return {!Array}
-     */
-    getComplementColumns(columns) {
-      return this.columnNames.filter(column => {
-        return !columns.includes(column);
-      });
-    },
-
-    /**
-     * @param {string} columnToCheck
-     * @param {!Array} columnsToDisplay
-     * @return {boolean}
-     */
-    isColumnHidden(columnToCheck, columnsToDisplay) {
-      return !columnsToDisplay.includes(columnToCheck);
-    },
-
-    /**
-     * The Project column was renamed to Repo, but some users may have
-     * preferences that use its old name. If that column is found, rename it
-     * before use.
-     *
-     * @param {!Array<string>} columns
-     * @return {!Array<string>} If the column was renamed, returns a new array
-     *     with the corrected name. Otherwise, it returns the original param.
-     */
-    getVisibleColumns(columns) {
-      const projectIndex = columns.indexOf('Project');
-      if (projectIndex === -1) { return columns; }
-      const newColumns = columns.slice(0);
-      newColumns[projectIndex] = 'Repo';
-      return newColumns;
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
new file mode 100644
index 0000000..6c469a5
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** @polymerBehavior Gerrit.ChangeTableBehavior */
+export const ChangeTableBehavior = {
+  properties: {
+    columnNames: {
+      type: Array,
+      value: [
+        'Subject',
+        'Status',
+        'Owner',
+        'Assignee',
+        'Reviewers',
+        'Comments',
+        'Repo',
+        'Branch',
+        'Updated',
+        'Size',
+      ],
+      readOnly: true,
+    },
+  },
+
+  /**
+   * Returns the complement to the given column array
+   *
+   * @param {Array} columns
+   * @return {!Array}
+   */
+  getComplementColumns(columns) {
+    return this.columnNames.filter(column => !columns.includes(column));
+  },
+
+  /**
+   * @param {string} columnToCheck
+   * @param {!Array} columnsToDisplay
+   * @return {boolean}
+   */
+  isColumnHidden(columnToCheck, columnsToDisplay) {
+    if ([columnsToDisplay, columnToCheck].some(arg => arg === undefined)) {
+      return false;
+    }
+    return !columnsToDisplay.includes(columnToCheck);
+  },
+
+  /**
+   * Is the column disabled by a server config or experiment? For example the
+   * assignee feature might be disabled and thus the corresponding column is
+   * also disabled.
+   *
+   * @param {string} column
+   * @param {Object} config
+   * @param {!Array<string>} experiments
+   * @return {boolean}
+   */
+  isColumnEnabled(column, config, experiments) {
+    if (!config || !config.change) return true;
+    if (column === 'Assignee') return !!config.change.enable_assignee;
+    if (column === 'Comments') return experiments.includes('comments-column');
+    if (column === 'Reviewers') return !!config.change.enable_attention_set;
+    return true;
+  },
+
+  /**
+   * @param {!Array<string>} columns
+   * @param {Object} config
+   * @param {!Array<string>} experiments
+   * @return {!Array<string>} enabled columns, see isColumnEnabled().
+   */
+  getEnabledColumns(columns, config, experiments) {
+    return columns.filter(
+        col => this.isColumnEnabled(col, config, experiments));
+  },
+
+  /**
+   * The Project column was renamed to Repo, but some users may have
+   * preferences that use its old name. If that column is found, rename it
+   * before use.
+   *
+   * @param {!Array<string>} columns
+   * @return {!Array<string>} If the column was renamed, returns a new array
+   *     with the corrected name. Otherwise, it returns the original param.
+   */
+  getVisibleColumns(columns) {
+    const projectIndex = columns.indexOf('Project');
+    if (projectIndex === -1) { return columns; }
+    const newColumns = columns.slice(0);
+    newColumns[projectIndex] = 'Repo';
+    return newColumns;
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.ChangeTableBehavior = ChangeTableBehavior;
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..3c5aedd 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
@@ -17,15 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-table-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <test-element></test-element>
@@ -40,86 +38,93 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-table-behavior tests', () => {
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {ChangeTableBehavior} from './gr-change-table-behavior.js';
+suite('gr-change-table-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.ChangeTableBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-    });
-
-    test('getComplementColumns', () => {
-      let columns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-      assert.deepEqual(element.getComplementColumns(columns), []);
-
-      columns = [
-        'Subject',
-        'Status',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Size',
-      ];
-      assert.deepEqual(element.getComplementColumns(columns),
-          ['Owner', 'Updated']);
-    });
-
-    test('isColumnHidden', () => {
-      const columnToCheck = 'Repo';
-      let columnsToDisplay = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-      assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
-
-      columnsToDisplay = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-      assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
-    });
-
-    test('getVisibleColumns maps Project to Repo', () => {
-      const columns = [
-        'Subject',
-        'Status',
-        'Owner',
-      ];
-      assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
-      assert.deepEqual(
-          element.getVisibleColumns(columns.concat(['Project'])),
-          columns.slice(0).concat(['Repo']));
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [ChangeTableBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+  });
+
+  test('getComplementColumns', () => {
+    let columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.deepEqual(element.getComplementColumns(columns), []);
+
+    columns = [
+      'Subject',
+      'Status',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Size',
+    ];
+    assert.deepEqual(element.getComplementColumns(columns),
+        ['Owner', 'Updated']);
+  });
+
+  test('isColumnHidden', () => {
+    const columnToCheck = 'Repo';
+    let columnsToDisplay = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
+
+    columnsToDisplay = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
+  });
+
+  test('getVisibleColumns maps Project to Repo', () => {
+    const columns = [
+      'Subject',
+      'Status',
+      'Owner',
+    ];
+    assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
+    assert.deepEqual(
+        element.getVisibleColumns(columns.concat(['Project'])),
+        columns.slice(0).concat(['Repo']));
+  });
+});
 </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
deleted file mode 100644
index 3106fc8..0000000
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html
+++ /dev/null
@@ -1,43 +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 src="../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
-
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.DisplayNameBehavior */
-  Gerrit.DisplayNameBehavior = {
-    // TODO(dmfilippov) replace DisplayNameBehavior with GrDisplayNameUtils
-
-    /**
-     * enableEmail when true enables to fallback to using email if
-     * the account name is not avilable.
-     */
-    getUserName(config, account, enableEmail) {
-      return GrDisplayNameUtils.getUserName(config, account, enableEmail);
-    },
-
-    getGroupDisplayName(group) {
-      return GrDisplayNameUtils.getGroupDisplayName(group);
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
new file mode 100644
index 0000000..607499b
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {GrDisplayNameUtils} from '../../scripts/gr-display-name-utils/gr-display-name-utils.js';
+
+/** @polymerBehavior Gerrit.DisplayNameBehavior */
+export const DisplayNameBehavior = {
+  // TODO(dmfilippov) replace DisplayNameBehavior with GrDisplayNameUtils
+
+  getUserName(config, account) {
+    return GrDisplayNameUtils.getUserName(config, account);
+  },
+
+  getDisplayName(config, account) {
+    return GrDisplayNameUtils.getDisplayName(config, account);
+  },
+
+  getGroupDisplayName(group) {
+    return GrDisplayNameUtils.getGroupDisplayName(group);
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.DisplayNameBehavior = DisplayNameBehavior;
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..fa72c40 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
@@ -17,83 +17,84 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-display-name-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <test-element-anon></test-element-anon>
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-display-name-behavior tests', () => {
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    const config = {
-      user: {
-        anonymous_coward_name: 'Anonymous Coward',
-      },
-    };
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {DisplayNameBehavior} from './gr-display-name-behavior.js';
+suite('gr-display-name-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  const config = {
+    user: {
+      anonymous_coward_name: 'Anonymous Coward',
+    },
+  };
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element-anon',
-        behaviors: [
-          Gerrit.DisplayNameBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('getUserName name only', () => {
-      const account = {
-        name: 'test-name',
-      };
-      assert.deepEqual(element.getUserName(config, account, true), 'test-name');
-    });
-
-    test('getUserName username only', () => {
-      const account = {
-        username: 'test-user',
-      };
-      assert.deepEqual(element.getUserName(config, account, true), 'test-user');
-    });
-
-    test('getUserName email only', () => {
-      const account = {
-        email: 'test-user@test-url.com',
-      };
-      assert.deepEqual(element.getUserName(config, account, true),
-          'test-user@test-url.com');
-    });
-
-    test('getUserName returns not Anonymous Coward as the anon name', () => {
-      assert.deepEqual(element.getUserName(config, null, true), 'Anonymous');
-    });
-
-    test('getUserName for the config returning the anon name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Test Anon',
-        },
-      };
-      assert.deepEqual(element.getUserName(config, null, true), 'Test Anon');
-    });
-
-    test('getGroupDisplayName', () => {
-      assert.equal(element.getGroupDisplayName({name: 'Some user name'}),
-          'Some user name (group)');
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element-anon',
+      behaviors: [
+        DisplayNameBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  test('getUserName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.equal(element.getUserName(config, account), 'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.equal(element.getUserName(config, account), 'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account = {
+      email: 'test-user@test-url.com',
+    };
+    assert.equal(element.getUserName(config, account),
+        'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.equal(element.getUserName(config, null), 'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
+    const config = {
+      user: {
+        anonymous_coward_name: 'Test Anon',
+      },
+    };
+    assert.equal(element.getUserName(config, null), 'Test Anon');
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(element.getGroupDisplayName({name: 'Some user name'}),
+        'Some user name (group)');
+  });
+});
 </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
deleted file mode 100644
index b6edb57..0000000
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
+++ /dev/null
@@ -1,63 +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.
--->
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.ListViewBehavior */
-  Gerrit.ListViewBehavior = [{
-    computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
-
-    computeShownItems(items) {
-      return items.slice(0, 25);
-    },
-
-    getUrl(path, item) {
-      return this.getBaseUrl() + path + this.encodeURL(item, true);
-    },
-
-    /**
-     * @param {Object} params
-     * @return {string}
-     */
-    getFilterValue(params) {
-      if (!params) { return ''; }
-      return params.filter || '';
-    },
-
-    /**
-     * @param {Object} params
-     * @return {number}
-     */
-    getOffsetValue(params) {
-      if (params && params.offset) {
-        return params.offset;
-      }
-      return 0;
-    },
-  },
-  Gerrit.BaseUrlBehavior,
-  Gerrit.URLEncodingBehavior,
-  ];
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
new file mode 100644
index 0000000..813c64a
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+
+/** @polymerBehavior ListViewBehavior */
+export const ListViewBehavior = [{
+  computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  },
+
+  computeShownItems(items) {
+    return items.slice(0, 25);
+  },
+
+  getUrl(path, item) {
+    return this.getBaseUrl() + path + this.encodeURL(item, true);
+  },
+
+  /**
+   * @param {Object} params
+   * @return {string}
+   */
+  getFilterValue(params) {
+    if (!params) { return ''; }
+    return params.filter || '';
+  },
+
+  /**
+   * @param {Object} params
+   * @return {number}
+   */
+  getOffsetValue(params) {
+    if (params && params.offset) {
+      return params.offset;
+    }
+    return 0;
+  },
+},
+BaseUrlBehavior,
+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
+   */
+  const ListViewMixin = base => // eslint-disable-line no-unused-vars
+    class extends base {
+      computeLoadingClass(loading) {}
+
+      computeShownItems(items) {}
+    };
+}
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.ListViewBehavior = ListViewBehavior;
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..80013bf 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
@@ -17,76 +17,77 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-list-view-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <test-element></test-element>
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-list-view-behavior tests', () => {
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {ListViewBehavior} from './gr-list-view-behavior.js';
+suite('gr-list-view-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.ListViewBehavior],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('computeLoadingClass', () => {
-      assert.equal(element.computeLoadingClass(true), 'loading');
-      assert.equal(element.computeLoadingClass(false), '');
-    });
-
-    test('computeShownItems', () => {
-      const myArr = new Array(26);
-      assert.equal(element.computeShownItems(myArr).length, 25);
-    });
-
-    test('getUrl', () => {
-      assert.equal(element.getUrl('/path/to/something/', 'item'),
-          '/path/to/something/item');
-      assert.equal(element.getUrl('/path/to/something/', 'item%test'),
-          '/path/to/something/item%2525test');
-    });
-
-    test('getFilterValue', () => {
-      let params;
-      assert.equal(element.getFilterValue(params), '');
-
-      params = {filter: null};
-      assert.equal(element.getFilterValue(params), '');
-
-      params = {filter: 'test'};
-      assert.equal(element.getFilterValue(params), 'test');
-    });
-
-    test('getOffsetValue', () => {
-      let params;
-      assert.equal(element.getOffsetValue(params), 0);
-
-      params = {offset: null};
-      assert.equal(element.getOffsetValue(params), 0);
-
-      params = {offset: 1};
-      assert.equal(element.getOffsetValue(params), 1);
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [ListViewBehavior],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  test('computeLoadingClass', () => {
+    assert.equal(element.computeLoadingClass(true), 'loading');
+    assert.equal(element.computeLoadingClass(false), '');
+  });
+
+  test('computeShownItems', () => {
+    const myArr = new Array(26);
+    assert.equal(element.computeShownItems(myArr).length, 25);
+  });
+
+  test('getUrl', () => {
+    assert.equal(element.getUrl('/path/to/something/', 'item'),
+        '/path/to/something/item');
+    assert.equal(element.getUrl('/path/to/something/', 'item%test'),
+        '/path/to/something/item%2525test');
+  });
+
+  test('getFilterValue', () => {
+    let params;
+    assert.equal(element.getFilterValue(params), '');
+
+    params = {filter: null};
+    assert.equal(element.getFilterValue(params), '');
+
+    params = {filter: 'test'};
+    assert.equal(element.getFilterValue(params), 'test');
+  });
+
+  test('getOffsetValue', () => {
+    let params;
+    assert.equal(element.getOffsetValue(params), 0);
+
+    params = {offset: null};
+    assert.equal(element.getOffsetValue(params), 0);
+
+    params = {offset: 1};
+    assert.equal(element.getOffsetValue(params), 1);
+  });
+});
 </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
deleted file mode 100644
index 28d6990..0000000
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ /dev/null
@@ -1,278 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  // Tags identifying ChangeMessages that move change into WIP state.
-  const WIP_TAGS = [
-    'autogenerated:gerrit:newWipPatchSet',
-    'autogenerated:gerrit:setWorkInProgress',
-  ];
-
-  // Tags identifying ChangeMessages that move change out of WIP state.
-  const READY_TAGS = [
-    'autogenerated:gerrit:setReadyForReview',
-  ];
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.PatchSetBehavior*/
-  Gerrit.PatchSetBehavior = {
-    EDIT_NAME: 'edit',
-    PARENT_NAME: 'PARENT',
-
-    /**
-     * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
-     * this function checks for patchNum equality.
-     *
-     * @param {string|number} a
-     * @param {string|number|undefined} b Undefined sometimes because
-     *    computeLatestPatchNum can return undefined.
-     * @return {boolean}
-     */
-    patchNumEquals(a, b) {
-      return a + '' === b + '';
-    },
-
-    /**
-     * Whether the given patch is a numbered parent of a merge (i.e. a negative
-     * number).
-     *
-     * @param  {string|number} n
-     * @return {Boolean}
-     */
-    isMergeParent(n) {
-      return (n + '')[0] === '-';
-    },
-
-    /**
-     * Given an object of revisions, get a particular revision based on patch
-     * num.
-     *
-     * @param {Object} revisions The object of revisions given by the API
-     * @param {number|string} patchNum The number index of the revision
-     * @return {Object} The correspondent revision obj from {revisions}
-     */
-    getRevisionByPatchNum(revisions, patchNum) {
-      for (const rev of Object.values(revisions || {})) {
-        if (Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum)) {
-          return rev;
-        }
-      }
-    },
-
-    /**
-     * Find change edit base revision if change edit exists.
-     *
-     * @param {!Array<!Object>} revisions The revisions array.
-     * @return {Object} change edit parent revision or null if change edit
-     *     doesn't exist.
-     */
-    findEditParentRevision(revisions) {
-      const editInfo =
-          revisions.find(info => info._number ===
-              Gerrit.PatchSetBehavior.EDIT_NAME);
-
-      if (!editInfo) { return null; }
-
-      return revisions.find(info => info._number === editInfo.basePatchNum) ||
-          null;
-    },
-
-    /**
-     * Find change edit base patch set number if change edit exists.
-     *
-     * @param {!Array<!Object>} revisions The revisions array.
-     * @return {number} Change edit patch set number or -1.
-     */
-    findEditParentPatchNum(revisions) {
-      const revisionInfo =
-          Gerrit.PatchSetBehavior.findEditParentRevision(revisions);
-      return revisionInfo ? revisionInfo._number : -1;
-    },
-
-    /**
-     * Sort given revisions array according to the patch set number, in
-     * descending order.
-     * The sort algorithm is change edit aware. Change edit has patch set number
-     * equals 'edit', but must appear after the patch set it was based on.
-     * Example: change edit is based on patch set 2, and another patch set was
-     * uploaded after change edit creation, the sorted order should be:
-     * 3, edit, 2, 1.
-     *
-     * @param {!Array<!Object>} revisions The revisions array
-     * @return {!Array<!Object>} The sorted {revisions} array
-     */
-    sortRevisions(revisions) {
-      const editParent =
-          Gerrit.PatchSetBehavior.findEditParentPatchNum(revisions);
-      // 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 ?
-        2 * editParent :
-        2 * (r._number - 1) + 1;
-      return revisions.sort((a, b) => num(b) - num(a));
-    },
-
-    /**
-     * Construct a chronological list of patch sets derived from change details.
-     * Each element of this list is an object with the following properties:
-     *
-     *   * num {number} The number identifying the patch set
-     *   * desc {!string} Optional patch set description
-     *   * wip {boolean} If true, this patch set was never subject to review.
-     *   * sha {string} hash of the commit
-     *
-     * The wip property is determined by the change's current work_in_progress
-     * property and its log of change messages.
-     *
-     * @param {!Object} change The change details
-     * @return {!Array<!Object>} Sorted list of patch set objects, as described
-     *     above
-     */
-    computeAllPatchSets(change) {
-      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]);
-        });
-        patchNums =
-          Gerrit.PatchSetBehavior.sortRevisions(revisions)
-              .map(e => {
-                // TODO(kaspern): Mark which patchset an edit was made on, if an
-                // edit exists -- perhaps with a temporary description.
-                return {
-                  num: e._number,
-                  desc: e.description,
-                  sha: e.sha,
-                };
-              });
-      }
-      return Gerrit.PatchSetBehavior._computeWipForPatchSets(change, patchNums);
-    },
-
-    /**
-     * Populate the wip properties of the given list of patch sets.
-     *
-     * @param {!Object} change The change details
-     * @param {!Array<!Object>} patchNums Sorted list of patch set objects, as
-     *     generated by computeAllPatchSets
-     * @return {!Array<!Object>} The given list of patch set objects, with the
-     *     wip property set on each of them
-     */
-    _computeWipForPatchSets(change, patchNums) {
-      if (!change.messages || !change.messages.length) {
-        return patchNums;
-      }
-      const psWip = {};
-      let wip = change.work_in_progress;
-      for (let i = 0; i < change.messages.length; i++) {
-        const msg = change.messages[i];
-        if (WIP_TAGS.includes(msg.tag)) {
-          wip = true;
-        } else if (READY_TAGS.includes(msg.tag)) {
-          wip = false;
-        }
-        if (psWip[msg._revision_number] !== false) {
-          psWip[msg._revision_number] = wip;
-        }
-      }
-
-      for (let i = 0; i < patchNums.length; i++) {
-        patchNums[i].wip = psWip[patchNums[i].num];
-      }
-      return patchNums;
-    },
-
-    /** @return {number|undefined} */
-    computeLatestPatchNum(allPatchSets) {
-      if (!allPatchSets || !allPatchSets.length) { return undefined; }
-      if (allPatchSets[0].num === Gerrit.PatchSetBehavior.EDIT_NAME) {
-        return allPatchSets[1].num;
-      }
-      return allPatchSets[0].num;
-    },
-
-    /** @return {Boolean} */
-    hasEditBasedOnCurrentPatchSet(allPatchSets) {
-      if (!allPatchSets || allPatchSets.length < 2) { return false; }
-      return allPatchSets[0].num === Gerrit.PatchSetBehavior.EDIT_NAME;
-    },
-
-    /** @return {Boolean} */
-    hasEditPatchsetLoaded(patchRangeRecord) {
-      const patchRange = patchRangeRecord.base;
-      if (!patchRange) { return false; }
-      return patchRange.patchNum === Gerrit.PatchSetBehavior.EDIT_NAME ||
-          patchRange.basePatchNum === Gerrit.PatchSetBehavior.EDIT_NAME;
-    },
-
-    /**
-     * Check whether there is no newer patch than the latest patch that was
-     * available when this change was loaded.
-     *
-     * @return {Promise<!Object>} A promise that yields true if the latest patch
-     *     has been loaded, and false if a newer patch has been uploaded in the
-     *     meantime. The promise is rejected on network error.
-     */
-    fetchChangeUpdates(change, restAPI) {
-      const knownLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
-          Gerrit.PatchSetBehavior.computeAllPatchSets(change));
-      return restAPI.getChangeDetail(change._number)
-          .then(detail => {
-            if (!detail) {
-              const error = new Error('Unable to check for latest patchset.');
-              return Promise.reject(error);
-            }
-            const actualLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
-                Gerrit.PatchSetBehavior.computeAllPatchSets(detail));
-            return {
-              isLatest: actualLatest <= knownLatest,
-              newStatus: change.status !== detail.status ? detail.status : null,
-              newMessages: change.messages.length < detail.messages.length,
-            };
-          });
-    },
-
-    /**
-     * @param {number|string} patchNum
-     * @param {!Array<!Object>} revisions A sorted array of revisions.
-     *
-     * @return {number} The index of the revision with the given patchNum.
-     */
-    findSortedIndex(patchNum, revisions) {
-      revisions = revisions || [];
-      const findNum = rev => rev._number + '' === patchNum + '';
-      return revisions.findIndex(findNum);
-    },
-
-    /**
-     * Convert parent indexes from patch range expressions to numbers.
-     * For example, in a patch range expression `"-3"` becomes `3`.
-     *
-     * @param {number|string} rangeBase
-     * @return {number}
-     */
-    getParentIndex(rangeBase) {
-      return -parseInt(rangeBase + '', 10);
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
new file mode 100644
index 0000000..fafec9d
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
@@ -0,0 +1,301 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Tags identifying ChangeMessages that move change into WIP state.
+const WIP_TAGS = [
+  'autogenerated:gerrit:newWipPatchSet',
+  'autogenerated:gerrit:setWorkInProgress',
+];
+
+// Tags identifying ChangeMessages that move change out of WIP state.
+const READY_TAGS = [
+  'autogenerated:gerrit:setReadyForReview',
+];
+
+/** @polymerBehavior Gerrit.PatchSetBehavior*/
+export const PatchSetBehavior = {
+  EDIT_NAME: 'edit',
+  PARENT_NAME: 'PARENT',
+
+  /**
+   * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
+   * this function checks for patchNum equality.
+   *
+   * @param {string|number} a
+   * @param {string|number|undefined} b Undefined sometimes because
+   *    computeLatestPatchNum can return undefined.
+   * @return {boolean}
+   */
+  patchNumEquals(a, b) {
+    return a + '' === b + '';
+  },
+
+  /**
+   * Whether the given patch is a numbered parent of a merge (i.e. a negative
+   * number).
+   *
+   * @param  {string|number} n
+   * @return {boolean}
+   */
+  isMergeParent(n) {
+    return (n + '')[0] === '-';
+  },
+
+  /**
+   * Given an object of revisions, get a particular revision based on patch
+   * num.
+   *
+   * @param {Object} revisions The object of revisions given by the API
+   * @param {number|string} patchNum The number index of the revision
+   * @return {Object} The correspondent revision obj from {revisions}
+   */
+  getRevisionByPatchNum(revisions, patchNum) {
+    for (const rev of Object.values(revisions || {})) {
+      if (PatchSetBehavior.patchNumEquals(rev._number, patchNum)) {
+        return rev;
+      }
+    }
+  },
+
+  /**
+   * Find change edit base revision if change edit exists.
+   *
+   * @param {!Array<!Object>} revisions The revisions array.
+   * @return {Object} change edit parent revision or null if change edit
+   *     doesn't exist.
+   */
+  findEditParentRevision(revisions) {
+    const editInfo =
+        revisions.find(info => info._number ===
+            PatchSetBehavior.EDIT_NAME);
+
+    if (!editInfo) { return null; }
+
+    return revisions.find(info => info._number === editInfo.basePatchNum) ||
+        null;
+  },
+
+  /**
+   * Find change edit base patch set number if change edit exists.
+   *
+   * @param {!Array<!Object>} revisions The revisions array.
+   * @return {number} Change edit patch set number or -1.
+   */
+  findEditParentPatchNum(revisions) {
+    const revisionInfo =
+        PatchSetBehavior.findEditParentRevision(revisions);
+    return revisionInfo ? revisionInfo._number : -1;
+  },
+
+  /**
+   * Sort given revisions array according to the patch set number, in
+   * descending order.
+   * The sort algorithm is change edit aware. Change edit has patch set number
+   * equals 'edit', but must appear after the patch set it was based on.
+   * Example: change edit is based on patch set 2, and another patch set was
+   * uploaded after change edit creation, the sorted order should be:
+   * 3, edit, 2, 1.
+   *
+   * @param {!Array<!Object>} revisions The revisions array
+   * @return {!Array<!Object>} The sorted {revisions} array
+   */
+  sortRevisions(revisions) {
+    const editParent =
+        PatchSetBehavior.findEditParentPatchNum(revisions);
+    // 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 === PatchSetBehavior.EDIT_NAME ?
+      2 * editParent :
+      2 * (r._number - 1) + 1);
+    return revisions.sort((a, b) => num(b) - num(a));
+  },
+
+  /**
+   * Construct a chronological list of patch sets derived from change details.
+   * Each element of this list is an object with the following properties:
+   *
+   *   * num {number} The number identifying the patch set
+   *   * desc {!string} Optional patch set description
+   *   * wip {boolean} If true, this patch set was never subject to review.
+   *   * sha {string} hash of the commit
+   *
+   * The wip property is determined by the change's current work_in_progress
+   * property and its log of change messages.
+   *
+   * @param {!Object} change The change details
+   * @return {!Array<!Object>} Sorted list of patch set objects, as described
+   *     above
+   */
+  computeAllPatchSets(change) {
+    if (!change) { return []; }
+    let patchNums = [];
+    if (change.revisions && Object.keys(change.revisions).length) {
+      const revisions = Object.keys(change.revisions)
+          .map(sha => Object.assign({sha}, change.revisions[sha]));
+      patchNums =
+        PatchSetBehavior.sortRevisions(revisions)
+            .map(e => {
+              // TODO(kaspern): Mark which patchset an edit was made on, if an
+              // edit exists -- perhaps with a temporary description.
+              return {
+                num: e._number,
+                desc: e.description,
+                sha: e.sha,
+              };
+            });
+    }
+    return PatchSetBehavior._computeWipForPatchSets(change, patchNums);
+  },
+
+  /**
+   * Populate the wip properties of the given list of patch sets.
+   *
+   * @param {!Object} change The change details
+   * @param {!Array<!Object>} patchNums Sorted list of patch set objects, as
+   *     generated by computeAllPatchSets
+   * @return {!Array<!Object>} The given list of patch set objects, with the
+   *     wip property set on each of them
+   */
+  _computeWipForPatchSets(change, patchNums) {
+    if (!change.messages || !change.messages.length) {
+      return patchNums;
+    }
+    const psWip = {};
+    let wip = change.work_in_progress;
+    for (let i = 0; i < change.messages.length; i++) {
+      const msg = change.messages[i];
+      if (WIP_TAGS.includes(msg.tag)) {
+        wip = true;
+      } else if (READY_TAGS.includes(msg.tag)) {
+        wip = false;
+      }
+      if (psWip[msg._revision_number] !== false) {
+        psWip[msg._revision_number] = wip;
+      }
+    }
+
+    for (let i = 0; i < patchNums.length; i++) {
+      patchNums[i].wip = psWip[patchNums[i].num];
+    }
+    return patchNums;
+  },
+
+  /** @return {number|undefined} */
+  computeLatestPatchNum(allPatchSets) {
+    if (!allPatchSets || !allPatchSets.length) { return undefined; }
+    if (allPatchSets[0].num === PatchSetBehavior.EDIT_NAME) {
+      return allPatchSets[1].num;
+    }
+    return allPatchSets[0].num;
+  },
+
+  /** @return {boolean} */
+  hasEditBasedOnCurrentPatchSet(allPatchSets) {
+    if (!allPatchSets || allPatchSets.length < 2) { return false; }
+    return allPatchSets[0].num === PatchSetBehavior.EDIT_NAME;
+  },
+
+  /** @return {boolean} */
+  hasEditPatchsetLoaded(patchRangeRecord) {
+    const patchRange = patchRangeRecord.base;
+    if (!patchRange) { return false; }
+    return patchRange.patchNum === PatchSetBehavior.EDIT_NAME ||
+        patchRange.basePatchNum === PatchSetBehavior.EDIT_NAME;
+  },
+
+  /**
+   * Check whether there is no newer patch than the latest patch that was
+   * available when this change was loaded.
+   *
+   * @return {Promise<!Object>} A promise that yields true if the latest patch
+   *     has been loaded, and false if a newer patch has been uploaded in the
+   *     meantime. The promise is rejected on network error.
+   */
+  fetchChangeUpdates(change, restAPI) {
+    const knownLatest = PatchSetBehavior.computeLatestPatchNum(
+        PatchSetBehavior.computeAllPatchSets(change));
+    return restAPI.getChangeDetail(change._number)
+        .then(detail => {
+          if (!detail) {
+            const error = new Error('Unable to check for latest patchset.');
+            return Promise.reject(error);
+          }
+          const actualLatest = PatchSetBehavior.computeLatestPatchNum(
+              PatchSetBehavior.computeAllPatchSets(detail));
+          return {
+            isLatest: actualLatest <= knownLatest,
+            newStatus: change.status !== detail.status ? detail.status : null,
+            newMessages: change.messages.length < detail.messages.length,
+          };
+        });
+  },
+
+  /**
+   * @param {number|string} patchNum
+   * @param {!Array<!Object>} revisions A sorted array of revisions.
+   *
+   * @return {number} The index of the revision with the given patchNum.
+   */
+  findSortedIndex(patchNum, revisions) {
+    revisions = revisions || [];
+    const findNum = rev => rev._number + '' === patchNum + '';
+    return revisions.findIndex(findNum);
+  },
+
+  /**
+   * Convert parent indexes from patch range expressions to numbers.
+   * For example, in a patch range expression `"-3"` becomes `3`.
+   *
+   * @param {number|string} rangeBase
+   * @return {number}
+   */
+  getParentIndex(rangeBase) {
+    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
+   */
+  const PatchSetMixin = base => // eslint-disable-line no-unused-vars
+    class extends base {
+      computeLatestPatchNum(allPatchSets) {}
+
+      hasEditPatchsetLoaded(patchRangeRecord) {}
+
+      hasEditBasedOnCurrentPatchSet(allPatchSets) {}
+
+      computeAllPatchSets(change) {}
+    };
+}
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.PatchSetBehavior = PatchSetBehavior;
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..f03e3ac 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
@@ -15,315 +15,310 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<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"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <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">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-patch-set-behavior tests', () => {
-    test('getRevisionByPatchNum', () => {
-      const get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
-      const revisions = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
-      ];
-      assert.deepEqual(get(revisions, '1'), revisions[1]);
-      assert.deepEqual(get(revisions, 2), revisions[2]);
-      assert.equal(get(revisions, '3'), undefined);
-    });
-
-    test('fetchChangeUpdates on latest', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(knownChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isTrue(result.isLatest);
-            assert.isNotOk(result.newStatus);
-            assert.isFalse(result.newMessages);
-            done();
-          });
-    });
-
-    test('fetchChangeUpdates not on latest', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const actualChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-          sha3: {description: 'patch 3', _number: 3},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(actualChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isFalse(result.isLatest);
-            assert.isNotOk(result.newStatus);
-            assert.isFalse(result.newMessages);
-            done();
-          });
-    });
-
-    test('fetchChangeUpdates new status', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const actualChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'MERGED',
-        messages: [],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(actualChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isTrue(result.isLatest);
-            assert.equal(result.newStatus, 'MERGED');
-            assert.isFalse(result.newMessages);
-            done();
-          });
-    });
-
-    test('fetchChangeUpdates new messages', done => {
-      const knownChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [],
-      };
-      const actualChange = {
-        revisions: {
-          sha1: {description: 'patch 1', _number: 1},
-          sha2: {description: 'patch 2', _number: 2},
-        },
-        status: 'NEW',
-        messages: [{message: 'blah blah'}],
-      };
-      const mockRestApi = {
-        getChangeDetail() {
-          return Promise.resolve(actualChange);
-        },
-      };
-      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-          .then(result => {
-            assert.isTrue(result.isLatest);
-            assert.isNotOk(result.newStatus);
-            assert.isTrue(result.newMessages);
-            done();
-          });
-    });
-
-    test('_computeWipForPatchSets', () => {
-      // Compute patch sets for a given timeline on a change. The initial WIP
-      // property of the change can be true or false. The map of tags by
-      // revision is keyed by patch set number. Each value is a list of change
-      // message tags in the order that they occurred in the timeline. These
-      // indicate actions that modify the WIP property of the change and/or
-      // create new patch sets.
-      //
-      // Returns the actual results with an assertWip method that can be used
-      // to compare against an expected value for a particular patch set.
-      const compute = (initialWip, tagsByRevision) => {
-        const change = {
-          messages: [],
-          work_in_progress: initialWip,
-        };
-        const revs = Object.keys(tagsByRevision).sort((a, b) => {
-          return a - b;
-        });
-        for (const rev of revs) {
-          for (const tag of tagsByRevision[rev]) {
-            change.messages.push({
-              tag,
-              _revision_number: rev,
-            });
-          }
-        }
-        let patchNums = revs.map(rev => { return {num: rev}; });
-        patchNums = Gerrit.PatchSetBehavior._computeWipForPatchSets(
-            change, patchNums);
-        const actualWipsByRevision = {};
-        for (const patchNum of patchNums) {
-          actualWipsByRevision[patchNum.num] = patchNum.wip;
-        }
-        const verifier = {
-          assertWip(revision, expectedWip) {
-            const patchNum = patchNums.find(patchNum => {
-              return patchNum.num == revision;
-            });
-            if (!patchNum) {
-              assert.fail('revision ' + revision + ' not found');
-            }
-            assert.equal(patchNum.wip, expectedWip,
-                'wip state for ' + revision + ' is ' +
-              patchNum.wip + '; expected ' + expectedWip);
-            return verifier;
-          },
-        };
-        return verifier;
-      };
-
-      compute(false, {1: ['upload']}).assertWip(1, false);
-      compute(true, {1: ['upload']}).assertWip(1, true);
-
-      const setWip = 'autogenerated:gerrit:setWorkInProgress';
-      const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
-      const clearWip = 'autogenerated:gerrit:setReadyForReview';
-
-      compute(false, {
-        1: ['upload', setWip],
-        2: ['upload'],
-        3: ['upload', clearWip],
-        4: ['upload', setWip],
-      }).assertWip(1, false) // Change was created with PS1 ready for review
-          .assertWip(2, true) // PS2 was uploaded during WIP
-          .assertWip(3, false) // PS3 was marked ready for review after upload
-          .assertWip(4, false); // PS4 was uploaded ready for review
-
-      compute(false, {
-        1: [uploadInWip, null, 'addReviewer'],
-        2: ['upload'],
-        3: ['upload', clearWip, setWip],
-        4: ['upload'],
-        5: ['upload', clearWip],
-        6: [uploadInWip],
-      }).assertWip(1, true) // Change was created in WIP
-          .assertWip(2, true) // PS2 was uploaded during WIP
-          .assertWip(3, false) // PS3 was marked ready for review
-          .assertWip(4, true) // PS4 was uploaded during WIP
-          .assertWip(5, false) // PS5 was marked ready for review
-          .assertWip(6, true); // PS6 was uploaded with WIP option
-    });
-
-    test('patchNumEquals', () => {
-      const equals = Gerrit.PatchSetBehavior.patchNumEquals;
-      assert.isFalse(equals('edit', 'PARENT'));
-      assert.isFalse(equals('edit', NaN));
-      assert.isFalse(equals(1, '2'));
-
-      assert.isTrue(equals(1, '1'));
-      assert.isTrue(equals(1, 1));
-      assert.isTrue(equals('edit', 'edit'));
-      assert.isTrue(equals('PARENT', 'PARENT'));
-    });
-
-    test('isMergeParent', () => {
-      const isParent = Gerrit.PatchSetBehavior.isMergeParent;
-      assert.isFalse(isParent(1));
-      assert.isFalse(isParent(4321));
-      assert.isFalse(isParent('52'));
-      assert.isFalse(isParent('edit'));
-      assert.isFalse(isParent('PARENT'));
-      assert.isFalse(isParent(0));
-
-      assert.isTrue(isParent(-23));
-      assert.isTrue(isParent(-1));
-      assert.isTrue(isParent('-42'));
-    });
-
-    test('findEditParentRevision', () => {
-      const findParent = Gerrit.PatchSetBehavior.findEditParentRevision;
-      let revisions = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
-      ];
-      assert.strictEqual(findParent(revisions), null);
-
-      revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
-      assert.strictEqual(findParent(revisions), null);
-
-      revisions = [...revisions, {_number: 3}];
-      assert.deepEqual(findParent(revisions), {_number: 3});
-    });
-
-    test('findEditParentPatchNum', () => {
-      const findNum = Gerrit.PatchSetBehavior.findEditParentPatchNum;
-      let revisions = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
-      ];
-      assert.equal(findNum(revisions), -1);
-
-      revisions =
-          [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
-      assert.deepEqual(findNum(revisions), 3);
-    });
-
-    test('sortRevisions', () => {
-      const sort = Gerrit.PatchSetBehavior.sortRevisions;
-      const revisions = [
-        {_number: 0},
-        {_number: 2},
-        {_number: 1},
-      ];
-      const sorted = [
-        {_number: 2},
-        {_number: 1},
-        {_number: 0},
-      ];
-
-      assert.deepEqual(sort(revisions), sorted);
-
-      // Edit patchset should follow directly after its basePatchNum.
-      revisions.push({_number: 'edit', basePatchNum: 2});
-      sorted.unshift({_number: 'edit', basePatchNum: 2});
-      assert.deepEqual(sort(revisions), sorted);
-
-      revisions[0].basePatchNum = 0;
-      const edit = sorted.shift();
-      edit.basePatchNum = 0;
-      // Edit patchset should be at index 2.
-      sorted.splice(2, 0, edit);
-      assert.deepEqual(sort(revisions), sorted);
-    });
-
-    test('getParentIndex', () => {
-      assert.equal(Gerrit.PatchSetBehavior.getParentIndex('-13'), 13);
-      assert.equal(Gerrit.PatchSetBehavior.getParentIndex(-4), 4);
-    });
+<script type="module">
+import '../../test/common-test-setup.js';
+import {PatchSetBehavior} from './gr-patch-set-behavior.js';
+suite('gr-patch-set-behavior tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const get = PatchSetBehavior.getRevisionByPatchNum;
+    const revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.deepEqual(get(revisions, '1'), revisions[1]);
+    assert.deepEqual(get(revisions, 2), revisions[2]);
+    assert.equal(get(revisions, '3'), undefined);
   });
+
+  test('fetchChangeUpdates on latest', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(knownChange);
+      },
+    };
+    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates not on latest', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+        sha3: {description: 'patch 3', _number: 3},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isFalse(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates new status', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'MERGED',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.equal(result.newStatus, 'MERGED');
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates new messages', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [{message: 'blah blah'}],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isTrue(result.newMessages);
+          done();
+        });
+  });
+
+  test('_computeWipForPatchSets', () => {
+    // Compute patch sets for a given timeline on a change. The initial WIP
+    // property of the change can be true or false. The map of tags by
+    // revision is keyed by patch set number. Each value is a list of change
+    // message tags in the order that they occurred in the timeline. These
+    // indicate actions that modify the WIP property of the change and/or
+    // create new patch sets.
+    //
+    // Returns the actual results with an assertWip method that can be used
+    // to compare against an expected value for a particular patch set.
+    const compute = (initialWip, tagsByRevision) => {
+      const change = {
+        messages: [],
+        work_in_progress: initialWip,
+      };
+      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
+      for (const rev of revs) {
+        for (const tag of tagsByRevision[rev]) {
+          change.messages.push({
+            tag,
+            _revision_number: rev,
+          });
+        }
+      }
+      let patchNums = revs.map(rev => { return {num: rev}; });
+      patchNums = PatchSetBehavior._computeWipForPatchSets(
+          change, patchNums);
+      const actualWipsByRevision = {};
+      for (const patchNum of patchNums) {
+        actualWipsByRevision[patchNum.num] = patchNum.wip;
+      }
+      const verifier = {
+        assertWip(revision, expectedWip) {
+          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
+          if (!patchNum) {
+            assert.fail('revision ' + revision + ' not found');
+          }
+          assert.equal(patchNum.wip, expectedWip,
+              'wip state for ' + revision + ' is ' +
+            patchNum.wip + '; expected ' + expectedWip);
+          return verifier;
+        },
+      };
+      return verifier;
+    };
+
+    compute(false, {1: ['upload']}).assertWip(1, false);
+    compute(true, {1: ['upload']}).assertWip(1, true);
+
+    const setWip = 'autogenerated:gerrit:setWorkInProgress';
+    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
+    const clearWip = 'autogenerated:gerrit:setReadyForReview';
+
+    compute(false, {
+      1: ['upload', setWip],
+      2: ['upload'],
+      3: ['upload', clearWip],
+      4: ['upload', setWip],
+    }).assertWip(1, false) // Change was created with PS1 ready for review
+        .assertWip(2, true) // PS2 was uploaded during WIP
+        .assertWip(3, false) // PS3 was marked ready for review after upload
+        .assertWip(4, false); // PS4 was uploaded ready for review
+
+    compute(false, {
+      1: [uploadInWip, null, 'addReviewer'],
+      2: ['upload'],
+      3: ['upload', clearWip, setWip],
+      4: ['upload'],
+      5: ['upload', clearWip],
+      6: [uploadInWip],
+    }).assertWip(1, true) // Change was created in WIP
+        .assertWip(2, true) // PS2 was uploaded during WIP
+        .assertWip(3, false) // PS3 was marked ready for review
+        .assertWip(4, true) // PS4 was uploaded during WIP
+        .assertWip(5, false) // PS5 was marked ready for review
+        .assertWip(6, true); // PS6 was uploaded with WIP option
+  });
+
+  test('patchNumEquals', () => {
+    const equals = PatchSetBehavior.patchNumEquals;
+    assert.isFalse(equals('edit', 'PARENT'));
+    assert.isFalse(equals('edit', NaN));
+    assert.isFalse(equals(1, '2'));
+
+    assert.isTrue(equals(1, '1'));
+    assert.isTrue(equals(1, 1));
+    assert.isTrue(equals('edit', 'edit'));
+    assert.isTrue(equals('PARENT', 'PARENT'));
+  });
+
+  test('isMergeParent', () => {
+    const isParent = PatchSetBehavior.isMergeParent;
+    assert.isFalse(isParent(1));
+    assert.isFalse(isParent(4321));
+    assert.isFalse(isParent('52'));
+    assert.isFalse(isParent('edit'));
+    assert.isFalse(isParent('PARENT'));
+    assert.isFalse(isParent(0));
+
+    assert.isTrue(isParent(-23));
+    assert.isTrue(isParent(-1));
+    assert.isTrue(isParent('-42'));
+  });
+
+  test('findEditParentRevision', () => {
+    const findParent = PatchSetBehavior.findEditParentRevision;
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.strictEqual(findParent(revisions), null);
+
+    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
+    assert.strictEqual(findParent(revisions), null);
+
+    revisions = [...revisions, {_number: 3}];
+    assert.deepEqual(findParent(revisions), {_number: 3});
+  });
+
+  test('findEditParentPatchNum', () => {
+    const findNum = PatchSetBehavior.findEditParentPatchNum;
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.equal(findNum(revisions), -1);
+
+    revisions =
+        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
+    assert.deepEqual(findNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const sort = PatchSetBehavior.sortRevisions;
+    const revisions = [
+      {_number: 0},
+      {_number: 2},
+      {_number: 1},
+    ];
+    const sorted = [
+      {_number: 2},
+      {_number: 1},
+      {_number: 0},
+    ];
+
+    assert.deepEqual(sort(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({_number: 'edit', basePatchNum: 2});
+    sorted.unshift({_number: 'edit', basePatchNum: 2});
+    assert.deepEqual(sort(revisions), sorted);
+
+    revisions[0].basePatchNum = 0;
+    const edit = sorted.shift();
+    edit.basePatchNum = 0;
+    // Edit patchset should be at index 2.
+    sorted.splice(2, 0, edit);
+    assert.deepEqual(sort(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(PatchSetBehavior.getParentIndex('-13'), 13);
+    assert.equal(PatchSetBehavior.getParentIndex(-4), 4);
+  });
+});
 </script>
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
deleted file mode 100644
index 5e597ae..0000000
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
+++ /dev/null
@@ -1,114 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<script src="../../scripts/util.js"></script>
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-  /** @polymerBehavior Gerrit.PathListBehavior */
-  Gerrit.PathListBehavior = {
-
-    COMMIT_MESSAGE_PATH: '/COMMIT_MSG',
-    MERGE_LIST_PATH: '/MERGE_LIST',
-
-    /**
-     * @param {string} a
-     * @param {string} b
-     * @return {number}
-     */
-    specialFilePathCompare(a, b) {
-      // The commit message always goes first.
-      if (a === Gerrit.PathListBehavior.COMMIT_MESSAGE_PATH) {
-        return -1;
-      }
-      if (b === Gerrit.PathListBehavior.COMMIT_MESSAGE_PATH) {
-        return 1;
-      }
-
-      // The merge list always comes next.
-      if (a === Gerrit.PathListBehavior.MERGE_LIST_PATH) {
-        return -1;
-      }
-      if (b === Gerrit.PathListBehavior.MERGE_LIST_PATH) {
-        return 1;
-      }
-
-      const aLastDotIndex = a.lastIndexOf('.');
-      const aExt = a.substr(aLastDotIndex + 1);
-      const aFile = a.substr(0, aLastDotIndex) || a;
-
-      const bLastDotIndex = b.lastIndexOf('.');
-      const bExt = b.substr(bLastDotIndex + 1);
-      const bFile = b.substr(0, bLastDotIndex) || b;
-
-      // Sort header files above others with the same base name.
-      const headerExts = ['h', 'hxx', 'hpp'];
-      if (aFile.length > 0 && aFile === bFile) {
-        if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
-          return a.localeCompare(b);
-        }
-        if (headerExts.includes(aExt)) {
-          return -1;
-        }
-        if (headerExts.includes(bExt)) {
-          return 1;
-        }
-      }
-      return aFile.localeCompare(bFile) || a.localeCompare(b);
-    },
-
-    computeDisplayPath(path) {
-      if (path === Gerrit.PathListBehavior.COMMIT_MESSAGE_PATH) {
-        return 'Commit message';
-      } else if (path === Gerrit.PathListBehavior.MERGE_LIST_PATH) {
-        return 'Merge list';
-      }
-      return path;
-    },
-
-    computeTruncatedPath(path) {
-      return Gerrit.PathListBehavior.truncatePath(
-          Gerrit.PathListBehavior.computeDisplayPath(path));
-    },
-
-    /**
-     * Truncates URLs to display filename only
-     * Example
-     * // returns '.../text.html'
-     * util.truncatePath.('dir/text.html');
-     * Example
-     * // returns 'text.html'
-     * util.truncatePath.('text.html');
-     *
-     * @param {string} path
-     * @param {number=} opt_threshold
-     * @return {string} Returns the truncated value of a URL.
-     */
-    truncatePath(path, opt_threshold) {
-      const threshold = opt_threshold || 1;
-      const pathPieces = path.split('/');
-
-      if (pathPieces.length <= threshold) { return path; }
-
-      const index = pathPieces.length - threshold;
-      // Character is an ellipsis.
-      return `\u2026/${pathPieces.slice(index).join('/')}`;
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
new file mode 100644
index 0000000..7745b42
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** @polymerBehavior Gerrit.PathListBehavior */
+export const PathListBehavior = {
+
+  COMMIT_MESSAGE_PATH: '/COMMIT_MSG',
+  MERGE_LIST_PATH: '/MERGE_LIST',
+
+  /**
+   * @param {string} a
+   * @param {string} b
+   * @return {number}
+   */
+  specialFilePathCompare(a, b) {
+    // The commit message always goes first.
+    if (a === PathListBehavior.COMMIT_MESSAGE_PATH) {
+      return -1;
+    }
+    if (b === PathListBehavior.COMMIT_MESSAGE_PATH) {
+      return 1;
+    }
+
+    // The merge list always comes next.
+    if (a === PathListBehavior.MERGE_LIST_PATH) {
+      return -1;
+    }
+    if (b === PathListBehavior.MERGE_LIST_PATH) {
+      return 1;
+    }
+
+    const aLastDotIndex = a.lastIndexOf('.');
+    const aExt = a.substr(aLastDotIndex + 1);
+    const aFile = a.substr(0, aLastDotIndex) || a;
+
+    const bLastDotIndex = b.lastIndexOf('.');
+    const bExt = b.substr(bLastDotIndex + 1);
+    const bFile = b.substr(0, bLastDotIndex) || b;
+
+    // Sort header files above others with the same base name.
+    const headerExts = ['h', 'hxx', 'hpp'];
+    if (aFile.length > 0 && aFile === bFile) {
+      if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
+        return a.localeCompare(b);
+      }
+      if (headerExts.includes(aExt)) {
+        return -1;
+      }
+      if (headerExts.includes(bExt)) {
+        return 1;
+      }
+    }
+    return aFile.localeCompare(bFile) || a.localeCompare(b);
+  },
+
+  computeDisplayPath(path) {
+    if (path === PathListBehavior.COMMIT_MESSAGE_PATH) {
+      return 'Commit message';
+    } else if (path === PathListBehavior.MERGE_LIST_PATH) {
+      return 'Merge list';
+    }
+    return path;
+  },
+
+  isMagicPath(path) {
+    return !!path &&
+        (path === PathListBehavior.COMMIT_MESSAGE_PATH || path ===
+            PathListBehavior.MERGE_LIST_PATH);
+  },
+
+  computeTruncatedPath(path) {
+    return PathListBehavior.truncatePath(
+        PathListBehavior.computeDisplayPath(path));
+  },
+
+  /**
+   * Truncates URLs to display filename only
+   * Example
+   * // returns '.../text.html'
+   * util.truncatePath.('dir/text.html');
+   * Example
+   * // returns 'text.html'
+   * util.truncatePath.('text.html');
+   *
+   * @param {string} path
+   * @param {number=} opt_threshold
+   * @return {string} Returns the truncated value of a URL.
+   */
+  truncatePath(path, opt_threshold) {
+    const threshold = opt_threshold || 1;
+    const pathPieces = path.split('/');
+
+    if (pathPieces.length <= threshold) { return path; }
+
+    const index = pathPieces.length - threshold;
+    // Character is an ellipsis.
+    return `\u2026/${pathPieces.slice(index).join('/')}`;
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.PathListBehavior = PathListBehavior;
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..1b7a42a 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
@@ -15,79 +15,86 @@
 limitations under the License.
 -->
 <!-- Polymer included for the html import polyfill. -->
-<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"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <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">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-path-list-behavior tests', () => {
-    test('special sort', () => {
-      const sort = Gerrit.PathListBehavior.specialFilePathCompare;
-      const testFiles = [
-        '/a.h',
-        '/MERGE_LIST',
-        '/a.cpp',
-        '/COMMIT_MSG',
-        '/asdasd',
-        '/mrPeanutbutter.py',
-      ];
-      assert.deepEqual(
-          testFiles.sort(sort),
-          [
-            '/COMMIT_MSG',
-            '/MERGE_LIST',
-            '/a.h',
-            '/a.cpp',
-            '/asdasd',
-            '/mrPeanutbutter.py',
-          ]);
-    });
-
-    test('file display name', () => {
-      const name = Gerrit.PathListBehavior.computeDisplayPath;
-      assert.equal(name('/foo/bar/baz'), '/foo/bar/baz');
-      assert.equal(name('/foobarbaz'), '/foobarbaz');
-      assert.equal(name('/COMMIT_MSG'), 'Commit message');
-      assert.equal(name('/MERGE_LIST'), 'Merge list');
-    });
-
-    test('truncatePath with long path should add ellipsis', () => {
-      const truncatePath = Gerrit.PathListBehavior.truncatePath;
-      let path = 'level1/level2/level3/level4/file.js';
-      let shortenedPath = truncatePath(path);
-      // The expected path is truncated with an ellipsis.
-      const expectedPath = '\u2026/file.js';
-      assert.equal(shortenedPath, expectedPath);
-
-      path = 'level2/file.js';
-      shortenedPath = truncatePath(path);
-      assert.equal(shortenedPath, expectedPath);
-    });
-
-    test('truncatePath with opt_threshold', () => {
-      const truncatePath = Gerrit.PathListBehavior.truncatePath;
-      let path = 'level1/level2/level3/level4/file.js';
-      let shortenedPath = truncatePath(path, 2);
-      // The expected path is truncated with an ellipsis.
-      const expectedPath = '\u2026/level4/file.js';
-      assert.equal(shortenedPath, expectedPath);
-
-      path = 'level2/file.js';
-      shortenedPath = truncatePath(path, 2);
-      assert.equal(shortenedPath, path);
-    });
-
-    test('truncatePath with short path should not add ellipsis', () => {
-      const truncatePath = Gerrit.PathListBehavior.truncatePath;
-      const path = 'file.js';
-      const expectedPath = 'file.js';
-      const shortenedPath = truncatePath(path);
-      assert.equal(shortenedPath, expectedPath);
-    });
+<script type="module">
+import '../../test/common-test-setup.js';
+import {PathListBehavior} from './gr-path-list-behavior.js';
+suite('gr-path-list-behavior tests', () => {
+  test('special sort', () => {
+    const sort = PathListBehavior.specialFilePathCompare;
+    const testFiles = [
+      '/a.h',
+      '/MERGE_LIST',
+      '/a.cpp',
+      '/COMMIT_MSG',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ];
+    assert.deepEqual(
+        testFiles.sort(sort),
+        [
+          '/COMMIT_MSG',
+          '/MERGE_LIST',
+          '/a.h',
+          '/a.cpp',
+          '/asdasd',
+          '/mrPeanutbutter.py',
+        ]);
   });
+
+  test('file display name', () => {
+    const name = PathListBehavior.computeDisplayPath;
+    assert.equal(name('/foo/bar/baz'), '/foo/bar/baz');
+    assert.equal(name('/foobarbaz'), '/foobarbaz');
+    assert.equal(name('/COMMIT_MSG'), 'Commit message');
+    assert.equal(name('/MERGE_LIST'), 'Merge list');
+  });
+
+  test('isMagicPath', () => {
+    const isMagic = 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 = PathListBehavior.truncatePath;
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+
+  test('truncatePath with opt_threshold', () => {
+    const truncatePath = PathListBehavior.truncatePath;
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path, 2);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/level4/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path, 2);
+    assert.equal(shortenedPath, path);
+  });
+
+  test('truncatePath with short path should not add ellipsis', () => {
+    const truncatePath = PathListBehavior.truncatePath;
+    const path = 'file.js';
+    const expectedPath = 'file.js';
+    const shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+});
 </script>
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
deleted file mode 100644
index 2fa9191..0000000
--- a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
+++ /dev/null
@@ -1,38 +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.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.RepoPluginConfig*/
-  Gerrit.RepoPluginConfig = {
-    // Should be kept in sync with
-    // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
-    ENTRY_TYPES: {
-      ARRAY: 'ARRAY',
-      BOOLEAN: 'BOOLEAN',
-      INT: 'INT',
-      LIST: 'LIST',
-      LONG: 'LONG',
-      STRING: 'STRING',
-    },
-    PLUGIN_CONFIG_CHANGED: 'plugin-config-changed',
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
new file mode 100644
index 0000000..3ba2e60
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
@@ -0,0 +1,38 @@
+/**
+ * @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.
+ */
+
+/** @polymerBehavior Gerrit.RepoPluginConfig*/
+export const RepoPluginConfig = {
+  // Should be kept in sync with
+  // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+  ENTRY_TYPES: {
+    ARRAY: 'ARRAY',
+    BOOLEAN: 'BOOLEAN',
+    INT: 'INT',
+    LIST: 'LIST',
+    LONG: 'LONG',
+    STRING: 'STRING',
+  },
+  PLUGIN_CONFIG_CHANGED: 'plugin-config-changed',
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.RepoPluginConfig = RepoPluginConfig;
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
deleted file mode 100644
index 0e2e99f..0000000
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
+++ /dev/null
@@ -1,21 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../elements/shared/gr-tooltip/gr-tooltip.html">
-<script src="../../scripts/rootElement.js"></script>
-
-<script src="gr-tooltip-behavior.js"></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..2a592f0 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
@@ -14,136 +14,160 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../scripts/bundled-polymer.js';
 
-  const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+import '../../elements/shared/gr-tooltip/gr-tooltip.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {getRootElement} from '../../scripts/rootElement.js';
 
-  window.Gerrit = window.Gerrit || {};
+const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
 
-  /** @polymerBehavior Gerrit.TooltipBehavior */
-  Gerrit.TooltipBehavior = {
+/** @polymerBehavior Gerrit.TooltipBehavior */
+export const TooltipBehavior = {
 
-    properties: {
-      hasTooltip: {
-        type: Boolean,
-        observer: '_setupTooltipListeners',
-      },
-      positionBelow: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
+  properties: {
+    hasTooltip: {
+      type: Boolean,
+      observer: '_setupTooltipListeners',
+    },
+    positionBelow: {
+      type: Boolean,
+      value: false,
+      reflectToAttribute: true,
+    },
 
-      _isTouchDevice: {
-        type: Boolean,
-        value() {
-          return 'ontouchstart' in document.documentElement;
-        },
-      },
-      _tooltip: Object,
-      _titleText: String,
-      _hasSetupTooltipListeners: {
-        type: Boolean,
-        value: false,
+    _isTouchDevice: {
+      type: Boolean,
+      value() {
+        return 'ontouchstart' in document.documentElement;
       },
     },
-
-    detached() {
-      this._handleHideTooltip();
+    _tooltip: Object,
+    _titleText: String,
+    _hasSetupTooltipListeners: {
+      type: Boolean,
+      value: false,
     },
+  },
 
-    _setupTooltipListeners() {
-      if (this._hasSetupTooltipListeners || !this.hasTooltip) { return; }
-      this._hasSetupTooltipListeners = true;
+  /** @override */
+  detached() {
+    // NOTE: if you define your own `detached` in your component
+    // then this won't take affect (as its not a class yet)
+    this._handleHideTooltip();
+    this.removeEventListener('mouseenter', this._mouseenterHandler);
+  },
 
-      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
-    },
+  _setupTooltipListeners() {
+    if (!this._mouseenterHandler) {
+      this._mouseenterHandler = this._handleShowTooltip.bind(this);
+    }
 
-    _handleShowTooltip(e) {
-      if (this._isTouchDevice) { return; }
+    if (!this.hasTooltip) {
+      // if attribute set to false, remove the listener
+      this.removeEventListener('mouseenter', this._mouseenterHandler);
+      this._hasSetupTooltipListeners = false;
+      return;
+    }
 
-      if (!this.hasAttribute('title') ||
-          this.getAttribute('title') === '' ||
-          this._tooltip) {
-        return;
-      }
+    if (this._hasSetupTooltipListeners) {
+      return;
+    }
+    this._hasSetupTooltipListeners = true;
 
-      // Store the title attribute text then set it to an empty string to
-      // prevent it from showing natively.
-      this._titleText = this.getAttribute('title');
-      this.setAttribute('title', '');
+    this.addEventListener('mouseenter', this._mouseenterHandler);
+  },
 
-      const tooltip = document.createElement('gr-tooltip');
-      tooltip.text = this._titleText;
-      tooltip.maxWidth = this.getAttribute('max-width');
-      tooltip.positionBelow = this.getAttribute('position-below');
+  _handleShowTooltip(e) {
+    if (this._isTouchDevice) { return; }
 
-      // Set visibility to hidden before appending to the DOM so that
-      // calculations can be made based on the element’s size.
-      tooltip.style.visibility = 'hidden';
-      Gerrit.getRootElement().appendChild(tooltip);
-      this._positionTooltip(tooltip);
-      tooltip.style.visibility = null;
+    if (!this.hasAttribute('title') ||
+        this.getAttribute('title') === '' ||
+        this._tooltip) {
+      return;
+    }
 
-      this._tooltip = tooltip;
-      this.listen(window, 'scroll', '_handleWindowScroll');
-      this.listen(this, 'mouseleave', '_handleHideTooltip');
-      this.listen(this, 'click', '_handleHideTooltip');
-    },
+    // Store the title attribute text then set it to an empty string to
+    // prevent it from showing natively.
+    this._titleText = this.getAttribute('title');
+    this.setAttribute('title', '');
 
-    _handleHideTooltip(e) {
-      if (this._isTouchDevice) { return; }
-      if (!this.hasAttribute('title') ||
-          this._titleText == null) {
-        return;
-      }
+    const tooltip = document.createElement('gr-tooltip');
+    tooltip.text = this._titleText;
+    tooltip.maxWidth = this.getAttribute('max-width');
+    tooltip.positionBelow = this.getAttribute('position-below');
 
-      this.unlisten(window, 'scroll', '_handleWindowScroll');
-      this.unlisten(this, 'mouseleave', '_handleHideTooltip');
-      this.unlisten(this, 'click', '_handleHideTooltip');
-      this.setAttribute('title', this._titleText);
-      if (this._tooltip && this._tooltip.parentNode) {
-        this._tooltip.parentNode.removeChild(this._tooltip);
-      }
-      this._tooltip = null;
-    },
+    // Set visibility to hidden before appending to the DOM so that
+    // calculations can be made based on the element’s size.
+    tooltip.style.visibility = 'hidden';
+    getRootElement().appendChild(tooltip);
+    this._positionTooltip(tooltip);
+    tooltip.style.visibility = null;
 
-    _handleWindowScroll(e) {
-      if (!this._tooltip) { return; }
+    this._tooltip = tooltip;
+    this.listen(window, 'scroll', '_handleWindowScroll');
+    this.listen(this, 'mouseleave', '_handleHideTooltip');
+    this.listen(this, 'click', '_handleHideTooltip');
+  },
 
-      this._positionTooltip(this._tooltip);
-    },
+  _handleHideTooltip(e) {
+    if (this._isTouchDevice) { return; }
+    if (!this.hasAttribute('title') ||
+        this._titleText == null) {
+      return;
+    }
 
-    _positionTooltip(tooltip) {
-      // This flush is needed for tooltips to be positioned correctly in Firefox
-      // and Safari.
-      Polymer.dom.flush();
-      const rect = this.getBoundingClientRect();
-      const boxRect = tooltip.getBoundingClientRect();
-      const parentRect = tooltip.parentElement.getBoundingClientRect();
-      const top = rect.top - parentRect.top;
-      const left =
-          rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
-      const right = parentRect.width - left - boxRect.width;
-      if (left < 0) {
-        tooltip.updateStyles({
-          '--gr-tooltip-arrow-center-offset': left + 'px',
-        });
-      } else if (right < 0) {
-        tooltip.updateStyles({
-          '--gr-tooltip-arrow-center-offset': (-0.5 * right) + 'px',
-        });
-      }
-      tooltip.style.left = Math.max(0, left) + 'px';
+    this.unlisten(window, 'scroll', '_handleWindowScroll');
+    this.unlisten(this, 'mouseleave', '_handleHideTooltip');
+    this.unlisten(this, 'click', '_handleHideTooltip');
+    this.setAttribute('title', this._titleText);
+    if (this._tooltip && this._tooltip.parentNode) {
+      this._tooltip.parentNode.removeChild(this._tooltip);
+    }
+    this._tooltip = null;
+  },
 
-      if (!this.positionBelow) {
-        tooltip.style.top = Math.max(0, top) + 'px';
-        tooltip.style.transform = 'translateY(calc(-100% - ' + BOTTOM_OFFSET +
-            'px))';
-      } else {
-        tooltip.style.top = top + rect.height + BOTTOM_OFFSET + 'px';
-      }
-    },
-  };
-})(window);
+  _handleWindowScroll(e) {
+    if (!this._tooltip) { return; }
+
+    this._positionTooltip(this._tooltip);
+  },
+
+  _positionTooltip(tooltip) {
+    // This flush is needed for tooltips to be positioned correctly in Firefox
+    // and Safari.
+    flush();
+    const rect = this.getBoundingClientRect();
+    const boxRect = tooltip.getBoundingClientRect();
+    const parentRect = tooltip.parentElement.getBoundingClientRect();
+    const top = rect.top - parentRect.top;
+    const left =
+        rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+    const right = parentRect.width - left - boxRect.width;
+    if (left < 0) {
+      tooltip.updateStyles({
+        '--gr-tooltip-arrow-center-offset': left + 'px',
+      });
+    } else if (right < 0) {
+      tooltip.updateStyles({
+        '--gr-tooltip-arrow-center-offset': (-0.5 * right) + 'px',
+      });
+    }
+    tooltip.style.left = Math.max(0, left) + 'px';
+
+    if (!this.positionBelow) {
+      tooltip.style.top = Math.max(0, top) + 'px';
+      tooltip.style.transform = 'translateY(calc(-100% - ' + BOTTOM_OFFSET +
+          'px))';
+    } else {
+      tooltip.style.top = top + rect.height + BOTTOM_OFFSET + 'px';
+    }
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.TooltipBehavior = TooltipBehavior;
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..79c515e 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,15 +17,11 @@
 -->
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-tooltip-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,116 +29,126 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip-behavior tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {TooltipBehavior} from './gr-tooltip-behavior.js';
+suite('gr-tooltip-behavior tests', () => {
+  let element;
+  let sandbox;
 
-    function makeTooltip(tooltipRect, parentRect) {
-      return {
-        getBoundingClientRect() { return tooltipRect; },
-        updateStyles: sinon.stub(),
-        style: {left: 0, top: 0},
-        parentElement: {
-          getBoundingClientRect() { return parentRect; },
-        },
-      };
-    }
+  function makeTooltip(tooltipRect, parentRect) {
+    return {
+      getBoundingClientRect() { return tooltipRect; },
+      updateStyles: sinon.stub(),
+      style: {left: 0, top: 0},
+      parentElement: {
+        getBoundingClientRect() { return parentRect; },
+      },
+    };
+  }
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'tooltip-behavior-element',
-        behaviors: [Gerrit.TooltipBehavior],
-      });
-    });
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('normal position', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 100, width: 200};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 50},
-          {top: 0, left: 0, width: 1000});
-
-      element._positionTooltip(tooltip);
-      assert.isFalse(tooltip.updateStyles.called);
-      assert.equal(tooltip.style.left, '175px');
-      assert.equal(tooltip.style.top, '100px');
-    });
-
-    test('left side position', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 10, width: 50};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 120},
-          {top: 0, left: 0, width: 1000});
-
-      element._positionTooltip(tooltip);
-      assert.isTrue(tooltip.updateStyles.called);
-      const offset = tooltip.updateStyles
-          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-      assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
-      assert.equal(tooltip.style.left, '0px');
-      assert.equal(tooltip.style.top, '100px');
-    });
-
-    test('right side position', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 950, width: 50};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 120},
-          {top: 0, left: 0, width: 1000});
-
-      element._positionTooltip(tooltip);
-      assert.isTrue(tooltip.updateStyles.called);
-      const offset = tooltip.updateStyles
-          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-      assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-      assert.equal(tooltip.style.left, '915px');
-      assert.equal(tooltip.style.top, '100px');
-    });
-
-    test('position to bottom', () => {
-      sandbox.stub(element, 'getBoundingClientRect', () => {
-        return {top: 100, left: 950, width: 50, height: 50};
-      });
-      const tooltip = makeTooltip(
-          {height: 30, width: 120},
-          {top: 0, left: 0, width: 1000});
-
-      element.positionBelow = true;
-      element._positionTooltip(tooltip);
-      assert.isTrue(tooltip.updateStyles.called);
-      const offset = tooltip.updateStyles
-          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-      assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-      assert.equal(tooltip.style.left, '915px');
-      assert.equal(tooltip.style.top, '157.2px');
-    });
-
-    test('hides tooltip when detached', () => {
-      sandbox.stub(element, '_handleHideTooltip');
-      element.remove();
-      flushAsynchronousOperations();
-      assert.isTrue(element._handleHideTooltip.called);
-    });
-
-    test('sets up listeners when has-tooltip is changed', () => {
-      const addListenerStub = sandbox.stub(element, 'addEventListener');
-      element.hasTooltip = true;
-      assert.isTrue(addListenerStub.called);
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'tooltip-behavior-element',
+      behaviors: [TooltipBehavior],
     });
   });
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('normal position', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 100, width: 200};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 50},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isFalse(tooltip.updateStyles.called);
+    assert.equal(tooltip.style.left, '175px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('left side position', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 10, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '0px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('right side position', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 950, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('position to bottom', () => {
+    sandbox.stub(element, 'getBoundingClientRect', () => {
+      return {top: 100, left: 950, width: 50, height: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '157.2px');
+  });
+
+  test('hides tooltip when detached', () => {
+    sandbox.stub(element, '_handleHideTooltip');
+    element.remove();
+    flushAsynchronousOperations();
+    assert.isTrue(element._handleHideTooltip.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', () => {
+    const addListenerStub = sandbox.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', () => {
+    const removeListenerStub = sandbox.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    element.hasTooltip = false;
+    assert.isTrue(removeListenerStub.called);
+  });
+});
 </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
deleted file mode 100644
index 64274d2..0000000
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
+++ /dev/null
@@ -1,59 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.URLEncodingBehavior */
-  Gerrit.URLEncodingBehavior = {
-    /**
-     * Pretty-encodes a URL. Double-encodes the string, and then replaces
-     *   benevolent characters for legibility.
-     *
-     * @param {string} url
-     * @param {boolean=} replaceSlashes
-     * @return {string}
-     */
-    encodeURL(url, replaceSlashes) {
-      // @see Issue 4255 regarding double-encoding.
-      let output = encodeURIComponent(encodeURIComponent(url));
-      // @see Issue 4577 regarding more readable URLs.
-      output = output.replace(/%253A/g, ':');
-      output = output.replace(/%2520/g, '+');
-      if (replaceSlashes) {
-        output = output.replace(/%252F/g, '/');
-      }
-      return output;
-    },
-
-    /**
-     * Single decode for URL components. Will decode plus signs ('+') to spaces.
-     * Note: because this function decodes once, it is not the inverse of
-     * encodeURL.
-     *
-     * @param {string} url
-     * @return {string}
-     */
-    singleDecodeURL(url) {
-      const withoutPlus = url.replace(/\+/g, '%20');
-      return decodeURIComponent(withoutPlus);
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
new file mode 100644
index 0000000..5c9e911
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** @polymerBehavior Gerrit.URLEncodingBehavior */
+export const URLEncodingBehavior = {
+  /**
+   * Pretty-encodes a URL. Double-encodes the string, and then replaces
+   *   benevolent characters for legibility.
+   *
+   * @param {string} url
+   * @param {boolean=} replaceSlashes
+   * @return {string}
+   */
+  encodeURL(url, replaceSlashes) {
+    // @see Issue 4255 regarding double-encoding.
+    let output = encodeURIComponent(encodeURIComponent(url));
+    // @see Issue 4577 regarding more readable URLs.
+    output = output.replace(/%253A/g, ':');
+    output = output.replace(/%2520/g, '+');
+    if (replaceSlashes) {
+      output = output.replace(/%252F/g, '/');
+    }
+    return output;
+  },
+
+  /**
+   * Single decode for URL components. Will decode plus signs ('+') to spaces.
+   * Note: because this function decodes once, it is not the inverse of
+   * encodeURL.
+   *
+   * @param {string} url
+   * @return {string}
+   */
+  singleDecodeURL(url) {
+    const withoutPlus = url.replace(/\+/g, '%20');
+    return decodeURIComponent(withoutPlus);
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.URLEncodingBehavior = URLEncodingBehavior;
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..d0a2cde 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,15 +17,11 @@
 -->
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-url-encoding-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,61 +29,64 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-url-encoding-behavior tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {URLEncodingBehavior} from './gr-url-encoding-behavior.js';
+suite('gr-url-encoding-behavior tests', () => {
+  let element;
+  let sandbox;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.URLEncodingBehavior],
-      });
-    });
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('encodeURL', () => {
-      test('double encodes', () => {
-        assert.equal(element.encodeURL('abc?123'), 'abc%253F123');
-        assert.equal(element.encodeURL('def/ghi'), 'def%252Fghi');
-        assert.equal(element.encodeURL('jkl'), 'jkl');
-        assert.equal(element.encodeURL(''), '');
-      });
-
-      test('does not convert colons', () => {
-        assert.equal(element.encodeURL('mno:pqr'), 'mno:pqr');
-      });
-
-      test('converts spaces to +', () => {
-        assert.equal(element.encodeURL('words with spaces'), 'words+with+spaces');
-      });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
-      });
-
-      test('does not convert slashes when configured', () => {
-        assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
-      });
-    });
-
-    suite('singleDecodeUrl', () => {
-      test('single decodes', () => {
-        assert.equal(element.singleDecodeURL('abc%3Fdef'), 'abc?def');
-      });
-
-      test('converts + to space', () => {
-        assert.equal(element.singleDecodeURL('ghi+jkl'), 'ghi jkl');
-      });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [URLEncodingBehavior],
     });
   });
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('encodeURL', () => {
+    test('double encodes', () => {
+      assert.equal(element.encodeURL('abc?123'), 'abc%253F123');
+      assert.equal(element.encodeURL('def/ghi'), 'def%252Fghi');
+      assert.equal(element.encodeURL('jkl'), 'jkl');
+      assert.equal(element.encodeURL(''), '');
+    });
+
+    test('does not convert colons', () => {
+      assert.equal(element.encodeURL('mno:pqr'), 'mno:pqr');
+    });
+
+    test('converts spaces to +', () => {
+      assert.equal(element.encodeURL('words with spaces'), 'words+with+spaces');
+    });
+
+    test('does not convert slashes when configured', () => {
+      assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+    });
+
+    test('does not convert slashes when configured', () => {
+      assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
+    });
+  });
+
+  suite('singleDecodeUrl', () => {
+    test('single decodes', () => {
+      assert.equal(element.singleDecodeURL('abc%3Fdef'), 'abc?def');
+    });
+
+    test('converts + to space', () => {
+      assert.equal(element.singleDecodeURL('ghi+jkl'), 'ghi jkl');
+    });
+  });
+});
 </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
deleted file mode 100644
index 3c5a733..0000000
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ /dev/null
@@ -1,616 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<!--
-
-How to Add a Keyboard Shortcut
-==============================
-
-A keyboard shortcut is composed of the following parts:
-
-  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
-  2. Documentation for the keyboard shortcut help dialog
-  3. A binding between key combos and the semantic identifier
-  4. A binding between the semantic identifier and a listener
-
-Parts (1) and (2) for all shortcuts are defined in this file. The semantic
-identifier is declared in the Shortcut enum near the head of this script:
-
-  const Shortcut = {
-    // ...
-    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
-    // ...
-  };
-
-Immediately following the Shortcut enum definition, there is a _describe
-function defined which is then invoked many times to populate the help dialog.
-Add a new invocation here to document the shortcut:
-
-  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
-      'Hide/show left diff');
-
-When an attached view binds one or more key combos to this shortcut, the help
-dialog will display this text in the given section (in this case, "Diffs"). See
-the ShortcutSection enum immediately below for the list of supported sections.
-
-Part (3), the actual key bindings, are declared by gr-app. In the future, this
-system may be expanded to allow key binding customizations by plugins or user
-preferences. Key bindings are defined in the following forms:
-
-  // Ordinary shortcut with a single binding.
-  this.bindShortcut(
-      this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-
-  // Ordinary shortcut with multiple bindings.
-  this.bindShortcut(
-      this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-
-  // A "go-key" keyboard shortcut, which is combined with a previously and
-  // continuously pressed "go" key (the go-key is hard-coded as 'g').
-  this.bindShortcut(
-      this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
-
-  // A "doc-only" keyboard shortcut. This declares the key-binding for help
-  // dialog purposes, but doesn't actually implement the binding. It is up
-  // to some element to implement this binding using iron-a11y-keys-behavior's
-  // keyBindings property.
-  this.bindShortcut(
-      this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
-
-Part (4), the listener definitions, are declared by the view or element that
-implements the shortcut behavior. This is done by implementing a method named
-keyboardShortcuts() in an element that mixes in this behavior, returning an
-object that maps semantic identifiers (as property names) to listener method
-names, like this:
-
-  keyboardShortcuts() {
-    return {
-      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-    };
-  },
-
-You can implement key bindings in an element that is hosted by a view IF that
-element is always attached exactly once under that view (e.g. the search bar in
-gr-app). When that is not the case, you will have to define a doc-only binding
-in gr-app, declare the shortcut in the view that hosts the element, and use
-iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
-element. An example of this is in comment threads. A diff view supports actions
-on comment threads, but there may be zero or many comment threads attached at
-any given point. So the shortcut is declared as doc-only by the diff view and
-by gr-app, and actually implemented by gr-comment-thread.
-
-NOTE: doc-only shortcuts will not be customizable in the same way that other
-shortcuts are.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
-
-<script>
-(function(window) {
-  'use strict';
-
-  const DOC_ONLY = 'DOC_ONLY';
-  const GO_KEY = 'GO_KEY';
-
-  // The maximum age of a keydown event to be used in a jump navigation. This
-  // is only for cases when the keyup event is lost.
-  const GO_KEY_TIMEOUT_MS = 1000;
-
-  const ShortcutSection = {
-    ACTIONS: 'Actions',
-    DIFFS: 'Diffs',
-    EVERYWHERE: 'Everywhere',
-    FILE_LIST: 'File list',
-    NAVIGATION: 'Navigation',
-    REPLY_DIALOG: 'Reply dialog',
-  };
-
-  const Shortcut = {
-    OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
-    GO_TO_USER_DASHBOARD: 'GO_TO_USER_DASHBOARD',
-    GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
-    GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
-    GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',
-    GO_TO_WATCHED_CHANGES: 'GO_TO_WATCHED_CHANGES',
-
-    CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
-    CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
-    OPEN_CHANGE: 'OPEN_CHANGE',
-    NEXT_PAGE: 'NEXT_PAGE',
-    PREV_PAGE: 'PREV_PAGE',
-    TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
-    TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
-    REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',
-
-    OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
-    OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
-    EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
-    COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
-    UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
-    UP_TO_CHANGE: 'UP_TO_CHANGE',
-    TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
-    REFRESH_CHANGE: 'REFRESH_CHANGE',
-    EDIT_TOPIC: 'EDIT_TOPIC',
-
-    NEXT_LINE: 'NEXT_LINE',
-    PREV_LINE: 'PREV_LINE',
-    NEXT_CHUNK: 'NEXT_CHUNK',
-    PREV_CHUNK: 'PREV_CHUNK',
-    EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
-    NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
-    PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
-    EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
-    COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
-    LEFT_PANE: 'LEFT_PANE',
-    RIGHT_PANE: 'RIGHT_PANE',
-    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
-    NEW_COMMENT: 'NEW_COMMENT',
-    SAVE_COMMENT: 'SAVE_COMMENT',
-    OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
-    TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',
-
-    NEXT_FILE: 'NEXT_FILE',
-    PREV_FILE: 'PREV_FILE',
-    NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
-    PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
-    NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
-    CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
-    CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
-    OPEN_FILE: 'OPEN_FILE',
-    TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
-    TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
-    TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
-
-    OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
-    OPEN_LAST_FILE: 'OPEN_LAST_FILE',
-
-    SEARCH: 'SEARCH',
-    SEND_REPLY: 'SEND_REPLY',
-    EMOJI_DROPDOWN: 'EMOJI_DROPDOWN',
-  };
-
-  const _help = new Map();
-
-  function _describe(shortcut, section, text) {
-    if (!_help.has(section)) {
-      _help.set(section, []);
-    }
-    _help.get(section).push({shortcut, text});
-  }
-
-  _describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
-  _describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
-      'Show this dialog');
-  _describe(Shortcut.GO_TO_USER_DASHBOARD, ShortcutSection.EVERYWHERE,
-      'Go to User Dashboard');
-  _describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
-      'Go to Opened Changes');
-  _describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
-      'Go to Merged Changes');
-  _describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
-      'Go to Abandoned Changes');
-  _describe(Shortcut.GO_TO_WATCHED_CHANGES, ShortcutSection.EVERYWHERE,
-      'Go to Watched Changes');
-
-  _describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
-      'Select next change');
-  _describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
-      'Select previous change');
-  _describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
-      'Show selected change');
-  _describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
-  _describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
-  _describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
-      'Open reply dialog to publish comments and add reviewers');
-  _describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
-      'Open download overlay');
-  _describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
-      'Expand all messages');
-  _describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
-      'Collapse all messages');
-  _describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
-      'Reload the change at the latest patch');
-  _describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
-      'Mark/unmark change as reviewed');
-  _describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
-      'Toggle review flag on selected file');
-  _describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
-      'Refresh list of changes');
-  _describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
-      'Star/unstar change');
-  _describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
-      'Add a change topic');
-
-  _describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
-  _describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
-  _describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
-      'Go to next diff chunk');
-  _describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
-      'Go to previous diff chunk');
-  _describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
-      'Expand all diff context');
-  _describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
-      'Go to next comment thread');
-  _describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
-      'Go to previous comment thread');
-  _describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
-      'Expand all comment threads');
-  _describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
-      'Collapse all comment threads');
-  _describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
-  _describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
-  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
-      'Hide/show left diff');
-  _describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
-  _describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
-  _describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
-      'Show diff preferences');
-  _describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
-      'Mark/unmark file as reviewed');
-  _describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
-      'Toggle unified/side-by-side diff');
-  _describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
-      'Mark file as reviewed and go to next unreviewed file');
-
-  _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
-  _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
-      'Select previous file');
-  _describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
-      'Select next file that has comments');
-  _describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
-      'Select previous file that has comments');
-  _describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
-      'Show first file');
-  _describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
-      'Show last file');
-  _describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
-      'Up to dashboard');
-  _describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
-
-  _describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
-      'Select next file');
-  _describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
-      'Select previous file');
-  _describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
-      'Go to selected file');
-  _describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
-      'Show/hide all inline diffs');
-  _describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
-      'Show/hide selected inline diff');
-
-  _describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
-  _describe(Shortcut.EMOJI_DROPDOWN, ShortcutSection.REPLY_DIALOG,
-      'Emoji dropdown');
-
-  // Must be declared outside behavior implementation to be accessed inside
-  // behavior functions.
-
-  /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
-  const getKeyboardEvent = function(e) {
-    e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
-    // When e is a keyboardEvent, e.event is not null.
-    if (e.event) { e = e.event; }
-    return e;
-  };
-
-  class ShortcutManager {
-    constructor() {
-      this.activeHosts = new Map();
-      this.bindings = new Map();
-      this.listeners = new Set();
-    }
-
-    bindShortcut(shortcut, ...bindings) {
-      this.bindings.set(shortcut, bindings);
-    }
-
-    getBindingsForShortcut(shortcut) {
-      return this.bindings.get(shortcut);
-    }
-
-    attachHost(host) {
-      if (!host.keyboardShortcuts) { return; }
-      const shortcuts = host.keyboardShortcuts();
-      this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
-      this.notifyListeners();
-      return shortcuts;
-    }
-
-    detachHost(host) {
-      if (this.activeHosts.delete(host)) {
-        this.notifyListeners();
-        return true;
-      }
-      return false;
-    }
-
-    addListener(listener) {
-      this.listeners.add(listener);
-      listener(this.directoryView());
-    }
-
-    removeListener(listener) {
-      return this.listeners.delete(listener);
-    }
-
-    activeShortcutsBySection() {
-      const activeShortcuts = new Set();
-      this.activeHosts.forEach(shortcuts => {
-        shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
-      });
-
-      const activeShortcutsBySection = new Map();
-      _help.forEach((shortcutList, section) => {
-        shortcutList.forEach(shortcutHelp => {
-          if (activeShortcuts.has(shortcutHelp.shortcut)) {
-            if (!activeShortcutsBySection.has(section)) {
-              activeShortcutsBySection.set(section, []);
-            }
-            activeShortcutsBySection.get(section).push(shortcutHelp);
-          }
-        });
-      });
-      return activeShortcutsBySection;
-    }
-
-    directoryView() {
-      const view = new Map();
-      this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
-        const sectionView = [];
-        shortcutHelps.forEach(shortcutHelp => {
-          const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
-          if (!bindingDesc) { return; }
-          this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
-            sectionView.push({
-              binding: bindingDesc,
-              text: shortcutHelp.text,
-            });
-          });
-        });
-        view.set(section, sectionView);
-      });
-      return view;
-    }
-
-    distributeBindingDesc(bindingDesc) {
-      if (bindingDesc.length === 1 ||
-          this.comboSetDisplayWidth(bindingDesc) < 21) {
-        return [bindingDesc];
-      }
-      // Find the largest prefix of bindings that is under the
-      // size threshold.
-      const head = [bindingDesc[0]];
-      for (let i = 1; i < bindingDesc.length; i++) {
-        head.push(bindingDesc[i]);
-        if (this.comboSetDisplayWidth(head) >= 21) {
-          head.pop();
-          return [head].concat(
-              this.distributeBindingDesc(bindingDesc.slice(i)));
-        }
-      }
-    }
-
-    comboSetDisplayWidth(bindingDesc) {
-      const bindingSizer = binding => binding.reduce(
-          (acc, key) => acc + key.length, 0);
-      // Width is the sum of strings + (n-1) * 2 to account for the word
-      // "or" joining them.
-      return bindingDesc.reduce(
-          (acc, binding) => acc + bindingSizer(binding), 0) +
-          2 * (bindingDesc.length - 1);
-    }
-
-    describeBindings(shortcut) {
-      const bindings = this.bindings.get(shortcut);
-      if (!bindings) { return null; }
-      if (bindings[0] === GO_KEY) {
-        return [['g'].concat(bindings.slice(1))];
-      }
-      return bindings
-          .filter(binding => binding !== DOC_ONLY)
-          .map(binding => this.describeBinding(binding));
-    }
-
-    describeBinding(binding) {
-      if (binding.length === 1) {
-        return [binding];
-      }
-      return binding.split(':')[0].split('+').map(part => {
-        switch (part) {
-          case 'shift':
-            return 'Shift';
-          case 'meta':
-            return 'Meta';
-          case 'ctrl':
-            return 'Ctrl';
-          case 'enter':
-            return 'Enter';
-          case 'up':
-            return '↑';
-          case 'down':
-            return '↓';
-          case 'left':
-            return '←';
-          case 'right':
-            return '→';
-          default:
-            return part;
-        }
-      });
-    }
-
-    notifyListeners() {
-      const view = this.directoryView();
-      this.listeners.forEach(listener => listener(view));
-    }
-  }
-
-  const shortcutManager = new ShortcutManager();
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior*/
-  Gerrit.KeyboardShortcutBehavior = [
-    Polymer.IronA11yKeysBehavior,
-    {
-      // Exports for convenience. Note: Closure compiler crashes when
-      // object-shorthand syntax is used here.
-      // eslint-disable-next-line object-shorthand
-      DOC_ONLY: DOC_ONLY,
-      // eslint-disable-next-line object-shorthand
-      GO_KEY: GO_KEY,
-      // eslint-disable-next-line object-shorthand
-      Shortcut: Shortcut,
-
-      properties: {
-        _shortcut_go_key_last_pressed: {
-          type: Number,
-          value: null,
-        },
-
-        _shortcut_go_table: {
-          type: Array,
-          value() { return new Map(); },
-        },
-      },
-
-      modifierPressed(e) {
-        e = getKeyboardEvent(e);
-        return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
-      },
-
-      isModifierPressed(e, modifier) {
-        return getKeyboardEvent(e)[modifier];
-      },
-
-      shouldSuppressKeyboardShortcut(e) {
-        e = getKeyboardEvent(e);
-        const tagName = Polymer.dom(e).rootTarget.tagName;
-        if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
-            (e.keyCode === 13 && tagName === 'A')) {
-          // Suppress shortcuts if the key is 'enter' and target is an anchor.
-          return true;
-        }
-        for (let i = 0; e.path && i < e.path.length; i++) {
-          if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
-        }
-        return false;
-      },
-
-      // Alias for getKeyboardEvent.
-      /** @return {!Event} */
-      getKeyboardEvent(e) {
-        return getKeyboardEvent(e);
-      },
-
-      getRootTarget(e) {
-        return Polymer.dom(getKeyboardEvent(e)).rootTarget;
-      },
-
-      bindShortcut(shortcut, ...bindings) {
-        shortcutManager.bindShortcut(shortcut, ...bindings);
-      },
-
-      _addOwnKeyBindings(shortcut, handler) {
-        const bindings = shortcutManager.getBindingsForShortcut(shortcut);
-        if (!bindings) {
-          return;
-        }
-        if (bindings[0] === DOC_ONLY) {
-          return;
-        }
-        if (bindings[0] === GO_KEY) {
-          this._shortcut_go_table.set(bindings[1], handler);
-        } else {
-          this.addOwnKeyBinding(bindings.join(' '), handler);
-        }
-      },
-
-      attached() {
-        const shortcuts = shortcutManager.attachHost(this);
-        if (!shortcuts) { return; }
-
-        for (const key of Object.keys(shortcuts)) {
-          this._addOwnKeyBindings(key, shortcuts[key]);
-        }
-
-        // If any of the shortcuts utilized GO_KEY, then they are handled
-        // directly by this behavior.
-        if (this._shortcut_go_table.size > 0) {
-          this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
-          this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
-          this._shortcut_go_table.forEach((handler, key) => {
-            this.addOwnKeyBinding(key, '_handleGoAction');
-          });
-        }
-      },
-
-      detached() {
-        if (shortcutManager.detachHost(this)) {
-          this.removeOwnKeyBindings();
-        }
-      },
-
-      keyboardShortcuts() {
-        return {};
-      },
-
-      addKeyboardShortcutDirectoryListener(listener) {
-        shortcutManager.addListener(listener);
-      },
-
-      removeKeyboardShortcutDirectoryListener(listener) {
-        shortcutManager.removeListener(listener);
-      },
-
-      _handleGoKeyDown(e) {
-        if (this.modifierPressed(e)) { return; }
-        this._shortcut_go_key_last_pressed = Date.now();
-      },
-
-      _handleGoKeyUp(e) {
-        this._shortcut_go_key_last_pressed = null;
-      },
-
-      _handleGoAction(e) {
-        if (!this._shortcut_go_key_last_pressed ||
-            (Date.now() - this._shortcut_go_key_last_pressed >
-                GO_KEY_TIMEOUT_MS) ||
-            !this._shortcut_go_table.has(e.detail.key) ||
-            this.shouldSuppressKeyboardShortcut(e)) {
-          return;
-        }
-        e.preventDefault();
-        const handler = this._shortcut_go_table.get(e.detail.key);
-        this[handler](e);
-      },
-    },
-  ];
-
-  Gerrit.KeyboardShortcutBinder = {
-    DOC_ONLY,
-    GO_KEY,
-    Shortcut,
-    ShortcutManager,
-    ShortcutSection,
-
-    bindShortcut(shortcut, ...bindings) {
-      shortcutManager.bindShortcut(shortcut, ...bindings);
-    },
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
new file mode 100644
index 0000000..cb21a9f
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
@@ -0,0 +1,660 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+
+How to Add a Keyboard Shortcut
+==============================
+
+A keyboard shortcut is composed of the following parts:
+
+  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
+  2. Documentation for the keyboard shortcut help dialog
+  3. A binding between key combos and the semantic identifier
+  4. A binding between the semantic identifier and a listener
+
+Parts (1) and (2) for all shortcuts are defined in this file. The semantic
+identifier is declared in the Shortcut enum near the head of this script:
+
+  const Shortcut = {
+    // ...
+    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+    // ...
+  };
+
+Immediately following the Shortcut enum definition, there is a _describe
+function defined which is then invoked many times to populate the help dialog.
+Add a new invocation here to document the shortcut:
+
+  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+      'Hide/show left diff');
+
+When an attached view binds one or more key combos to this shortcut, the help
+dialog will display this text in the given section (in this case, "Diffs"). See
+the ShortcutSection enum immediately below for the list of supported sections.
+
+Part (3), the actual key bindings, are declared by gr-app. In the future, this
+system may be expanded to allow key binding customizations by plugins or user
+preferences. Key bindings are defined in the following forms:
+
+  // Ordinary shortcut with a single binding.
+  this.bindShortcut(
+      this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
+  // Ordinary shortcut with multiple bindings.
+  this.bindShortcut(
+      this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+
+  // A "go-key" keyboard shortcut, which is combined with a previously and
+  // continuously pressed "go" key (the go-key is hard-coded as 'g').
+  this.bindShortcut(
+      this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+
+  // A "doc-only" keyboard shortcut. This declares the key-binding for help
+  // dialog purposes, but doesn't actually implement the binding. It is up
+  // to some element to implement this binding using iron-a11y-keys-behavior's
+  // keyBindings property.
+  this.bindShortcut(
+      this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+
+Part (4), the listener definitions, are declared by the view or element that
+implements the shortcut behavior. This is done by implementing a method named
+keyboardShortcuts() in an element that mixes in this behavior, returning an
+object that maps semantic identifiers (as property names) to listener method
+names, like this:
+
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+    };
+  },
+
+You can implement key bindings in an element that is hosted by a view IF that
+element is always attached exactly once under that view (e.g. the search bar in
+gr-app). When that is not the case, you will have to define a doc-only binding
+in gr-app, declare the shortcut in the view that hosts the element, and use
+iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
+element. An example of this is in comment threads. A diff view supports actions
+on comment threads, but there may be zero or many comment threads attached at
+any given point. So the shortcut is declared as doc-only by the diff view and
+by gr-app, and actually implemented by gr-comment-thread.
+
+NOTE: doc-only shortcuts will not be customizable in the same way that other
+shortcuts are.
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import '../../scripts/bundled-polymer.js';
+
+import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const DOC_ONLY = 'DOC_ONLY';
+const GO_KEY = 'GO_KEY';
+
+// The maximum age of a keydown event to be used in a jump navigation. This
+// is only for cases when the keyup event is lost.
+const GO_KEY_TIMEOUT_MS = 1000;
+
+const ShortcutSection = {
+  ACTIONS: 'Actions',
+  DIFFS: 'Diffs',
+  EVERYWHERE: 'Everywhere',
+  FILE_LIST: 'File list',
+  NAVIGATION: 'Navigation',
+  REPLY_DIALOG: 'Reply dialog',
+};
+
+const Shortcut = {
+  OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
+  GO_TO_USER_DASHBOARD: 'GO_TO_USER_DASHBOARD',
+  GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
+  GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
+  GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',
+  GO_TO_WATCHED_CHANGES: 'GO_TO_WATCHED_CHANGES',
+
+  CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
+  CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
+  OPEN_CHANGE: 'OPEN_CHANGE',
+  NEXT_PAGE: 'NEXT_PAGE',
+  PREV_PAGE: 'PREV_PAGE',
+  TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
+  TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
+  REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',
+
+  OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
+  OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
+  EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
+  COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
+  UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
+  UP_TO_CHANGE: 'UP_TO_CHANGE',
+  TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
+  REFRESH_CHANGE: 'REFRESH_CHANGE',
+  EDIT_TOPIC: 'EDIT_TOPIC',
+
+  NEXT_LINE: 'NEXT_LINE',
+  PREV_LINE: 'PREV_LINE',
+  VISIBLE_LINE: 'VISIBLE_LINE',
+  NEXT_CHUNK: 'NEXT_CHUNK',
+  PREV_CHUNK: 'PREV_CHUNK',
+  EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
+  NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
+  PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
+  EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
+  COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
+  LEFT_PANE: 'LEFT_PANE',
+  RIGHT_PANE: 'RIGHT_PANE',
+  TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+  NEW_COMMENT: 'NEW_COMMENT',
+  SAVE_COMMENT: 'SAVE_COMMENT',
+  OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
+  TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',
+
+  NEXT_FILE: 'NEXT_FILE',
+  PREV_FILE: 'PREV_FILE',
+  NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
+  PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
+  NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
+  CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
+  CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
+  OPEN_FILE: 'OPEN_FILE',
+  TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
+  TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
+  TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
+
+  OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
+  OPEN_LAST_FILE: 'OPEN_LAST_FILE',
+
+  SEARCH: 'SEARCH',
+  SEND_REPLY: 'SEND_REPLY',
+  EMOJI_DROPDOWN: 'EMOJI_DROPDOWN',
+  TOGGLE_BLAME: 'TOGGLE_BLAME',
+};
+
+const _help = new Map();
+
+function _describe(shortcut, section, text) {
+  if (!_help.has(section)) {
+    _help.set(section, []);
+  }
+  _help.get(section).push({shortcut, text});
+}
+
+_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
+_describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
+    'Show this dialog');
+_describe(Shortcut.GO_TO_USER_DASHBOARD, ShortcutSection.EVERYWHERE,
+    'Go to User Dashboard');
+_describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
+    'Go to Opened Changes');
+_describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
+    'Go to Merged Changes');
+_describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
+    'Go to Abandoned Changes');
+_describe(Shortcut.GO_TO_WATCHED_CHANGES, ShortcutSection.EVERYWHERE,
+    'Go to Watched Changes');
+
+_describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
+    'Select next change');
+_describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
+    'Select previous change');
+_describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
+    'Show selected change');
+_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
+_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
+_describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
+    'Open reply dialog to publish comments and add reviewers');
+_describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
+    'Open download overlay');
+_describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
+    'Expand all messages');
+_describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
+    'Collapse all messages');
+_describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
+    'Reload the change at the latest patch');
+_describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
+    'Mark/unmark change as reviewed');
+_describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
+    'Toggle review flag on selected file');
+_describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
+    'Refresh list of changes');
+_describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
+    'Star/unstar change');
+_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
+    'Add a change topic');
+
+_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
+_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+_describe(Shortcut.VISIBLE_LINE, ShortcutSection.DIFFS,
+    'Move cursor to currently visible code');
+_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
+    'Go to next diff chunk');
+_describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
+    'Go to previous diff chunk');
+_describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
+    'Expand all diff context');
+_describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
+    'Go to next comment thread');
+_describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
+    'Go to previous comment thread');
+_describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
+    'Expand all comment threads');
+_describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
+    'Collapse all comment threads');
+_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
+_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
+_describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+    'Hide/show left diff');
+_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
+_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
+_describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
+    'Show diff preferences');
+_describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
+    'Mark/unmark file as reviewed');
+_describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
+    'Toggle unified/side-by-side diff');
+_describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
+    'Mark file as reviewed and go to next unreviewed file');
+_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
+
+_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
+_describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
+    'Go to previous file');
+_describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
+    'Go to next file that has comments');
+_describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
+    'Go to previous file that has comments');
+_describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
+    'Go to first file');
+_describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
+    'Go to last file');
+_describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
+    'Up to dashboard');
+_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
+
+_describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
+    'Select next file');
+_describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
+    'Select previous file');
+_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
+    'Go to selected file');
+_describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
+    'Show/hide all inline diffs');
+_describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
+    'Show/hide selected inline diff');
+
+_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
+_describe(Shortcut.EMOJI_DROPDOWN, ShortcutSection.REPLY_DIALOG,
+    'Emoji dropdown');
+
+// Must be declared outside behavior implementation to be accessed inside
+// behavior functions.
+
+/** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
+const getKeyboardEvent = function(e) {
+  e = dom(e.detail ? e.detail.keyboardEvent : e);
+  // When e is a keyboardEvent, e.event is not null.
+  if (e.event) { e = e.event; }
+  return e;
+};
+
+class ShortcutManager {
+  constructor() {
+    this.activeHosts = new Map();
+    this.bindings = new Map();
+    this.listeners = new Set();
+  }
+
+  bindShortcut(shortcut, ...bindings) {
+    this.bindings.set(shortcut, bindings);
+  }
+
+  getBindingsForShortcut(shortcut) {
+    return this.bindings.get(shortcut);
+  }
+
+  attachHost(host) {
+    if (!host.keyboardShortcuts) { return; }
+    const shortcuts = host.keyboardShortcuts();
+    this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+    this.notifyListeners();
+    return shortcuts;
+  }
+
+  detachHost(host) {
+    if (this.activeHosts.delete(host)) {
+      this.notifyListeners();
+      return true;
+    }
+    return false;
+  }
+
+  addListener(listener) {
+    this.listeners.add(listener);
+    listener(this.directoryView());
+  }
+
+  removeListener(listener) {
+    return this.listeners.delete(listener);
+  }
+
+  getDescription(section, shortcutName) {
+    const binding =
+        _help.get(section).find(binding => binding.shortcut == shortcutName);
+    return binding ? binding.text : '';
+  }
+
+  getShortcut(shortcutName) {
+    const binding = this.bindings.get(shortcutName);
+    return binding ? this.describeBinding(binding) : '';
+  }
+
+  activeShortcutsBySection() {
+    const activeShortcuts = new Set();
+    this.activeHosts.forEach(shortcuts => {
+      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+    });
+
+    const activeShortcutsBySection = new Map();
+    _help.forEach((shortcutList, section) => {
+      shortcutList.forEach(shortcutHelp => {
+        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+          if (!activeShortcutsBySection.has(section)) {
+            activeShortcutsBySection.set(section, []);
+          }
+          activeShortcutsBySection.get(section).push(shortcutHelp);
+        }
+      });
+    });
+    return activeShortcutsBySection;
+  }
+
+  directoryView() {
+    const view = new Map();
+    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+      const sectionView = [];
+      shortcutHelps.forEach(shortcutHelp => {
+        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+        if (!bindingDesc) { return; }
+        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+          sectionView.push({
+            binding: bindingDesc,
+            text: shortcutHelp.text,
+          });
+        });
+      });
+      view.set(section, sectionView);
+    });
+    return view;
+  }
+
+  distributeBindingDesc(bindingDesc) {
+    if (bindingDesc.length === 1 ||
+        this.comboSetDisplayWidth(bindingDesc) < 21) {
+      return [bindingDesc];
+    }
+    // Find the largest prefix of bindings that is under the
+    // size threshold.
+    const head = [bindingDesc[0]];
+    for (let i = 1; i < bindingDesc.length; i++) {
+      head.push(bindingDesc[i]);
+      if (this.comboSetDisplayWidth(head) >= 21) {
+        head.pop();
+        return [head].concat(
+            this.distributeBindingDesc(bindingDesc.slice(i)));
+      }
+    }
+  }
+
+  comboSetDisplayWidth(bindingDesc) {
+    const bindingSizer = binding => binding.reduce(
+        (acc, key) => acc + key.length, 0);
+    // Width is the sum of strings + (n-1) * 2 to account for the word
+    // "or" joining them.
+    return bindingDesc.reduce(
+        (acc, binding) => acc + bindingSizer(binding), 0) +
+        2 * (bindingDesc.length - 1);
+  }
+
+  describeBindings(shortcut) {
+    const bindings = this.bindings.get(shortcut);
+    if (!bindings) { return null; }
+    if (bindings[0] === GO_KEY) {
+      return [['g'].concat(bindings.slice(1))];
+    }
+    return bindings
+        .filter(binding => binding !== DOC_ONLY)
+        .map(binding => this.describeBinding(binding));
+  }
+
+  describeBinding(binding) {
+    if (binding.length === 1) {
+      return [binding];
+    }
+    return binding.split(':')[0].split('+').map(part => {
+      switch (part) {
+        case 'shift':
+          return 'Shift';
+        case 'meta':
+          return 'Meta';
+        case 'ctrl':
+          return 'Ctrl';
+        case 'enter':
+          return 'Enter';
+        case 'up':
+          return '↑';
+        case 'down':
+          return '↓';
+        case 'left':
+          return '←';
+        case 'right':
+          return '→';
+        default:
+          return part;
+      }
+    });
+  }
+
+  notifyListeners() {
+    const view = this.directoryView();
+    this.listeners.forEach(listener => listener(view));
+  }
+}
+
+const shortcutManager = new ShortcutManager();
+
+/** @polymerBehavior Gerrit.KeyboardShortcutBehavior*/
+export const KeyboardShortcutBehavior = [
+  IronA11yKeysBehavior,
+  {
+    // Exports for convenience. Note: Closure compiler crashes when
+    // object-shorthand syntax is used here.
+    // eslint-disable-next-line object-shorthand
+    DOC_ONLY: DOC_ONLY,
+    // eslint-disable-next-line object-shorthand
+    GO_KEY: GO_KEY,
+    // eslint-disable-next-line object-shorthand
+    Shortcut: Shortcut,
+    // eslint-disable-next-line object-shorthand
+    ShortcutSection: ShortcutSection,
+
+    properties: {
+      _shortcut_go_key_last_pressed: {
+        type: Number,
+        value: null,
+      },
+
+      _shortcut_go_table: {
+        type: Array,
+        value() { return new Map(); },
+      },
+    },
+
+    modifierPressed(e) {
+      e = getKeyboardEvent(e);
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+    },
+
+    isModifierPressed(e, modifier) {
+      return getKeyboardEvent(e)[modifier];
+    },
+
+    shouldSuppressKeyboardShortcut(e) {
+      e = getKeyboardEvent(e);
+      const tagName = dom(e).rootTarget.tagName;
+      if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
+          (e.keyCode === 13 && tagName === 'A')) {
+        // Suppress shortcuts if the key is 'enter' and target is an anchor.
+        return true;
+      }
+      for (let i = 0; e.path && i < e.path.length; i++) {
+        if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
+      }
+
+      this.dispatchEvent(new CustomEvent('shortcut-triggered', {
+        detail: {
+          event: e,
+          goKey: this._inGoKeyMode(),
+        },
+        composed: true, bubbles: true,
+      }));
+      return false;
+    },
+
+    // Alias for getKeyboardEvent.
+    /** @return {!Event} */
+    getKeyboardEvent(e) {
+      return getKeyboardEvent(e);
+    },
+
+    getRootTarget(e) {
+      return dom(getKeyboardEvent(e)).rootTarget;
+    },
+
+    bindShortcut(shortcut, ...bindings) {
+      shortcutManager.bindShortcut(shortcut, ...bindings);
+    },
+
+    createTitle(shortcutName, section) {
+      const desc = shortcutManager.getDescription(section, shortcutName);
+      const shortcut = shortcutManager.getShortcut(shortcutName);
+      return (desc && shortcut) ? `${desc} (shortcut: ${shortcut})` : '';
+    },
+
+    _addOwnKeyBindings(shortcut, handler) {
+      const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+      if (!bindings) {
+        return;
+      }
+      if (bindings[0] === DOC_ONLY) {
+        return;
+      }
+      if (bindings[0] === GO_KEY) {
+        this._shortcut_go_table.set(bindings[1], handler);
+      } else {
+        this.addOwnKeyBinding(bindings.join(' '), handler);
+      }
+    },
+
+    /** @override */
+    attached() {
+      const shortcuts = shortcutManager.attachHost(this);
+      if (!shortcuts) { return; }
+
+      for (const key of Object.keys(shortcuts)) {
+        this._addOwnKeyBindings(key, shortcuts[key]);
+      }
+
+      // If any of the shortcuts utilized GO_KEY, then they are handled
+      // directly by this behavior.
+      if (this._shortcut_go_table.size > 0) {
+        this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+        this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+        this._shortcut_go_table.forEach((handler, key) => {
+          this.addOwnKeyBinding(key, '_handleGoAction');
+        });
+      }
+    },
+
+    /** @override */
+    detached() {
+      if (shortcutManager.detachHost(this)) {
+        this.removeOwnKeyBindings();
+      }
+    },
+
+    keyboardShortcuts() {
+      return {};
+    },
+
+    addKeyboardShortcutDirectoryListener(listener) {
+      shortcutManager.addListener(listener);
+    },
+
+    removeKeyboardShortcutDirectoryListener(listener) {
+      shortcutManager.removeListener(listener);
+    },
+
+    _handleGoKeyDown(e) {
+      if (this.modifierPressed(e)) { return; }
+      this._shortcut_go_key_last_pressed = Date.now();
+    },
+
+    _handleGoKeyUp(e) {
+      this._shortcut_go_key_last_pressed = null;
+    },
+
+    _inGoKeyMode() {
+      return this._shortcut_go_key_last_pressed &&
+          (Date.now() - this._shortcut_go_key_last_pressed <=
+              GO_KEY_TIMEOUT_MS);
+    },
+
+    _handleGoAction(e) {
+      if (!this._inGoKeyMode() ||
+          !this._shortcut_go_table.has(e.detail.key) ||
+          this.shouldSuppressKeyboardShortcut(e)) {
+        return;
+      }
+      e.preventDefault();
+      const handler = this._shortcut_go_table.get(e.detail.key);
+      this[handler](e);
+    },
+  },
+];
+
+export const KeyboardShortcutBinder = {
+  DOC_ONLY,
+  GO_KEY,
+  Shortcut,
+  ShortcutManager,
+  ShortcutSection,
+
+  bindShortcut(shortcut, ...bindings) {
+    shortcutManager.bindShortcut(shortcut, ...bindings);
+  },
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.KeyboardShortcutBehavior = KeyboardShortcutBehavior;
+window.Gerrit.KeyboardShortcutBinder = KeyboardShortcutBinder;
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..fcb7b4f 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
@@ -17,15 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="keyboard-shortcut-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <test-element></test-element>
@@ -40,402 +38,405 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('keyboard-shortcut-behavior tests', () => {
-    const kb = window.Gerrit.KeyboardShortcutBinder;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {KeyboardShortcutBehavior, KeyboardShortcutBinder} from './keyboard-shortcut-behavior.js';
+suite('keyboard-shortcut-behavior tests', () => {
+  const kb = KeyboardShortcutBinder;
 
-    let element;
-    let overlay;
-    let sandbox;
+  let element;
+  let overlay;
+  let sandbox;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [Gerrit.KeyboardShortcutBehavior],
-        keyBindings: {
-          k: '_handleKey',
-          enter: '_handleKey',
-        },
-        _handleKey() {},
-      });
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [KeyboardShortcutBehavior],
+      keyBindings: {
+        k: '_handleKey',
+        enter: '_handleKey',
+      },
+      _handleKey() {},
+    });
+  });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('ShortcutManager', () => {
+    test('bindings management', () => {
+      const mgr = new kb.ShortcutManager();
+      const {NEXT_FILE} = kb.Shortcut;
+
+      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+      assert.deepEqual(
+          mgr.getBindingsForShortcut(NEXT_FILE),
+          [']', '}', 'right']);
     });
 
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-      sandbox = sinon.sandbox.create();
-    });
+    suite('binding descriptions', () => {
+      function mapToObject(m) {
+        const o = {};
+        m.forEach((v, k) => o[k] = v);
+        return o;
+      }
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('ShortcutManager', () => {
-      test('bindings management', () => {
+      test('single combo description', () => {
         const mgr = new kb.ShortcutManager();
-        const {NEXT_FILE} = kb.Shortcut;
-
-        assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-        mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+        assert.deepEqual(mgr.describeBinding('a'), ['a']);
+        assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+        assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
         assert.deepEqual(
-            mgr.getBindingsForShortcut(NEXT_FILE),
-            [']', '}', 'right']);
+            mgr.describeBinding('ctrl+shift+up:keyup'),
+            ['Ctrl', 'Shift', '↑']);
       });
 
-      suite('binding descriptions', () => {
-        function mapToObject(m) {
-          const o = {};
-          m.forEach((v, k) => o[k] = v);
-          return o;
-        }
+      test('combo set description', () => {
+        const {GO_KEY, DOC_ONLY, ShortcutManager} = kb;
+        const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut;
 
-        test('single combo description', () => {
-          const mgr = new kb.ShortcutManager();
-          assert.deepEqual(mgr.describeBinding('a'), ['a']);
-          assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
-          assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
-          assert.deepEqual(
-              mgr.describeBinding('ctrl+shift+up:keyup'),
-              ['Ctrl', 'Shift', '↑']);
+        const mgr = new ShortcutManager();
+        assert.isNull(mgr.describeBindings(NEXT_FILE));
+
+        mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+        assert.deepEqual(
+            mgr.describeBindings(GO_TO_OPENED_CHANGES),
+            [['g', 'o']]);
+
+        mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup');
+        assert.deepEqual(
+            mgr.describeBindings(NEXT_FILE),
+            [[']'], ['Ctrl', 'Shift', '→']]);
+
+        mgr.bindShortcut(PREV_FILE, '[');
+        assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]);
+      });
+
+      test('combo set description width', () => {
+        const mgr = new kb.ShortcutManager();
+        assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+        assert.strictEqual(
+            mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+            12);
+      });
+
+      test('distribute shortcut help', () => {
+        const mgr = new kb.ShortcutManager();
+        assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([['g', 'o']]),
+            [[['g', 'o']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+            [[['ctrl', 'shift', 'meta', 'enter']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([
+              ['ctrl', 'shift', 'meta', 'enter'],
+              ['o'],
+            ]),
+            [
+              [['ctrl', 'shift', 'meta', 'enter']],
+              [['o']],
+            ]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([
+              ['ctrl', 'enter'],
+              ['meta', 'enter'],
+              ['ctrl', 's'],
+              ['meta', 's'],
+            ]),
+            [
+              [['ctrl', 'enter'], ['meta', 'enter']],
+              [['ctrl', 's'], ['meta', 's']],
+            ]);
+      });
+
+      test('active shortcuts by section', () => {
+        const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} =
+            kb.Shortcut;
+        const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+
+        const mgr = new kb.ShortcutManager();
+        mgr.bindShortcut(NEXT_FILE, ']');
+        mgr.bindShortcut(NEXT_LINE, 'j');
+        mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o');
+        mgr.bindShortcut(SEARCH, '/');
+
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [NEXT_FILE]: null,
+            };
+          },
         });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [NAVIGATION]: [
+                {shortcut: NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
 
-        test('combo set description', () => {
-          const {GO_KEY, DOC_ONLY, ShortcutManager} = kb;
-          const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut;
-
-          const mgr = new ShortcutManager();
-          assert.isNull(mgr.describeBindings(NEXT_FILE));
-
-          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
-          assert.deepEqual(
-              mgr.describeBindings(GO_TO_OPENED_CHANGES),
-              [['g', 'o']]);
-
-          mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup');
-          assert.deepEqual(
-              mgr.describeBindings(NEXT_FILE),
-              [[']'], ['Ctrl', 'Shift', '→']]);
-
-          mgr.bindShortcut(PREV_FILE, '[');
-          assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]);
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [NEXT_LINE]: null,
+            };
+          },
         });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [DIFFS]: [
+                {shortcut: NEXT_LINE, text: 'Go to next line'},
+              ],
+              [NAVIGATION]: [
+                {shortcut: NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
 
-        test('combo set description width', () => {
-          const mgr = new kb.ShortcutManager();
-          assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
-          assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
-          assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
-          assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
-          assert.strictEqual(
-              mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
-              12);
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [SEARCH]: null,
+              [GO_TO_OPENED_CHANGES]: null,
+            };
+          },
         });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [DIFFS]: [
+                {shortcut: NEXT_LINE, text: 'Go to next line'},
+              ],
+              [EVERYWHERE]: [
+                {shortcut: SEARCH, text: 'Search'},
+                {
+                  shortcut: GO_TO_OPENED_CHANGES,
+                  text: 'Go to Opened Changes',
+                },
+              ],
+              [NAVIGATION]: [
+                {shortcut: NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+      });
 
-        test('distribute shortcut help', () => {
-          const mgr = new kb.ShortcutManager();
-          assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([['g', 'o']]),
-              [[['g', 'o']]]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
-              [[['ctrl', 'shift', 'meta', 'enter']]]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([
-                ['ctrl', 'shift', 'meta', 'enter'],
-                ['o'],
-              ]),
-              [
-                [['ctrl', 'shift', 'meta', 'enter']],
-                [['o']],
-              ]);
-          assert.deepEqual(
-              mgr.distributeBindingDesc([
-                ['ctrl', 'enter'],
-                ['meta', 'enter'],
-                ['ctrl', 's'],
-                ['meta', 's'],
-              ]),
-              [
-                [['ctrl', 'enter'], ['meta', 'enter']],
-                [['ctrl', 's'], ['meta', 's']],
-              ]);
+      test('directory view', () => {
+        const {
+          NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
+          SAVE_COMMENT,
+        } = kb.Shortcut;
+        const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
+        const {GO_KEY, ShortcutManager} = kb;
+
+        const mgr = new ShortcutManager();
+        mgr.bindShortcut(NEXT_FILE, ']');
+        mgr.bindShortcut(NEXT_LINE, 'j');
+        mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
+        mgr.bindShortcut(SEARCH, '/');
+        mgr.bindShortcut(
+            SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+
+        assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [GO_TO_OPENED_CHANGES]: null,
+              [NEXT_FILE]: null,
+              [NEXT_LINE]: null,
+              [SAVE_COMMENT]: null,
+              [SEARCH]: null,
+            };
+          },
         });
-
-        test('active shortcuts by section', () => {
-          const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} =
-              kb.Shortcut;
-          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
-
-          const mgr = new kb.ShortcutManager();
-          mgr.bindShortcut(NEXT_FILE, ']');
-          mgr.bindShortcut(NEXT_LINE, 'j');
-          mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o');
-          mgr.bindShortcut(SEARCH, '/');
-
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {});
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [NEXT_FILE]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {
-                [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Select next file'},
-                ],
-              });
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [NEXT_LINE]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {
-                [DIFFS]: [
-                  {shortcut: NEXT_LINE, text: 'Go to next line'},
-                ],
-                [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Select next file'},
-                ],
-              });
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [SEARCH]: null,
-                [GO_TO_OPENED_CHANGES]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.activeShortcutsBySection()),
-              {
-                [DIFFS]: [
-                  {shortcut: NEXT_LINE, text: 'Go to next line'},
-                ],
-                [EVERYWHERE]: [
-                  {shortcut: SEARCH, text: 'Search'},
-                  {
-                    shortcut: GO_TO_OPENED_CHANGES,
-                    text: 'Go to Opened Changes',
-                  },
-                ],
-                [NAVIGATION]: [
-                  {shortcut: NEXT_FILE, text: 'Select next file'},
-                ],
-              });
-        });
-
-        test('directory view', () => {
-          const {
-            NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
-            SAVE_COMMENT,
-          } = kb.Shortcut;
-          const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
-          const {GO_KEY, ShortcutManager} = kb;
-
-          const mgr = new ShortcutManager();
-          mgr.bindShortcut(NEXT_FILE, ']');
-          mgr.bindShortcut(NEXT_LINE, 'j');
-          mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
-          mgr.bindShortcut(SEARCH, '/');
-          mgr.bindShortcut(
-              SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
-
-          assert.deepEqual(mapToObject(mgr.directoryView()), {});
-
-          mgr.attachHost({
-            keyboardShortcuts() {
-              return {
-                [GO_TO_OPENED_CHANGES]: null,
-                [NEXT_FILE]: null,
-                [NEXT_LINE]: null,
-                [SAVE_COMMENT]: null,
-                [SEARCH]: null,
-              };
-            },
-          });
-          assert.deepEqual(
-              mapToObject(mgr.directoryView()),
-              {
-                [DIFFS]: [
-                  {binding: [['j']], text: 'Go to next line'},
-                  {
-                    binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
-                    text: 'Save comment',
-                  },
-                  {
-                    binding: [['Ctrl', 's'], ['Meta', 's']],
-                    text: 'Save comment',
-                  },
-                ],
-                [EVERYWHERE]: [
-                  {binding: [['/']], text: 'Search'},
-                  {binding: [['g', 'o']], text: 'Go to Opened Changes'},
-                ],
-                [NAVIGATION]: [
-                  {binding: [[']']], text: 'Select next file'},
-                ],
-              });
-        });
-      });
-    });
-
-    test('doesn’t block kb shortcuts for non-whitelisted els', done => {
-      const divEl = document.createElement('div');
-      element.appendChild(divEl);
-      element._handleKey = e => {
-        assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    });
-
-    test('blocks kb shortcuts for input els', done => {
-      const inputEl = document.createElement('input');
-      element.appendChild(inputEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-    });
-
-    test('blocks kb shortcuts for textarea els', done => {
-      const textareaEl = document.createElement('textarea');
-      element.appendChild(textareaEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
-    });
-
-    test('blocks kb shortcuts for anything in a gr-overlay', done => {
-      const divEl = document.createElement('div');
-      const element = overlay.querySelector('test-element');
-      element.appendChild(divEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    });
-
-    test('blocks enter shortcut on an anchor', done => {
-      const anchorEl = document.createElement('a');
-      const element = overlay.querySelector('test-element');
-      element.appendChild(anchorEl);
-      element._handleKey = e => {
-        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-        done();
-      };
-      MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
-    });
-
-    test('modifierPressed returns accurate values', () => {
-      const spy = sandbox.spy(element, 'modifierPressed');
-      element._handleKey = e => {
-        element.modifierPressed(e);
-      };
-      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-    });
-
-    test('isModifierPressed returns accurate value', () => {
-      const spy = sandbox.spy(element, 'isModifierPressed');
-      element._handleKey = e => {
-        element.isModifierPressed(e, 'shiftKey');
-      };
-      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-      assert.isTrue(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, null, 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-      assert.isFalse(spy.lastCall.returnValue);
-    });
-
-    suite('GO_KEY timing', () => {
-      let handlerStub;
-
-      setup(() => {
-        element._shortcut_go_table.set('a', '_handleA');
-        handlerStub = element._handleA = sinon.stub();
-        sandbox.stub(Date, 'now').returns(10000);
-      });
-
-      test('success', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = 9000;
-        element._handleGoAction(e);
-        assert.isTrue(handlerStub.calledOnce);
-        assert.strictEqual(handlerStub.lastCall.args[0], e);
-      });
-
-      test('go key not pressed', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = null;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
-      });
-
-      test('go key pressed too long ago', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = 3000;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
-      });
-
-      test('should suppress', () => {
-        const e = {detail: {key: 'a'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-        element._shortcut_go_key_last_pressed = 9000;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
-      });
-
-      test('unrecognized key', () => {
-        const e = {detail: {key: 'f'}, preventDefault: () => {}};
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        element._shortcut_go_key_last_pressed = 9000;
-        element._handleGoAction(e);
-        assert.isFalse(handlerStub.called);
+        assert.deepEqual(
+            mapToObject(mgr.directoryView()),
+            {
+              [DIFFS]: [
+                {binding: [['j']], text: 'Go to next line'},
+                {
+                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
+                  text: 'Save comment',
+                },
+                {
+                  binding: [['Ctrl', 's'], ['Meta', 's']],
+                  text: 'Save comment',
+                },
+              ],
+              [EVERYWHERE]: [
+                {binding: [['/']], text: 'Search'},
+                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+              ],
+              [NAVIGATION]: [
+                {binding: [[']']], text: 'Go to next file'},
+              ],
+            });
       });
     });
   });
+
+  test('doesn’t block kb shortcuts for non-whitelisted els', done => {
+    const divEl = document.createElement('div');
+    element.appendChild(divEl);
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for input els', done => {
+    const inputEl = document.createElement('input');
+    element.appendChild(inputEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for textarea els', done => {
+    const textareaEl = document.createElement('textarea');
+    element.appendChild(textareaEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for anything in a gr-overlay', done => {
+    const divEl = document.createElement('div');
+    const element = overlay.querySelector('test-element');
+    element.appendChild(divEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+  });
+
+  test('blocks enter shortcut on an anchor', done => {
+    const anchorEl = document.createElement('a');
+    const element = overlay.querySelector('test-element');
+    element.appendChild(anchorEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+  });
+
+  test('modifierPressed returns accurate values', () => {
+    const spy = sandbox.spy(element, 'modifierPressed');
+    element._handleKey = e => {
+      element.modifierPressed(e);
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+  });
+
+  test('isModifierPressed returns accurate value', () => {
+    const spy = sandbox.spy(element, 'isModifierPressed');
+    element._handleKey = e => {
+      element.isModifierPressed(e, 'shiftKey');
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+  });
+
+  suite('GO_KEY timing', () => {
+    let handlerStub;
+
+    setup(() => {
+      element._shortcut_go_table.set('a', '_handleA');
+      handlerStub = element._handleA = sinon.stub();
+      sandbox.stub(Date, 'now').returns(10000);
+    });
+
+    test('success', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isTrue(handlerStub.calledOnce);
+      assert.strictEqual(handlerStub.lastCall.args[0], e);
+    });
+
+    test('go key not pressed', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = null;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('go key pressed too long ago', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 3000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('should suppress', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('unrecognized key', () => {
+      const e = {detail: {key: 'f'}, preventDefault: () => {}};
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+  });
+});
 </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
deleted file mode 100644
index 85bc6a1..0000000
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ /dev/null
@@ -1,181 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.RESTClientBehavior */
-  Gerrit.RESTClientBehavior = [{
-    ChangeDiffType: {
-      ADDED: 'ADDED',
-      COPIED: 'COPIED',
-      DELETED: 'DELETED',
-      MODIFIED: 'MODIFIED',
-      RENAMED: 'RENAMED',
-      REWRITE: 'REWRITE',
-    },
-
-    ChangeStatus: {
-      ABANDONED: 'ABANDONED',
-      MERGED: 'MERGED',
-      NEW: 'NEW',
-    },
-
-    // Must be kept in sync with the ListChangesOption enum and protobuf.
-    ListChangesOption: {
-      LABELS: 0,
-      DETAILED_LABELS: 8,
-
-      // Return information on the current patch set of the change.
-      CURRENT_REVISION: 1,
-      ALL_REVISIONS: 2,
-
-      // If revisions are included, parse the commit object.
-      CURRENT_COMMIT: 3,
-      ALL_COMMITS: 4,
-
-      // If a patch set is included, include the files of the patch set.
-      CURRENT_FILES: 5,
-      ALL_FILES: 6,
-
-      // If accounts are included, include detailed account info.
-      DETAILED_ACCOUNTS: 7,
-
-      // Include messages associated with the change.
-      MESSAGES: 9,
-
-      // Include allowed actions client could perform.
-      CURRENT_ACTIONS: 10,
-
-      // Set the reviewed boolean for the caller.
-      REVIEWED: 11,
-
-      // Include download commands for the caller.
-      DOWNLOAD_COMMANDS: 13,
-
-      // Include patch set weblinks.
-      WEB_LINKS: 14,
-
-      // Include consistency check results.
-      CHECK: 15,
-
-      // Include allowed change actions client could perform.
-      CHANGE_ACTIONS: 16,
-
-      // Include a copy of commit messages including review footers.
-      COMMIT_FOOTERS: 17,
-
-      // Include push certificate information along with any patch sets.
-      PUSH_CERTIFICATES: 18,
-
-      // Include change's reviewer updates.
-      REVIEWER_UPDATES: 19,
-
-      // Set the submittable boolean.
-      SUBMITTABLE: 20,
-
-      // If tracking ids are included, include detailed tracking ids info.
-      TRACKING_IDS: 21,
-
-      // Skip mergeability data.
-      SKIP_MERGEABLE: 22,
-
-      /**
-       * Skip diffstat computation that compute the insertions field (number of lines inserted) and
-       * deletions field (number of lines deleted)
-       */
-      SKIP_DIFFSTAT: 23,
-    },
-
-    listChangesOptionsToHex(...args) {
-      let v = 0;
-      for (let i = 0; i < args.length; i++) {
-        v |= 1 << args[i];
-      }
-      return v.toString(16);
-    },
-
-    /**
-     *  @return {string}
-     */
-    changeBaseURL(project, changeNum, patchNum) {
-      let v = this.getBaseUrl() + '/changes/' +
-         encodeURIComponent(project) + '~' + changeNum;
-      if (patchNum) {
-        v += '/revisions/' + patchNum;
-      }
-      return v;
-    },
-
-    changePath(changeNum) {
-      return this.getBaseUrl() + '/c/' + changeNum;
-    },
-
-    changeIsOpen(change) {
-      return change && change.status === this.ChangeStatus.NEW;
-    },
-
-    /**
-     * @param {!Object} change
-     * @param {!Object=} opt_options
-     *
-     * @return {!Array}
-     */
-    changeStatuses(change, opt_options) {
-      const states = [];
-      if (change.status === this.ChangeStatus.MERGED) {
-        states.push('Merged');
-      } else if (change.status === this.ChangeStatus.ABANDONED) {
-        states.push('Abandoned');
-      } else if (change.mergeable === false ||
-          (opt_options && opt_options.mergeable === false)) {
-        // 'mergeable' prop may not always exist (@see Issue 6819)
-        states.push('Merge Conflict');
-      }
-      if (change.work_in_progress) { states.push('WIP'); }
-      if (change.is_private) { states.push('Private'); }
-
-      // If there are any pre-defined statuses, only return those. Otherwise,
-      // will determine the derived status.
-      if (states.length || !opt_options) { return states; }
-
-      // If no missing requirements, either active or ready to submit.
-      if (change.submittable && opt_options.submitEnabled) {
-        states.push('Ready to submit');
-      } else {
-        // Otherwise it is active.
-        states.push('Active');
-      }
-      return states;
-    },
-
-    /**
-     * @param {!Object} change
-     * @return {String}
-     */
-    changeStatusString(change) {
-      return this.changeStatuses(change).join(', ');
-    },
-  },
-  Gerrit.BaseUrlBehavior,
-  ];
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
new file mode 100644
index 0000000..919a763
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
@@ -0,0 +1,201 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../scripts/bundled-polymer.js';
+import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
+
+/** @polymerBehavior Gerrit.RESTClientBehavior */
+export const RESTClientBehavior = [{
+  ChangeDiffType: {
+    ADDED: 'ADDED',
+    COPIED: 'COPIED',
+    DELETED: 'DELETED',
+    MODIFIED: 'MODIFIED',
+    RENAMED: 'RENAMED',
+    REWRITE: 'REWRITE',
+  },
+
+  ChangeStatus: {
+    ABANDONED: 'ABANDONED',
+    MERGED: 'MERGED',
+    NEW: 'NEW',
+  },
+
+  // Must be kept in sync with the ListChangesOption enum and protobuf.
+  ListChangesOption: {
+    LABELS: 0,
+    DETAILED_LABELS: 8,
+
+    // Return information on the current patch set of the change.
+    CURRENT_REVISION: 1,
+    ALL_REVISIONS: 2,
+
+    // If revisions are included, parse the commit object.
+    CURRENT_COMMIT: 3,
+    ALL_COMMITS: 4,
+
+    // If a patch set is included, include the files of the patch set.
+    CURRENT_FILES: 5,
+    ALL_FILES: 6,
+
+    // If accounts are included, include detailed account info.
+    DETAILED_ACCOUNTS: 7,
+
+    // Include messages associated with the change.
+    MESSAGES: 9,
+
+    // Include allowed actions client could perform.
+    CURRENT_ACTIONS: 10,
+
+    // Set the reviewed boolean for the caller.
+    REVIEWED: 11,
+
+    // Include download commands for the caller.
+    DOWNLOAD_COMMANDS: 13,
+
+    // Include patch set weblinks.
+    WEB_LINKS: 14,
+
+    // Include consistency check results.
+    CHECK: 15,
+
+    // Include allowed change actions client could perform.
+    CHANGE_ACTIONS: 16,
+
+    // Include a copy of commit messages including review footers.
+    COMMIT_FOOTERS: 17,
+
+    // Include push certificate information along with any patch sets.
+    PUSH_CERTIFICATES: 18,
+
+    // Include change's reviewer updates.
+    REVIEWER_UPDATES: 19,
+
+    // Set the submittable boolean.
+    SUBMITTABLE: 20,
+
+    // If tracking ids are included, include detailed tracking ids info.
+    TRACKING_IDS: 21,
+
+    // Skip mergeability data.
+    SKIP_MERGEABLE: 22,
+
+    /**
+     * Skip diffstat computation that compute the insertions field (number of lines inserted) and
+     * deletions field (number of lines deleted)
+     */
+    SKIP_DIFFSTAT: 23,
+  },
+
+  listChangesOptionsToHex(...args) {
+    let v = 0;
+    for (let i = 0; i < args.length; i++) {
+      v |= 1 << args[i];
+    }
+    return v.toString(16);
+  },
+
+  /**
+   *  @return {string}
+   */
+  changeBaseURL(project, changeNum, patchNum) {
+    let v = this.getBaseUrl() + '/changes/' +
+       encodeURIComponent(project) + '~' + changeNum;
+    if (patchNum) {
+      v += '/revisions/' + patchNum;
+    }
+    return v;
+  },
+
+  changePath(changeNum) {
+    return this.getBaseUrl() + '/c/' + changeNum;
+  },
+
+  changeIsOpen(change) {
+    return change && change.status === this.ChangeStatus.NEW;
+  },
+
+  /**
+   * @param {!Object} change
+   * @param {!Object=} opt_options
+   *
+   * @return {!Array}
+   */
+  changeStatuses(change, opt_options) {
+    const states = [];
+    if (change.status === this.ChangeStatus.MERGED) {
+      states.push('Merged');
+    } else if (change.status === this.ChangeStatus.ABANDONED) {
+      states.push('Abandoned');
+    } else if (change.mergeable === false ||
+        (opt_options && opt_options.mergeable === false)) {
+      // 'mergeable' prop may not always exist (@see Issue 6819)
+      states.push('Merge Conflict');
+    }
+    if (change.work_in_progress) { states.push('WIP'); }
+    if (change.is_private) { states.push('Private'); }
+
+    // If there are any pre-defined statuses, only return those. Otherwise,
+    // will determine the derived status.
+    if (states.length || !opt_options) { return states; }
+
+    // If no missing requirements, either active or ready to submit.
+    if (change.submittable && opt_options.submitEnabled) {
+      states.push('Ready to submit');
+    } else {
+      // Otherwise it is active.
+      states.push('Active');
+    }
+    return states;
+  },
+
+  /**
+   * @param {!Object} change
+   * @return {string}
+   */
+  changeStatusString(change) {
+    return this.changeStatuses(change).join(', ');
+  },
+},
+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
+   */
+  const RESTClientMixin = base => // eslint-disable-line no-unused-vars
+    class extends base {
+      changeStatusString(change) {}
+
+      changeStatuses(change, opt_options) {}
+    };
+}
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.RESTClientBehavior = RESTClientBehavior;
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..980bc8f 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
@@ -17,21 +17,19 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<script>
-  /** @type {string} */
-  window.CANONICAL_PATH = '/r';
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../test/common-test-setup.js';
+/** @type {string} */
+window.CANONICAL_PATH = '/r';
 </script>
 
-<link rel="import" href="../base-url-behavior/base-url-behavior.html">
-<link rel="import" href="rest-client-behavior.html">
-
 <test-fixture id="basic">
   <template>
     <test-element></test-element>
@@ -46,190 +44,194 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('rest-client-behavior tests', () => {
-    let element;
-    // eslint-disable-next-line no-unused-vars
-    let overlay;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
+import {RESTClientBehavior} from './rest-client-behavior.js';
+suite('rest-client-behavior tests', () => {
+  let element;
+  // eslint-disable-next-line no-unused-vars
+  let overlay;
 
-    suiteSetup(() => {
-      // Define a Polymer element that uses this behavior.
-      Polymer({
-        is: 'test-element',
-        behaviors: [
-          Gerrit.BaseUrlBehavior,
-          Gerrit.RESTClientBehavior,
-        ],
-      });
-    });
-
-    setup(() => {
-      element = fixture('basic');
-      overlay = fixture('within-overlay');
-    });
-
-    test('changeBaseURL', () => {
-      assert.deepEqual(
-          element.changeBaseURL('test/project', '1', '2'),
-          '/r/changes/test%2Fproject~1/revisions/2'
-      );
-    });
-
-    test('changePath', () => {
-      assert.deepEqual(element.changePath('1'), '/r/c/1');
-    });
-
-    test('Open status', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      let statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, []);
-      assert.equal(statusString, '');
-
-      change.submittable = false;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true});
-      assert.deepEqual(statuses, ['Active']);
-
-      // With no missing labels but no submitEnabled option.
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true});
-      assert.deepEqual(statuses, ['Active']);
-
-      // Without missing labels and enabled submit
-      statuses = element.changeStatuses(change,
-          {includeDerived: true, submitEnabled: true});
-      assert.deepEqual(statuses, ['Ready to submit']);
-
-      change.mergeable = false;
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true});
-      assert.deepEqual(statuses, ['Merge Conflict']);
-
-      delete change.mergeable;
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true, mergeable: true, submitEnabled: true});
-      assert.deepEqual(statuses, ['Ready to submit']);
-
-      change.submittable = true;
-      statuses = element.changeStatuses(change,
-          {includeDerived: true, mergeable: false});
-      assert.deepEqual(statuses, ['Merge Conflict']);
-    });
-
-    test('Merge conflict', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: false,
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Merge Conflict']);
-      assert.equal(statusString, 'Merge Conflict');
-    });
-
-    test('mergeable prop undefined', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, []);
-      assert.equal(statusString, '');
-    });
-
-    test('Merged status', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'MERGED',
-        labels: {},
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Merged']);
-      assert.equal(statusString, 'Merged');
-    });
-
-    test('Abandoned status', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'ABANDONED',
-        labels: {},
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Abandoned']);
-      assert.equal(statusString, 'Abandoned');
-    });
-
-    test('Open status with private and wip', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        is_private: true,
-        work_in_progress: true,
-        labels: {},
-        mergeable: true,
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['WIP', 'Private']);
-      assert.equal(statusString, 'WIP, Private');
-    });
-
-    test('Merge conflict with private and wip', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        is_private: true,
-        work_in_progress: true,
-        labels: {},
-        mergeable: false,
-      };
-      const statuses = element.changeStatuses(change);
-      const statusString = element.changeStatusString(change);
-      assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
-      assert.equal(statusString, 'Merge Conflict, WIP, Private');
+  suiteSetup(() => {
+    // Define a Polymer element that uses this behavior.
+    Polymer({
+      is: 'test-element',
+      behaviors: [
+        BaseUrlBehavior,
+        RESTClientBehavior,
+      ],
     });
   });
+
+  setup(() => {
+    element = fixture('basic');
+    overlay = fixture('within-overlay');
+  });
+
+  test('changeBaseURL', () => {
+    assert.deepEqual(
+        element.changeBaseURL('test/project', '1', '2'),
+        '/r/changes/test%2Fproject~1/revisions/2'
+    );
+  });
+
+  test('changePath', () => {
+    assert.deepEqual(element.changePath('1'), '/r/c/1');
+  });
+
+  test('Open status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    let statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, []);
+    assert.equal(statusString, '');
+
+    change.submittable = false;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // With no missing labels but no submitEnabled option.
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // Without missing labels and enabled submit
+    statuses = element.changeStatuses(change,
+        {includeDerived: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.mergeable = false;
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Merge Conflict']);
+
+    delete change.mergeable;
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true, mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.submittable = true;
+    statuses = element.changeStatuses(change,
+        {includeDerived: true, mergeable: false});
+    assert.deepEqual(statuses, ['Merge Conflict']);
+  });
+
+  test('Merge conflict', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: false,
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Merge Conflict']);
+    assert.equal(statusString, 'Merge Conflict');
+  });
+
+  test('mergeable prop undefined', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, []);
+    assert.equal(statusString, '');
+  });
+
+  test('Merged status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'MERGED',
+      labels: {},
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Merged']);
+    assert.equal(statusString, 'Merged');
+  });
+
+  test('Abandoned status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'ABANDONED',
+      labels: {},
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Abandoned']);
+    assert.equal(statusString, 'Abandoned');
+  });
+
+  test('Open status with private and wip', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: true,
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['WIP', 'Private']);
+    assert.equal(statusString, 'WIP, Private');
+  });
+
+  test('Merge conflict with private and wip', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: false,
+    };
+    const statuses = element.changeStatuses(change);
+    const statusString = element.changeStatusString(change);
+    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
+    assert.equal(statusString, 'Merge Conflict, WIP, Private');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
deleted file mode 100644
index 8f08f0c..0000000
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
+++ /dev/null
@@ -1,77 +0,0 @@
-<!--
-@license
-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.
--->
-<script>
-(function(window) {
-  'use strict';
-
-  window.Gerrit = window.Gerrit || {};
-
-  /** @polymerBehavior Gerrit.SafeTypes */
-  Gerrit.SafeTypes = {};
-
-  const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
-
-  /**
-   * Wraps a string to be used as a URL. An error is thrown if the string cannot
-   * be considered safe.
-   *
-   * @constructor
-   * @param {string} url the unwrapped, potentially unsafe URL.
-   */
-  Gerrit.SafeTypes.SafeUrl = function(url) {
-    if (!SAFE_URL_PATTERN.test(url)) {
-      throw new Error(`URL not marked as safe: ${url}`);
-    }
-    this._url = url;
-  };
-
-  /**
-   * Get the string representation of the safe URL.
-   *
-   * @returns {string}
-   */
-  Gerrit.SafeTypes.SafeUrl.prototype.asString = function() {
-    return this._url;
-  };
-
-  Gerrit.SafeTypes.safeTypesBridge = function(value, type) {
-    // If the value is being bound to a URL, ensure the value is wrapped in the
-    // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
-    // to surface the error.
-    if (type === 'URL') {
-      let safeValue = null;
-      if (value instanceof Gerrit.SafeTypes.SafeUrl) {
-        safeValue = value;
-      } else if (typeof value === 'string') {
-        safeValue = new Gerrit.SafeTypes.SafeUrl(value);
-      }
-      if (safeValue) {
-        return safeValue.asString();
-      }
-    }
-
-    // If the value is being bound to a string or a constant, then the string
-    // can be used as is.
-    if (type === 'STRING' || type === 'CONSTANT') {
-      return value;
-    }
-
-    // Otherwise fail.
-    throw new Error(`Refused to bind value as ${type}: ${value}`);
-  };
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
new file mode 100644
index 0000000..ec7a9f4
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * 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.
+ */
+
+const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
+
+/** @polymerBehavior Gerrit.SafeTypes */
+export const SafeTypes = {};
+
+/**
+ * Wraps a string to be used as a URL. An error is thrown if the string cannot
+ * be considered safe.
+ *
+ * @constructor
+ * @param {string} url the unwrapped, potentially unsafe URL.
+ */
+SafeTypes.SafeUrl = function(url) {
+  if (!SAFE_URL_PATTERN.test(url)) {
+    throw new Error(`URL not marked as safe: ${url}`);
+  }
+  this._url = url;
+};
+
+/**
+ * Get the string representation of the safe URL.
+ *
+ * @returns {string}
+ */
+SafeTypes.SafeUrl.prototype.asString = function() {
+  return this._url;
+};
+
+SafeTypes.safeTypesBridge = function(value, type) {
+  // If the value is being bound to a URL, ensure the value is wrapped in the
+  // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
+  // to surface the error.
+  if (type === 'URL') {
+    let safeValue = null;
+    if (value instanceof SafeTypes.SafeUrl) {
+      safeValue = value;
+    } else if (typeof value === 'string') {
+      safeValue = new SafeTypes.SafeUrl(value);
+    }
+    if (safeValue) {
+      return safeValue.asString();
+    }
+  }
+
+  // If the value is being bound to a string or a constant, then the string
+  // can be used as is.
+  if (type === 'STRING' || type === 'CONSTANT') {
+    return value;
+  }
+
+  // Otherwise fail.
+  throw new Error(`Refused to bind value as ${type}: ${value}`);
+};
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use the behavior because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.SafeTypes = SafeTypes;
+
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..6fe4460 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,15 +17,11 @@
 -->
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="safe-types-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,91 +29,94 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip-behavior tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {SafeTypes} from './safe-types-behavior.js';
+suite('gr-tooltip-behavior tests', () => {
+  let element;
+  let sandbox;
 
-    suiteSetup(() => {
-      Polymer({
-        is: 'safe-types-element',
-        behaviors: [Gerrit.SafeTypes],
-      });
-    });
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('SafeUrl accepts valid urls', () => {
-      function accepts(url) {
-        const safeUrl = new element.SafeUrl(url);
-        assert.isOk(safeUrl);
-        assert.equal(url, safeUrl.asString());
-      }
-      accepts('http://www.google.com/');
-      accepts('https://www.google.com/');
-      accepts('HtTpS://www.google.com/');
-      accepts('//www.google.com/');
-      accepts('/c/1234/file/path.html@45');
-      accepts('#hash-url');
-      accepts('mailto:name@example.com');
-    });
-
-    test('SafeUrl rejects invalid urls', () => {
-      function rejects(url) {
-        assert.throws(() => { new element.SafeUrl(url); });
-      }
-      rejects('javascript://alert("evil");');
-      rejects('ftp:example.com');
-      rejects('data:text/html,scary business');
-    });
-
-    suite('safeTypesBridge', () => {
-      function acceptsString(value, type) {
-        assert.equal(Gerrit.SafeTypes.safeTypesBridge(value, type),
-            value);
-      }
-
-      function rejects(value, type) {
-        assert.throws(() => { Gerrit.SafeTypes.safeTypesBridge(value, type); });
-      }
-
-      test('accepts valid URL strings', () => {
-        acceptsString('/foo/bar', 'URL');
-        acceptsString('#baz', 'URL');
-      });
-
-      test('rejects invalid URL strings', () => {
-        rejects('javascript://void();', 'URL');
-      });
-
-      test('accepts SafeUrl values', () => {
-        const url = '/abc/123';
-        const safeUrl = new element.SafeUrl(url);
-        assert.equal(Gerrit.SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
-      });
-
-      test('rejects non-string or non-SafeUrl types', () => {
-        rejects(3.1415926, 'URL');
-      });
-
-      test('accepts any binding to STRING or CONSTANT', () => {
-        acceptsString('foo/bar/baz', 'STRING');
-        acceptsString('lorem ipsum dolor', 'CONSTANT');
-      });
-
-      test('rejects all other types', () => {
-        rejects('foo', 'JAVASCRIPT');
-        rejects('foo', 'HTML');
-        rejects('foo', 'RESOURCE_URL');
-        rejects('foo', 'STYLE');
-      });
+  suiteSetup(() => {
+    Polymer({
+      is: 'safe-types-element',
+      behaviors: [SafeTypes],
     });
   });
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('SafeUrl accepts valid urls', () => {
+    function accepts(url) {
+      const safeUrl = new element.SafeUrl(url);
+      assert.isOk(safeUrl);
+      assert.equal(url, safeUrl.asString());
+    }
+    accepts('http://www.google.com/');
+    accepts('https://www.google.com/');
+    accepts('HtTpS://www.google.com/');
+    accepts('//www.google.com/');
+    accepts('/c/1234/file/path.html@45');
+    accepts('#hash-url');
+    accepts('mailto:name@example.com');
+  });
+
+  test('SafeUrl rejects invalid urls', () => {
+    function rejects(url) {
+      assert.throws(() => { new element.SafeUrl(url); });
+    }
+    rejects('javascript://alert("evil");');
+    rejects('ftp:example.com');
+    rejects('data:text/html,scary business');
+  });
+
+  suite('safeTypesBridge', () => {
+    function acceptsString(value, type) {
+      assert.equal(SafeTypes.safeTypesBridge(value, type),
+          value);
+    }
+
+    function rejects(value, type) {
+      assert.throws(() => { SafeTypes.safeTypesBridge(value, type); });
+    }
+
+    test('accepts valid URL strings', () => {
+      acceptsString('/foo/bar', 'URL');
+      acceptsString('#baz', 'URL');
+    });
+
+    test('rejects invalid URL strings', () => {
+      rejects('javascript://void();', 'URL');
+    });
+
+    test('accepts SafeUrl values', () => {
+      const url = '/abc/123';
+      const safeUrl = new element.SafeUrl(url);
+      assert.equal(SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
+    });
+
+    test('rejects non-string or non-SafeUrl types', () => {
+      rejects(3.1415926, 'URL');
+    });
+
+    test('accepts any binding to STRING or CONSTANT', () => {
+      acceptsString('foo/bar/baz', 'STRING');
+      acceptsString('lorem ipsum dolor', 'CONSTANT');
+    });
+
+    test('rejects all other types', () => {
+      rejects('foo', 'JAVASCRIPT');
+      rejects('foo', 'HTML');
+      rejects('foo', 'RESOURCE_URL');
+      rejects('foo', 'STYLE');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/constants/README.md b/polygerrit-ui/app/constants/README.md
new file mode 100644
index 0000000..b459e69
--- /dev/null
+++ b/polygerrit-ui/app/constants/README.md
@@ -0,0 +1,7 @@
+`constants` folder should contain:
+
+1. constants used across files
+2. messages used across files, like toasters, notifications etc
+
+For every constant defined, please add a `@desc` for it, once the list grows bigger,
+we should consider grouping them with sub folders / files.
\ No newline at end of file
diff --git a/polygerrit-ui/app/constants/constants.js b/polygerrit-ui/app/constants/constants.js
new file mode 100644
index 0000000..cab50f6
--- /dev/null
+++ b/polygerrit-ui/app/constants/constants.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @enum
+ * @desc Tab names for primary tabs on change view page.
+ */
+export const PrimaryTabs = {
+  FILES: '_files',
+  FINDINGS: '_findings',
+};
+
+/**
+ * @enum
+ * @desc Tab names for secondary tabs on change view page.
+ */
+export const SecondaryTabs = {
+  CHANGE_LOG: '_changeLog',
+  COMMENT_THREADS: '_commentThreads',
+};
+
diff --git a/polygerrit-ui/app/constants/messages.js b/polygerrit-ui/app/constants/messages.js
new file mode 100644
index 0000000..8562cd9
--- /dev/null
+++ b/polygerrit-ui/app/constants/messages.js
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** @desc Default message shown when no threads in gr-thread-list */
+export const NO_THREADS_MSG =
+  'There are no inline comment threads on any diff for this change.';
+
+/** @desc Message shown when no threads in gr-thread-list for robot comments */
+export const NO_ROBOT_COMMENTS_THREADS_MSG =
+  'There are no findings for this patchset.';
+
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
deleted file mode 100644
index ac65360..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
+++ /dev/null
@@ -1,166 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-permission/gr-permission.html">
-
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-access-section">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-l);
-      }
-      fieldset {
-        border: 1px solid var(--border-color);
-      }
-      .name {
-        align-items: center;
-        display: flex;
-      }
-      .header,
-      #deletedContainer {
-        align-items: center;
-        background: var(--table-header-background-color);
-        border-bottom: 1px dotted var(--border-color);
-        display: flex;
-        justify-content: space-between;
-        min-height: 3em;
-        padding: 0 var(--spacing-m);
-      }
-      #deletedContainer {
-        border-bottom: 0;
-      }
-      .sectionContent {
-        padding: var(--spacing-m);
-      }
-      #editBtn,
-      .editing #editBtn.global,
-      #deletedContainer,
-      .deleted #mainContainer,
-      #addPermission,
-      #deleteBtn,
-      .editingRef .name,
-      .editRefInput {
-        display: none;
-      }
-      .editing #editBtn,
-      .editingRef .editRefInput {
-        display: flex;
-      }
-      .deleted #deletedContainer {
-        display: flex;
-      }
-      .editing #addPermission,
-      #mainContainer,
-      .editing #deleteBtn  {
-        display: block;
-      }
-      .editing #deleteBtn,
-      #undoRemoveBtn {
-        padding-right: var(--spacing-m);
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <fieldset id="section"
-        class$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]">
-      <div id="mainContainer">
-        <div class="header">
-          <div class="name">
-            <h3>[[_computeSectionName(section.id)]]</h3>
-            <gr-button
-                id="editBtn"
-                link
-                class$="[[_computeEditBtnClass(section.id)]]"
-                on-click="editReference">
-              <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
-            </gr-button>
-          </div>
-          <iron-input
-              class="editRefInput"
-              bind-value="{{section.id}}"
-              type="text"
-              on-input="_handleValueChange">
-            <input
-                class="editRefInput"
-                bind-value="{{section.id}}"
-                is="iron-input"
-                type="text"
-                on-input="_handleValueChange">
-          </iron-input>
-          <gr-button
-              link
-              id="deleteBtn"
-              on-click="_handleRemoveReference">Remove</gr-button>
-        </div><!-- end header -->
-        <div class="sectionContent">
-          <template
-              is="dom-repeat"
-              items="{{_permissions}}"
-              as="permission">
-            <gr-permission
-                name="[[_computePermissionName(section.id, permission, permissionValues, capabilities)]]"
-                permission="{{permission}}"
-                labels="[[labels]]"
-                section="[[section.id]]"
-                editing="[[editing]]"
-                groups="[[groups]]"
-                on-added-permission-removed="_handleAddedPermissionRemoved">
-            </gr-permission>
-          </template>
-          <div id="addPermission">
-            Add permission:
-            <select id="permissionSelect">
-              <!-- called with a third parameter so that permissions update
-                  after a new section is added. -->
-              <template
-                  is="dom-repeat"
-                  items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]">
-                <option value="[[item.value.id]]">[[item.value.name]]</option>
-              </template>
-            </select>
-            <gr-button
-                link
-                id="addBtn"
-                on-click="_handleAddPermission">Add</gr-button>
-          </div>
-          <!-- end addPermission -->
-        </div><!-- end sectionContent -->
-      </div><!-- end mainContainer -->
-      <div id="deletedContainer">
-        <span>[[_computeSectionName(section.id)]] was deleted</span>
-        <gr-button
-            link
-            id="undoRemoveBtn"
-            on-click="_handleUndoRemove">Undo</gr-button>
-      </div><!-- end deletedContainer -->
-    </fieldset>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-access-section.js"></script>
-</dom-module>
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 8178709..e07a64e 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
@@ -14,33 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-permission/gr-permission.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {htmlTemplate} from './gr-access-section_html.js';
+import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
 
-  /**
-   * Fired when the section has been modified or removed.
-   *
-   * @event access-modified
-   */
+/**
+ * Fired when the section has been modified or removed.
+ *
+ * @event access-modified
+ */
 
-  /**
-   * Fired when a section that was previously added was removed.
-   *
-   * @event added-section-removed
-   */
+/**
+ * Fired when a section that was previously added was removed.
+ *
+ * @event added-section-removed
+ */
 
-  const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
+const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
 
-  // The name that gets automatically input when a new reference is added.
-  const NEW_NAME = 'refs/heads/*';
-  const REFS_NAME = 'refs/';
-  const ON_BEHALF_OF = '(On Behalf Of)';
-  const LABEL = 'Label';
+// The name that gets automatically input when a new reference is added.
+const NEW_NAME = 'refs/heads/*';
+const REFS_NAME = 'refs/';
+const ON_BEHALF_OF = '(On Behalf Of)';
+const LABEL = 'Label';
 
-  Polymer({
-    is: 'gr-access-section',
+/**
+ * @extends Polymer.Element
+ */
+class GrAccessSection extends mixinBehaviors( [
+  AccessBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-access-section'; }
+
+  static get properties() {
+    return {
       capabilities: Object,
       /** @type {?} */
       section: {
@@ -67,234 +90,222 @@
         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,
-    ],
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved',
+        () => this._handleAccessSaved());
+  }
 
-    listeners: {
-      'access-saved': '_handleAccessSaved',
-    },
+  _updateSection(section) {
+    this._permissions = this.toSortedArray(section.value.permissions);
+    this._originalId = section.id;
+  }
 
-    _updateSection(section) {
-      let permissions = this.toSortedArray(section.value.permissions);
-      // We do not care about permissions for global capabilities that are not
-      // currently supported by the server (f.i. capabilities provided by
-      // plugins that are no longer installed).
-      if (section.id === GLOBAL_NAME) {
-        permissions = permissions.filter(
-            p => this.capabilities.hasOwnProperty(p.id));
-      }
-      this._permissions = 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);
+  }
 
-    _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) {
+      this.section.value.modified = this.section.id !== this._originalId;
+      // Allows overall access page to know a change has been made.
+      // For a new section, this is not fired because new permissions and
+      // rules have to be added in order to save, modifying the ref is not
+      // enough.
+      this.dispatchEvent(new CustomEvent(
+          'access-modified', {bubbles: true, composed: true}));
+    }
+    this.section.value.updatedId = this.section.id;
+  }
 
-    _handleValueChange() {
-      if (!this.section.value.added) {
-        this.section.value.modified = this.section.id !== this._originalId;
-        // Allows overall access page to know a change has been made.
-        // For a new section, this is not fired because new permissions and
-        // rules have to be added in order to save, modifying the ref is not
-        // enough.
-        this.dispatchEvent(new CustomEvent(
-            'access-modified', {bubbles: true, composed: true}));
-      }
-      this.section.value.updatedId = this.section.id;
-    },
-
-    _handleEditingChanged(editing, editingOld) {
-      // Ignore when editing gets set initially.
-      if (!editingOld) { return; }
-      // Restore original values if no longer editing.
-      if (!editing) {
-        this._editingRef = false;
-        this._deleted = false;
-        delete this.section.value.deleted;
-        // Restore section ref.
-        this.set(['section', 'id'], this._originalId);
-        // Remove any unsaved but added permissions.
-        this._permissions = this._permissions.filter(p => !p.value.added);
-        for (const key of Object.keys(this.section.value.permissions)) {
-          if (this.section.value.permissions[key].added) {
-            delete this.section.value.permissions[key];
-          }
-        }
-      }
-    },
-
-    _computePermissions(name, capabilities, labels) {
-      let allPermissions;
-      if (!this.section || !this.section.value) {
-        return [];
-      }
-      if (name === GLOBAL_NAME) {
-        allPermissions = this.toSortedArray(capabilities);
-      } else {
-        const labelOptions = this._computeLabelOptions(labels);
-        allPermissions = labelOptions.concat(
-            this.toSortedArray(this.permissionValues));
-      }
-      return allPermissions.filter(permission => {
-        return !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 = [];
-      if (!labels) { return []; }
-      for (const labelName of Object.keys(labels)) {
-        labelOptions.push({
-          id: 'label-' + labelName,
-          value: {
-            name: `${LABEL} ${labelName}`,
-            id: 'label-' + labelName,
-          },
-        });
-        labelOptions.push({
-          id: 'labelAs-' + labelName,
-          value: {
-            name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
-            id: 'labelAs-' + labelName,
-          },
-        });
-      }
-      return labelOptions;
-    },
-
-    _computePermissionName(name, permission, permissionValues, capabilities) {
-      if (name === GLOBAL_NAME) {
-        return capabilities[permission.id].name;
-      } else if (permissionValues[permission.id]) {
-        return permissionValues[permission.id].name;
-      } else if (permission.value.label) {
-        let behalfOf = '';
-        if (permission.id.startsWith('labelAs-')) {
-          behalfOf = ON_BEHALF_OF;
-        }
-        return `${LABEL} ${permission.value.label}${behalfOf}`;
-      }
-    },
-
-    _computeSectionName(name) {
-      // When a new section is created, it doesn't yet have a ref. Set into
-      // edit mode so that the user can input one.
-      if (!name) {
-        this._editingRef = true;
-        // Needed for the title value. This is the same default as GWT.
-        name = NEW_NAME;
-        // Needed for the input field value.
-        this.set('section.id', name);
-      }
-      if (name === GLOBAL_NAME) {
-        return 'Global Capabilities';
-      } else if (name.startsWith(REFS_NAME)) {
-        return `Reference: ${name}`;
-      }
-      return name;
-    },
-
-    _handleRemoveReference() {
-      if (this.section.value.added) {
-        this.dispatchEvent(new CustomEvent(
-            'added-section-removed', {bubbles: true, composed: true}));
-      }
-      this._deleted = true;
-      this.section.value.deleted = true;
-      this.dispatchEvent(
-          new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
-
-    _handleUndoRemove() {
+  _handleEditingChanged(editing, editingOld) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) { return; }
+    // Restore original values if no longer editing.
+    if (!editing) {
+      this._editingRef = false;
       this._deleted = false;
       delete this.section.value.deleted;
-    },
+      // Restore section ref.
+      this.set(['section', 'id'], this._originalId);
+      // Remove any unsaved but added permissions.
+      this._permissions = this._permissions.filter(p => !p.value.added);
+      for (const key of Object.keys(this.section.value.permissions)) {
+        if (this.section.value.permissions[key].added) {
+          delete this.section.value.permissions[key];
+        }
+      }
+    }
+  }
 
-    editRefInput() {
-      return Polymer.dom(this.root).querySelector(Polymer.Element ?
-        'iron-input.editRefInput' :
-        'input[is=iron-input].editRefInput');
-    },
+  _computePermissions(name, capabilities, labels) {
+    let allPermissions;
+    if (!this.section || !this.section.value) {
+      return [];
+    }
+    if (name === GLOBAL_NAME) {
+      allPermissions = this.toSortedArray(capabilities);
+    } else {
+      const labelOptions = this._computeLabelOptions(labels);
+      allPermissions = labelOptions.concat(
+          this.toSortedArray(this.permissionValues));
+    }
+    return allPermissions
+        .filter(permission => !this.section.value.permissions[permission.id]);
+  }
 
-    editReference() {
+  _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 = [];
+    if (!labels) { return []; }
+    for (const labelName of Object.keys(labels)) {
+      labelOptions.push({
+        id: 'label-' + labelName,
+        value: {
+          name: `${LABEL} ${labelName}`,
+          id: 'label-' + labelName,
+        },
+      });
+      labelOptions.push({
+        id: 'labelAs-' + labelName,
+        value: {
+          name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+          id: 'labelAs-' + labelName,
+        },
+      });
+    }
+    return labelOptions;
+  }
+
+  _computePermissionName(name, permission, permissionValues, capabilities) {
+    if (name === GLOBAL_NAME) {
+      return capabilities[permission.id].name;
+    } else if (permissionValues[permission.id]) {
+      return permissionValues[permission.id].name;
+    } else if (permission.value.label) {
+      let behalfOf = '';
+      if (permission.id.startsWith('labelAs-')) {
+        behalfOf = ON_BEHALF_OF;
+      }
+      return `${LABEL} ${permission.value.label}${behalfOf}`;
+    }
+  }
+
+  _computeSectionName(name) {
+    // When a new section is created, it doesn't yet have a ref. Set into
+    // edit mode so that the user can input one.
+    if (!name) {
       this._editingRef = true;
-      this.editRefInput().focus();
-    },
+      // Needed for the title value. This is the same default as GWT.
+      name = NEW_NAME;
+      // Needed for the input field value.
+      this.set('section.id', name);
+    }
+    if (name === GLOBAL_NAME) {
+      return 'Global Capabilities';
+    } else if (name.startsWith(REFS_NAME)) {
+      return `Reference: ${name}`;
+    }
+    return name;
+  }
 
-    _isEditEnabled(canUpload, ownerOf, sectionId) {
-      return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
-    },
+  _handleRemoveReference() {
+    if (this.section.value.added) {
+      this.dispatchEvent(new CustomEvent(
+          'added-section-removed', {bubbles: true, composed: true}));
+    }
+    this._deleted = true;
+    this.section.value.deleted = true;
+    this.dispatchEvent(
+        new CustomEvent('access-modified', {bubbles: true, composed: true}));
+  }
 
-    _computeSectionClass(editing, canUpload, ownerOf, editingRef, deleted) {
-      const classList = [];
-      if (editing && this._isEditEnabled(canUpload, ownerOf, this.section.id)) {
-        classList.push('editing');
-      }
-      if (editingRef) {
-        classList.push('editingRef');
-      }
-      if (deleted) {
-        classList.push('deleted');
-      }
-      return classList.join(' ');
-    },
+  _handleUndoRemove() {
+    this._deleted = false;
+    delete this.section.value.deleted;
+  }
 
-    _computeEditBtnClass(name) {
-      return name === GLOBAL_NAME ? 'global' : '';
-    },
+  editRefInput() {
+    return dom(this.root).querySelector(PolymerElement ?
+      'iron-input.editRefInput' :
+      'input[is=iron-input].editRefInput');
+  }
 
-    _handleAddPermission() {
-      const value = this.$.permissionSelect.value;
-      const permission = {
-        id: value,
-        value: {rules: {}, added: true},
-      };
+  editReference() {
+    this._editingRef = true;
+    this.editRefInput().focus();
+  }
 
-      // This is needed to update the 'label' property of the
-      // 'label-<label-name>' permission.
-      //
-      // The value from the add permission dropdown will either be
-      // label-<label-name> or labelAs-<labelName>.
-      // But, the format of the API response is as such:
-      // "permissions": {
-      //  "label-Code-Review": {
-      //    "label": "Code-Review",
-      //    "rules": {...}
-      //    }
-      //  }
-      // }
-      // When we add a new item, we have to push the new permission in the same
-      // format as the ones that have been returned by the API.
-      if (value.startsWith('label')) {
-        permission.value.label =
-            value.replace('label-', '').replace('labelAs-', '');
-      }
-      // Add to the end of the array (used in dom-repeat) and also to the
-      // section object that is two way bound with its parent element.
-      this.push('_permissions', permission);
-      this.set(['section.value.permissions', permission.id],
-          permission.value);
-    },
-  });
-})();
+  _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)) {
+      classList.push('editing');
+    }
+    if (editingRef) {
+      classList.push('editingRef');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _computeEditBtnClass(name) {
+    return name === GLOBAL_NAME ? 'global' : '';
+  }
+
+  _handleAddPermission() {
+    const value = this.$.permissionSelect.value;
+    const permission = {
+      id: value,
+      value: {rules: {}, added: true},
+    };
+
+    // This is needed to update the 'label' property of the
+    // 'label-<label-name>' permission.
+    //
+    // The value from the add permission dropdown will either be
+    // label-<label-name> or labelAs-<labelName>.
+    // But, the format of the API response is as such:
+    // "permissions": {
+    //  "label-Code-Review": {
+    //    "label": "Code-Review",
+    //    "rules": {...}
+    //    }
+    //  }
+    // }
+    // When we add a new item, we have to push the new permission in the same
+    // format as the ones that have been returned by the API.
+    if (value.startsWith('label')) {
+      permission.value.label =
+          value.replace('label-', '').replace('labelAs-', '');
+    }
+    // Add to the end of the array (used in dom-repeat) and also to the
+    // section object that is two way bound with its parent element.
+    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_html.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
new file mode 100644
index 0000000..c46cf30
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
@@ -0,0 +1,157 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-l);
+    }
+    fieldset {
+      border: 1px solid var(--border-color);
+    }
+    .name {
+      align-items: center;
+      display: flex;
+    }
+    .header,
+    #deletedContainer {
+      align-items: center;
+      background: var(--table-header-background-color);
+      border-bottom: 1px dotted var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      min-height: 3em;
+      padding: 0 var(--spacing-m);
+    }
+    #deletedContainer {
+      border-bottom: 0;
+    }
+    .sectionContent {
+      padding: var(--spacing-m);
+    }
+    #editBtn,
+    .editing #editBtn.global,
+    #deletedContainer,
+    .deleted #mainContainer,
+    #addPermission,
+    #deleteBtn,
+    .editingRef .name,
+    .editRefInput {
+      display: none;
+    }
+    .editing #editBtn,
+    .editingRef .editRefInput {
+      display: flex;
+    }
+    .deleted #deletedContainer {
+      display: flex;
+    }
+    .editing #addPermission,
+    #mainContainer,
+    .editing #deleteBtn {
+      display: block;
+    }
+    .editing #deleteBtn,
+    #undoRemoveBtn {
+      padding-right: var(--spacing-m);
+    }
+  </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">
+      <div class="header">
+        <div class="name">
+          <h3>[[_computeSectionName(section.id)]]</h3>
+          <gr-button
+            id="editBtn"
+            link=""
+            class$="[[_computeEditBtnClass(section.id)]]"
+            on-click="editReference"
+          >
+            <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
+          </gr-button>
+        </div>
+        <iron-input
+          class="editRefInput"
+          bind-value="{{section.id}}"
+          type="text"
+          on-input="_handleValueChange"
+        >
+          <input
+            class="editRefInput"
+            bind-value="{{section.id}}"
+            is="iron-input"
+            type="text"
+            on-input="_handleValueChange"
+          />
+        </iron-input>
+        <gr-button link="" id="deleteBtn" on-click="_handleRemoveReference"
+          >Remove</gr-button
+        >
+      </div>
+      <!-- end header -->
+      <div class="sectionContent">
+        <template is="dom-repeat" items="{{_permissions}}" as="permission">
+          <gr-permission
+            name="[[_computePermissionName(section.id, permission, permissionValues, capabilities)]]"
+            permission="{{permission}}"
+            labels="[[labels]]"
+            section="[[section.id]]"
+            editing="[[editing]]"
+            groups="[[groups]]"
+            on-added-permission-removed="_handleAddedPermissionRemoved"
+          >
+          </gr-permission>
+        </template>
+        <div id="addPermission">
+          Add permission:
+          <select id="permissionSelect">
+            <!-- called with a third parameter so that permissions update
+                  after a new section is added. -->
+            <template
+              is="dom-repeat"
+              items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]"
+            >
+              <option value="[[item.value.id]]">[[item.value.name]]</option>
+            </template>
+          </select>
+          <gr-button link="" id="addBtn" on-click="_handleAddPermission"
+            >Add</gr-button
+          >
+        </div>
+        <!-- end addPermission -->
+      </div>
+      <!-- end sectionContent -->
+    </div>
+    <!-- end mainContainer -->
+    <div id="deletedContainer">
+      <span>[[_computeSectionName(section.id)]] was deleted</span>
+      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
+        >Undo</gr-button
+      >
+    </div>
+    <!-- end deletedContainer -->
+  </fieldset>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 5575014..95345fe 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-access-section.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,21 +32,348 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-access-section tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-access-section.js';
+suite('gr-access-section tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('unit tests', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      element.section = {
+        id: 'refs/*',
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+      element.capabilities = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+        administrateServer: {
+          id: 'administrateServer',
+          name: 'Administrate Server',
+        },
+        batchChangesLimit: {
+          id: 'batchChangesLimit',
+          name: 'Batch Changes Limit',
+        },
+        createAccount: {
+          id: 'createAccount',
+          name: 'Create Account',
+        },
+      };
+      element.labels = {
+        'Code-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',
+          },
+          default_value: 0,
+        },
+      };
+      element._updateSection(element.section);
+      flushAsynchronousOperations();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_updateSection', () => {
+      // _updateSection was called in setup, so just make assertions.
+      const expectedPermissions = [
+        {
+          id: 'read',
+          value: {
+            rules: {},
+          },
+        },
+      ];
+      assert.deepEqual(element._permissions, expectedPermissions);
+      assert.equal(element._originalId, element.section.id);
     });
 
-    suite('unit tests', () => {
+    test('_computeLabelOptions', () => {
+      const expectedLabelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      assert.deepEqual(element._computeLabelOptions(element.labels),
+          expectedLabelOptions);
+    });
+
+    test('_handleAccessSaved', () => {
+      assert.equal(element._originalId, 'refs/*');
+      element.section.id = 'refs/for/bar';
+      element._handleAccessSaved();
+      assert.equal(element._originalId, 'refs/for/bar');
+    });
+
+    test('_computePermissions', () => {
+      sandbox.stub(element, 'toSortedArray').returns(
+          [{
+            id: 'push',
+            value: {
+              rules: {},
+            },
+          },
+          {
+            id: 'read',
+            value: {
+              rules: {},
+            },
+          },
+          ]);
+
+      const expectedPermissions = [{
+        id: 'push',
+        value: {
+          rules: {},
+        },
+      },
+      ];
+      const labelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      // For global capabilities, just return the sorted array filtered by
+      // existing permissions.
+      let name = 'GLOBAL_CAPABILITIES';
+      assert.deepEqual(element._computePermissions(name, element.capabilities,
+          element.labels), expectedPermissions);
+
+      // Uses the capabilities array to come up with possible values.
+      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,
+          element.labels), labelOptions.concat(expectedPermissions));
+
+      // Uses permissionValues (defined in gr-access-behavior) to come up with
+      // possible values.
+      assert.isTrue(element.toSortedArray.lastCall.
+          calledWithExactly(element.permissionValues));
+    });
+
+    test('_computePermissionName', () => {
+      let name = 'GLOBAL_CAPABILITIES';
+      let permission = {
+        id: 'administrateServer',
+        value: {},
+      };
+      assert.equal(element._computePermissionName(name, permission,
+          element.permissionValues, element.capabilities),
+      element.capabilities[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'abandon',
+        value: {},
+      };
+
+      assert.equal(element._computePermissionName(
+          name, permission, element.permissionValues, element.capabilities),
+      element.permissionValues[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          element.permissionValues, element.capabilities),
+      'Label Code-Review');
+
+      permission = {
+        id: 'labelAs-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          element.permissionValues, element.capabilities),
+      'Label Code-Review(On Behalf Of)');
+    });
+
+    test('_computeSectionName', () => {
+      let name;
+      // When computing the section name for an undefined name, it means a
+      // new section is being added. In this case, it should defualt to
+      // 'refs/heads/*'.
+      element._editingRef = false;
+      assert.equal(element._computeSectionName(name),
+          'Reference: refs/heads/*');
+      assert.isTrue(element._editingRef);
+      assert.equal(element.section.id, 'refs/heads/*');
+
+      // Reset editing to false.
+      element._editingRef = false;
+      name = 'GLOBAL_CAPABILITIES';
+      assert.equal(element._computeSectionName(name), 'Global Capabilities');
+      assert.isFalse(element._editingRef);
+
+      name = 'refs/for/*';
+      assert.equal(element._computeSectionName(name),
+          'Reference: refs/for/*');
+      assert.isFalse(element._editingRef);
+    });
+
+    test('editReference', () => {
+      element.editReference();
+      assert.isTrue(element._editingRef);
+    });
+
+    test('_computeSectionClass', () => {
+      let editingRef = false;
+      let canUpload = false;
+      let ownerOf = [];
+      let editing = false;
+      let deleted = false;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), '');
+
+      ownerOf = ['refs/*'];
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing');
+
+      ownerOf = [];
+      canUpload = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing');
+
+      editingRef = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing editingRef');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing editingRef deleted');
+
+      editingRef = false;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing deleted');
+    });
+
+    test('_computeEditBtnClass', () => {
+      let name = 'GLOBAL_CAPABILITIES';
+      assert.equal(element._computeEditBtnClass(name), 'global');
+      name = 'refs/for/*';
+      assert.equal(element._computeEditBtnClass(name), '');
+    });
+  });
+
+  suite('interactive tests', () => {
+    setup(() => {
+      element.labels = {
+        'Code-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',
+          },
+          default_value: 0,
+        },
+      };
+    });
+    suite('Global section', () => {
+      setup(() => {
+        element.section = {
+          id: 'GLOBAL_CAPABILITIES',
+          value: {
+            permissions: {
+              accessDatabase: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {
+          accessDatabase: {
+            id: 'accessDatabase',
+            name: 'Access Database',
+          },
+          administrateServer: {
+            id: 'administrateServer',
+            name: 'Administrate Server',
+          },
+          batchChangesLimit: {
+            id: 'batchChangesLimit',
+            name: 'Batch Changes Limit',
+          },
+          createAccount: {
+            id: 'createAccount',
+            name: 'Create Account',
+          },
+        };
+        element._updateSection(element.section);
+        flushAsynchronousOperations();
+      });
+
+      test('classes are assigned correctly', () => {
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        assert.isFalse(element.$.section.classList.contains('deleted'));
+        assert.isTrue(element.$.editBtn.classList.contains('global'));
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
+      });
+    });
+
+    suite('Non-global section', () => {
       setup(() => {
         element.section = {
           id: 'refs/*',
@@ -61,493 +385,172 @@
             },
           },
         };
-        element.capabilities = {
-          accessDatabase: {
-            id: 'accessDatabase',
-            name: 'Access Database',
-          },
-          administrateServer: {
-            id: 'administrateServer',
-            name: 'Administrate Server',
-          },
-          batchChangesLimit: {
-            id: 'batchChangesLimit',
-            name: 'Batch Changes Limit',
-          },
-          createAccount: {
-            id: 'createAccount',
-            name: 'Create Account',
-          },
-        };
-        element.labels = {
-          'Code-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',
-            },
-            default_value: 0,
-          },
-        };
+        element.capabilities = {};
         element._updateSection(element.section);
         flushAsynchronousOperations();
       });
 
-      test('_updateSection', () => {
-        // _updateSection was called in setup, so just make assertions.
-        const expectedPermissions = [
-          {
-            id: 'read',
-            value: {
-              rules: {},
-            },
-          },
-        ];
-        assert.deepEqual(element._permissions, expectedPermissions);
-        assert.equal(element._originalId, element.section.id);
+      test('classes are assigned correctly', () => {
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        assert.isFalse(element.$.section.classList.contains('deleted'));
+        assert.isFalse(element.$.editBtn.classList.contains('global'));
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        flushAsynchronousOperations();
+        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
       });
 
-      test('_computeLabelOptions', () => {
-        const expectedLabelOptions = [
-          {
-            id: 'label-Code-Review',
-            value: {
-              name: 'Label Code-Review',
-              id: 'label-Code-Review',
-            },
-          },
-          {
-            id: 'labelAs-Code-Review',
-            value: {
-              name: 'Label Code-Review (On Behalf Of)',
-              id: 'labelAs-Code-Review',
-            },
-          },
-        ];
+      test('add permission', () => {
+        element.editing = true;
+        element.$.permissionSelect.value = 'label-Code-Review';
+        assert.equal(element._permissions.length, 1);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            1);
+        MockInteractions.tap(element.$.addBtn);
+        flushAsynchronousOperations();
 
-        assert.deepEqual(element._computeLabelOptions(element.labels),
-            expectedLabelOptions);
-      });
-
-      test('_handleAccessSaved', () => {
-        assert.equal(element._originalId, 'refs/*');
-        element.section.id = 'refs/for/bar';
-        element._handleAccessSaved();
-        assert.equal(element._originalId, 'refs/for/bar');
-      });
-
-      test('_computePermissions', () => {
-        sandbox.stub(element, 'toSortedArray').returns(
-            [{
-              id: 'push',
-              value: {
-                rules: {},
-              },
-            },
-            {
-              id: 'read',
-              value: {
-                rules: {},
-              },
-            },
-            ]);
-
-        const expectedPermissions = [{
-          id: 'push',
-          value: {
-            rules: {},
-          },
-        },
-        ];
-        const labelOptions = [
-          {
-            id: 'label-Code-Review',
-            value: {
-              name: 'Label Code-Review',
-              id: 'label-Code-Review',
-            },
-          },
-          {
-            id: 'labelAs-Code-Review',
-            value: {
-              name: 'Label Code-Review (On Behalf Of)',
-              id: 'labelAs-Code-Review',
-            },
-          },
-        ];
-
-        // For global capabilities, just return the sorted array filtered by
-        // existing permissions.
-        let name = 'GLOBAL_CAPABILITIES';
-        assert.deepEqual(element._computePermissions(name, element.capabilities,
-            element.labels), expectedPermissions);
-
-        // Uses the capabilities array to come up with possible values.
-        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,
-            element.labels), labelOptions.concat(expectedPermissions));
-
-        // Uses permissionValues (defined in gr-access-behavior) to come up with
-        // possible values.
-        assert.isTrue(element.toSortedArray.lastCall.
-            calledWithExactly(element.permissionValues));
-      });
-
-      test('_computePermissionName', () => {
-        let name = 'GLOBAL_CAPABILITIES';
+        // The permission is added to both the permissions array and also
+        // the section's permission object.
+        assert.equal(element._permissions.length, 2);
         let permission = {
-          id: 'administrateServer',
-          value: {},
-        };
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
-        element.capabilities[permission.id].name);
-
-        name = 'refs/for/*';
-        permission = {
-          id: 'abandon',
-          value: {},
-        };
-
-        assert.equal(element._computePermissionName(
-            name, permission, element.permissionValues, element.capabilities),
-        element.permissionValues[permission.id].name);
-
-        name = 'refs/for/*';
-        permission = {
           id: 'label-Code-Review',
           value: {
+            added: true,
             label: 'Code-Review',
+            rules: {},
           },
         };
+        assert.equal(element._permissions.length, 2);
+        assert.deepEqual(element._permissions[1], permission);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            2);
+        assert.deepEqual(
+            element.section.value.permissions['label-Code-Review'],
+            permission.value);
 
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
-        'Label Code-Review');
+        element.$.permissionSelect.value = 'abandon';
+        MockInteractions.tap(element.$.addBtn);
+        flushAsynchronousOperations();
 
         permission = {
-          id: 'labelAs-Code-Review',
+          id: 'abandon',
           value: {
-            label: 'Code-Review',
+            added: true,
+            rules: {},
           },
         };
 
-        assert.equal(element._computePermissionName(name, permission,
-            element.permissionValues, element.capabilities),
-        'Label Code-Review(On Behalf Of)');
+        assert.equal(element._permissions.length, 3);
+        assert.deepEqual(element._permissions[2], permission);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            3);
+        assert.deepEqual(element.section.value.permissions['abandon'],
+            permission.value);
+
+        // Unsaved changes are discarded when editing is cancelled.
+        element.editing = false;
+        assert.equal(element._permissions.length, 1);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            1);
       });
 
-      test('_computeSectionName', () => {
-        let name;
-        // When computing the section name for an undefined name, it means a
-        // new section is being added. In this case, it should defualt to
-        // 'refs/heads/*'.
-        element._editingRef = false;
-        assert.equal(element._computeSectionName(name),
-            'Reference: refs/heads/*');
-        assert.isTrue(element._editingRef);
-        assert.equal(element.section.id, 'refs/heads/*');
-
-        // Reset editing to false.
-        element._editingRef = false;
-        name = 'GLOBAL_CAPABILITIES';
-        assert.equal(element._computeSectionName(name), 'Global Capabilities');
+      test('edit section reference', done => {
+        element.canUpload = true;
+        element.ownerOf = [];
+        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        element.editing = true;
+        assert.isTrue(element.$.section.classList.contains('editing'));
         assert.isFalse(element._editingRef);
-
-        name = 'refs/for/*';
-        assert.equal(element._computeSectionName(name),
-            'Reference: refs/for/*');
-        assert.isFalse(element._editingRef);
-      });
-
-      test('editReference', () => {
-        element.editReference();
-        assert.isTrue(element._editingRef);
-      });
-
-      test('_computeSectionClass', () => {
-        let editingRef = false;
-        let canUpload = false;
-        let ownerOf = [];
-        let editing = false;
-        let deleted = false;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), '');
-
-        editing = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), '');
-
-        ownerOf = ['refs/*'];
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing');
-
-        ownerOf = [];
-        canUpload = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing');
-
-        editingRef = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing editingRef');
-
-        deleted = true;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing editingRef deleted');
-
-        editingRef = false;
-        assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-            editingRef, deleted), 'editing deleted');
-      });
-
-      test('_computeEditBtnClass', () => {
-        let name = 'GLOBAL_CAPABILITIES';
-        assert.equal(element._computeEditBtnClass(name), 'global');
-        name = 'refs/for/*';
-        assert.equal(element._computeEditBtnClass(name), '');
-      });
-    });
-
-    suite('interactive tests', () => {
-      setup(() => {
-        element.labels = {
-          'Code-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',
-            },
-            default_value: 0,
-          },
-        };
-        element.capabilities = {
-          accessDatabase: {
-            id: 'accessDatabase',
-            name: 'Access Database',
-          },
-          administrateServer: {
-            id: 'administrateServer',
-            name: 'Administrate Server',
-          },
-          batchChangesLimit: {
-            id: 'batchChangesLimit',
-            name: 'Batch Changes Limit',
-          },
-          createAccount: {
-            id: 'createAccount',
-            name: 'Create Account',
-          },
-        };
-      });
-      suite('Global section', () => {
-        setup(() => {
-          element.section = {
-            id: 'GLOBAL_CAPABILITIES',
-            value: {
-              permissions: {
-                accessDatabase: {
-                  rules: {},
-                },
-              },
-            },
-          };
-          element._updateSection(element.section);
-          flushAsynchronousOperations();
-        });
-
-        test('classes are assigned correctly', () => {
-          assert.isFalse(element.$.section.classList.contains('editing'));
-          assert.isFalse(element.$.section.classList.contains('deleted'));
-          assert.isTrue(element.$.editBtn.classList.contains('global'));
-          element.editing = true;
-          element.canUpload = true;
-          element.ownerOf = [];
-          assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-        });
-      });
-
-      suite('Non-global section', () => {
-        setup(() => {
-          element.section = {
-            id: 'refs/*',
-            value: {
-              permissions: {
-                read: {
-                  rules: {},
-                },
-              },
-            },
-          };
-          element._updateSection(element.section);
-          flushAsynchronousOperations();
-        });
-
-        test('classes are assigned correctly', () => {
-          assert.isFalse(element.$.section.classList.contains('editing'));
-          assert.isFalse(element.$.section.classList.contains('deleted'));
-          assert.isFalse(element.$.editBtn.classList.contains('global'));
-          element.editing = true;
-          element.canUpload = true;
-          element.ownerOf = [];
-          flushAsynchronousOperations();
-          assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-        });
-
-        test('add permission', () => {
-          element.editing = true;
-          element.$.permissionSelect.value = 'label-Code-Review';
-          assert.equal(element._permissions.length, 1);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              1);
-          MockInteractions.tap(element.$.addBtn);
-          flushAsynchronousOperations();
-
-          // The permission is added to both the permissions array and also
-          // the section's permission object.
-          assert.equal(element._permissions.length, 2);
-          let permission = {
-            id: 'label-Code-Review',
-            value: {
-              added: true,
-              label: 'Code-Review',
-              rules: {},
-            },
-          };
-          assert.equal(element._permissions.length, 2);
-          assert.deepEqual(element._permissions[1], permission);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              2);
-          assert.deepEqual(
-              element.section.value.permissions['label-Code-Review'],
-              permission.value);
-
-          element.$.permissionSelect.value = 'abandon';
-          MockInteractions.tap(element.$.addBtn);
-          flushAsynchronousOperations();
-
-          permission = {
-            id: 'abandon',
-            value: {
-              added: true,
-              rules: {},
-            },
-          };
-
-          assert.equal(element._permissions.length, 3);
-          assert.deepEqual(element._permissions[2], permission);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              3);
-          assert.deepEqual(element.section.value.permissions['abandon'],
-              permission.value);
-
-          // Unsaved changes are discarded when editing is cancelled.
+        MockInteractions.tap(element.$.editBtn);
+        element.editRefInput().bindValue='new/ref';
+        setTimeout(() => {
+          assert.equal(element.section.id, 'new/ref');
+          assert.isTrue(element._editingRef);
+          assert.isTrue(element.$.section.classList.contains('editingRef'));
           element.editing = false;
-          assert.equal(element._permissions.length, 1);
-          assert.equal(Object.keys(element.section.value.permissions).length,
-              1);
-        });
-
-        test('edit section reference', done => {
-          element.canUpload = true;
-          element.ownerOf = [];
-          element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-          assert.isFalse(element.$.section.classList.contains('editing'));
-          element.editing = true;
-          assert.isTrue(element.$.section.classList.contains('editing'));
           assert.isFalse(element._editingRef);
-          MockInteractions.tap(element.$.editBtn);
-          element.editRefInput().bindValue='new/ref';
-          setTimeout(() => {
-            assert.equal(element.section.id, 'new/ref');
-            assert.isTrue(element._editingRef);
-            assert.isTrue(element.$.section.classList.contains('editingRef'));
-            element.editing = false;
-            assert.isFalse(element._editingRef);
-            assert.equal(element.section.id, 'refs/for/bar');
-            done();
-          });
+          assert.equal(element.section.id, 'refs/for/bar');
+          done();
         });
+      });
 
-        test('_handleValueChange', () => {
-          // For an exising section.
-          const modifiedHandler = sandbox.stub();
-          element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-          assert.notOk(element.section.value.updatedId);
-          element.section.id = 'refs/for/baz';
-          element.addEventListener('access-modified', modifiedHandler);
-          assert.isNotOk(element.section.value.modified);
-          element._handleValueChange();
-          assert.equal(element.section.value.updatedId, 'refs/for/baz');
-          assert.isTrue(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 1);
-          element.section.id = 'refs/for/bar';
-          element._handleValueChange();
-          assert.isFalse(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 2);
+      test('_handleValueChange', () => {
+        // For an exising section.
+        const modifiedHandler = sandbox.stub();
+        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
+        assert.notOk(element.section.value.updatedId);
+        element.section.id = 'refs/for/baz';
+        element.addEventListener('access-modified', modifiedHandler);
+        assert.isNotOk(element.section.value.modified);
+        element._handleValueChange();
+        assert.equal(element.section.value.updatedId, 'refs/for/baz');
+        assert.isTrue(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 1);
+        element.section.id = 'refs/for/bar';
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
 
-          // For a new section.
-          element.section.value.added = true;
-          element._handleValueChange();
-          assert.isFalse(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 2);
-          element.section.id = 'refs/for/bar';
-          element._handleValueChange();
-          assert.isFalse(element.section.value.modified);
-          assert.equal(modifiedHandler.callCount, 2);
-        });
+        // For a new section.
+        element.section.value.added = true;
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+        element.section.id = 'refs/for/bar';
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+      });
 
-        test('remove section', () => {
-          element.editing = true;
-          element.canUpload = true;
-          element.ownerOf = [];
-          assert.isFalse(element._deleted);
-          assert.isNotOk(element.section.value.deleted);
-          MockInteractions.tap(element.$.deleteBtn);
-          flushAsynchronousOperations();
-          assert.isTrue(element._deleted);
-          assert.isTrue(element.section.value.deleted);
-          assert.isTrue(element.$.section.classList.contains('deleted'));
-          assert.isTrue(element.section.value.deleted);
+      test('remove section', () => {
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
+        MockInteractions.tap(element.$.deleteBtn);
+        flushAsynchronousOperations();
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.section.value.deleted);
+        assert.isTrue(element.$.section.classList.contains('deleted'));
+        assert.isTrue(element.section.value.deleted);
 
-          MockInteractions.tap(element.$.undoRemoveBtn);
-          flushAsynchronousOperations();
-          assert.isFalse(element._deleted);
-          assert.isNotOk(element.section.value.deleted);
+        MockInteractions.tap(element.$.undoRemoveBtn);
+        flushAsynchronousOperations();
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
 
-          MockInteractions.tap(element.$.deleteBtn);
-          assert.isTrue(element._deleted);
-          assert.isTrue(element.section.value.deleted);
-          element.editing = false;
-          assert.isFalse(element._deleted);
-          assert.isNotOk(element.section.value.deleted);
-        });
+        MockInteractions.tap(element.$.deleteBtn);
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.section.value.deleted);
+        element.editing = false;
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
+      });
 
-        test('removing an added permission', () => {
-          element.editing = true;
-          assert.equal(element._permissions.length, 1);
-          element.$$('gr-permission').fire('added-permission-removed');
-          flushAsynchronousOperations();
-          assert.equal(element._permissions.length, 0);
-        });
+      test('removing an added permission', () => {
+        element.editing = true;
+        assert.equal(element._permissions.length, 1);
+        element.shadowRoot
+            .querySelector('gr-permission').dispatchEvent(
+                new CustomEvent('added-permission-removed', {
+                  composed: true, bubbles: true,
+                }));
+        flushAsynchronousOperations();
+        assert.equal(element._permissions.length, 0);
+      });
 
-        test('remove an added section', () => {
-          const removeStub = sandbox.stub();
-          element.addEventListener('added-section-removed', removeStub);
-          element.editing = true;
-          element.section.value.added = true;
-          MockInteractions.tap(element.$.deleteBtn);
-          assert.isTrue(removeStub.called);
-        });
+      test('remove an added section', () => {
+        const removeStub = sandbox.stub();
+        element.addEventListener('added-section-removed', removeStub);
+        element.editing = true;
+        element.section.value.added = true;
+        MockInteractions.tap(element.$.deleteBtn);
+        assert.isTrue(removeStub.called);
       });
     });
   });
+});
 </script>
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
deleted file mode 100644
index dd8758f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
+++ /dev/null
@@ -1,89 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.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-create-group-dialog/gr-create-group-dialog.html">
-
-<dom-module id="gr-admin-group-list">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
-    <gr-list-view
-        create-new="[[_createNewCapability]]"
-        filter="[[_filter]]"
-        items="[[_groups]]"
-        items-per-page="[[_groupsPerPage]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_path]]">
-      <table id="list" class="genericList">
-        <tr class="headerRow">
-          <th class="name topHeader">Group Name</th>
-          <th class="description topHeader">Group Description</th>
-          <th class="visibleToAll topHeader">Visible To All</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
-          <template is="dom-repeat" items="[[_shownGroups]]">
-            <tr class="table">
-              <td class="name">
-                <a href$="[[_computeGroupUrl(item.id)]]">[[item.name]]</a>
-              </td>
-              <td class="description">[[item.description]]</td>
-              <td class="visibleToAll">[[_visibleToAll(item)]]</td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          id="createDialog"
-          class="confirmDialog"
-          disabled="[[!_hasNewGroupName]]"
-          confirm-label="Create"
-          confirm-on-enter
-          on-confirm="_handleCreateGroup"
-          on-cancel="_handleCloseCreate">
-        <div class="header" slot="header">
-          Create Group
-        </div>
-        <div class="main" slot="main">
-          <gr-create-group-dialog
-              has-new-group-name="{{_hasNewGroupName}}"
-              params="[[params]]"
-              id="createNewModal"></gr-create-group-dialog>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-admin-group-list.js"></script>
-</dom-module>
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 cf2100d..36e2cc4 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
@@ -14,16 +14,41 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-admin-group-list',
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-group-dialog/gr-create-group-dialog.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-admin-group-list_html.js';
+import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
+/**
+ * @appliesMixin ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrAdminGroupList extends mixinBehaviors( [
+  ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-admin-group-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -64,99 +89,101 @@
         value: true,
       },
       _filter: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.ListViewBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this._getCreateGroupCapability();
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'Groups'},
+      composed: true, bubbles: true,
+    }));
+    this._maybeOpenCreateOverlay(this.params);
+  }
 
-    attached() {
-      this._getCreateGroupCapability();
-      this.fire('title-change', {title: 'Groups'});
-      this._maybeOpenCreateOverlay(this.params);
-    },
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
 
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
+    return this._getGroups(this._filter, this._groupsPerPage,
+        this._offset);
+  }
 
-      return this._getGroups(this._filter, this._groupsPerPage,
-          this._offset);
-    },
-
-    /**
-     * Opens the create overlay if the route has a hash 'create'
-     *
-     * @param {!Object} params
-     */
-    _maybeOpenCreateOverlay(params) {
-      if (params && params.openCreateModal) {
-        this.$.createOverlay.open();
-      }
-    },
-
-    /**
-     * Generates groups link (/admin/groups/<uuid>)
-     *
-     * @param {string} id
-     */
-    _computeGroupUrl(id) {
-      return Gerrit.Nav.getUrlForGroup(decodeURIComponent(id));
-    },
-
-    _getCreateGroupCapability() {
-      return this.$.restAPI.getAccount().then(account => {
-        if (!account) { return; }
-        return this.$.restAPI.getAccountCapabilities(['createGroup'])
-            .then(capabilities => {
-              if (capabilities.createGroup) {
-                this._createNewCapability = true;
-              }
-            });
-      });
-    },
-
-    _getGroups(filter, groupsPerPage, offset) {
-      this._groups = [];
-      return this.$.restAPI.getGroups(filter, groupsPerPage, offset)
-          .then(groups => {
-            if (!groups) {
-              return;
-            }
-            this._groups = Object.keys(groups)
-                .map(key => {
-                  const group = groups[key];
-                  group.name = key;
-                  return group;
-                });
-            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() {
+  /**
+   * Opens the create overlay if the route has a hash 'create'
+   *
+   * @param {!Object} params
+   */
+  _maybeOpenCreateOverlay(params) {
+    if (params && params.openCreateModal) {
       this.$.createOverlay.open();
-    },
+    }
+  }
 
-    _visibleToAll(item) {
-      return item.options.visible_to_all === true ? 'Y' : 'N';
-    },
-  });
-})();
+  /**
+   * Generates groups link (/admin/groups/<uuid>)
+   *
+   * @param {string} id
+   */
+  _computeGroupUrl(id) {
+    return GerritNav.getUrlForGroup(decodeURIComponent(id));
+  }
+
+  _getCreateGroupCapability() {
+    return this.$.restAPI.getAccount().then(account => {
+      if (!account) { return; }
+      return this.$.restAPI.getAccountCapabilities(['createGroup'])
+          .then(capabilities => {
+            if (capabilities.createGroup) {
+              this._createNewCapability = true;
+            }
+          });
+    });
+  }
+
+  _getGroups(filter, groupsPerPage, offset) {
+    this._groups = [];
+    return this.$.restAPI.getGroups(filter, groupsPerPage, offset)
+        .then(groups => {
+          if (!groups) {
+            return;
+          }
+          this._groups = Object.keys(groups)
+              .map(key => {
+                const group = groups[key];
+                group.name = key;
+                return group;
+              });
+          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_html.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
new file mode 100644
index 0000000..4548a45
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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]]"
+    items="[[_groups]]"
+    items-per-page="[[_groupsPerPage]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    on-create-clicked="_handleCreateClicked"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Group Name</th>
+          <th class="description topHeader">Group Description</th>
+          <th class="visibleToAll topHeader">Visible To All</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownGroups]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_computeGroupUrl(item.id)]]">[[item.name]]</a>
+            </td>
+            <td class="description">[[item.description]]</td>
+            <td class="visibleToAll">[[_visibleToAll(item)]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      id="createDialog"
+      class="confirmDialog"
+      disabled="[[!_hasNewGroupName]]"
+      confirm-label="Create"
+      confirm-on-enter=""
+      on-confirm="_handleCreateGroup"
+      on-cancel="_handleCloseCreate"
+    >
+      <div class="header" slot="header">
+        Create Group
+      </div>
+      <div class="main" slot="main">
+        <gr-create-group-dialog
+          has-new-group-name="{{_hasNewGroupName}}"
+          params="[[params]]"
+          id="createNewModal"
+        ></gr-create-group-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 bd9b30a..c8c7f0c 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
@@ -17,19 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../../test/common-test-setup.html"/>
-
-<link rel="import" href="gr-admin-group-list.html">
-
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -37,174 +32,188 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter = 0;
-  const groupGenerator = () => {
-    return {
-      name: `test${++counter}`,
-      id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-      url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-      options: {
-        visible_to_all: false,
-      },
-      description: 'Gerrit Site Administrators',
-      group_id: 1,
-      owner: 'Administrators',
-      owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
-    };
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-admin-group-list.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+let counter = 0;
+const groupGenerator = () => {
+  return {
+    name: `test${++counter}`,
+    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
   };
+};
 
-  suite('gr-admin-group-list tests', () => {
-    let element;
-    let groups;
-    let sandbox;
-    let value;
+suite('gr-admin-group-list tests', () => {
+  let element;
+  let groups;
+  let sandbox;
+  let value;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('_computeGroupUrl', () => {
-      let urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
-          () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+  test('_computeGroupUrl', () => {
+    let urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
 
-      let group = {
-        id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-      };
-      assert.equal(element._computeGroupUrl(group),
-          '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+    let group = {
+      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+    };
+    assert.equal(element._computeGroupUrl(group),
+        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
 
-      urlStub.restore();
+    urlStub.restore();
 
-      urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
-          () => '/admin/groups/user/test');
+    urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
+        () => '/admin/groups/user/test');
 
-      group = {
-        id: 'user%2Ftest',
-      };
-      assert.equal(element._computeGroupUrl(group),
-          '/admin/groups/user/test');
+    group = {
+      id: 'user%2Ftest',
+    };
+    assert.equal(element._computeGroupUrl(group),
+        '/admin/groups/user/test');
 
-      urlStub.restore();
-    });
+    urlStub.restore();
+  });
 
-    suite('list with groups', () => {
-      setup(done => {
-        groups = _.times(26, groupGenerator);
+  suite('list with groups', () => {
+    setup(done => {
+      groups = _.times(26, groupGenerator);
 
-        stub('gr-rest-api-interface', {
-          getGroups(num, offset) {
-            return Promise.resolve(groups);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('test for test group in the list', done => {
-        flush(() => {
-          assert.equal(element._groups[1].name, '1');
-          assert.equal(element._groups[1].options.visible_to_all, false);
-          done();
-        });
-      });
-
-      test('_shownGroups', () => {
-        assert.equal(element._shownGroups.length, 25);
-      });
-
-      test('_maybeOpenCreateOverlay', () => {
-        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
-        element._maybeOpenCreateOverlay();
-        assert.isFalse(overlayOpen.called);
-        const params = {};
-        element._maybeOpenCreateOverlay(params);
-        assert.isFalse(overlayOpen.called);
-        params.openCreateModal = true;
-        element._maybeOpenCreateOverlay(params);
-        assert.isTrue(overlayOpen.called);
-      });
-    });
-
-    suite('test with less then 25 groups', () => {
-      setup(done => {
-        groups = _.times(25, groupGenerator);
-
-        stub('gr-rest-api-interface', {
-          getGroups(num, offset) {
-            return Promise.resolve(groups);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('_shownGroups', () => {
-        assert.equal(element._shownGroups.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getGroups', () => {
+      stub('gr-rest-api-interface', {
+        getGroups(num, offset) {
           return Promise.resolve(groups);
-        });
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value).then(() => {
-          assert.isTrue(element.$.restAPI.getGroups.lastCall
-              .calledWithExactly('test', 25, 25));
-          done();
-        });
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('test for test group in the list', done => {
+      flush(() => {
+        assert.equal(element._groups[1].name, '1');
+        assert.equal(element._groups[1].options.visible_to_all, false);
+        done();
       });
     });
 
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._groups = _.times(25, groupGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
-      });
+    test('_shownGroups', () => {
+      assert.equal(element._shownGroups.length, 25);
     });
 
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sandbox.stub(element, '_handleCreateClicked');
-        element.$$('gr-list-view').fire('create-clicked');
-        assert.isTrue(element._handleCreateClicked.called);
+    test('_maybeOpenCreateOverlay', () => {
+      const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+      element._maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      const params = {};
+      element._maybeOpenCreateOverlay(params);
+      assert.isFalse(overlayOpen.called);
+      params.openCreateModal = true;
+      element._maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('test with less then 25 groups', () => {
+    setup(done => {
+      groups = _.times(25, groupGenerator);
+
+      stub('gr-rest-api-interface', {
+        getGroups(num, offset) {
+          return Promise.resolve(groups);
+        },
       });
 
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sandbox.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
 
-      test('_handleCreateGroup called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateGroup');
-        element.$.createDialog.fire('confirm');
-        assert.isTrue(element._handleCreateGroup.called);
-      });
+    test('_shownGroups', () => {
+      assert.equal(element._shownGroups.length, 25);
+    });
+  });
 
-      test('_handleCloseCreate called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreate.called);
+  suite('filter', () => {
+    test('_paramsChanged', done => {
+      sandbox.stub(
+          element.$.restAPI,
+          'getGroups',
+          () => Promise.resolve(groups));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getGroups.lastCall
+            .calledWithExactly('test', 25, 25));
+        done();
       });
     });
   });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._groups = _.times(25, groupGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sandbox.stub(element, '_handleCreateClicked');
+      element.shadowRoot
+          .querySelector('gr-list-view').dispatchEvent(
+              new CustomEvent('create-clicked', {
+                composed: true, bubbles: true,
+              }));
+      assert.isTrue(element._handleCreateClicked.called);
+    });
+
+    test('_handleCreateClicked opens modal', () => {
+      const openStub = sandbox.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateGroup called when confirm fired', () => {
+      sandbox.stub(element, '_handleCreateGroup');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateGroup.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sandbox.stub(element, '_handleCloseCreate');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index c7187a9..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ /dev/null
@@ -1,196 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/gr-page-nav-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-admin-group-list/gr-admin-group-list.html">
-<link rel="import" href="../gr-group/gr-group.html">
-<link rel="import" href="../gr-group-audit-log/gr-group-audit-log.html">
-<link rel="import" href="../gr-group-members/gr-group-members.html">
-<link rel="import" href="../gr-plugin-list/gr-plugin-list.html">
-<link rel="import" href="../gr-repo/gr-repo.html">
-<link rel="import" href="../gr-repo-access/gr-repo-access.html">
-<link rel="import" href="../gr-repo-commands/gr-repo-commands.html">
-<link rel="import" href="../gr-repo-dashboards/gr-repo-dashboards.html">
-<link rel="import" href="../gr-repo-detail-list/gr-repo-detail-list.html">
-<link rel="import" href="../gr-repo-list/gr-repo-list.html">
-
-<dom-module id="gr-admin-view">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-menu-page-styles"></style>
-    <style include="gr-page-nav-styles">
-      gr-dropdown-list {
-        --trigger-style: {
-          text-transform: none;
-        }
-      }
-      .breadcrumbText {
-        /* Same as dropdown trigger so chevron spacing is consistent. */
-        padding: 5px 4px;
-      }
-      iron-icon {
-        margin: 0 var(--spacing-xs);
-      }
-      .breadcrumb {
-        align-items: center;
-        display: flex;
-      }
-      .mainHeader {
-        align-items: baseline;
-        border-bottom: 1px solid var(--border-color);
-        display: flex;
-      }
-      .selectText {
-        display: none;
-      }
-      .selectText.show {
-        display: inline-block;
-      }
-      main.breadcrumbs:not(.table) {
-        margin-top: var(--spacing-l);
-      }
-    </style>
-    <gr-page-nav class="navStyles">
-      <ul class="sectionContent">
-        <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
-          <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
-            <a class="title" href="[[_computeLinkURL(item)]]"
-                  rel="noopener">[[item.name]]</a>
-          </li>
-          <template is="dom-repeat" items="[[item.children]]" as="child">
-            <li class$="[[_computeSelectedClass(child.view, params)]]">
-              <a href$="[[_computeLinkURL(child)]]"
-                  rel="noopener">[[child.name]]</a>
-            </li>
-          </template>
-          <template is="dom-if" if="[[item.subsection]]">
-            <!--If a section has a subsection, render that.-->
-            <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
-              <a class="title" href$="[[_computeLinkURL(item.subsection)]]"
-                  rel="noopener">
-                [[item.subsection.name]]</a>
-            </li>
-            <!--Loop through the links in the sub-section.-->
-            <template is="dom-repeat"
-                items="[[item.subsection.children]]" as="child">
-              <li class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]">
-                <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
-              </li>
-            </template>
-          </template>
-        </template>
-      </ul>
-    </gr-page-nav>
-    <template is="dom-if" if="[[_subsectionLinks.length]]">
-      <section class="mainHeader">
-        <span class="breadcrumb">
-          <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-        </span>
-        <gr-dropdown-list
-            lowercase
-            id="pageSelect"
-            value="[[_computeSelectValue(params)]]"
-            items="[[_subsectionLinks]]"
-            on-value-change="_handleSubsectionChange">
-        </gr-dropdown-list>
-      </section>
-    </template>
-    <template is="dom-if" if="[[_showRepoList]]" restamp="true">
-      <main class="table">
-        <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showGroupList]]" restamp="true">
-      <main class="table">
-        <gr-admin-group-list class="table" params="[[params]]">
-        </gr-admin-group-list>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showPluginList]]" restamp="true">
-      <main class="table">
-        <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-      <main class="breadcrumbs">
-        <gr-repo repo="[[params.repo]]"></gr-repo>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showGroup]]" restamp="true">
-      <main class="breadcrumbs">
-        <gr-group
-            group-id="[[params.groupId]]"
-            on-name-changed="_updateGroupName"></gr-group>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-      <main class="breadcrumbs">
-        <gr-group-members
-            group-id="[[params.groupId]]"></gr-group-members>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-      <main class="table breadcrumbs">
-        <gr-repo-detail-list
-            params="[[params]]"
-            class="table"></gr-repo-detail-list>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-      <main class="table breadcrumbs">
-        <gr-group-audit-log
-            group-id="[[params.groupId]]"
-            class="table"></gr-group-audit-log>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-      <main class="breadcrumbs">
-        <gr-repo-commands
-            repo="[[params.repo]]"></gr-repo-commands>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-      <main class="breadcrumbs">
-        <gr-repo-access
-            path="[[path]]"
-            repo="[[params.repo]]"></gr-repo-access>
-      </main>
-    </template>
-    <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-      <main class="table breadcrumbs">
-        <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
-      </main>
-    </template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  </template>
-  <script src="gr-admin-view.js"></script>
-</dom-module>
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..b318ee6 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
@@ -14,17 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/gr-page-nav-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-page-nav/gr-page-nav.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-admin-group-list/gr-admin-group-list.js';
+import '../gr-group/gr-group.js';
+import '../gr-group-audit-log/gr-group-audit-log.js';
+import '../gr-group-members/gr-group-members.js';
+import '../gr-plugin-list/gr-plugin-list.js';
+import '../gr-repo/gr-repo.js';
+import '../gr-repo-access/gr-repo-access.js';
+import '../gr-repo-commands/gr-repo-commands.js';
+import '../gr-repo-dashboards/gr-repo-dashboards.js';
+import '../gr-repo-detail-list/gr-repo-detail-list.js';
+import '../gr-repo-list/gr-repo-list.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-admin-view_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {AdminNavBehavior} from '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-  const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+/**
+ * @extends Polymer.Element
+ */
+class GrAdminView extends mixinBehaviors( [
+  AdminNavBehavior,
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-  Polymer({
-    is: 'gr-admin-view',
+  static get is() { return 'gr-admin-view'; }
 
-    properties: {
-      /** @type {?} */
+  static get properties() {
+    return {
+    /** @type {?} */
       params: Object,
       path: String,
       adminView: String,
@@ -62,220 +101,220 @@
       _showRepoMain: Boolean,
       _showRepoList: Boolean,
       _showPluginList: Boolean,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.AdminNavBehavior,
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_paramsChanged(params)',
-    ],
+    ];
+  }
 
-    attached() {
+  /** @override */
+  attached() {
+    super.attached();
+    this.reload();
+  }
+
+  reload() {
+    const promises = [
+      this.$.restAPI.getAccount(),
+      pluginLoader.awaitPluginsLoaded(),
+    ];
+    return Promise.all(promises).then(result => {
+      this._account = result[0];
+      let options;
+      if (this._repoName) {
+        options = {repoName: this._repoName};
+      } else if (this._groupId) {
+        options = {
+          groupId: this._groupId,
+          groupName: this._groupName,
+          groupIsInternal: this._groupIsInternal,
+          isAdmin: this._isAdmin,
+          groupOwner: this._groupOwner,
+        };
+      }
+
+      return this.getAdminLinks(this._account,
+          this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
+          this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
+          options)
+          .then(res => {
+            this._filteredLinks = res.links;
+            this._breadcrumbParentName = res.expandedSection ?
+              res.expandedSection.name : '';
+
+            if (!res.expandedSection) {
+              this._subsectionLinks = [];
+              return;
+            }
+            this._subsectionLinks = [res.expandedSection]
+                .concat(res.expandedSection.children).map(section => {
+                  return {
+                    text: !section.detailType ? 'Home' : section.name,
+                    value: section.view + (section.detailType || ''),
+                    view: section.view,
+                    url: section.url,
+                    detailType: section.detailType,
+                    parent: this._groupId || this._repoName || '',
+                  };
+                });
+          });
+    });
+  }
+
+  _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
+        .find(section => section.value === e.detail.value);
+
+    // This is when it gets set initially.
+    if (this._selectedIsCurrentPage(selected)) {
+      return;
+    }
+    GerritNav.navigateToRelativeUrl(selected.url);
+  }
+
+  _paramsChanged(params) {
+    const isGroupView = params.view === GerritNav.View.GROUP;
+    const isRepoView = params.view === GerritNav.View.REPO;
+    const isAdminView = params.view === GerritNav.View.ADMIN;
+
+    this.set('_showGroup', isGroupView && !params.detail);
+    this.set('_showGroupAuditLog', isGroupView &&
+        params.detail === GerritNav.GroupDetailView.LOG);
+    this.set('_showGroupMembers', isGroupView &&
+        params.detail === GerritNav.GroupDetailView.MEMBERS);
+
+    this.set('_showGroupList', isAdminView &&
+        params.adminView === 'gr-admin-group-list');
+
+    this.set('_showRepoAccess', isRepoView &&
+        params.detail === GerritNav.RepoDetailView.ACCESS);
+    this.set('_showRepoCommands', isRepoView &&
+        params.detail === GerritNav.RepoDetailView.COMMANDS);
+    this.set('_showRepoDetailList', isRepoView &&
+        (params.detail === GerritNav.RepoDetailView.BRANCHES ||
+         params.detail === GerritNav.RepoDetailView.TAGS));
+    this.set('_showRepoDashboards', isRepoView &&
+        params.detail === GerritNav.RepoDetailView.DASHBOARDS);
+    this.set('_showRepoMain', isRepoView && !params.detail);
+
+    this.set('_showRepoList', isAdminView &&
+        params.adminView === 'gr-repo-list');
+
+    this.set('_showPluginList', isAdminView &&
+        params.adminView === 'gr-plugin-list');
+
+    let needsReload = false;
+    if (params.repo !== this._repoName) {
+      this._repoName = params.repo || '';
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    if (params.groupId !== this._groupId) {
+      this._groupId = params.groupId || '';
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    if (this._breadcrumbParentName && !params.groupId && !params.repo) {
+      needsReload = true;
+    }
+    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 ''; }
+    if (link.target || !link.noBaseUrl) {
+      return link.url;
+    }
+    return this._computeRelativeURL(link.url);
+  }
+
+  /**
+   * @param {string} itemView
+   * @param {Object} params
+   * @param {string=} opt_detailType
+   */
+  _computeSelectedClass(itemView, params, opt_detailType) {
+    if (!params) return '';
+    // Group params are structured differently from admin params. Compute
+    // selected differently for groups.
+    // TODO(wyatta): Simplify this when all routes work like group params.
+    if (params.view === GerritNav.View.GROUP &&
+        itemView === GerritNav.View.GROUP) {
+      if (!params.detail && !opt_detailType) { return 'selected'; }
+      if (params.detail === opt_detailType) { return 'selected'; }
+      return '';
+    }
+
+    if (params.view === GerritNav.View.REPO &&
+        itemView === GerritNav.View.REPO) {
+      if (!params.detail && !opt_detailType) { return 'selected'; }
+      if (params.detail === opt_detailType) { return 'selected'; }
+      return '';
+    }
+
+    if (params.detailType && params.detailType !== opt_detailType) {
+      return '';
+    }
+    return itemView === params.adminView ? 'selected' : '';
+  }
+
+  _computeGroupName(groupId) {
+    if (!groupId) { return ''; }
+
+    const promises = [];
+    this.$.restAPI.getGroupConfig(groupId).then(group => {
+      if (!group || !group.name) { return; }
+
+      this._groupName = group.name;
+      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
       this.reload();
-    },
 
-    reload() {
-      const promises = [
-        this.$.restAPI.getAccount(),
-        Gerrit.awaitPluginsLoaded(),
-      ];
-      return Promise.all(promises).then(result => {
-        this._account = result[0];
-        let options;
-        if (this._repoName) {
-          options = {repoName: this._repoName};
-        } else if (this._groupId) {
-          options = {
-            groupId: this._groupId,
-            groupName: this._groupName,
-            groupIsInternal: this._groupIsInternal,
-            isAdmin: this._isAdmin,
-            groupOwner: this._groupOwner,
-          };
-        }
+      promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+        this._isAdmin = isAdmin;
+      }));
 
-        return this.getAdminLinks(this._account,
-            this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
-            this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
-            options)
-            .then(res => {
-              this._filteredLinks = res.links;
-              this._breadcrumbParentName = res.expandedSection ?
-                res.expandedSection.name : '';
+      promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
+          isOwner => {
+            this._groupOwner = isOwner;
+          }));
 
-              if (!res.expandedSection) {
-                this._subsectionLinks = [];
-                return;
-              }
-              this._subsectionLinks = [res.expandedSection]
-                  .concat(res.expandedSection.children).map(section => {
-                    return {
-                      text: !section.detailType ? 'Home' : section.name,
-                      value: section.view + (section.detailType || ''),
-                      view: section.view,
-                      url: section.url,
-                      detailType: section.detailType,
-                      parent: this._groupId || this._repoName || '',
-                    };
-                  });
-            });
-      });
-    },
-
-    _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
-          .find(section => section.value === e.detail.value);
-
-      // This is when it gets set initially.
-      if (this._selectedIsCurrentPage(selected)) {
-        return;
-      }
-      Gerrit.Nav.navigateToRelativeUrl(selected.url);
-    },
-
-    _paramsChanged(params) {
-      const isGroupView = params.view === Gerrit.Nav.View.GROUP;
-      const isRepoView = params.view === Gerrit.Nav.View.REPO;
-      const isAdminView = params.view === Gerrit.Nav.View.ADMIN;
-
-      this.set('_showGroup', isGroupView && !params.detail);
-      this.set('_showGroupAuditLog', isGroupView &&
-          params.detail === Gerrit.Nav.GroupDetailView.LOG);
-      this.set('_showGroupMembers', isGroupView &&
-          params.detail === Gerrit.Nav.GroupDetailView.MEMBERS);
-
-      this.set('_showGroupList', isAdminView &&
-          params.adminView === 'gr-admin-group-list');
-
-      this.set('_showRepoAccess', isRepoView &&
-          params.detail === Gerrit.Nav.RepoDetailView.ACCESS);
-      this.set('_showRepoCommands', isRepoView &&
-          params.detail === Gerrit.Nav.RepoDetailView.COMMANDS);
-      this.set('_showRepoDetailList', isRepoView &&
-          (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES ||
-           params.detail === Gerrit.Nav.RepoDetailView.TAGS));
-      this.set('_showRepoDashboards', isRepoView &&
-          params.detail === Gerrit.Nav.RepoDetailView.DASHBOARDS);
-      this.set('_showRepoMain', isRepoView && !params.detail);
-
-      this.set('_showRepoList', isAdminView &&
-          params.adminView === 'gr-repo-list');
-
-      this.set('_showPluginList', isAdminView &&
-          params.adminView === 'gr-plugin-list');
-
-      let needsReload = false;
-      if (params.repo !== this._repoName) {
-        this._repoName = params.repo || '';
-        // Reloads the admin menu.
-        needsReload = true;
-      }
-      if (params.groupId !== this._groupId) {
-        this._groupId = params.groupId || '';
-        // Reloads the admin menu.
-        needsReload = true;
-      }
-      if (this._breadcrumbParentName && !params.groupId && !params.repo) {
-        needsReload = true;
-      }
-      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 ''; }
-      if (link.target || !link.noBaseUrl) {
-        return link.url;
-      }
-      return this._computeRelativeURL(link.url);
-    },
-
-    /**
-     * @param {string} itemView
-     * @param {Object} params
-     * @param {string=} opt_detailType
-     */
-    _computeSelectedClass(itemView, params, opt_detailType) {
-      if (!params) return '';
-      // Group params are structured differently from admin params. Compute
-      // selected differently for groups.
-      // TODO(wyatta): Simplify this when all routes work like group params.
-      if (params.view === Gerrit.Nav.View.GROUP &&
-          itemView === Gerrit.Nav.View.GROUP) {
-        if (!params.detail && !opt_detailType) { return 'selected'; }
-        if (params.detail === opt_detailType) { return 'selected'; }
-        return '';
-      }
-
-      if (params.view === Gerrit.Nav.View.REPO &&
-          itemView === Gerrit.Nav.View.REPO) {
-        if (!params.detail && !opt_detailType) { return 'selected'; }
-        if (params.detail === opt_detailType) { return 'selected'; }
-        return '';
-      }
-
-      if (params.detailType && params.detailType !== opt_detailType) {
-        return '';
-      }
-      return itemView === params.adminView ? 'selected' : '';
-    },
-
-    _computeGroupName(groupId) {
-      if (!groupId) { return ''; }
-
-      const promises = [];
-      this.$.restAPI.getGroupConfig(groupId).then(group => {
-        if (!group || !group.name) { return; }
-
-        this._groupName = group.name;
-        this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+      return Promise.all(promises).then(() => {
         this.reload();
-
-        promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = isAdmin;
-        }));
-
-        promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
-            isOwner => {
-              this._groupOwner = isOwner;
-            }));
-
-        return Promise.all(promises).then(() => {
-          this.reload();
-        });
       });
-    },
+    });
+  }
 
-    _updateGroupName(e) {
-      this._groupName = e.detail.name;
-      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_html.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
new file mode 100644
index 0000000..b62a41b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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: {
+        text-transform: none;
+      }
+    }
+    .breadcrumbText {
+      /* Same as dropdown trigger so chevron spacing is consistent. */
+      padding: 5px 4px;
+    }
+    iron-icon {
+      margin: 0 var(--spacing-xs);
+    }
+    .breadcrumb {
+      align-items: center;
+      display: flex;
+    }
+    .mainHeader {
+      align-items: baseline;
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+    }
+    .selectText {
+      display: none;
+    }
+    .selectText.show {
+      display: inline-block;
+    }
+    main.breadcrumbs:not(.table) {
+      margin-top: var(--spacing-l);
+    }
+  </style>
+  <gr-page-nav class="navStyles">
+    <ul class="sectionContent">
+      <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
+        <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
+          <a class="title" href="[[_computeLinkURL(item)]]" rel="noopener"
+            >[[item.name]]</a
+          >
+        </li>
+        <template is="dom-repeat" items="[[item.children]]" as="child">
+          <li class$="[[_computeSelectedClass(child.view, params)]]">
+            <a href$="[[_computeLinkURL(child)]]" rel="noopener"
+              >[[child.name]]</a
+            >
+          </li>
+        </template>
+        <template is="dom-if" if="[[item.subsection]]">
+          <!--If a section has a subsection, render that.-->
+          <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
+            <a
+              class="title"
+              href$="[[_computeLinkURL(item.subsection)]]"
+              rel="noopener"
+            >
+              [[item.subsection.name]]</a
+            >
+          </li>
+          <!--Loop through the links in the sub-section.-->
+          <template
+            is="dom-repeat"
+            items="[[item.subsection.children]]"
+            as="child"
+          >
+            <li
+              class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]"
+            >
+              <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
+            </li>
+          </template>
+        </template>
+      </template>
+    </ul>
+  </gr-page-nav>
+  <template is="dom-if" if="[[_subsectionLinks.length]]">
+    <section class="mainHeader">
+      <span class="breadcrumb">
+        <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
+        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+      </span>
+      <gr-dropdown-list
+        lowercase=""
+        id="pageSelect"
+        value="[[_computeSelectValue(params)]]"
+        items="[[_subsectionLinks]]"
+        on-value-change="_handleSubsectionChange"
+      >
+      </gr-dropdown-list>
+    </section>
+  </template>
+  <template is="dom-if" if="[[_showRepoList]]" restamp="true">
+    <main class="table">
+      <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroupList]]" restamp="true">
+    <main class="table">
+      <gr-admin-group-list class="table" params="[[params]]">
+      </gr-admin-group-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showPluginList]]" restamp="true">
+    <main class="table">
+      <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-repo repo="[[params.repo]]"></gr-repo>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroup]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-group
+        group-id="[[params.groupId]]"
+        on-name-changed="_updateGroupName"
+      ></gr-group>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
+    <main class="table breadcrumbs">
+      <gr-repo-detail-list
+        params="[[params]]"
+        class="table"
+      ></gr-repo-detail-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
+    <main class="table breadcrumbs">
+      <gr-group-audit-log
+        group-id="[[params.groupId]]"
+        class="table"
+      ></gr-group-audit-log>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
+    <main class="table breadcrumbs">
+      <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
+    </main>
+  </template>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
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..ec72bfd 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-admin-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,607 +31,654 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-admin-view tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-admin-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-rest-api-interface', {
-        getProjectConfig() {
-          return Promise.resolve({});
-        },
+suite('gr-admin-view tests', () => {
+  let element;
+  let sandbox;
+
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-rest-api-interface', {
+      getProjectConfig() {
+        return Promise.resolve({});
+      },
+    });
+    const pluginsLoaded = Promise.resolve();
+    sandbox.stub(pluginLoader, 'awaitPluginsLoaded').returns(pluginsLoaded);
+    pluginsLoaded.then(() => flush(done));
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeURLHelper', () => {
+    const path = '/test';
+    const host = 'http://www.testsite.com';
+    const computedPath = element._computeURLHelper(host, path);
+    assert.equal(computedPath, '//http://www.testsite.com/test');
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+        element._computeLinkURL({url: '/test', noBaseUrl: true}),
+        '//' + window.location.host + '/test');
+
+    sandbox.stub(element, 'getBaseUrl').returns('/foo');
+    assert.equal(
+        element._computeLinkURL({url: '/test', noBaseUrl: true}),
+        '//' + window.location.host + '/foo/test');
+    assert.equal(element._computeLinkURL({url: '/test'}), '/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test', target: '_blank'}),
+        '/test');
+  });
+
+  test('current page gets selected and is displayed', () => {
+    element._filteredLinks = [{
+      name: 'Repositories',
+      url: '/admin/repos',
+      view: 'gr-repo-list',
+    }];
+
+    element.params = {
+      view: 'admin',
+      adminView: 'gr-repo-list',
+    };
+
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root).querySelectorAll(
+        '.selected').length, 1);
+    assert.ok(element.shadowRoot
+        .querySelector('gr-repo-list'));
+    assert.isNotOk(element.shadowRoot
+        .querySelector('gr-admin-create-repo'));
+  });
+
+  test('_filteredLinks admin', done => {
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        })
+    );
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 3);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Plugins
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks non admin authenticated', done => {
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({})
+    );
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 2);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks non admin unathenticated', done => {
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 1);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks from plugin', () => {
+    sandbox.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
+      {text: 'internal link text', url: '/internal/link/url'},
+      {text: 'external link text', url: 'http://external/link/url'},
+    ]);
+    return element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 3);
+      assert.deepEqual(element._filteredLinks[1], {
+        capability: null,
+        url: '/internal/link/url',
+        name: 'internal link text',
+        noBaseUrl: true,
+        view: null,
+        viewableToAll: true,
+        target: null,
       });
-      const pluginsLoaded = Promise.resolve();
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(pluginsLoaded);
-      pluginsLoaded.then(() => flush(done));
+      assert.deepEqual(element._filteredLinks[2], {
+        capability: null,
+        url: 'http://external/link/url',
+        name: 'external link text',
+        noBaseUrl: false,
+        view: null,
+        viewableToAll: true,
+        target: '_blank',
+      });
     });
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeURLHelper', () => {
-      const path = '/test';
-      const host = 'http://www.testsite.com';
-      const computedPath = element._computeURLHelper(host, path);
-      assert.equal(computedPath, '//http://www.testsite.com/test');
-    });
-
-    test('link URLs', () => {
+  test('Repo shows up in nav', done => {
+    element._repoName = 'Test Repo';
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flushAsynchronousOperations();
+      assert.equal(dom(element.root)
+          .querySelectorAll('.sectionTitle').length, 3);
+      assert.equal(element.shadowRoot
+          .querySelector('.breadcrumbText').innerText, 'Test Repo');
       assert.equal(
-          element._computeLinkURL({url: '/test', noBaseUrl: true}),
-          '//' + window.location.host + '/test');
-
-      sandbox.stub(element, 'getBaseUrl').returns('/foo');
-      assert.equal(
-          element._computeLinkURL({url: '/test', noBaseUrl: true}),
-          '//' + window.location.host + '/foo/test');
-      assert.equal(element._computeLinkURL({url: '/test'}), '/test');
-      assert.equal(
-          element._computeLinkURL({url: '/test', target: '_blank'}),
-          '/test');
+          element.shadowRoot.querySelector('#pageSelect').items.length,
+          6
+      );
+      done();
     });
+  });
 
-    test('current page gets selected and is displayed', () => {
-      element._filteredLinks = [{
+  test('Group shows up in nav', done => {
+    element._groupId = 'a15262';
+    element._groupName = 'my-group';
+    element._groupIsInternal = true;
+    element._isAdmin = true;
+    element._groupOwner = false;
+    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sandbox.stub(
+        element.$.restAPI,
+        'getAccountCapabilities',
+        () => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flushAsynchronousOperations();
+      assert.equal(element._filteredLinks.length, 3);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.equal(element._filteredLinks[1].subsection.children.length, 2);
+      assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
+
+      // Plugins
+      assert.isNotOk(element._filteredLinks[2].subsection);
+      done();
+    });
+  });
+
+  test('Nav is reloaded when repo changes', () => {
+    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);
+    element.params = {repo: 'Test Repo 2',
+      adminView: 'gr-repo'};
+    assert.equal(element.reload.callCount, 2);
+  });
+
+  test('Nav is reloaded when group changes', () => {
+    sandbox.stub(element, '_computeGroupName');
+    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);
+  });
+
+  test('Nav is reloaded when group name changes', done => {
+    const newName = 'newName';
+    sandbox.stub(element, '_computeGroupName');
+    sandbox.stub(element, 'reload', () => {
+      assert.equal(element._groupName, newName);
+      assert.isTrue(element.reload.called);
+      done();
+    });
+    element.params = {group: 1, view: GerritNav.View.GROUP};
+    element._groupName = 'oldName';
+    flushAsynchronousOperations();
+    element.shadowRoot
+        .querySelector('gr-group').dispatchEvent(
+            new CustomEvent('name-changed', {
+              detail: {name: newName},
+              composed: true, bubbles: true,
+            }));
+  });
+
+  test('dropdown displays if there is a subsection', () => {
+    assert.isNotOk(element.shadowRoot
+        .querySelector('.mainHeader'));
+    element._subsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: 'repo',
+        url: '',
+        parent: 'my-repo',
+        detailType: undefined,
+      },
+    ];
+    flushAsynchronousOperations();
+    assert.isOk(element.shadowRoot
+        .querySelector('.mainHeader'));
+    element._subsectionLinks = undefined;
+    flushAsynchronousOperations();
+    assert.equal(
+        getComputedStyle(element.shadowRoot
+            .querySelector('.mainHeader')).display,
+        'none');
+  });
+
+  test('Dropdown only triggers navigation on explicit select', done => {
+    element._repoName = 'my-repo';
+    element.params = {
+      repo: 'my-repo',
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.ACCESS,
+    };
+    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 = [
+      {
         name: 'Repositories',
+        noBaseUrl: true,
         url: '/admin/repos',
         view: 'gr-repo-list',
-      }];
-
-      element.params = {
-        view: 'admin',
-        adminView: 'gr-repo-list',
-      };
-
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root).querySelectorAll(
-          '.selected').length, 1);
-      assert.ok(element.$$('gr-repo-list'));
-      assert.isNotOk(element.$$('gr-admin-create-repo'));
-    });
-
-    test('_filteredLinks admin', done => {
-      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,
-        });
-      });
-      element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 3);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Groups
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Plugins
-        assert.isNotOk(element._filteredLinks[0].subsection);
-        done();
-      });
-    });
-
-    test('_filteredLinks non admin authenticated', done => {
-      sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-        name: 'test-user',
-      }));
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({});
-      });
-      element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 2);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Groups
-        assert.isNotOk(element._filteredLinks[0].subsection);
-        done();
-      });
-    });
-
-    test('_filteredLinks non admin unathenticated', done => {
-      element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 1);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-        done();
-      });
-    });
-
-    test('_filteredLinks from plugin', () => {
-      sandbox.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
-        {text: 'internal link text', url: '/internal/link/url'},
-        {text: 'external link text', url: 'http://external/link/url'},
-      ]);
-      return element.reload().then(() => {
-        assert.equal(element._filteredLinks.length, 3);
-        assert.deepEqual(element._filteredLinks[1], {
-          capability: null,
-          url: '/internal/link/url',
-          name: 'internal link text',
-          noBaseUrl: true,
-          view: null,
-          viewableToAll: true,
-          target: null,
-        });
-        assert.deepEqual(element._filteredLinks[2], {
-          capability: null,
-          url: 'http://external/link/url',
-          name: 'external link text',
-          noBaseUrl: false,
-          view: null,
-          viewableToAll: true,
-          target: '_blank',
-        });
-      });
-    });
-
-    test('Repo shows up in nav', done => {
-      element._repoName = 'Test Repo';
-      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,
-        });
-      });
-      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);
-        done();
-      });
-    });
-
-    test('Group shows up in nav', done => {
-      element._groupId = 'a15262';
-      element._groupName = 'my-group';
-      element._groupIsInternal = true;
-      element._isAdmin = true;
-      element._groupOwner = false;
-      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,
-        });
-      });
-      element.reload().then(() => {
-        flushAsynchronousOperations();
-        assert.equal(element._filteredLinks.length, 3);
-
-        // Repos
-        assert.isNotOk(element._filteredLinks[0].subsection);
-
-        // Groups
-        assert.equal(element._filteredLinks[1].subsection.children.length, 2);
-        assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
-
-        // Plugins
-        assert.isNotOk(element._filteredLinks[2].subsection);
-        done();
-      });
-    });
-
-    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, 'reload');
-      element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
-      assert.equal(element.reload.callCount, 1);
-      element.params = {repo: 'Test Repo 2',
-        adminView: 'gr-repo'};
-      assert.equal(element.reload.callCount, 2);
-    });
-
-    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, 'reload');
-      element.params = {groupId: '1', adminView: 'gr-group'};
-      assert.equal(element.reload.callCount, 1);
-    });
-
-    test('Nav is reloaded when group name changes', done => {
-      const newName = 'newName';
-      sandbox.stub(element, '_computeGroupName');
-      sandbox.stub(element, 'reload', () => {
-        assert.equal(element._groupName, newName);
-        assert.isTrue(element.reload.called);
-        done();
-      });
-      element.params = {group: 1, view: Gerrit.Nav.View.GROUP};
-      element._groupName = 'oldName';
-      flushAsynchronousOperations();
-      element.$$('gr-group').fire('name-changed', {name: newName});
-    });
-
-    test('dropdown displays if there is a subsection', () => {
-      assert.isNotOk(element.$$('.mainHeader'));
-      element._subsectionLinks = [
-        {
-          text: 'Home',
-          value: 'repo',
+        viewableToAll: true,
+        subsection: {
+          name: 'my-repo',
           view: 'repo',
           url: '',
-          parent: 'my-repo',
-          detailType: undefined,
+          children: [
+            {
+              name: 'Access',
+              view: 'repo',
+              detailType: 'access',
+              url: '',
+            },
+            {
+              name: 'Commands',
+              view: 'repo',
+              detailType: 'commands',
+              url: '',
+            },
+            {
+              name: 'Branches',
+              view: 'repo',
+              detailType: 'branches',
+              url: '',
+            },
+            {
+              name: 'Tags',
+              view: 'repo',
+              detailType: 'tags',
+              url: '',
+            },
+            {
+              name: 'Dashboards',
+              view: 'repo',
+              detailType: 'dashboards',
+              url: '',
+            },
+          ],
         },
-      ];
-      flushAsynchronousOperations();
-      assert.isOk(element.$$('.mainHeader'));
-      element._subsectionLinks = undefined;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.$$('.mainHeader')).display, 'none');
-    });
-
-    test('Dropdown only triggers navigation on explicit select', done => {
-      element._repoName = 'my-repo';
-      element.params = {
-        repo: 'my-repo',
-        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});
-      });
-      flushAsynchronousOperations();
-      const expectedFilteredLinks = [
-        {
-          name: 'Repositories',
-          noBaseUrl: true,
-          url: '/admin/repos',
-          view: 'gr-repo-list',
-          viewableToAll: true,
-          subsection: {
-            name: 'my-repo',
-            view: 'repo',
-            url: '',
-            children: [
-              {
-                name: 'Access',
-                view: 'repo',
-                detailType: 'access',
-                url: '',
-              },
-              {
-                name: 'Commands',
-                view: 'repo',
-                detailType: 'commands',
-                url: '',
-              },
-              {
-                name: 'Branches',
-                view: 'repo',
-                detailType: 'branches',
-                url: '',
-              },
-              {
-                name: 'Tags',
-                view: 'repo',
-                detailType: 'tags',
-                url: '',
-              },
-              {
-                name: 'Dashboards',
-                view: 'repo',
-                detailType: 'dashboards',
-                url: '',
-              },
-            ],
-          },
-        },
-        {
-          name: 'Groups',
-          section: 'Groups',
-          noBaseUrl: true,
-          url: '/admin/groups',
-          view: 'gr-admin-group-list',
-        },
-        {
-          name: 'Plugins',
-          capability: 'viewPlugins',
-          section: 'Plugins',
-          noBaseUrl: true,
-          url: '/admin/plugins',
-          view: 'gr-plugin-list',
-        },
-      ];
-      const expectedSubsectionLinks = [
-        {
-          text: 'Home',
-          value: 'repo',
-          view: 'repo',
-          url: '',
-          parent: 'my-repo',
-          detailType: undefined,
-        },
-        {
-          text: 'Access',
-          value: 'repoaccess',
-          view: 'repo',
-          url: '',
-          detailType: 'access',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Commands',
-          value: 'repocommands',
-          view: 'repo',
-          url: '',
-          detailType: 'commands',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Branches',
-          value: 'repobranches',
-          view: 'repo',
-          url: '',
-          detailType: 'branches',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Tags',
-          value: 'repotags',
-          view: 'repo',
-          url: '',
-          detailType: 'tags',
-          parent: 'my-repo',
-        },
-        {
-          text: 'Dashboards',
-          value: 'repodashboards',
-          view: 'repo',
-          url: '',
-          detailType: 'dashboards',
-          parent: 'my-repo',
-        },
-      ];
-      sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
-      sandbox.spy(element, '_selectedIsCurrentPage');
-      sandbox.spy(element, '_handleSubsectionChange');
-      element.reload().then(() => {
-        assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
-        assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-        assert.equal(element.$$('#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';
-        assert.isTrue(element._selectedIsCurrentPage.calledTwice);
-        assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.calledOnce);
-        done();
-      });
-    });
-
-    test('_selectedIsCurrentPage', () => {
-      element._repoName = 'my-repo';
-      element.params = {view: 'repo', repo: 'my-repo'};
-      const selected = {
+      },
+      {
+        name: 'Groups',
+        section: 'Groups',
+        noBaseUrl: true,
+        url: '/admin/groups',
+        view: 'gr-admin-group-list',
+      },
+      {
+        name: 'Plugins',
+        capability: 'viewPlugins',
+        section: 'Plugins',
+        noBaseUrl: true,
+        url: '/admin/plugins',
+        view: 'gr-plugin-list',
+      },
+    ];
+    const expectedSubsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
         view: 'repo',
-        detailType: undefined,
+        url: '',
         parent: 'my-repo',
-      };
-      assert.isTrue(element._selectedIsCurrentPage(selected));
-      selected.parent = 'my-second-repo';
-      assert.isFalse(element._selectedIsCurrentPage(selected));
-      selected.detailType = 'detailType';
-      assert.isFalse(element._selectedIsCurrentPage(selected));
-    });
+        detailType: undefined,
+      },
+      {
+        text: 'Access',
+        value: 'repoaccess',
+        view: 'repo',
+        url: '',
+        detailType: 'access',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Commands',
+        value: 'repocommands',
+        view: 'repo',
+        url: '',
+        detailType: 'commands',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Branches',
+        value: 'repobranches',
+        view: 'repo',
+        url: '',
+        detailType: 'branches',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Tags',
+        value: 'repotags',
+        view: 'repo',
+        url: '',
+        detailType: 'tags',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Dashboards',
+        value: 'repodashboards',
+        view: 'repo',
+        url: '',
+        detailType: 'dashboards',
+        parent: 'my-repo',
+      },
+    ];
+    sandbox.stub(GerritNav, 'navigateToRelativeUrl');
+    sandbox.spy(element, '_selectedIsCurrentPage');
+    sandbox.spy(element, '_handleSubsectionChange');
+    element.reload().then(() => {
+      assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
+      assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
+      assert.equal(
+          element.shadowRoot.querySelector('#pageSelect').value,
+          'repoaccess'
+      );
+      assert.isTrue(element._selectedIsCurrentPage.calledOnce);
+      // Doesn't trigger navigation from the page select menu.
+      assert.isFalse(GerritNav.navigateToRelativeUrl.called);
 
-    suite('_computeSelectedClass', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-          return Promise.resolve({
+      // When explicitly changed, navigation is called
+      element.shadowRoot.querySelector('#pageSelect').value = 'repo';
+      assert.isTrue(element._selectedIsCurrentPage.calledTwice);
+      assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
+      done();
+    });
+  });
+
+  test('_selectedIsCurrentPage', () => {
+    element._repoName = 'my-repo';
+    element.params = {view: 'repo', repo: 'my-repo'};
+    const selected = {
+      view: 'repo',
+      detailType: undefined,
+      parent: 'my-repo',
+    };
+    assert.isTrue(element._selectedIsCurrentPage(selected));
+    selected.parent = 'my-second-repo';
+    assert.isFalse(element._selectedIsCurrentPage(selected));
+    selected.detailType = 'detailType';
+    assert.isFalse(element._selectedIsCurrentPage(selected));
+  });
+
+  suite('_computeSelectedClass', () => {
+    setup(() => {
+      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();
+    });
+
+    suite('repos', () => {
+      setup(() => {
+        stub('gr-repo-access', {
+          _repoChanged: () => {},
         });
-        sandbox.stub(element.$.restAPI, 'getAccount', () => {
-          return Promise.resolve({_id: 1});
+      });
+
+      test('repo list', () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-repo-list',
+          openCreateModal: false,
+        };
+        flushAsynchronousOperations();
+        const selected = element.shadowRoot
+            .querySelector('gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent.trim(), 'Repositories');
+      });
+
+      test('repo', () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'foo');
+        });
+      });
+
+      test('repo access', () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.ACCESS,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Access');
+        });
+      });
+
+      test('repo dashboards', () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.DASHBOARDS,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Dashboards');
+        });
+      });
+    });
+
+    suite('groups', () => {
+      setup(() => {
+        stub('gr-group', {
+          _loadGroup: () => Promise.resolve({}),
+        });
+        stub('gr-group-members', {
+          _loadGroupDetails: () => {},
         });
 
+        sandbox.stub(element.$.restAPI, 'getGroupConfig')
+            .returns(Promise.resolve({
+              name: 'foo',
+              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+            }));
+        sandbox.stub(element.$.restAPI, 'getIsGroupOwner')
+            .returns(Promise.resolve(true));
         return element.reload();
       });
 
-      suite('repos', () => {
-        setup(() => {
-          stub('gr-repo-access', {
-            _repoChanged: () => {},
-          });
-        });
+      test('group list', () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          openCreateModal: false,
+        };
+        flushAsynchronousOperations();
+        const selected = element.shadowRoot
+            .querySelector('gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent.trim(), 'Groups');
+      });
 
-        test('repo list', () => {
-          element.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            openCreateModal: false,
-          };
+      test('internal group', () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
           flushAsynchronousOperations();
-          const selected = element.$$('gr-page-nav .selected');
+          const subsectionItems = dom(element.root)
+              .querySelectorAll('.subsectionItem');
+          assert.equal(subsectionItems.length, 2);
+          assert.isTrue(element._groupIsInternal);
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
           assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Repositories');
-        });
-
-        test('repo', () => {
-          element.params = {
-            view: Gerrit.Nav.View.REPO,
-            repoName: 'foo',
-          };
-          element._repoName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.$$('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'foo');
-          });
-        });
-
-        test('repo access', () => {
-          element.params = {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.ACCESS,
-            repoName: 'foo',
-          };
-          element._repoName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.$$('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'Access');
-          });
-        });
-
-        test('repo dashboards', () => {
-          element.params = {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-            repoName: 'foo',
-          };
-          element._repoName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.$$('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'Dashboards');
-          });
+          assert.equal(selected.textContent.trim(), 'foo');
         });
       });
 
-      suite('groups', () => {
-        setup(() => {
-          stub('gr-group', {
-            _loadGroup: () => Promise.resolve({}),
-          });
-          stub('gr-group-members', {
-            _loadGroupDetails: () => {},
-          });
-
-          sandbox.stub(element.$.restAPI, 'getGroupConfig')
-              .returns(Promise.resolve({
-                name: 'foo',
-                id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-              }));
-          sandbox.stub(element.$.restAPI, 'getIsGroupOwner')
-              .returns(Promise.resolve(true));
-          return element.reload();
-        });
-
-        test('group list', () => {
-          element.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
-            openCreateModal: false,
-          };
+      test('external group', () => {
+        element.$.restAPI.getGroupConfig.restore();
+        sandbox.stub(element.$.restAPI, 'getGroupConfig')
+            .returns(Promise.resolve({
+              name: 'foo',
+              id: 'external-id',
+            }));
+        element.params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
           flushAsynchronousOperations();
-          const selected = element.$$('gr-page-nav .selected');
+          const subsectionItems = dom(element.root)
+              .querySelectorAll('.subsectionItem');
+          assert.equal(subsectionItems.length, 0);
+          assert.isFalse(element._groupIsInternal);
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
           assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Groups');
+          assert.equal(selected.textContent.trim(), 'foo');
         });
+      });
 
-        test('internal group', () => {
-          element.params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-          };
-          element._groupName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const subsectionItems = Polymer.dom(element.root)
-                .querySelectorAll('.subsectionItem');
-            assert.equal(subsectionItems.length, 2);
-            assert.isTrue(element._groupIsInternal);
-            const selected = element.$$('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'foo');
-          });
-        });
-
-        test('external group', () => {
-          element.$.restAPI.getGroupConfig.restore();
-          sandbox.stub(element.$.restAPI, 'getGroupConfig')
-              .returns(Promise.resolve({
-                name: 'foo',
-                id: 'external-id',
-              }));
-          element.params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-          };
-          element._groupName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const subsectionItems = Polymer.dom(element.root)
-                .querySelectorAll('.subsectionItem');
-            assert.equal(subsectionItems.length, 0);
-            assert.isFalse(element._groupIsInternal);
-            const selected = element.$$('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'foo');
-          });
-        });
-
-        test('group members', () => {
-          element.params = {
-            view: Gerrit.Nav.View.GROUP,
-            detail: Gerrit.Nav.GroupDetailView.MEMBERS,
-            groupId: 1234,
-          };
-          element._groupName = 'foo';
-          return element.reload().then(() => {
-            flushAsynchronousOperations();
-            const selected = element.$$('gr-page-nav .selected');
-            assert.isOk(selected);
-            assert.equal(selected.textContent.trim(), 'Members');
-          });
+      test('group members', () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          detail: GerritNav.GroupDetailView.MEMBERS,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Members');
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
deleted file mode 100644
index 9d8ee18..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
+++ /dev/null
@@ -1,48 +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.
--->
-
-<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">
-
-<dom-module id="gr-confirm-delete-item-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        width: 30em;
-      }
-    </style>
-    <gr-dialog
-        confirm-label="Delete [[_computeItemName(itemType)]]"
-        confirm-on-enter
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">[[_computeItemName(itemType)]] Deletion</div>
-      <div class="main" slot="main">
-        <label for="branchInput">
-          Do you really want to delete the following [[_computeItemName(itemType)]]?
-        </label>
-        <div>
-          [[item]]
-        </div>
-      </div>
-    </gr-dialog>
-  </template>
-  <script src="gr-confirm-delete-item-dialog.js"></script>
-</dom-module>
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..b6b21bd 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
@@ -14,59 +14,74 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DETAIL_TYPES = {
-    BRANCHES: 'branches',
-    ID: 'id',
-    TAGS: 'tags',
-  };
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-delete-item-dialog_html.js';
 
-  Polymer({
-    is: 'gr-confirm-delete-item-dialog',
+const DETAIL_TYPES = {
+  BRANCHES: 'branches',
+  ID: 'id',
+  TAGS: 'tags',
+};
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmDeleteItemDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-confirm-delete-item-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
       item: String,
       itemType: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    },
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
+  _computeItemName(detailType) {
+    if (detailType === DETAIL_TYPES.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === DETAIL_TYPES.TAGS) {
+      return 'Tag';
+    } else if (detailType === DETAIL_TYPES.ID) {
+      return 'ID';
+    }
+  }
+}
 
-    _computeItemName(detailType) {
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return 'Branch';
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return 'Tag';
-      } 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_html.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
new file mode 100644
index 0000000..3810d32
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 30em;
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Delete [[_computeItemName(itemType)]]"
+    confirm-on-enter=""
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">
+      [[_computeItemName(itemType)]] Deletion
+    </div>
+    <div class="main" slot="main">
+      <label for="branchInput">
+        Do you really want to delete the following
+        [[_computeItemName(itemType)]]?
+      </label>
+      <div>
+        [[item]]
+      </div>
+    </div>
+  </gr-dialog>
+`;
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..003edfb 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-delete-item-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,50 +31,60 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-delete-item-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-delete-item-dialog.js';
+suite('gr-confirm-delete-item-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_handleConfirmTap', () => {
-      const confirmHandler = sandbox.stub();
-      element.addEventListener('confirm', confirmHandler);
-      sandbox.spy(element, '_handleConfirmTap');
-      element.$$('gr-dialog').fire('confirm');
-      assert.isTrue(confirmHandler.called);
-      assert.isTrue(confirmHandler.calledOnce);
-      assert.isTrue(element._handleConfirmTap.called);
-      assert.isTrue(element._handleConfirmTap.calledOnce);
-    });
-
-    test('_handleCancelTap', () => {
-      const cancelHandler = sandbox.stub();
-      element.addEventListener('cancel', cancelHandler);
-      sandbox.spy(element, '_handleCancelTap');
-      element.$$('gr-dialog').fire('cancel');
-      assert.isTrue(cancelHandler.called);
-      assert.isTrue(cancelHandler.calledOnce);
-      assert.isTrue(element._handleCancelTap.called);
-      assert.isTrue(element._handleCancelTap.calledOnce);
-    });
-
-    test('_computeItemName function for branches', () => {
-      assert.deepEqual(element._computeItemName('branches'), 'Branch');
-      assert.notEqual(element._computeItemName('branches'), 'Tag');
-    });
-
-    test('_computeItemName function for tags', () => {
-      assert.deepEqual(element._computeItemName('tags'), 'Tag');
-      assert.notEqual(element._computeItemName('tags'), 'Branch');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sandbox.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sandbox.spy(element, '_handleConfirmTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._handleConfirmTap.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sandbox.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sandbox.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+
+  test('_computeItemName function for branches', () => {
+    assert.deepEqual(element._computeItemName('branches'), 'Branch');
+    assert.notEqual(element._computeItemName('branches'), 'Tag');
+  });
+
+  test('_computeItemName function for tags', () => {
+    assert.deepEqual(element._computeItemName('tags'), 'Tag');
+    assert.notEqual(element._computeItemName('tags'), 'Branch');
+  });
+});
 </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
deleted file mode 100644
index 2a95991..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ /dev/null
@@ -1,131 +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.
--->
-
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-create-change-dialog">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      input:not([type="checkbox"]),
-      gr-autocomplete,
-      iron-autogrow-textarea {
-        width: 100%;
-      }
-      .value {
-        width: 32em;
-      }
-      gr-autocomplete {
-        --gr-autocomplete: {
-          padding: 0 var(--spacing-xs);
-        }
-      }
-      .hide {
-        display: none;
-      }
-      @media only screen and (max-width: 40em) {
-        .value {
-          width: 29em;
-        }
-      }
-    </style>
-    <div class="gr-form-styles">
-      <section class$="[[_computeBranchClass(baseChange)]]">
-        <span class="title">Select branch for new change</span>
-        <span class="value">
-          <gr-autocomplete
-              id="branchInput"
-              text="{{branch}}"
-              query="[[_query]]"
-              placeholder="Destination branch">
-          </gr-autocomplete>
-        </span>
-      </section>
-      <section class$="[[_computeBranchClass(baseChange)]]">
-        <span class="title">Provide base commit sha1 for change</span>
-        <span class="value">
-          <iron-input
-              maxlength="40"
-              placeholder="(optional)"
-              bind-value="{{baseCommit}}">
-            <input
-                is="iron-input"
-                id="baseCommitInput"
-                maxlength="40"
-                placeholder="(optional)"
-                bind-value="{{baseCommit}}">
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Enter topic for new change</span>
-        <span class="value">
-          <iron-input
-              maxlength="1024"
-              placeholder="(optional)"
-              bind-value="{{topic}}">
-            <input
-                is="iron-input"
-                id="tagNameInput"
-                maxlength="1024"
-                placeholder="(optional)"
-                bind-value="{{topic}}">
-          </iron-input>
-        </span>
-      </section>
-      <section id="description">
-        <span class="title">Description</span>
-        <span class="value">
-          <iron-autogrow-textarea
-              id="messageInput"
-              class="message"
-              autocomplete="on"
-              rows="4"
-              max-rows="15"
-              bind-value="{{subject}}"
-              placeholder="Insert the description of the change.">
-          </iron-autogrow-textarea>
-        </span>
-      </section>
-      <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
-        <label
-            class="title"
-            for="privateChangeCheckBox">Private change</label>
-        <span class="value">
-          <input
-              type="checkbox"
-              id="privateChangeCheckBox"
-              checked$="[[_formatBooleanString(privateByDefault)]]">
-        </span>
-      </section>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-change-dialog.js"></script>
-</dom-module>
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..3347655 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
@@ -14,16 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  const SUGGESTIONS_LIMIT = 15;
-  const REF_PREFIX = 'refs/heads/';
+import '@polymer/iron-input/iron-input.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-change-dialog_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-create-change-dialog',
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrCreateChangeDialog extends mixinBehaviors( [
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-create-change-dialog'; }
+
+  static get properties() {
+    return {
       repoName: String,
       branch: String,
       /** @type {?} */
@@ -45,103 +72,99 @@
         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(); }
 
-    attached() {
-      if (!this.repoName) { return Promise.resolve(); }
+    const promises = [];
 
-      const promises = [];
+    promises.push(this.$.restAPI.getProjectConfig(this.repoName)
+        .then(config => {
+          this.privateByDefault = config.private_by_default;
+        }));
 
-      promises.push(this.$.restAPI.getProjectConfig(this.repoName)
-          .then(config => {
-            this.privateByDefault = config.private_by_default;
-          }));
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      if (!config) { return; }
 
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        if (!config) { return; }
+      this._privateConfig = config && config.change &&
+          config.change.disable_private_changes;
+    }));
 
-        this._privateConfig = config && config.change &&
-            config.change.disable_private_changes;
-      }));
+    return Promise.all(promises);
+  }
 
-      return Promise.all(promises);
-    },
-
-    observers: [
+  static get observers() {
+    return [
       '_allowCreate(branch, subject)',
-    ],
+    ];
+  }
 
-    _computeBranchClass(baseChange) {
-      return baseChange ? 'hide' : '';
-    },
+  _computeBranchClass(baseChange) {
+    return baseChange ? 'hide' : '';
+  }
 
-    _allowCreate(branch, subject) {
-      this.canCreate = !!branch && !!subject;
-    },
+  _allowCreate(branch, subject) {
+    this.canCreate = !!branch && !!subject;
+  }
 
-    handleCreateChange() {
-      const isPrivate = this.$.privateChangeCheckBox.checked;
-      const isWip = true;
-      return this.$.restAPI.createChange(this.repoName, this.branch,
-          this.subject, this.topic, isPrivate, isWip, this.baseChange,
-          this.baseCommit || null)
-          .then(changeCreated => {
-            if (!changeCreated) { return; }
-            Gerrit.Nav.navigateToChange(changeCreated);
-          });
-    },
+  handleCreateChange() {
+    const isPrivate = this.$.privateChangeCheckBox.checked;
+    const isWip = true;
+    return this.$.restAPI.createChange(this.repoName, this.branch,
+        this.subject, this.topic, isPrivate, isWip, this.baseChange,
+        this.baseCommit || null)
+        .then(changeCreated => {
+          if (!changeCreated) { return; }
+          GerritNav.navigateToChange(changeCreated);
+        });
+  }
 
-    _getRepoBranchesSuggestions(input) {
-      if (input.startsWith(REF_PREFIX)) {
-        input = input.substring(REF_PREFIX.length);
-      }
-      return this.$.restAPI.getRepoBranches(
-          input, this.repoName, SUGGESTIONS_LIMIT).then(response => {
-        const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!response.hasOwnProperty(key)) { continue; }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
-        }
-        return branches;
-      });
-    },
-
-    _formatBooleanString(config) {
-      if (config && config.configured_value === 'TRUE') {
-        return true;
-      } else if (config && config.configured_value === 'FALSE') {
-        return false;
-      } else if (config && config.configured_value === 'INHERIT') {
-        if (config && config.inherited_value) {
-          return true;
+  _getRepoBranchesSuggestions(input) {
+    if (input.startsWith(REF_PREFIX)) {
+      input = input.substring(REF_PREFIX.length);
+    }
+    return this.$.restAPI.getRepoBranches(
+        input, this.repoName, SUGGESTIONS_LIMIT).then(response => {
+      const branches = [];
+      let branch;
+      for (const key in response) {
+        if (!response.hasOwnProperty(key)) { continue; }
+        if (response[key].ref.startsWith('refs/heads/')) {
+          branch = response[key].ref.substring('refs/heads/'.length);
         } else {
-          return false;
+          branch = response[key].ref;
         }
+        branches.push({
+          name: branch,
+        });
+      }
+      return branches;
+    });
+  }
+
+  _formatBooleanString(config) {
+    if (config && config.configured_value === 'TRUE') {
+      return true;
+    } else if (config && config.configured_value === 'FALSE') {
+      return false;
+    } else if (config && config.configured_value === 'INHERIT') {
+      if (config && config.inherited_value) {
+        return true;
       } else {
         return false;
       }
-    },
+    } else {
+      return false;
+    }
+  }
 
-    _computePrivateSectionClass(config) {
-      return config ? 'hide' : '';
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
new file mode 100644
index 0000000..f18da81
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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,
+    iron-autogrow-textarea {
+      width: 100%;
+    }
+    .value {
+      width: 32em;
+    }
+    .hide {
+      display: none;
+    }
+    @media only screen and (max-width: 40em) {
+      .value {
+        width: 29em;
+      }
+    }
+  </style>
+  <div class="gr-form-styles">
+    <section class$="[[_computeBranchClass(baseChange)]]">
+      <span class="title">Select branch for new change</span>
+      <span class="value">
+        <gr-autocomplete
+          id="branchInput"
+          text="{{branch}}"
+          query="[[_query]]"
+          placeholder="Destination branch"
+        >
+        </gr-autocomplete>
+      </span>
+    </section>
+    <section class$="[[_computeBranchClass(baseChange)]]">
+      <span class="title">Provide base commit sha1 for change</span>
+      <span class="value">
+        <iron-input
+          maxlength="40"
+          placeholder="(optional)"
+          bind-value="{{baseCommit}}"
+        >
+          <input
+            is="iron-input"
+            id="baseCommitInput"
+            maxlength="40"
+            placeholder="(optional)"
+            bind-value="{{baseCommit}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Enter topic for new change</span>
+      <span class="value">
+        <iron-input
+          maxlength="1024"
+          placeholder="(optional)"
+          bind-value="{{topic}}"
+        >
+          <input
+            is="iron-input"
+            id="tagNameInput"
+            maxlength="1024"
+            placeholder="(optional)"
+            bind-value="{{topic}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section id="description">
+      <span class="title">Description</span>
+      <span class="value">
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          rows="4"
+          max-rows="15"
+          bind-value="{{subject}}"
+          placeholder="Insert the description of the change."
+        >
+        </iron-autogrow-textarea>
+      </span>
+    </section>
+    <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
+      <label class="title" for="privateChangeCheckBox">Private change</label>
+      <span class="value">
+        <input
+          type="checkbox"
+          id="privateChangeCheckBox"
+          checked$="[[_formatBooleanString(privateByDefault)]]"
+        />
+      </span>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..87105a7 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-change-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,141 +31,137 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-change-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-create-change-dialog.js';
+suite('gr-create-change-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getRepoBranches(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                ref: 'refs/heads/test-branch',
-                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-                can_delete: true,
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
-        },
-      });
-      element = fixture('basic');
-      element.repoName = 'test-repo',
-      element._repoConfig = {
-        private_by_default: {
-          configured_value: 'FALSE',
-          inherited_value: false,
-        },
-      };
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
     });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('new change created with default', done => {
-      const configInputObj = {
-        branch: 'test-branch',
-        subject: 'first change created with polygerrit ui',
-        topic: 'test-topic',
-        is_private: false,
-        work_in_progress: true,
-      };
-
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'createChange', () => {
-            return Promise.resolve({});
-          });
-
-      element.branch = 'test-branch';
-      element.topic = 'test-topic';
-      element.subject = 'first change created with polygerrit ui';
-      assert.isFalse(element.$.privateChangeCheckBox.checked);
-
-      element.$.branchInput.bindValue = configInputObj.branch;
-      element.$.tagNameInput.bindValue = configInputObj.topic;
-      element.$.messageInput.bindValue = configInputObj.subject;
-
-      element.handleCreateChange().then(() => {
-        // Private change
-        assert.isFalse(saveStub.lastCall.args[4]);
-        // WIP Change
-        assert.isTrue(saveStub.lastCall.args[5]);
-        assert.isTrue(saveStub.called);
-        done();
-      });
-    });
-
-    test('new change created with private', done => {
-      element.privateByDefault = {
-        configured_value: 'TRUE',
+    element = fixture('basic');
+    element.repoName = 'test-repo',
+    element._repoConfig = {
+      private_by_default: {
+        configured_value: 'FALSE',
         inherited_value: false,
-      };
-      sandbox.stub(element, '_formatBooleanString', () => {
-        return Promise.resolve(true);
-      });
-      flushAsynchronousOperations();
+      },
+    };
+  });
 
-      const configInputObj = {
-        branch: 'test-branch',
-        subject: 'first change created with polygerrit ui',
-        topic: 'test-topic',
-        is_private: true,
-        work_in_progress: true,
-      };
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'createChange', () => {
-            return Promise.resolve({});
-          });
+  test('new change created with default', done => {
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: false,
+      work_in_progress: true,
+    };
 
-      element.branch = 'test-branch';
-      element.topic = 'test-topic';
-      element.subject = 'first change created with polygerrit ui';
-      assert.isTrue(element.$.privateChangeCheckBox.checked);
+    const saveStub = sandbox.stub(element.$.restAPI,
+        'createChange', () => Promise.resolve({}));
 
-      element.$.branchInput.bindValue = configInputObj.branch;
-      element.$.tagNameInput.bindValue = configInputObj.topic;
-      element.$.messageInput.bindValue = configInputObj.subject;
+    element.branch = 'test-branch';
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isFalse(element.$.privateChangeCheckBox.checked);
 
-      element.handleCreateChange().then(() => {
-        // Private change
-        assert.isTrue(saveStub.lastCall.args[4]);
-        // WIP Change
-        assert.isTrue(saveStub.lastCall.args[5]);
-        assert.isTrue(saveStub.called);
-        done();
-      });
-    });
+    element.$.branchInput.bindValue = configInputObj.branch;
+    element.$.tagNameInput.bindValue = configInputObj.topic;
+    element.$.messageInput.bindValue = configInputObj.subject;
 
-    test('_getRepoBranchesSuggestions empty', done => {
-      element._getRepoBranchesSuggestions('nonexistent').then(branches => {
-        assert.equal(branches.length, 0);
-        done();
-      });
-    });
-
-    test('_getRepoBranchesSuggestions non-empty', done => {
-      element._getRepoBranchesSuggestions('test-branch').then(branches => {
-        assert.equal(branches.length, 1);
-        assert.equal(branches[0].name, 'test-branch');
-        done();
-      });
-    });
-
-    test('_computeBranchClass', () => {
-      assert.equal(element._computeBranchClass(true), 'hide');
-      assert.equal(element._computeBranchClass(false), '');
-    });
-
-    test('_computePrivateSectionClass', () => {
-      assert.equal(element._computePrivateSectionClass(true), 'hide');
-      assert.equal(element._computePrivateSectionClass(false), '');
+    element.handleCreateChange().then(() => {
+      // Private change
+      assert.isFalse(saveStub.lastCall.args[4]);
+      // WIP Change
+      assert.isTrue(saveStub.lastCall.args[5]);
+      assert.isTrue(saveStub.called);
+      done();
     });
   });
+
+  test('new change created with private', done => {
+    element.privateByDefault = {
+      configured_value: 'TRUE',
+      inherited_value: false,
+    };
+    sandbox.stub(element, '_formatBooleanString', () => Promise.resolve(true));
+    flushAsynchronousOperations();
+
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: true,
+      work_in_progress: true,
+    };
+
+    const saveStub = sandbox.stub(element.$.restAPI,
+        'createChange', () => Promise.resolve({}));
+
+    element.branch = 'test-branch';
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isTrue(element.$.privateChangeCheckBox.checked);
+
+    element.$.branchInput.bindValue = configInputObj.branch;
+    element.$.tagNameInput.bindValue = configInputObj.topic;
+    element.$.messageInput.bindValue = configInputObj.subject;
+
+    element.handleCreateChange().then(() => {
+      // Private change
+      assert.isTrue(saveStub.lastCall.args[4]);
+      // WIP Change
+      assert.isTrue(saveStub.lastCall.args[5]);
+      assert.isTrue(saveStub.called);
+      done();
+    });
+  });
+
+  test('_getRepoBranchesSuggestions empty', done => {
+    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
+    });
+  });
+
+  test('_getRepoBranchesSuggestions non-empty', done => {
+    element._getRepoBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+
+  test('_computeBranchClass', () => {
+    assert.equal(element._computeBranchClass(true), 'hide');
+    assert.equal(element._computeBranchClass(false), '');
+  });
+
+  test('_computePrivateSectionClass', () => {
+    assert.equal(element._computePrivateSectionClass(true), 'hide');
+    assert.equal(element._computePrivateSectionClass(false), '');
+  });
+});
 </script>
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
deleted file mode 100644
index 8a4287b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
+++ /dev/null
@@ -1,54 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-create-group-dialog">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      :host {
-        display: inline-block;
-      }
-      input {
-        width: 20em;
-      }
-    </style>
-    <div class="gr-form-styles">
-      <div id="form">
-        <section>
-          <span class="title">Group name</span>
-          <iron-input
-              bind-value="{{_name}}">
-            <input
-                is="iron-input"
-                bind-value="{{_name}}">
-          </iron-input>
-        </section>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-group-dialog.js"></script>
-</dom-module>
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..b21bdde 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
@@ -14,13 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-create-group-dialog',
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-group-dialog_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import page from 'page/page.mjs';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrCreateGroupDialog extends mixinBehaviors( [
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-create-group-dialog'; }
+
+  static get properties() {
+    return {
       params: Object,
       hasNewGroupName: {
         type: Boolean,
@@ -32,36 +55,35 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_updateGroupName(_name)',
-    ],
+    ];
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  _computeGroupUrl(groupId) {
+    return this.getBaseUrl() + '/admin/groups/' +
+        this.encodeURL(groupId, true);
+  }
 
-    _computeGroupUrl(groupId) {
-      return this.getBaseUrl() + '/admin/groups/' +
-          this.encodeURL(groupId, true);
-    },
+  _updateGroupName(name) {
+    this.hasNewGroupName = !!name;
+  }
 
-    _updateGroupName(name) {
-      this.hasNewGroupName = !!name;
-    },
+  handleCreateGroup() {
+    return this.$.restAPI.createGroup({name: this._name})
+        .then(groupRegistered => {
+          if (groupRegistered.status !== 201) { return; }
+          this._groupCreated = true;
+          return this.$.restAPI.getGroupConfig(this._name)
+              .then(group => {
+                page.show(this._computeGroupUrl(group.group_id));
+              });
+        });
+  }
+}
 
-    handleCreateGroup() {
-      return this.$.restAPI.createGroup({name: this._name})
-          .then(groupRegistered => {
-            if (groupRegistered.status !== 201) { return; }
-            this._groupCreated = true;
-            return this.$.restAPI.getGroupConfig(this._name)
-                .then(group => {
-                  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_html.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
new file mode 100644
index 0000000..bc1f24c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    input {
+      width: 20em;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <div id="form">
+      <section>
+        <span class="title">Group name</span>
+        <iron-input bind-value="{{_name}}">
+          <input is="iron-input" bind-value="{{_name}}" />
+        </iron-input>
+      </section>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..164db53 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-create-group-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,64 +32,69 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-group-dialog tests', () => {
-    let element;
-    let sandbox;
-    const GROUP_NAME = 'test-group';
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-create-group-dialog.js';
+import page from 'page/page.mjs';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
+suite('gr-create-group-dialog tests', () => {
+  let element;
+  let sandbox;
+  const GROUP_NAME = 'test-group';
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('name is updated correctly', done => {
-      assert.isFalse(element.hasNewGroupName);
+  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);
-        assert.deepEqual(element._name, GROUP_NAME);
-        done();
-      });
-    });
-
-    test('test for redirecting to group on successful creation', done => {
-      sandbox.stub(element.$.restAPI, 'createGroup')
-          .returns(Promise.resolve({status: 201}));
-
-      sandbox.stub(element.$.restAPI, 'getGroupConfig')
-          .returns(Promise.resolve({group_id: 551}));
-
-      const showStub = sandbox.stub(page, 'show');
-      element.handleCreateGroup()
-          .then(() => {
-            assert.isTrue(showStub.calledWith('/admin/groups/551'));
-            done();
-          });
-    });
-
-    test('test for unsuccessful group creation', done => {
-      sandbox.stub(element.$.restAPI, 'createGroup')
-          .returns(Promise.resolve({status: 409}));
-
-      sandbox.stub(element.$.restAPI, 'getGroupConfig')
-          .returns(Promise.resolve({group_id: 551}));
-
-      const showStub = sandbox.stub(page, 'show');
-      element.handleCreateGroup()
-          .then(() => {
-            assert.isFalse(showStub.called);
-            done();
-          });
+    setTimeout(() => {
+      assert.isTrue(element.hasNewGroupName);
+      assert.deepEqual(element._name, GROUP_NAME);
+      done();
     });
   });
+
+  test('test for redirecting to group on successful creation', done => {
+    sandbox.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 201}));
+
+    sandbox.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sandbox.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isTrue(showStub.calledWith('/admin/groups/551'));
+          done();
+        });
+  });
+
+  test('test for unsuccessful group creation', done => {
+    sandbox.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 409}));
+
+    sandbox.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sandbox.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isFalse(showStub.called);
+          done();
+        });
+  });
+});
 </script>
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
deleted file mode 100644
index 33153eb..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
+++ /dev/null
@@ -1,87 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-create-pointer-dialog">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      :host {
-        display: inline-block;
-      }
-      input {
-        width: 20em;
-      }
-      /* Add css selector with #id to increase priority
-      (otherwise ".gr-form-styles section" rule wins) */
-      .hideItem,
-      #itemAnnotationSection.hideItem {
-        display: none;
-      }
-    </style>
-    <div class="gr-form-styles">
-      <div id="form">
-        <section id="itemNameSection">
-          <span class="title">[[detailType]] name</span>
-          <iron-input
-              placeholder="[[detailType]] Name"
-              bind-value="{{_itemName}}">
-            <input
-                is="iron-input"
-                placeholder="[[detailType]] Name"
-                bind-value="{{_itemName}}">
-          </iron-input>
-        </section>
-        <section id="itemRevisionSection">
-          <span class="title">Initial Revision</span>
-          <iron-input
-              placeholder="Revision (Branch or SHA-1)"
-              bind-value="{{_itemRevision}}">
-            <input
-                is="iron-input"
-                placeholder="Revision (Branch or SHA-1)"
-                bind-value="{{_itemRevision}}">
-          </iron-input>
-        </section>
-        <section id="itemAnnotationSection"
-                 class$="[[_computeHideItemClass(itemDetail)]]">
-          <span class="title">Annotation</span>
-          <iron-input
-              placeholder="Annotation (Optional)"
-              bind-value="{{_itemAnnotation}}">
-            <input
-                is="iron-input"
-                placeholder="Annotation (Optional)"
-                bind-value="{{_itemAnnotation}}">
-          </iron-input>
-        </section>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-pointer-dialog.js"></script>
-</dom-module>
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..72a6b99 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
@@ -14,18 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DETAIL_TYPES = {
-    branches: 'branches',
-    tags: 'tags',
-  };
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-pointer-dialog_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import page from 'page/page.mjs';
 
-  Polymer({
-    is: 'gr-create-pointer-dialog',
+const DETAIL_TYPES = {
+  branches: 'branches',
+  tags: 'tags',
+};
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrCreatePointerDialog extends mixinBehaviors( [
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-create-pointer-dialog'; }
+
+  static get properties() {
+    return {
       detailType: String,
       repoName: String,
       hasNewItemName: {
@@ -37,55 +62,54 @@
       _itemName: String,
       _itemRevision: String,
       _itemAnnotation: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_updateItemName(_itemName)',
-    ],
+    ];
+  }
 
-    _updateItemName(name) {
-      this.hasNewItemName = !!name;
-    },
+  _updateItemName(name) {
+    this.hasNewItemName = !!name;
+  }
 
-    _computeItemUrl(project) {
-      if (this.itemDetail === DETAIL_TYPES.branches) {
-        return this.getBaseUrl() + '/admin/repos/' +
-            this.encodeURL(this.repoName, true) + ',branches';
-      } else if (this.itemDetail === DETAIL_TYPES.tags) {
-        return this.getBaseUrl() + '/admin/repos/' +
-            this.encodeURL(this.repoName, true) + ',tags';
-      }
-    },
+  _computeItemUrl(project) {
+    if (this.itemDetail === DETAIL_TYPES.branches) {
+      return this.getBaseUrl() + '/admin/repos/' +
+          this.encodeURL(this.repoName, true) + ',branches';
+    } else if (this.itemDetail === DETAIL_TYPES.tags) {
+      return this.getBaseUrl() + '/admin/repos/' +
+          this.encodeURL(this.repoName, true) + ',tags';
+    }
+  }
 
-    handleCreateItem() {
-      const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
-      if (this.itemDetail === DETAIL_TYPES.branches) {
-        return this.$.restAPI.createRepoBranch(this.repoName,
-            this._itemName, {revision: USE_HEAD})
-            .then(itemRegistered => {
-              if (itemRegistered.status === 201) {
-                page.show(this._computeItemUrl(this.itemDetail));
-              }
-            });
-      } else if (this.itemDetail === DETAIL_TYPES.tags) {
-        return this.$.restAPI.createRepoTag(this.repoName,
-            this._itemName,
-            {revision: USE_HEAD, message: this._itemAnnotation || null})
-            .then(itemRegistered => {
-              if (itemRegistered.status === 201) {
-                page.show(this._computeItemUrl(this.itemDetail));
-              }
-            });
-      }
-    },
+  handleCreateItem() {
+    const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
+    if (this.itemDetail === DETAIL_TYPES.branches) {
+      return this.$.restAPI.createRepoBranch(this.repoName,
+          this._itemName, {revision: USE_HEAD})
+          .then(itemRegistered => {
+            if (itemRegistered.status === 201) {
+              page.show(this._computeItemUrl(this.itemDetail));
+            }
+          });
+    } else if (this.itemDetail === DETAIL_TYPES.tags) {
+      return this.$.restAPI.createRepoTag(this.repoName,
+          this._itemName,
+          {revision: USE_HEAD, message: this._itemAnnotation || null})
+          .then(itemRegistered => {
+            if (itemRegistered.status === 201) {
+              page.show(this._computeItemUrl(this.itemDetail));
+            }
+          });
+    }
+  }
 
-    _computeHideItemClass(type) {
-      return type === DETAIL_TYPES.branches ? 'hideItem' : '';
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
new file mode 100644
index 0000000..62a2e0f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    input {
+      width: 20em;
+    }
+    /* Add css selector with #id to increase priority
+      (otherwise ".gr-form-styles section" rule wins) */
+    .hideItem,
+    #itemAnnotationSection.hideItem {
+      display: none;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <div id="form">
+      <section id="itemNameSection">
+        <span class="title">[[detailType]] name</span>
+        <iron-input
+          placeholder="[[detailType]] Name"
+          bind-value="{{_itemName}}"
+        >
+          <input
+            is="iron-input"
+            placeholder="[[detailType]] Name"
+            bind-value="{{_itemName}}"
+          />
+        </iron-input>
+      </section>
+      <section id="itemRevisionSection">
+        <span class="title">Initial Revision</span>
+        <iron-input
+          placeholder="Revision (Branch or SHA-1)"
+          bind-value="{{_itemRevision}}"
+        >
+          <input
+            is="iron-input"
+            placeholder="Revision (Branch or SHA-1)"
+            bind-value="{{_itemRevision}}"
+          />
+        </iron-input>
+      </section>
+      <section
+        id="itemAnnotationSection"
+        class$="[[_computeHideItemClass(itemDetail)]]"
+      >
+        <span class="title">Annotation</span>
+        <iron-input
+          placeholder="Annotation (Optional)"
+          bind-value="{{_itemAnnotation}}"
+        >
+          <input
+            is="iron-input"
+            placeholder="Annotation (Optional)"
+            bind-value="{{_itemAnnotation}}"
+          />
+        </iron-input>
+      </section>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..2778d40 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-pointer-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,95 +31,105 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-pointer-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-create-pointer-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-create-pointer-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
+  const ironInput = function(element) {
+    return dom(element).querySelector('iron-input');
+  };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('branch created', done => {
-      sandbox.stub(element.$.restAPI, 'createRepoBranch', () => {
-        return Promise.resolve({});
-      });
+  test('branch created', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'createRepoBranch',
+        () => Promise.resolve({}));
 
-      assert.isFalse(element.hasNewItemName);
+    assert.isFalse(element.hasNewItemName);
 
-      element._itemName = 'test-branch';
-      element.itemDetail = 'branches';
+    element._itemName = 'test-branch';
+    element.itemDetail = 'branches';
 
-      ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
-      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
 
-      setTimeout(() => {
-        assert.isTrue(element.hasNewItemName);
-        assert.equal(element._itemName, 'test-branch2');
-        assert.equal(element._itemRevision, 'HEAD');
-        done();
-      });
-    });
-
-    test('tag created', done => {
-      sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
-        return Promise.resolve({});
-      });
-
-      assert.isFalse(element.hasNewItemName);
-
-      element._itemName = 'test-tag';
-      element.itemDetail = 'tags';
-
-      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-      setTimeout(() => {
-        assert.isTrue(element.hasNewItemName);
-        assert.equal(element._itemName, 'test-tag2');
-        assert.equal(element._itemRevision, 'HEAD');
-        done();
-      });
-    });
-
-    test('tag created with annotations', done => {
-      sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
-        return Promise.resolve({});
-      });
-
-      assert.isFalse(element.hasNewItemName);
-
-      element._itemName = 'test-tag';
-      element._itemAnnotation = 'test-message';
-      element.itemDetail = 'tags';
-
-      ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-      ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
-      ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-      setTimeout(() => {
-        assert.isTrue(element.hasNewItemName);
-        assert.equal(element._itemName, 'test-tag2');
-        assert.equal(element._itemAnnotation, 'test-message2');
-        assert.equal(element._itemRevision, 'HEAD');
-        done();
-      });
-    });
-
-    test('_computeHideItemClass returns hideItem if type is branches', () => {
-      assert.equal(element._computeHideItemClass('branches'), 'hideItem');
-    });
-
-    test('_computeHideItemClass returns strings if not branches', () => {
-      assert.equal(element._computeHideItemClass('tags'), '');
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-branch2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
     });
   });
+
+  test('tag created', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'createRepoTag',
+        () => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag';
+    element.itemDetail = 'tags';
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-tag2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
+    });
+  });
+
+  test('tag created with annotations', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'createRepoTag',
+        () => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag';
+    element._itemAnnotation = 'test-message';
+    element.itemDetail = 'tags';
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-tag2');
+      assert.equal(element._itemAnnotation, 'test-message2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
+    });
+  });
+
+  test('_computeHideItemClass returns hideItem if type is branches', () => {
+    assert.equal(element._computeHideItemClass('branches'), 'hideItem');
+  });
+
+  test('_computeHideItemClass returns strings if not branches', () => {
+    assert.equal(element._computeHideItemClass('tags'), '');
+  });
+});
 </script>
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
deleted file mode 100644
index d1a2471..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
+++ /dev/null
@@ -1,117 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-create-repo-dialog">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      :host {
-        display: inline-block;
-      }
-      input {
-        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;
-        }
-      }
-    </style>
-
-    <div class="gr-form-styles">
-      <div id="form">
-        <section>
-          <span class="title">Repository name</span>
-          <iron-input autocomplete="on"
-                      bind-value="{{_repoConfig.name}}">
-            <input is="iron-input"
-                   id="repoNameInput"
-                   autocomplete="on"
-                   bind-value="{{_repoConfig.name}}">
-          </iron-input>
-        </section>
-        <section>
-          <span class="title">Rights inherit from</span>
-          <span class="value">
-            <gr-autocomplete
-                id="rightsInheritFromInput"
-                text="{{_repoConfig.parent}}"
-                query="[[_query]]"
-                placeholder="Optional, defaults to 'All-Projects'">
-            </gr-autocomplete>
-          </span>
-        </section>
-        <section>
-          <span class="title">Owner</span>
-          <span class="value">
-            <gr-autocomplete
-                id="ownerInput"
-                text="{{_repoOwner}}"
-                value="{{_repoOwnerId}}"
-                query="[[_queryGroups]]">
-            </gr-autocomplete>
-          </span>
-        </section>
-        <section>
-          <span class="title">Create initial empty commit</span>
-          <span class="value">
-            <gr-select
-                id="initialCommit"
-                bind-value="{{_repoConfig.create_empty_commit}}">
-              <select>
-                <option value="false">False</option>
-                <option value="true">True</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <span class="title">Only serve as parent for other repositories</span>
-          <span class="value">
-            <gr-select
-                id="parentRepo"
-                bind-value="{{_repoConfig.permissions_only}}">
-              <select>
-                <option value="false">False</option>
-                <option value="true">True</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-create-repo-dialog.js"></script>
-</dom-module>
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..040f41b 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
@@ -14,13 +14,39 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-create-repo-dialog',
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-repo-dialog_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import page from 'page/page.mjs';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrCreateRepoDialog extends mixinBehaviors( [
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-create-repo-dialog'; }
+
+  static get properties() {
+    return {
       params: Object,
       hasNewRepoName: {
         type: Boolean,
@@ -32,7 +58,7 @@
       _repoConfig: {
         type: Object,
         value: () => {
-          // Set default values for dropdowns.
+        // Set default values for dropdowns.
           return {
             create_empty_commit: true,
             permissions_only: false,
@@ -61,72 +87,71 @@
           return this._getGroupSuggestions.bind(this);
         },
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_updateRepoName(_repoConfig.name)',
-    ],
+    ];
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  _computeRepoUrl(repoName) {
+    return this.getBaseUrl() + '/admin/repos/' +
+        this.encodeURL(repoName, true);
+  }
 
-    _computeRepoUrl(repoName) {
-      return this.getBaseUrl() + '/admin/repos/' +
-          this.encodeURL(repoName, true);
-    },
+  _updateRepoName(name) {
+    this.hasNewRepoName = !!name;
+  }
 
-    _updateRepoName(name) {
-      this.hasNewRepoName = !!name;
-    },
+  _repoOwnerIdUpdate(id) {
+    if (id) {
+      this.set('_repoConfig.owners', [id]);
+    } else {
+      this.set('_repoConfig.owners', undefined);
+    }
+  }
 
-    _repoOwnerIdUpdate(id) {
-      if (id) {
-        this.set('_repoConfig.owners', [id]);
-      } else {
-        this.set('_repoConfig.owners', undefined);
-      }
-    },
+  handleCreateRepo() {
+    return this.$.restAPI.createRepo(this._repoConfig)
+        .then(repoRegistered => {
+          if (repoRegistered.status === 201) {
+            this._repoCreated = true;
+            page.show(this._computeRepoUrl(this._repoConfig.name));
+          }
+        });
+  }
 
-    handleCreateRepo() {
-      return this.$.restAPI.createRepo(this._repoConfig)
-          .then(repoRegistered => {
-            if (repoRegistered.status === 201) {
-              this._repoCreated = true;
-              page.show(this._computeRepoUrl(this._repoConfig.name));
-            }
-          });
-    },
+  _getRepoSuggestions(input) {
+    return this.$.restAPI.getSuggestedProjects(input)
+        .then(response => {
+          const repos = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            repos.push({
+              name: key,
+              value: response[key],
+            });
+          }
+          return repos;
+        });
+  }
 
-    _getRepoSuggestions(input) {
-      return this.$.restAPI.getSuggestedProjects(input)
-          .then(response => {
-            const repos = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              repos.push({
-                name: key,
-                value: response[key],
-              });
-            }
-            return repos;
-          });
-    },
+  _getGroupSuggestions(input) {
+    return this.$.restAPI.getSuggestedGroups(input)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
+            });
+          }
+          return groups;
+        });
+  }
+}
 
-    _getGroupSuggestions(input) {
-      return this.$.restAPI.getSuggestedGroups(input)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: decodeURIComponent(response[key].id),
-              });
-            }
-            return groups;
-          });
-    },
-  });
-})();
+customElements.define(GrCreateRepoDialog.is, GrCreateRepoDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
new file mode 100644
index 0000000..680986c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    input {
+      width: 20em;
+    }
+    gr-autocomplete {
+      width: 20em;
+    }
+  </style>
+
+  <div class="gr-form-styles">
+    <div id="form">
+      <section>
+        <span class="title">Repository name</span>
+        <iron-input autocomplete="on" bind-value="{{_repoConfig.name}}">
+          <input
+            is="iron-input"
+            id="repoNameInput"
+            autocomplete="on"
+            bind-value="{{_repoConfig.name}}"
+          />
+        </iron-input>
+      </section>
+      <section>
+        <span class="title">Rights inherit from</span>
+        <span class="value">
+          <gr-autocomplete
+            id="rightsInheritFromInput"
+            text="{{_repoConfig.parent}}"
+            query="[[_query]]"
+            placeholder="Optional, defaults to 'All-Projects'"
+          >
+          </gr-autocomplete>
+        </span>
+      </section>
+      <section>
+        <span class="title">Owner</span>
+        <span class="value">
+          <gr-autocomplete
+            id="ownerInput"
+            text="{{_repoOwner}}"
+            value="{{_repoOwnerId}}"
+            query="[[_queryGroups]]"
+          >
+          </gr-autocomplete>
+        </span>
+      </section>
+      <section>
+        <span class="title">Create initial empty commit</span>
+        <span class="value">
+          <gr-select
+            id="initialCommit"
+            bind-value="{{_repoConfig.create_empty_commit}}"
+          >
+            <select>
+              <option value="false">False</option>
+              <option value="true">True</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+      <section>
+        <span class="title">Only serve as parent for other repositories</span>
+        <span class="value">
+          <gr-select
+            id="parentRepo"
+            bind-value="{{_repoConfig.permissions_only}}"
+          >
+            <select>
+              <option value="false">False</option>
+              <option value="true">True</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..dfab4ac 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-repo-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,75 +31,75 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-repo-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-create-repo-dialog.js';
+suite('gr-create-repo-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('default values are populated', () => {
-      assert.isTrue(element.$.initialCommit.bindValue);
-      assert.isFalse(element.$.parentRepo.bindValue);
-    });
+  test('default values are populated', () => {
+    assert.isTrue(element.$.initialCommit.bindValue);
+    assert.isFalse(element.$.parentRepo.bindValue);
+  });
 
-    test('repo created', done => {
-      const configInputObj = {
-        name: 'test-repo',
-        create_empty_commit: true,
-        parent: 'All-Project',
-        permissions_only: false,
-        owners: ['testId'],
-      };
+  test('repo created', done => {
+    const configInputObj = {
+      name: 'test-repo',
+      create_empty_commit: true,
+      parent: 'All-Project',
+      permissions_only: false,
+      owners: ['testId'],
+    };
 
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'createRepo', () => {
-            return Promise.resolve({});
-          });
+    const saveStub = sandbox.stub(element.$.restAPI,
+        'createRepo', () => Promise.resolve({}));
 
-      assert.isFalse(element.hasNewRepoName);
+    assert.isFalse(element.hasNewRepoName);
 
-      element._repoConfig = {
-        name: 'test-repo',
-        create_empty_commit: true,
-        parent: 'All-Project',
-        permissions_only: false,
-      };
+    element._repoConfig = {
+      name: 'test-repo',
+      create_empty_commit: true,
+      parent: 'All-Project',
+      permissions_only: false,
+    };
 
-      element._repoOwner = 'test';
-      element._repoOwnerId = 'testId';
+    element._repoOwner = 'test';
+    element._repoOwnerId = 'testId';
 
-      element.$.repoNameInput.bindValue = configInputObj.name;
-      element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-      element.$.ownerInput.text = configInputObj.owners[0];
-      element.$.initialCommit.bindValue =
-          configInputObj.create_empty_commit;
-      element.$.parentRepo.bindValue =
-          configInputObj.permissions_only;
+    element.$.repoNameInput.bindValue = configInputObj.name;
+    element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
+    element.$.ownerInput.text = configInputObj.owners[0];
+    element.$.initialCommit.bindValue =
+        configInputObj.create_empty_commit;
+    element.$.parentRepo.bindValue =
+        configInputObj.permissions_only;
 
-      assert.isTrue(element.hasNewRepoName);
+    assert.isTrue(element.hasNewRepoName);
 
-      assert.deepEqual(element._repoConfig, configInputObj);
+    assert.deepEqual(element._repoConfig, configInputObj);
 
-      element.handleCreateRepo().then(() => {
-        assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
-        done();
-      });
-    });
-
-    test('testing observer of _repoOwner', () => {
-      element._repoOwnerId = 'test-5';
-      assert.deepEqual(element._repoConfig.owners, ['test-5']);
+    element.handleCreateRepo().then(() => {
+      assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      done();
     });
   });
+
+  test('testing observer of _repoOwner', () => {
+    element._repoOwnerId = 'test-5';
+    assert.deepEqual(element._repoConfig.owners, ['test-5']);
+  });
+});
 </script>
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
deleted file mode 100644
index c15f091..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
+++ /dev/null
@@ -1,80 +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.
--->
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-
-<dom-module id="gr-group-audit-log">
-  <template>
-    <style include="shared-styles"></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,
-      .genericList tr td:last-of-type {
-        text-align: left;
-      }
-    </style>
-    <table id="list" class="genericList">
-      <tr class="headerRow">
-        <th class="date topHeader">Date</th>
-        <th class="type topHeader">Type</th>
-        <th class="member topHeader">Member</th>
-        <th class="by-user topHeader">By User</th>
-      </tr>
-      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-        <td>Loading...</td>
-      </tr>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_auditLog]]">
-          <tr class="table">
-            <td class="date">
-              <gr-date-formatter
-                  has-tooltip
-                  date-str="[[item.date]]">
-              </gr-date-formatter>
-            </td>
-            <td class="type">[[itemType(item.type)]]</td>
-            <td class="member">
-              <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
-                <a href$="[[_computeGroupUrl(item.member)]]">
-                  [[_getNameForGroup(item.member)]]
-                </a>
-              </template>
-              <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
-                <gr-account-link account="[[item.member]]"></gr-account-link>
-                [[_getIdForUser(item.member)]]
-              </template>
-            </td>
-            <td class="by-user">
-              <gr-account-link account="[[item.user]]"></gr-account-link>
-              [[_getIdForUser(item.user)]]
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group-audit-log.js"></script>
-</dom-module>
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..a3c05cb 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
@@ -14,100 +14,129 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group-audit-log_html.js';
+import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-group-audit-log',
+const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrGroupAuditLog extends mixinBehaviors( [
+  ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-group-audit-log'; }
+
+  static get properties() {
+    return {
       groupId: String,
       _auditLog: Array,
       _loading: {
         type: Boolean,
         value: true,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.ListViewBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'Audit Log'},
+      composed: true, bubbles: true,
+    }));
+  }
 
-    attached() {
-      this.fire('title-change', {title: 'Audit Log'});
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    this._getAuditLogs();
+  }
 
-    ready() {
-      this._getAuditLogs();
-    },
+  _getAuditLogs() {
+    if (!this.groupId) { return ''; }
 
-    _getAuditLogs() {
-      if (!this.groupId) { return ''; }
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    return this.$.restAPI.getGroupAuditLog(this.groupId, errFn)
+        .then(auditLog => {
+          if (!auditLog) {
+            this._auditLog = [];
+            return;
+          }
+          this._auditLog = auditLog;
+          this._loading = false;
+        });
+  }
 
-      return this.$.restAPI.getGroupAuditLog(this.groupId, errFn)
-          .then(auditLog => {
-            if (!auditLog) {
-              this._auditLog = [];
-              return;
-            }
-            this._auditLog = auditLog;
-            this._loading = false;
-          });
-    },
+  _status(item) {
+    return item.disabled ? 'Disabled' : 'Enabled';
+  }
 
-    _status(item) {
-      return item.disabled ? 'Disabled' : 'Enabled';
-    },
+  itemType(type) {
+    let item;
+    switch (type) {
+      case 'ADD_GROUP':
+      case 'ADD_USER':
+        item = 'Added';
+        break;
+      case 'REMOVE_GROUP':
+      case 'REMOVE_USER':
+        item = 'Removed';
+        break;
+      default:
+        item = '';
+    }
+    return item;
+  }
 
-    itemType(type) {
-      let item;
-      switch (type) {
-        case 'ADD_GROUP':
-        case 'ADD_USER':
-          item = 'Added';
-          break;
-        case 'REMOVE_GROUP':
-        case 'REMOVE_USER':
-          item = 'Removed';
-          break;
-        default:
-          item = '';
-      }
-      return item;
-    },
+  _isGroupEvent(type) {
+    return GROUP_EVENTS.indexOf(type) !== -1;
+  }
 
-    _isGroupEvent(type) {
-      return GROUP_EVENTS.indexOf(type) !== -1;
-    },
+  _computeGroupUrl(group) {
+    if (group && group.url && group.id) {
+      return GerritNav.getUrlForGroup(group.id);
+    }
 
-    _computeGroupUrl(group) {
-      if (group && group.url && group.id) {
-        return Gerrit.Nav.getUrlForGroup(group.id);
-      }
+    return '';
+  }
 
-      return '';
-    },
+  _getIdForUser(account) {
+    return account._account_id ? ' (' + account._account_id + ')' : '';
+  }
 
-    _getIdForUser(account) {
-      return account._account_id ? ' (' + account._account_id + ')' : '';
-    },
+  _getNameForGroup(group) {
+    if (group && group.name) {
+      return group.name;
+    } else if (group && group.id) {
+      // The URL encoded id of the member
+      return decodeURIComponent(group.id);
+    }
 
-    _getNameForGroup(group) {
-      if (group && group.name) {
-        return group.name;
-      } else if (group && group.id) {
-        // The URL encoded id of the member
-        return decodeURIComponent(group.id);
-      }
+    return '';
+  }
+}
 
-      return '';
-    },
-  });
-})();
+customElements.define(GrGroupAuditLog.is, GrGroupAuditLog);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
new file mode 100644
index 0000000..130efbb
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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,
+    .genericList tr td:last-of-type {
+      text-align: left;
+    }
+  </style>
+  <table id="list" class="genericList">
+    <tbody>
+      <tr class="headerRow">
+        <th class="date topHeader">Date</th>
+        <th class="type topHeader">Type</th>
+        <th class="member topHeader">Member</th>
+        <th class="by-user topHeader">By User</th>
+      </tr>
+      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <td>Loading...</td>
+      </tr>
+    </tbody>
+    <tbody class$="[[computeLoadingClass(_loading)]]">
+      <template is="dom-repeat" items="[[_auditLog]]">
+        <tr class="table">
+          <td class="date">
+            <gr-date-formatter has-tooltip="" date-str="[[item.date]]">
+            </gr-date-formatter>
+          </td>
+          <td class="type">[[itemType(item.type)]]</td>
+          <td class="member">
+            <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
+              <a href$="[[_computeGroupUrl(item.member)]]">
+                [[_getNameForGroup(item.member)]]
+              </a>
+            </template>
+            <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
+              <gr-account-link account="[[item.member]]"></gr-account-link>
+              [[_getIdForUser(item.member)]]
+            </template>
+          </td>
+          <td class="by-user">
+            <gr-account-link account="[[item.user]]"></gr-account-link>
+            [[_getIdForUser(item.user)]]
+          </td>
+        </tr>
+      </template>
+    </tbody>
+  </table>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..4590220 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group-audit-log.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,84 +31,86 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group-audit-log tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-group-audit-log.js';
+suite('gr-group-audit-log tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('members', () => {
+    test('test _getNameForGroup', () => {
+      let group = {
+        member: {
+          name: 'test-name',
+        },
+      };
+      assert.equal(element._getNameForGroup(group.member), 'test-name');
+
+      group = {
+        member: {
+          id: 'test-id',
+        },
+      };
+      assert.equal(element._getNameForGroup(group.member), 'test-id');
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    test('test _isGroupEvent', () => {
+      assert.isTrue(element._isGroupEvent('ADD_GROUP'));
+      assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
 
-    suite('members', () => {
-      test('test _getNameForGroup', () => {
-        let group = {
-          member: {
-            name: 'test-name',
-          },
-        };
-        assert.equal(element._getNameForGroup(group.member), 'test-name');
-
-        group = {
-          member: {
-            id: 'test-id',
-          },
-        };
-        assert.equal(element._getNameForGroup(group.member), 'test-id');
-      });
-
-      test('test _isGroupEvent', () => {
-        assert.isTrue(element._isGroupEvent('ADD_GROUP'));
-        assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
-
-        assert.isFalse(element._isGroupEvent('ADD_USER'));
-        assert.isFalse(element._isGroupEvent('REMOVE_USER'));
-      });
-    });
-
-    suite('users', () => {
-      test('test _getIdForUser', () => {
-        const account = {
-          user: {
-            username: 'test-user',
-            _account_id: 12,
-          },
-        };
-        assert.equal(element._getIdForUser(account.user), ' (12)');
-      });
-
-      test('test _account_id not present', () => {
-        const account = {
-          user: {
-            username: 'test-user',
-          },
-        };
-        assert.equal(element._getIdForUser(account.user), '');
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        element.groupId = 1;
-
-        const response = {status: 404};
-        sandbox.stub(
-            element.$.restAPI, 'getGroupAuditLog', (group, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        element._getAuditLogs();
-      });
+      assert.isFalse(element._isGroupEvent('ADD_USER'));
+      assert.isFalse(element._isGroupEvent('REMOVE_USER'));
     });
   });
+
+  suite('users', () => {
+    test('test _getIdForUser', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+          _account_id: 12,
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), ' (12)');
+    });
+
+    test('test _account_id not present', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      element.groupId = 1;
+
+      const response = {status: 404};
+      sandbox.stub(
+          element.$.restAPI, 'getGroupAuditLog', (group, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._getAuditLogs();
+    });
+  });
+});
 </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
deleted file mode 100644
index 86f66c4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ /dev/null
@@ -1,188 +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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.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-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
-
-<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="shared-styles">
-      .input {
-        width: 15em;
-      }
-      gr-autocomplete {
-        width: 20em;
-        --gr-autocomplete: {
-          height: 2em;
-          width: 20em;
-        }
-      }
-      a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      a:hover {
-        text-decoration: underline;
-      }
-      th {
-        border-bottom: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-        text-align: left;
-      }
-      .canModify #groupMemberSearchInput,
-      .canModify #saveGroupMember,
-      .canModify .deleteHeader,
-      .canModify .deleteColumn,
-      .canModify #includedGroupSearchInput,
-      .canModify #saveIncludedGroups,
-      .canModify .deleteIncludedHeader,
-      .canModify #saveIncludedGroups {
-        display: none;
-      }
-    </style>
-    <main class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]">
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-        Loading...
-      </div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <h1 id="Title">[[_groupName]]</h1>
-        <div id="form">
-          <h3 id="members">Members</h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                  id="groupMemberSearchInput"
-                  text="{{_groupMemberSearchName}}"
-                  value="{{_groupMemberSearchId}}"
-                  query="[[_queryMembers]]"
-                  placeholder="Name Or Email">
-              </gr-autocomplete>
-            </span>
-            <gr-button
-                id="saveGroupMember"
-                on-click="_handleSavingGroupMember"
-                disabled="[[!_groupMemberSearchId]]">
-              Add
-            </gr-button>
-            <table id="groupMembers">
-              <tr class="headerRow">
-                <th class="nameHeader">Name</th>
-                <th class="emailAddressHeader">Email Address</th>
-                <th class="deleteHeader">Delete Member</th>
-              </tr>
-              <tbody>
-                <template is="dom-repeat" items="[[_groupMembers]]">
-                  <tr>
-                    <td class="nameColumn">
-                      <gr-account-link account="[[item]]"></gr-account-link>
-                    </td>
-                    <td>[[item.email]]</td>
-                    <td class="deleteColumn">
-                      <gr-button
-                          class="deleteMembersButton"
-                          on-click="_handleDeleteMember">
-                        Delete
-                      </gr-button>
-                    </td>
-                  </tr>
-                </template>
-              </tbody>
-            </table>
-          </fieldset>
-          <h3 id="includedGroups">Included Groups</h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                  id="includedGroupSearchInput"
-                  text="{{_includedGroupSearchName}}"
-                  value="{{_includedGroupSearchId}}"
-                  query="[[_queryIncludedGroup]]"
-                  placeholder="Group Name">
-              </gr-autocomplete>
-            </span>
-            <gr-button
-                id="saveIncludedGroups"
-                on-click="_handleSavingIncludedGroups"
-                disabled="[[!_includedGroupSearchId]]">
-              Add
-            </gr-button>
-            <table id="includedGroups">
-              <tr class="headerRow">
-                <th class="groupNameHeader">Group Name</th>
-                <th class="descriptionHeader">Description</th>
-                <th class="deleteIncludedHeader">
-                  Delete Group
-                </th>
-              </tr>
-              <tbody>
-                <template is="dom-repeat" items="[[_includedGroups]]">
-                  <tr>
-                    <td class="nameColumn">
-                      <template is="dom-if" if="[[item.url]]">
-                        <a href$="[[_computeGroupUrl(item.url)]]"
-                            rel="noopener">
-                          [[item.name]]
-                        </a>
-                      </template>
-                      <template is="dom-if" if="[[!item.url]]">
-                        [[item.name]]
-                      </template>
-                    </td>
-                    <td>[[item.description]]</td>
-                    <td class="deleteColumn">
-                      <gr-button
-                          class="deleteIncludedGroupButton"
-                          on-click="_handleDeleteIncludedGroup">
-                        Delete
-                      </gr-button>
-                    </td>
-                  </tr>
-                </template>
-              </tbody>
-            </table>
-          </fieldset>
-        </div>
-      </div>
-    </main>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-delete-item-dialog
-          class="confirmDialog"
-          on-confirm="_handleDeleteConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          item="[[_itemName]]"
-          item-type="[[_itemType]]"></gr-confirm-delete-item-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group-members.js"></script>
-</dom-module>
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..45f7612 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
@@ -14,19 +14,47 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group-members_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
-  const SUGGESTIONS_LIMIT = 15;
-  const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
-      'permission to add it';
+const SUGGESTIONS_LIMIT = 15;
+const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
+    'permission to add it';
 
-  const URL_REGEX = '^(?:[a-z]+:)?//';
+const URL_REGEX = '^(?:[a-z]+:)?//';
 
-  Polymer({
-    is: 'gr-group-members',
+/**
+ * @extends Polymer.Element
+ */
+class GrGroupMembers extends mixinBehaviors( [
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-group-members'; }
+
+  static get properties() {
+    return {
       groupId: Number,
       _groupMemberSearchId: String,
       _groupMemberSearchName: String,
@@ -61,221 +89,225 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadGroupDetails();
 
-    attached() {
-      this._loadGroupDetails();
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'Members'},
+      composed: true, bubbles: true,
+    }));
+  }
 
-      this.fire('title-change', {title: 'Members'});
-    },
+  _loadGroupDetails() {
+    if (!this.groupId) { return; }
 
-    _loadGroupDetails() {
-      if (!this.groupId) { return; }
+    const promises = [];
 
-      const promises = [];
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    return this.$.restAPI.getGroupConfig(this.groupId, errFn)
+        .then(config => {
+          if (!config || !config.name) { return Promise.resolve(); }
 
-      return this.$.restAPI.getGroupConfig(this.groupId, errFn)
-          .then(config => {
-            if (!config || !config.name) { return Promise.resolve(); }
+          this._groupName = config.name;
 
-            this._groupName = config.name;
+          promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+            this._isAdmin = isAdmin ? true : false;
+          }));
 
-            promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-              this._isAdmin = isAdmin ? true : false;
-            }));
-
-            promises.push(this.$.restAPI.getIsGroupOwner(config.name)
-                .then(isOwner => {
-                  this._groupOwner = isOwner ? true : false;
-                }));
-
-            promises.push(this.$.restAPI.getGroupMembers(config.name).then(
-                members => {
-                  this._groupMembers = members;
-                }));
-
-            promises.push(this.$.restAPI.getIncludedGroup(config.name)
-                .then(includedGroup => {
-                  this._includedGroups = includedGroup;
-                }));
-
-            return Promise.all(promises).then(() => {
-              this._loading = false;
-            });
-          });
-    },
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    },
-
-    _computeGroupUrl(url) {
-      if (!url) { return; }
-
-      const r = new RegExp(URL_REGEX, 'i');
-      if (r.test(url)) {
-        return url;
-      }
-
-      // For GWT compatibility
-      if (url.startsWith('#')) {
-        return this.getBaseUrl() + url.slice(1);
-      }
-      return this.getBaseUrl() + url;
-    },
-
-    _handleSavingGroupMember() {
-      return this.$.restAPI.saveGroupMembers(this._groupName,
-          this._groupMemberSearchId).then(config => {
-        if (!config) {
-          return;
-        }
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-          this._groupMembers = members;
-        });
-        this._groupMemberSearchName = '';
-        this._groupMemberSearchId = '';
-      });
-    },
-
-    _handleDeleteConfirm() {
-      this.$.overlay.close();
-      if (this._itemType === 'member') {
-        return this.$.restAPI.deleteGroupMembers(this._groupName,
-            this._itemId)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this.$.restAPI.getGroupMembers(this._groupName)
-                    .then(members => {
-                      this._groupMembers = members;
-                    });
-              }
-            });
-      } else if (this._itemType === 'includedGroup') {
-        return this.$.restAPI.deleteIncludedGroup(this._groupName,
-            this._itemId)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204 || itemDeleted.status === 205) {
-                this.$.restAPI.getIncludedGroup(this._groupName)
-                    .then(includedGroup => {
-                      this._includedGroups = includedGroup;
-                    });
-              }
-            });
-      }
-    },
-
-    _handleConfirmDialogCancel() {
-      this.$.overlay.close();
-    },
-
-    _handleDeleteMember(e) {
-      const id = e.model.get('item._account_id');
-      const name = e.model.get('item.name');
-      const username = e.model.get('item.username');
-      const email = e.model.get('item.email');
-      const item = username || name || email || id;
-      if (!item) {
-        return '';
-      }
-      this._itemName = item;
-      this._itemId = id;
-      this._itemType = 'member';
-      this.$.overlay.open();
-    },
-
-    _handleSavingIncludedGroups() {
-      return this.$.restAPI.saveIncludedGroup(this._groupName,
-          this._includedGroupSearchId.replace(/\+/g, ' '), err => {
-            if (err.status === 404) {
-              this.dispatchEvent(new CustomEvent('show-alert', {
-                detail: {message: SAVING_ERROR_TEXT},
-                bubbles: true,
-                composed: true,
+          promises.push(this.$.restAPI.getIsGroupOwner(config.name)
+              .then(isOwner => {
+                this._groupOwner = isOwner ? true : false;
               }));
-              return err;
-            }
-            throw Error(err.statusText);
-          })
-          .then(config => {
-            if (!config) {
-              return;
-            }
-            this.$.restAPI.getIncludedGroup(this._groupName)
-                .then(includedGroup => {
-                  this._includedGroups = includedGroup;
-                });
-            this._includedGroupSearchName = '';
-            this._includedGroupSearchId = '';
-          });
-    },
 
-    _handleDeleteIncludedGroup(e) {
-      const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
-      const name = e.model.get('item.name');
-      const item = name || id;
-      if (!item) { return ''; }
-      this._itemName = item;
-      this._itemId = id;
-      this._itemType = 'includedGroup';
-      this.$.overlay.open();
-    },
+          promises.push(this.$.restAPI.getGroupMembers(config.name).then(
+              members => {
+                this._groupMembers = members;
+              }));
 
-    _getAccountSuggestions(input) {
-      if (input.length === 0) { return Promise.resolve([]); }
-      return this.$.restAPI.getSuggestedAccounts(
-          input, SUGGESTIONS_LIMIT).then(accounts => {
-        const accountSuggestions = [];
-        let nameAndEmail;
-        if (!accounts) { return []; }
-        for (const key in accounts) {
-          if (!accounts.hasOwnProperty(key)) { continue; }
-          if (accounts[key].email !== undefined) {
-            nameAndEmail = accounts[key].name +
-                  ' <' + accounts[key].email + '>';
-          } else {
-            nameAndEmail = accounts[key].name;
-          }
-          accountSuggestions.push({
-            name: nameAndEmail,
-            value: accounts[key]._account_id,
+          promises.push(this.$.restAPI.getIncludedGroup(config.name)
+              .then(includedGroup => {
+                this._includedGroups = includedGroup;
+              }));
+
+          return Promise.all(promises).then(() => {
+            this._loading = false;
           });
-        }
-        return accountSuggestions;
+        });
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _computeGroupUrl(url) {
+    if (!url) { return; }
+
+    const r = new RegExp(URL_REGEX, 'i');
+    if (r.test(url)) {
+      return url;
+    }
+
+    // For GWT compatibility
+    if (url.startsWith('#')) {
+      return this.getBaseUrl() + url.slice(1);
+    }
+    return this.getBaseUrl() + url;
+  }
+
+  _handleSavingGroupMember() {
+    return this.$.restAPI.saveGroupMembers(this._groupName,
+        this._groupMemberSearchId).then(config => {
+      if (!config) {
+        return;
+      }
+      this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+        this._groupMembers = members;
       });
-    },
+      this._groupMemberSearchName = '';
+      this._groupMemberSearchId = '';
+    });
+  }
 
-    _getGroupSuggestions(input) {
-      return this.$.restAPI.getSuggestedGroups(input)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: decodeURIComponent(response[key].id),
-              });
+  _handleDeleteConfirm() {
+    this.$.overlay.close();
+    if (this._itemType === 'member') {
+      return this.$.restAPI.deleteGroupMembers(this._groupName,
+          this._itemId)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204) {
+              this.$.restAPI.getGroupMembers(this._groupName)
+                  .then(members => {
+                    this._groupMembers = members;
+                  });
             }
-            return groups;
           });
-    },
+    } else if (this._itemType === 'includedGroup') {
+      return this.$.restAPI.deleteIncludedGroup(this._groupName,
+          this._itemId)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204 || itemDeleted.status === 205) {
+              this.$.restAPI.getIncludedGroup(this._groupName)
+                  .then(includedGroup => {
+                    this._includedGroups = includedGroup;
+                  });
+            }
+          });
+    }
+  }
 
-    _computeHideItemClass(owner, admin) {
-      return admin || owner ? '' : 'canModify';
-    },
-  });
-})();
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteMember(e) {
+    const id = e.model.get('item._account_id');
+    const name = e.model.get('item.name');
+    const username = e.model.get('item.username');
+    const email = e.model.get('item.email');
+    const item = username || name || email || id;
+    if (!item) {
+      return '';
+    }
+    this._itemName = item;
+    this._itemId = id;
+    this._itemType = 'member';
+    this.$.overlay.open();
+  }
+
+  _handleSavingIncludedGroups() {
+    return this.$.restAPI.saveIncludedGroup(this._groupName,
+        this._includedGroupSearchId.replace(/\+/g, ' '), err => {
+          if (err.status === 404) {
+            this.dispatchEvent(new CustomEvent('show-alert', {
+              detail: {message: SAVING_ERROR_TEXT},
+              bubbles: true,
+              composed: true,
+            }));
+            return err;
+          }
+          throw Error(err.statusText);
+        })
+        .then(config => {
+          if (!config) {
+            return;
+          }
+          this.$.restAPI.getIncludedGroup(this._groupName)
+              .then(includedGroup => {
+                this._includedGroups = includedGroup;
+              });
+          this._includedGroupSearchName = '';
+          this._includedGroupSearchId = '';
+        });
+  }
+
+  _handleDeleteIncludedGroup(e) {
+    const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
+    const name = e.model.get('item.name');
+    const item = name || id;
+    if (!item) { return ''; }
+    this._itemName = item;
+    this._itemId = id;
+    this._itemType = 'includedGroup';
+    this.$.overlay.open();
+  }
+
+  _getAccountSuggestions(input) {
+    if (input.length === 0) { return Promise.resolve([]); }
+    return this.$.restAPI.getSuggestedAccounts(
+        input, SUGGESTIONS_LIMIT).then(accounts => {
+      const accountSuggestions = [];
+      let nameAndEmail;
+      if (!accounts) { return []; }
+      for (const key in accounts) {
+        if (!accounts.hasOwnProperty(key)) { continue; }
+        if (accounts[key].email !== undefined) {
+          nameAndEmail = accounts[key].name +
+                ' <' + accounts[key].email + '>';
+        } else {
+          nameAndEmail = accounts[key].name;
+        }
+        accountSuggestions.push({
+          name: nameAndEmail,
+          value: accounts[key]._account_id,
+        });
+      }
+      return accountSuggestions;
+    });
+  }
+
+  _getGroupSuggestions(input) {
+    return this.$.restAPI.getSuggestedGroups(input)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
+            });
+          }
+          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_html.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
new file mode 100644
index 0000000..c5577c2
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
@@ -0,0 +1,184 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    a {
+      color: var(--primary-text-color);
+      text-decoration: none;
+    }
+    a:hover {
+      text-decoration: underline;
+    }
+    th {
+      border-bottom: 1px solid var(--border-color);
+      font-weight: var(--font-weight-bold);
+      text-align: left;
+    }
+    .canModify #groupMemberSearchInput,
+    .canModify #saveGroupMember,
+    .canModify .deleteHeader,
+    .canModify .deleteColumn,
+    .canModify #includedGroupSearchInput,
+    .canModify #saveIncludedGroups,
+    .canModify .deleteIncludedHeader,
+    .canModify #saveIncludedGroups {
+      display: none;
+    }
+  </style>
+  <main
+    class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
+  >
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h1 id="Title">[[_groupName]]</h1>
+      <div id="form">
+        <h3 id="members">Members</h3>
+        <fieldset>
+          <span class="value">
+            <gr-autocomplete
+              id="groupMemberSearchInput"
+              text="{{_groupMemberSearchName}}"
+              value="{{_groupMemberSearchId}}"
+              query="[[_queryMembers]]"
+              placeholder="Name Or Email"
+            >
+            </gr-autocomplete>
+          </span>
+          <gr-button
+            id="saveGroupMember"
+            on-click="_handleSavingGroupMember"
+            disabled="[[!_groupMemberSearchId]]"
+          >
+            Add
+          </gr-button>
+          <table id="groupMembers">
+            <tbody>
+              <tr class="headerRow">
+                <th class="nameHeader">Name</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="deleteHeader">Delete Member</th>
+              </tr>
+            </tbody>
+            <tbody>
+              <template is="dom-repeat" items="[[_groupMembers]]">
+                <tr>
+                  <td class="nameColumn">
+                    <gr-account-link account="[[item]]"></gr-account-link>
+                  </td>
+                  <td>[[item.email]]</td>
+                  <td class="deleteColumn">
+                    <gr-button
+                      class="deleteMembersButton"
+                      on-click="_handleDeleteMember"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+              </template>
+            </tbody>
+          </table>
+        </fieldset>
+        <h3 id="includedGroups">Included Groups</h3>
+        <fieldset>
+          <span class="value">
+            <gr-autocomplete
+              id="includedGroupSearchInput"
+              text="{{_includedGroupSearchName}}"
+              value="{{_includedGroupSearchId}}"
+              query="[[_queryIncludedGroup]]"
+              placeholder="Group Name"
+            >
+            </gr-autocomplete>
+          </span>
+          <gr-button
+            id="saveIncludedGroups"
+            on-click="_handleSavingIncludedGroups"
+            disabled="[[!_includedGroupSearchId]]"
+          >
+            Add
+          </gr-button>
+          <table id="includedGroups">
+            <tbody>
+              <tr class="headerRow">
+                <th class="groupNameHeader">Group Name</th>
+                <th class="descriptionHeader">Description</th>
+                <th class="deleteIncludedHeader">
+                  Delete Group
+                </th>
+              </tr>
+            </tbody>
+            <tbody>
+              <template is="dom-repeat" items="[[_includedGroups]]">
+                <tr>
+                  <td class="nameColumn">
+                    <template is="dom-if" if="[[item.url]]">
+                      <a href$="[[_computeGroupUrl(item.url)]]" rel="noopener">
+                        [[item.name]]
+                      </a>
+                    </template>
+                    <template is="dom-if" if="[[!item.url]]">
+                      [[item.name]]
+                    </template>
+                  </td>
+                  <td>[[item.description]]</td>
+                  <td class="deleteColumn">
+                    <gr-button
+                      class="deleteIncludedGroupButton"
+                      on-click="_handleDeleteIncludedGroup"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+              </template>
+            </tbody>
+          </table>
+        </fieldset>
+      </div>
+    </div>
+  </main>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-confirm-delete-item-dialog
+      class="confirmDialog"
+      on-confirm="_handleDeleteConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      item="[[_itemName]]"
+      item-type="[[_itemType]]"
+    ></gr-confirm-delete-item-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..4dd9a7b 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group-members.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,336 +31,344 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group-members tests', () => {
-    let element;
-    let sandbox;
-    let groups;
-    let groupMembers;
-    let includedGroups;
-    let groupStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-group-members.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-group-members tests', () => {
+  let element;
+  let sandbox;
+  let groups;
+  let groupMembers;
+  let includedGroups;
+  let groupStub;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      groups = {
-        name: 'Administrators',
-        owner: 'Administrators',
-        group_id: 1,
-      };
+    groups = {
+      name: 'Administrators',
+      owner: 'Administrators',
+      group_id: 1,
+    };
 
-      groupMembers = [
-        {
-          _account_id: 1000097,
-          name: 'Jane Roe',
-          email: 'jane.roe@example.com',
-          username: 'jane',
-        },
-        {
-          _account_id: 1000096,
-          name: 'Test User',
-          email: 'john.doe@example.com',
-        },
-        {
-          _account_id: 1000095,
-          name: 'Gerrit',
-        },
-        {
-          _account_id: 1000098,
-        },
-      ];
-
-      includedGroups = [{
-        url: 'https://group/url',
-        options: {},
-        id: 'testId',
-        name: 'testName',
+    groupMembers = [
+      {
+        _account_id: 1000097,
+        name: 'Jane Roe',
+        email: 'jane.roe@example.com',
+        username: 'jane',
       },
       {
-        url: '/group/url',
-        options: {},
-        id: 'testId2',
-        name: 'testName2',
+        _account_id: 1000096,
+        name: 'Test User',
+        email: 'john.doe@example.com',
       },
       {
-        url: '#/group/url',
-        options: {},
-        id: 'testId3',
-        name: 'testName3',
+        _account_id: 1000095,
+        name: 'Gerrit',
       },
-      ];
+      {
+        _account_id: 1000098,
+      },
+    ];
 
-      stub('gr-rest-api-interface', {
-        getSuggestedAccounts(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                _account_id: 1000096,
-                name: 'test-account',
-                email: 'test.account@example.com',
-                username: 'test123',
-              },
-              {
-                _account_id: 1001439,
-                name: 'test-admin',
-                email: 'test.admin@example.com',
-                username: 'test_admin',
-              },
-              {
-                _account_id: 1001439,
-                name: 'test-git',
-                username: 'test_git',
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
-        },
-        getSuggestedGroups(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve({
-              'test-admin': {
-                id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
-              },
-              'test/Administrator (admin)': {
-                id: 'test%3Aadmin',
-              },
-            });
-          } else {
-            return Promise.resolve({});
-          }
-        },
-        getLoggedIn() { return Promise.resolve(true); },
-        getConfig() {
-          return Promise.resolve();
-        },
-        getGroupMembers() {
-          return Promise.resolve(groupMembers);
-        },
-        getIsGroupOwner() {
-          return Promise.resolve(true);
-        },
-        getIncludedGroup() {
-          return Promise.resolve(includedGroups);
-        },
-        getAccountCapabilities() {
-          return Promise.resolve();
-        },
-      });
-      element = fixture('basic');
-      sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
-      element.groupId = 1;
-      groupStub = sandbox.stub(element.$.restAPI, 'getGroupConfig', () => {
-        return Promise.resolve(groups);
-      });
-      return element._loadGroupDetails();
-    });
+    includedGroups = [{
+      url: 'https://group/url',
+      options: {},
+      id: 'testId',
+      name: 'testName',
+    },
+    {
+      url: '/group/url',
+      options: {},
+      id: 'testId2',
+      name: 'testName2',
+    },
+    {
+      url: '#/group/url',
+      options: {},
+      id: 'testId3',
+      name: 'testName3',
+    },
+    ];
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_includedGroups', () => {
-      assert.equal(element._includedGroups.length, 3);
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('.nameColumn a')[1].href,
-      'https://test/site/group/url');
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('.nameColumn a')[2].href,
-      'https://test/site/group/url');
-    });
-
-    test('save members correctly', () => {
-      element._groupOwner = true;
-
-      const memberName = 'test-admin';
-
-      const saveStub = sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-          () => {
-            return Promise.resolve({});
+    stub('gr-rest-api-interface', {
+      getSuggestedAccounts(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              _account_id: 1000096,
+              name: 'test-account',
+              email: 'test.account@example.com',
+              username: 'test123',
+            },
+            {
+              _account_id: 1001439,
+              name: 'test-admin',
+              email: 'test.admin@example.com',
+              username: 'test_admin',
+            },
+            {
+              _account_id: 1001439,
+              name: 'test-git',
+              username: 'test_git',
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getSuggestedGroups(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve({
+            'test-admin': {
+              id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+            },
+            'test/Administrator (admin)': {
+              id: 'test%3Aadmin',
+            },
           });
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getLoggedIn() { return Promise.resolve(true); },
+      getConfig() {
+        return Promise.resolve();
+      },
+      getGroupMembers() {
+        return Promise.resolve(groupMembers);
+      },
+      getIsGroupOwner() {
+        return Promise.resolve(true);
+      },
+      getIncludedGroup() {
+        return Promise.resolve(includedGroups);
+      },
+      getAccountCapabilities() {
+        return Promise.resolve();
+      },
+    });
+    element = fixture('basic');
+    sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
+    element.groupId = 1;
+    groupStub = sandbox.stub(
+        element.$.restAPI,
+        'getGroupConfig',
+        () => Promise.resolve(groups));
+    return element._loadGroupDetails();
+  });
 
-      const button = element.$.saveGroupMember;
+  teardown(() => {
+    sandbox.restore();
+  });
 
+  test('_includedGroups', () => {
+    assert.equal(element._includedGroups.length, 3);
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[1].href,
+    'https://test/site/group/url');
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[2].href,
+    'https://test/site/group/url');
+  });
+
+  test('save members correctly', () => {
+    element._groupOwner = true;
+
+    const memberName = 'test-admin';
+
+    const saveStub = sandbox.stub(element.$.restAPI, 'saveGroupMembers',
+        () => Promise.resolve({}));
+
+    const button = element.$.saveGroupMember;
+
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    assert.isFalse(button.hasAttribute('disabled'));
+
+    return element._handleSavingGroupMember().then(() => {
       assert.isTrue(button.hasAttribute('disabled'));
-
-      element.$.groupMemberSearchInput.text = memberName;
-      element.$.groupMemberSearchInput.value = 1234;
-
-      assert.isFalse(button.hasAttribute('disabled'));
-
-      return element._handleSavingGroupMember().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
-            1234));
-      });
-    });
-
-    test('save included groups correctly', () => {
-      element._groupOwner = true;
-
-      const includedGroupName = 'testName';
-
-      const saveIncludedGroupStub = sandbox.stub(
-          element.$.restAPI, 'saveIncludedGroup', () => {
-            return Promise.resolve({});
-          });
-
-      const button = element.$.saveIncludedGroups;
-
-      assert.isTrue(button.hasAttribute('disabled'));
-
-      element.$.includedGroupSearchInput.text = includedGroupName;
-      element.$.includedGroupSearchInput.value = 'testId';
-
-      assert.isFalse(button.hasAttribute('disabled'));
-
-      return element._handleSavingIncludedGroups().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
-        assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
-      });
-    });
-
-    test('add included group 404 shows helpful error text', () => {
-      element._groupOwner = true;
-
-      const memberName = 'bad-name';
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-      const error = new Error('error');
-      error.status = 404;
-      sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-          () => Promise.reject(error));
-
-      element.$.groupMemberSearchInput.text = memberName;
-      element.$.groupMemberSearchInput.value = 1234;
-
-      return element._handleSavingIncludedGroups().then(() => {
-        assert.isTrue(alertStub.called);
-      });
-    });
-
-    test('_getAccountSuggestions empty', () => {
-      return element._getAccountSuggestions('nonexistent').then(accounts => {
-        assert.equal(accounts.length, 0);
-      });
-    });
-
-    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('_getGroupSuggestions empty', () => {
-      return element._getGroupSuggestions('nonexistent').then(groups => {
-        assert.equal(groups.length, 0);
-      });
-    });
-
-    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('_computeHideItemClass returns string for admin', () => {
-      const admin = true;
-      const owner = false;
-      assert.equal(element._computeHideItemClass(owner, admin), '');
-    });
-
-    test('_computeHideItemClass returns hideItem for admin and owner', () => {
-      const admin = false;
-      const owner = false;
-      assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
-    });
-
-    test('_computeHideItemClass returns string for owner', () => {
-      const admin = false;
-      const owner = true;
-      assert.equal(element._computeHideItemClass(owner, admin), '');
-    });
-
-    test('delete member', () => {
-      const deletelBtns = Polymer.dom(element.root)
-          .querySelectorAll('.deleteMembersButton');
-      MockInteractions.tap(deletelBtns[0]);
-      assert.equal(element._itemId, '1000097');
-      assert.equal(element._itemName, 'jane');
-      MockInteractions.tap(deletelBtns[1]);
-      assert.equal(element._itemId, '1000096');
-      assert.equal(element._itemName, 'Test User');
-      MockInteractions.tap(deletelBtns[2]);
-      assert.equal(element._itemId, '1000095');
-      assert.equal(element._itemName, 'Gerrit');
-      MockInteractions.tap(deletelBtns[3]);
-      assert.equal(element._itemId, '1000098');
-      assert.equal(element._itemName, '1000098');
-    });
-
-    test('delete included groups', () => {
-      const deletelBtns = Polymer.dom(element.root)
-          .querySelectorAll('.deleteIncludedGroupButton');
-      MockInteractions.tap(deletelBtns[0]);
-      assert.equal(element._itemId, 'testId');
-      assert.equal(element._itemName, 'testName');
-      MockInteractions.tap(deletelBtns[1]);
-      assert.equal(element._itemId, 'testId2');
-      assert.equal(element._itemName, 'testName2');
-      MockInteractions.tap(deletelBtns[2]);
-      assert.equal(element._itemId, 'testId3');
-      assert.equal(element._itemName, 'testName3');
-    });
-
-    test('_computeLoadingClass', () => {
-      assert.equal(element._computeLoadingClass(true), 'loading');
-
-      assert.equal(element._computeLoadingClass(false), '');
-    });
-
-    test('_computeGroupUrl', () => {
-      assert.isUndefined(element._computeGroupUrl(undefined));
-
-      assert.isUndefined(element._computeGroupUrl(false));
-
-      let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-      assert.equal(element._computeGroupUrl(url),
-          'https://test/site/admin/groups/' +
-          'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
-
-      url = 'https://gerrit.local/admin/groups/' +
-          'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-      assert.equal(element._computeGroupUrl(url), url);
-    });
-
-    test('fires page-error', done => {
-      groupStub.restore();
-
-      element.groupId = 1;
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getGroupConfig', (group, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._loadGroupDetails();
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
+          1234));
     });
   });
+
+  test('save included groups correctly', () => {
+    element._groupOwner = true;
+
+    const includedGroupName = 'testName';
+
+    const saveIncludedGroupStub = sandbox.stub(
+        element.$.restAPI, 'saveIncludedGroup', () => Promise.resolve({}));
+
+    const button = element.$.saveIncludedGroups;
+
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    element.$.includedGroupSearchInput.text = includedGroupName;
+    element.$.includedGroupSearchInput.value = 'testId';
+
+    assert.isFalse(button.hasAttribute('disabled'));
+
+    return element._handleSavingIncludedGroups().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
+      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
+    });
+  });
+
+  test('add included group 404 shows helpful error text', () => {
+    element._groupOwner = true;
+
+    const memberName = 'bad-name';
+    const alertStub = sandbox.stub();
+    element.addEventListener('show-alert', alertStub);
+    const error = new Error('error');
+    error.status = 404;
+    sandbox.stub(element.$.restAPI, 'saveGroupMembers',
+        () => Promise.reject(error));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    return element._handleSavingIncludedGroups().then(() => {
+      assert.isTrue(alertStub.called);
+    });
+  });
+
+  test('_getAccountSuggestions empty', done => {
+    element
+        ._getAccountSuggestions('nonexistent').then(accounts => {
+          assert.equal(accounts.length, 0);
+          done();
+        });
+  });
+
+  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', done => {
+    element
+        ._getGroupSuggestions('nonexistent').then(groups => {
+          assert.equal(groups.length, 0);
+          done();
+        });
+  });
+
+  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', () => {
+    const admin = true;
+    const owner = false;
+    assert.equal(element._computeHideItemClass(owner, admin), '');
+  });
+
+  test('_computeHideItemClass returns hideItem for admin and owner', () => {
+    const admin = false;
+    const owner = false;
+    assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
+  });
+
+  test('_computeHideItemClass returns string for owner', () => {
+    const admin = false;
+    const owner = true;
+    assert.equal(element._computeHideItemClass(owner, admin), '');
+  });
+
+  test('delete member', () => {
+    const deletelBtns = dom(element.root)
+        .querySelectorAll('.deleteMembersButton');
+    MockInteractions.tap(deletelBtns[0]);
+    assert.equal(element._itemId, '1000097');
+    assert.equal(element._itemName, 'jane');
+    MockInteractions.tap(deletelBtns[1]);
+    assert.equal(element._itemId, '1000096');
+    assert.equal(element._itemName, 'Test User');
+    MockInteractions.tap(deletelBtns[2]);
+    assert.equal(element._itemId, '1000095');
+    assert.equal(element._itemName, 'Gerrit');
+    MockInteractions.tap(deletelBtns[3]);
+    assert.equal(element._itemId, '1000098');
+    assert.equal(element._itemName, '1000098');
+  });
+
+  test('delete included groups', () => {
+    const deletelBtns = dom(element.root)
+        .querySelectorAll('.deleteIncludedGroupButton');
+    MockInteractions.tap(deletelBtns[0]);
+    assert.equal(element._itemId, 'testId');
+    assert.equal(element._itemName, 'testName');
+    MockInteractions.tap(deletelBtns[1]);
+    assert.equal(element._itemId, 'testId2');
+    assert.equal(element._itemName, 'testName2');
+    MockInteractions.tap(deletelBtns[2]);
+    assert.equal(element._itemId, 'testId3');
+    assert.equal(element._itemName, 'testName3');
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('_computeGroupUrl', () => {
+    assert.isUndefined(element._computeGroupUrl(undefined));
+
+    assert.isUndefined(element._computeGroupUrl(false));
+
+    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element._computeGroupUrl(url),
+        'https://test/site/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
+
+    url = 'https://gerrit.local/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element._computeGroupUrl(url), url);
+  });
+
+  test('fires page-error', done => {
+    groupStub.restore();
+
+    element.groupId = 1;
+
+    const response = {status: 404};
+    sandbox.stub(
+        element.$.restAPI, 'getGroupConfig', (group, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadGroupDetails();
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
deleted file mode 100644
index 7617c19..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
+++ /dev/null
@@ -1,149 +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.
--->
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-group">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-subpage-styles">
-      h3.edited:after {
-        color: var(--deemphasized-text-color);
-        content: ' *';
-      }
-      .inputUpdateBtn {
-        margin-top: var(--spacing-s);
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <main class="gr-form-styles read-only">
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-        Loading...
-      </div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <h1 id="Title">[[_groupName]]</h1>
-        <h2 id="configurations">General</h2>
-        <div id="form">
-          <fieldset>
-            <h3 id="groupUUID">Group UUID</h3>
-            <fieldset>
-              <gr-copy-clipboard
-                  id="uuid"
-                  text="[[_getGroupUUID(_groupConfig.id)]]"></gr-copy-clipboard>
-            </fieldset>
-            <h3 id="groupName" class$="[[_computeHeaderClass(_rename)]]">
-              Group Name
-            </h3>
-            <fieldset>
-              <span class="value">
-                <gr-autocomplete
-                    id="groupNameInput"
-                    text="{{_groupConfig.name}}"
-                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"></gr-autocomplete>
-              </span>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    id="inputUpdateNameBtn"
-                    on-click="_handleSaveName"
-                    disabled="[[!_rename]]">
-                  Rename Group</gr-button>
-              </span>
-            </fieldset>
-            <h3 class$="[[_computeHeaderClass(_owner)]]">
-              Owners
-            </h3>
-            <fieldset>
-              <span class="value">
-                <gr-autocomplete
-                    id="groupOwnerInput"
-                    text="{{_groupConfig.owner}}"
-                    value="{{_groupConfigOwner}}"
-                    query="[[_query]]"
-                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                </gr-autocomplete>
-              </span>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    on-click="_handleSaveOwner"
-                    disabled="[[!_owner]]">
-                  Change Owners</gr-button>
-              </span>
-            </fieldset>
-            <h3 class$="[[_computeHeaderClass(_description)]]">
-              Description
-            </h3>
-            <fieldset>
-              <div>
-                <iron-autogrow-textarea
-                    class="description"
-                    autocomplete="on"
-                    bind-value="{{_groupConfig.description}}"
-                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"></iron-autogrow-textarea>
-              </div>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    on-click="_handleSaveDescription"
-                    disabled="[[!_description]]">
-                  Save Description
-                </gr-button>
-              </span>
-            </fieldset>
-            <h3 id="options" class$="[[_computeHeaderClass(_options)]]">
-              Group Options
-            </h3>
-            <fieldset id="visableToAll">
-              <section>
-                <span class="title">
-                  Make group visible to all registered users
-                </span>
-                <span class="value">
-                  <gr-select
-                      id="visibleToAll"
-                      bind-value="{{_groupConfig.options.visible_to_all}}">
-                    <select disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                      <template is="dom-repeat" items="[[_submitTypes]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]">
-                <gr-button
-                    on-click="_handleSaveOptions"
-                    disabled="[[!_options]]">
-                  Save Group Options
-                </gr-button>
-              </span>
-            </fieldset>
-          </fieldset>
-        </div>
-      </div>
-    </main>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group.js"></script>
-</dom-module>
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 09953fb..1c7cc91 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -14,32 +14,50 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group_html.js';
 
-  const OPTIONS = {
-    submitFalse: {
-      value: false,
-      label: 'False',
-    },
-    submitTrue: {
-      value: true,
-      label: 'True',
-    },
-  };
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-  Polymer({
-    is: 'gr-group',
+const OPTIONS = {
+  submitFalse: {
+    value: false,
+    label: 'False',
+  },
+  submitTrue: {
+    value: true,
+    label: 'True',
+  },
+};
 
-    /**
-     * Fired when the group name changes.
-     *
-     * @event name-changed
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrGroup extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-group'; }
+  /**
+   * Fired when the group name changes.
+   *
+   * @event name-changed
+   */
+
+  static get properties() {
+    return {
       groupId: Number,
       _rename: {
         type: Boolean,
@@ -86,161 +104,172 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_handleConfigName(_groupConfig.name)',
       '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
       '_handleConfigDescription(_groupConfig.description)',
       '_handleConfigOptions(_groupConfig.options.visible_to_all)',
-    ],
+    ];
+  }
 
-    attached() {
-      this._loadGroup();
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadGroup();
+  }
 
-    _loadGroup() {
-      if (!this.groupId) { return; }
+  _loadGroup() {
+    if (!this.groupId) { return; }
 
-      const promises = [];
+    const promises = [];
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
 
-      return this.$.restAPI.getGroupConfig(this.groupId, errFn)
-          .then(config => {
-            if (!config || !config.name) { return Promise.resolve(); }
+    return this.$.restAPI.getGroupConfig(this.groupId, errFn)
+        .then(config => {
+          if (!config || !config.name) { return Promise.resolve(); }
 
-            this._groupName = config.name;
-            this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+          this._groupName = config.name;
+          this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
-            promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-              this._isAdmin = isAdmin ? true : false;
+          promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+            this._isAdmin = isAdmin ? true : false;
+          }));
+
+          promises.push(this.$.restAPI.getIsGroupOwner(config.name)
+              .then(isOwner => {
+                this._groupOwner = isOwner ? true : false;
+              }));
+
+          // If visible to all is undefined, set to false. If it is defined
+          // as false, setting to false is fine. If any optional values
+          // are added with a default of true, then this would need to be an
+          // undefined check and not a truthy/falsy check.
+          if (!config.options.visible_to_all) {
+            config.options.visible_to_all = false;
+          }
+          this._groupConfig = config;
+
+          this.dispatchEvent(new CustomEvent('title-change', {
+            detail: {title: config.name},
+            composed: true, bubbles: true,
+          }));
+
+          return Promise.all(promises).then(() => {
+            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)
+        .then(config => {
+          if (config.status === 200) {
+            this._groupName = this._groupConfig.name;
+            this.dispatchEvent(new CustomEvent('name-changed', {
+              detail: {name: this._groupConfig.name,
+                external: this._groupIsExtenral},
+              composed: true, bubbles: true,
             }));
+            this._rename = false;
+          }
+        });
+  }
 
-            promises.push(this.$.restAPI.getIsGroupOwner(config.name)
-                .then(isOwner => {
-                  this._groupOwner = isOwner ? true : false;
-                }));
+  _handleSaveOwner() {
+    let owner = this._groupConfig.owner;
+    if (this._groupConfigOwner) {
+      owner = decodeURIComponent(this._groupConfigOwner);
+    }
+    return this.$.restAPI.saveGroupOwner(this.groupId,
+        owner).then(config => {
+      this._owner = false;
+    });
+  }
 
-            // If visible to all is undefined, set to false. If it is defined
-            // as false, setting to false is fine. If any optional values
-            // are added with a default of true, then this would need to be an
-            // undefined check and not a truthy/falsy check.
-            if (!config.options.visible_to_all) {
-              config.options.visible_to_all = false;
-            }
-            this._groupConfig = config;
+  _handleSaveDescription() {
+    return this.$.restAPI.saveGroupDescription(this.groupId,
+        this._groupConfig.description).then(config => {
+      this._description = false;
+    });
+  }
 
-            this.fire('title-change', {title: config.name});
+  _handleSaveOptions() {
+    const visible = this._groupConfig.options.visible_to_all;
 
-            return Promise.all(promises).then(() => {
-              this._loading = false;
+    const options = {visible_to_all: visible};
+
+    return this.$.restAPI.saveGroupOptions(this.groupId,
+        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)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
             });
-          });
-    },
+          }
+          return groups;
+        });
+  }
 
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
+  _computeGroupDisabled(owner, admin, groupIsInternal) {
+    return groupIsInternal && (admin || owner) ? false : true;
+  }
 
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    },
+  _getGroupUUID(id) {
+    if (!id) return;
 
-    _handleSaveName() {
-      return this.$.restAPI.saveGroupName(this.groupId, this._groupConfig.name)
-          .then(config => {
-            if (config.status === 200) {
-              this._groupName = this._groupConfig.name;
-              this.fire('name-changed', {name: this._groupConfig.name,
-                external: this._groupIsExtenral});
-              this._rename = false;
-            }
-          });
-    },
+    return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
+  }
+}
 
-    _handleSaveOwner() {
-      let owner = this._groupConfig.owner;
-      if (this._groupConfigOwner) {
-        owner = decodeURIComponent(this._groupConfigOwner);
-      }
-      return this.$.restAPI.saveGroupOwner(this.groupId,
-          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;
-
-      const options = {visible_to_all: visible};
-
-      return this.$.restAPI.saveGroupOptions(this.groupId,
-          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)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: decodeURIComponent(response[key].id),
-              });
-            }
-            return groups;
-          });
-    },
-
-    _computeGroupDisabled(owner, admin, groupIsInternal) {
-      return groupIsInternal && (admin || owner) ? false : true;
-    },
-
-    _getGroupUUID(id) {
-      if (!id) return;
-
-      return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
-    },
-  });
-})();
+customElements.define(GrGroup.is, GrGroup);
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
new file mode 100644
index 0000000..e11f989
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
@@ -0,0 +1,163 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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);
+      content: ' *';
+    }
+    .inputUpdateBtn {
+      margin-top: var(--spacing-s);
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <main class="gr-form-styles read-only">
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h1 id="Title">[[_groupName]]</h1>
+      <h2 id="configurations">General</h2>
+      <div id="form">
+        <fieldset>
+          <h3 id="groupUUID">Group UUID</h3>
+          <fieldset>
+            <gr-copy-clipboard
+              id="uuid"
+              text="[[_getGroupUUID(_groupConfig.id)]]"
+            ></gr-copy-clipboard>
+          </fieldset>
+          <h3 id="groupName" class$="[[_computeHeaderClass(_rename)]]">
+            Group Name
+          </h3>
+          <fieldset>
+            <span class="value">
+              <gr-autocomplete
+                id="groupNameInput"
+                text="{{_groupConfig.name}}"
+                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+              ></gr-autocomplete>
+            </span>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button
+                id="inputUpdateNameBtn"
+                on-click="_handleSaveName"
+                disabled="[[!_rename]]"
+              >
+                Rename Group</gr-button
+              >
+            </span>
+          </fieldset>
+          <h3 id="groupOwner" class$="[[_computeHeaderClass(_owner)]]">
+            Owners
+          </h3>
+          <fieldset>
+            <span class="value">
+              <gr-autocomplete
+                id="groupOwnerInput"
+                text="{{_groupConfig.owner}}"
+                value="{{_groupConfigOwner}}"
+                query="[[_query]]"
+                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+              >
+              </gr-autocomplete>
+            </span>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button
+                id="inputUpdateOwnerBtn"
+                on-click="_handleSaveOwner"
+                disabled="[[!_owner]]"
+              >
+                Change Owners</gr-button
+              >
+            </span>
+          </fieldset>
+          <h3 class$="[[_computeHeaderClass(_description)]]">
+            Description
+          </h3>
+          <fieldset>
+            <div>
+              <iron-autogrow-textarea
+                class="description"
+                autocomplete="on"
+                bind-value="{{_groupConfig.description}}"
+                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+              ></iron-autogrow-textarea>
+            </div>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button
+                on-click="_handleSaveDescription"
+                disabled="[[!_description]]"
+              >
+                Save Description
+              </gr-button>
+            </span>
+          </fieldset>
+          <h3 id="options" class$="[[_computeHeaderClass(_options)]]">
+            Group Options
+          </h3>
+          <fieldset id="visableToAll">
+            <section>
+              <span class="title">
+                Make group visible to all registered users
+              </span>
+              <span class="value">
+                <gr-select
+                  id="visibleToAll"
+                  bind-value="{{_groupConfig.options.visible_to_all}}"
+                >
+                  <select
+                    disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+                  >
+                    <template is="dom-repeat" items="[[_submitTypes]]">
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button on-click="_handleSaveOptions" disabled="[[!_options]]">
+                Save Group Options
+              </gr-button>
+            </span>
+          </fieldset>
+        </fieldset>
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 fc04c02..5621fff 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,227 +31,259 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group tests', () => {
-    let element;
-    let sandbox;
-    let groupStub;
-    const group = {
-      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-      url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
-      options: {},
-      description: 'Gerrit Site Administrators',
-      group_id: 1,
-      owner: 'Administrators',
-      owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-      name: 'Administrators',
-    };
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-group.js';
+suite('gr-group tests', () => {
+  let element;
+  let sandbox;
+  let groupStub;
+  const group = {
+    id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    options: {},
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    name: 'Administrators',
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
-      groupStub = sandbox.stub(element.$.restAPI, 'getGroupConfig', () => {
-        return Promise.resolve(group);
-      });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
     });
+    element = fixture('basic');
+    groupStub = sandbox.stub(
+        element.$.restAPI,
+        'getGroupConfig',
+        () => Promise.resolve(group)
+    );
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('loading displays before group config is loaded', () => {
-      assert.isTrue(element.$.loading.classList.contains('loading'));
-      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-      assert.isTrue(getComputedStyle(element.$.loadedContent)
-          .display === 'none');
-    });
+  test('loading displays before group config is loaded', () => {
+    assert.isTrue(element.$.loading.classList.contains('loading'));
+    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+    assert.isTrue(getComputedStyle(element.$.loadedContent)
+        .display === 'none');
+  });
 
-    test('default values are populated with internal group', done => {
-      sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
-        return Promise.resolve(true);
-      });
-      element.groupId = 1;
-      element._loadGroup().then(() => {
-        assert.isTrue(element._groupIsInternal);
-        assert.isFalse(element.$.visibleToAll.bindValue);
-        done();
-      });
-    });
-
-    test('default values with external group', done => {
-      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);
-      });
-      element.groupId = 1;
-      element._loadGroup().then(() => {
-        assert.isFalse(element._groupIsInternal);
-        assert.isFalse(element.$.visibleToAll.bindValue);
-        done();
-      });
-    });
-
-    test('rename group', done => {
-      const groupName = 'test-group';
-      const groupName2 = 'test-group2';
-      element.groupId = 1;
-      element._groupConfig = {
-        name: groupName,
-      };
-      element._groupConfigOwner = 'testId';
-      element._groupName = groupName;
-      element._groupOwner = true;
-
-      sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
-        return Promise.resolve(true);
-      });
-
-      sandbox.stub(element.$.restAPI, 'saveGroupName', () => {
-        return Promise.resolve({status: 200});
-      });
-
-      const button = element.$.inputUpdateNameBtn;
-
-      element._loadGroup().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-
-        element.$.groupNameInput.text = groupName2;
-
-        element.$.groupOwnerInput.text = 'testId2';
-
-        assert.isFalse(button.hasAttribute('disabled'));
-        assert.isTrue(element.$.groupName.classList.contains('edited'));
-
-        element._handleSaveName().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.equal(element._groupName, groupName2);
-          done();
-        });
-
-        element._handleSaveOwner().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.equal(element._groupConfigOwner, 'testId2');
-          done();
-        });
-      });
-    });
-
-    test('test for undefined group name', done => {
-      groupStub.restore();
-
-      sandbox.stub(element.$.restAPI, 'getGroupConfig', () => {
-        return Promise.resolve({});
-      });
-
-      assert.isUndefined(element.groupId);
-
-      element.groupId = 1;
-
-      assert.isDefined(element.groupId);
-
-      // Test that loading shows instead of filling
-      // in group details
-      element._loadGroup().then(() => {
-        assert.isTrue(element.$.loading.classList.contains('loading'));
-
-        assert.isTrue(element._loading);
-
-        done();
-      });
-    });
-
-    test('test fire event', done => {
-      element._groupConfig = {
-        name: 'test-group',
-      };
-
-      sandbox.stub(element.$.restAPI, 'saveGroupName')
-          .returns(Promise.resolve({status: 200}));
-
-      const showStub = sandbox.stub(element, 'fire');
-      element._handleSaveName()
-          .then(() => {
-            assert.isTrue(showStub.called);
-            done();
-          });
-    });
-
-    test('_computeGroupDisabled', () => {
-      let admin = true;
-      let owner = false;
-      let groupIsInternal = true;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), false);
-
-      admin = false;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-
-      owner = true;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), false);
-
-      owner = false;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-
-      groupIsInternal = false;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-
-      admin = true;
-      assert.equal(element._computeGroupDisabled(owner, admin,
-          groupIsInternal), true);
-    });
-
-    test('_computeLoadingClass', () => {
-      assert.equal(element._computeLoadingClass(true), 'loading');
-      assert.equal(element._computeLoadingClass(false), '');
-    });
-
-    test('fires page-error', done => {
-      groupStub.restore();
-
-      element.groupId = 1;
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getGroupConfig', (group, errFn) => {
-            errFn(response);
-          });
-
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._loadGroup();
-    });
-
-    test('uuid', () => {
-      element._groupConfig = {
-        id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-      };
-
-      assert.equal(element._groupConfig.id, element.$.uuid.text);
-
-      element._groupConfig = {
-        id: 'user%2Fgroup',
-      };
-
-      assert.equal('user/group', element.$.uuid.text);
+  test('default values are populated with internal group', done => {
+    sandbox.stub(
+        element.$.restAPI,
+        'getIsGroupOwner',
+        () => Promise.resolve(true));
+    element.groupId = 1;
+    element._loadGroup().then(() => {
+      assert.isTrue(element._groupIsInternal);
+      assert.isFalse(element.$.visibleToAll.bindValue);
+      done();
     });
   });
+
+  test('default values with external group', done => {
+    const groupExternal = Object.assign({}, group);
+    groupExternal.id = 'external-group-id';
+    groupStub.restore();
+    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);
+      assert.isFalse(element.$.visibleToAll.bindValue);
+      done();
+    });
+  });
+
+  test('rename group', done => {
+    const groupName = 'test-group';
+    const groupName2 = 'test-group2';
+    element.groupId = 1;
+    element._groupConfig = {
+      name: groupName,
+    };
+    element._groupName = groupName;
+
+    sandbox.stub(
+        element.$.restAPI,
+        'getIsGroupOwner',
+        () => Promise.resolve(true));
+
+    sandbox.stub(
+        element.$.restAPI,
+        'saveGroupName',
+        () => Promise.resolve({status: 200}));
+
+    const button = element.$.inputUpdateNameBtn;
+
+    element._loadGroup().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+
+      element.$.groupNameInput.text = groupName2;
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.groupName.classList.contains('edited'));
+
+      element._handleSaveName().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        assert.equal(element._groupName, groupName2);
+        done();
+      });
+    });
+  });
+
+  test('rename group owner', done => {
+    const groupName = 'test-group';
+    element.groupId = 1;
+    element._groupConfig = {
+      name: groupName,
+    };
+    element._groupConfigOwner = 'testId';
+    element._groupOwner = true;
+
+    sandbox.stub(
+        element.$.restAPI,
+        'getIsGroupOwner',
+        () => Promise.resolve({status: 200}));
+
+    const button = element.$.inputUpdateOwnerBtn;
+
+    element._loadGroup().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+
+      element.$.groupOwnerInput.text = 'testId2';
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.groupOwner.classList.contains('edited'));
+
+      element._handleSaveOwner().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        done();
+      });
+    });
+  });
+
+  test('test for undefined group name', done => {
+    groupStub.restore();
+
+    sandbox.stub(
+        element.$.restAPI,
+        'getGroupConfig',
+        () => Promise.resolve({}));
+
+    assert.isUndefined(element.groupId);
+
+    element.groupId = 1;
+
+    assert.isDefined(element.groupId);
+
+    // Test that loading shows instead of filling
+    // in group details
+    element._loadGroup().then(() => {
+      assert.isTrue(element.$.loading.classList.contains('loading'));
+
+      assert.isTrue(element._loading);
+
+      done();
+    });
+  });
+
+  test('test fire event', done => {
+    element._groupConfig = {
+      name: 'test-group',
+    };
+
+    sandbox.stub(element.$.restAPI, 'saveGroupName')
+        .returns(Promise.resolve({status: 200}));
+
+    const showStub = sandbox.stub(element, 'dispatchEvent');
+    element._handleSaveName()
+        .then(() => {
+          assert.isTrue(showStub.called);
+          done();
+        });
+  });
+
+  test('_computeGroupDisabled', () => {
+    let admin = true;
+    let owner = false;
+    let groupIsInternal = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), false);
+
+    admin = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    owner = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), false);
+
+    owner = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    groupIsInternal = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    admin = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('fires page-error', done => {
+    groupStub.restore();
+
+    element.groupId = 1;
+
+    const response = {status: 404};
+    sandbox.stub(
+        element.$.restAPI, 'getGroupConfig', (group, errFn) => {
+          errFn(response);
+        });
+
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadGroup();
+  });
+
+  test('uuid', () => {
+    element._groupConfig = {
+      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    };
+
+    assert.equal(element._groupConfig.id, element.$.uuid.text);
+
+    element._groupConfig = {
+      id: 'user%2Fgroup',
+    };
+
+    assert.equal('user/group', element.$.uuid.text);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
deleted file mode 100644
index 931e2cd..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
+++ /dev/null
@@ -1,146 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-rule-editor/gr-rule-editor.html">
-
-<dom-module id="gr-permission">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-m);
-      }
-      .header {
-        align-items: baseline;
-        display: flex;
-        justify-content: space-between;
-        margin: var(--spacing-s) var(--spacing-m);
-      }
-      .rules {
-        background: var(--table-header-background-color);
-        border: 1px solid var(--border-color);
-        border-bottom: 0;
-      }
-      .editing .rules {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .title {
-        margin-bottom: var(--spacing-s);
-      }
-      #addRule,
-      #removeBtn {
-        display: none;
-      }
-      .right {
-        display: flex;
-        align-items: center;
-      }
-      .editing #removeBtn {
-        display: block;
-        margin-left: var(--spacing-xl);
-      }
-      .editing #addRule {
-        display: block;
-        padding: var(--spacing-m);
-      }
-      #deletedContainer,
-      .deleted #mainContainer {
-        display: none;
-      }
-      .deleted #deletedContainer {
-        align-items: baseline;
-        border: 1px solid var(--border-color);
-        display: flex;
-        justify-content: space-between;
-        padding: var(--spacing-m);
-      }
-      #mainContainer {
-        display: block;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <style include="gr-menu-page-styles"></style>
-    <section
-        id="permission"
-        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
-      <div id="mainContainer">
-        <div class="header">
-          <span class="title">[[name]]</span>
-          <div class="right">
-            <template is=dom-if if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]">
-              <paper-toggle-button
-                  id="exclusiveToggle"
-                  checked="{{permission.value.exclusive}}"
-                  on-change="_handleValueChange"
-                  disabled$="[[!editing]]"></paper-toggle-button>Exclusive
-            </template>
-            <gr-button
-                link
-                id="removeBtn"
-                on-click="_handleRemovePermission">Remove</gr-button>
-          </div>
-        </div><!-- end header -->
-        <div class="rules">
-          <template
-              is="dom-repeat"
-              items="{{_rules}}"
-              as="rule">
-            <gr-rule-editor
-                has-range="[[_computeHasRange(name)]]"
-                label="[[_label]]"
-                editing="[[editing]]"
-                group-id="[[rule.id]]"
-                group-name="[[_computeGroupName(groups, rule.id)]]"
-                permission="[[permission.id]]"
-                rule="{{rule}}"
-                section="[[section]]"
-                on-added-rule-removed="_handleAddedRuleRemoved"></gr-rule-editor>
-          </template>
-          <div id="addRule">
-            <gr-autocomplete
-                id="groupAutocomplete"
-                text="{{_groupFilter}}"
-                query="[[_query]]"
-                placeholder="Add group"
-                on-commit="_handleAddRuleItem">
-            </gr-autocomplete>
-          </div>
-          <!-- end addRule -->
-        </div> <!-- end rules -->
-      </div><!-- end mainContainer -->
-      <div id="deletedContainer">
-        <span>[[name]] was deleted</span>
-        <gr-button
-            link
-            id="undoRemoveBtn"
-            on-click="_handleUndoRemove">Undo</gr-button>
-      </div><!-- end deletedContainer -->
-    </section>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-permission.js"></script>
-</dom-module>
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..ea4e05c 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -14,32 +14,53 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_AUTOCOMPLETE_RESULTS = 20;
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-rule-editor/gr-rule-editor.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-permission_html.js';
+import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
 
-  const RANGE_NAMES = [
-    'QUERY LIMIT',
-    'BATCH CHANGES LIMIT',
-  ];
+const MAX_AUTOCOMPLETE_RESULTS = 20;
 
-  /**
-   * Fired when the permission has been modified or removed.
-   *
-   * @event access-modified
-   */
+const RANGE_NAMES = [
+  'QUERY LIMIT',
+  'BATCH CHANGES LIMIT',
+];
 
-  /**
-   * Fired when a permission that was previously added was removed.
-   *
-   * @event added-permission-removed
-   */
+/**
+ * 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 mixinBehaviors( [
+  AccessBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-  Polymer({
-    is: 'gr-permission',
+  static get is() { return 'gr-permission'; }
 
-    properties: {
+  static get properties() {
+    return {
       labels: Object,
       name: String,
       /** @type {?} */
@@ -73,229 +94,235 @@
         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,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_handleRulesChanged(_rules.splices)',
-    ],
+    ];
+  }
 
-    listeners: {
-      'access-saved': '_handleAccessSaved',
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved',
+        () => this._handleAccessSaved());
+  }
 
-    ready() {
-      this._setupValues();
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    this._setupValues();
+  }
 
-    _setupValues() {
-      if (!this.permission) { return; }
-      this._originalExclusiveValue = !!this.permission.value.exclusive;
-      Polymer.dom.flush();
-    },
+  _setupValues() {
+    if (!this.permission) { return; }
+    this._originalExclusiveValue = !!this.permission.value.exclusive;
+    flush();
+  }
 
-    _handleAccessSaved() {
-      // Set a new 'original' value to keep track of after the value has been
-      // saved.
-      this._setupValues();
-    },
+  _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';
-    },
+  _permissionIsOwnerOrGlobal(permissionId, section) {
+    return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
+  }
 
-    _handleEditingChanged(editing, editingOld) {
-      // Ignore when editing gets set initially.
-      if (!editingOld) { return; }
-      // Restore original values if no longer editing.
-      if (!editing) {
-        this._deleted = false;
-        delete this.permission.value.deleted;
-        this._groupFilter = '';
-        this._rules = this._rules.filter(rule => !rule.value.added);
-        for (const key of Object.keys(this.permission.value.rules)) {
-          if (this.permission.value.rules[key].added) {
-            delete this.permission.value.rules[key];
-          }
-        }
-
-        // Restore exclusive bit to original.
-        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) {
-        this.dispatchEvent(new CustomEvent(
-            'added-permission-removed', {bubbles: true, composed: true}));
-      }
-      this._deleted = true;
-      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 = [];
-      if (editing) {
-        classList.push('editing');
-      }
-      if (deleted) {
-        classList.push('deleted');
-      }
-      return classList.join(' ');
-    },
-
-    _handleUndoRemove() {
+  _handleEditingChanged(editing, editingOld) {
+    // Ignore when editing gets set initially.
+    if (!editingOld) { return; }
+    // Restore original values if no longer editing.
+    if (!editing) {
       this._deleted = false;
       delete this.permission.value.deleted;
-    },
-
-    _computeLabel(permission, labels) {
-      if (!labels || !permission ||
-          !permission.value || !permission.value.label) { return; }
-
-      const labelName = permission.value.label;
-
-      // It is possible to have a label name that is not included in the
-      // 'labels' object. In this case, treat it like anything else.
-      if (!labels[labelName]) { return; }
-      const label = {
-        name: labelName,
-        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);
-      });
-
-      for (const key of keys) {
-        let text = values[key];
-        if (!text) { text = ''; }
-        // The value from the server being used to choose which item is
-        // selected is in integer form, so this must be converted.
-        valuesArr.push({value: parseInt(key, 10), text});
+      this._groupFilter = '';
+      this._rules = this._rules.filter(rule => !rule.value.added);
+      for (const key of Object.keys(this.permission.value.rules)) {
+        if (this.permission.value.rules[key].added) {
+          delete this.permission.value.rules[key];
+        }
       }
-      return valuesArr;
-    },
 
-    /**
-     * @param {!Array} rules
-     * @return {!Object} Object with groups with rues as keys, and true as
-     *    value.
-     */
-    _computeGroupsWithRules(rules) {
-      const groups = {};
-      for (const rule of rules) {
-        groups[rule.id] = true;
-      }
-      return groups;
-    },
+      // Restore exclusive bit to original.
+      this.set(['permission', 'value', 'exclusive'],
+          this._originalExclusiveValue);
+    }
+  }
 
-    _computeGroupName(groups, groupId) {
-      return groups && groups[groupId] && groups[groupId].name ?
-        groups[groupId].name : groupId;
-    },
+  _handleAddedRuleRemoved(e) {
+    const index = e.model.index;
+    this._rules = this._rules.slice(0, index)
+        .concat(this._rules.slice(index + 1, this._rules.length));
+  }
 
-    _getGroupSuggestions() {
-      return this.$.restAPI.getSuggestedGroups(
-          this._groupFilter,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(response => {
-            const groups = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              groups.push({
-                name: key,
-                value: response[key],
-              });
-            }
-            // Does not return groups in which we already have rules for.
-            return groups.filter(group => {
-              return !this._groupsWithRules[group.value.id];
+  _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) {
+      this.dispatchEvent(new CustomEvent(
+          'added-permission-removed', {bubbles: true, composed: true}));
+    }
+    this._deleted = true;
+    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 = [];
+    if (editing) {
+      classList.push('editing');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _handleUndoRemove() {
+    this._deleted = false;
+    delete this.permission.value.deleted;
+  }
+
+  _computeLabel(permission, labels) {
+    if (!labels || !permission ||
+        !permission.value || !permission.value.label) { return; }
+
+    const labelName = permission.value.label;
+
+    // It is possible to have a label name that is not included in the
+    // 'labels' object. In this case, treat it like anything else.
+    if (!labels[labelName]) { return; }
+    const label = {
+      name: labelName,
+      values: this._computeLabelValues(labels[labelName].values),
+    };
+    return label;
+  }
+
+  _computeLabelValues(values) {
+    const valuesArr = [];
+    const keys = Object.keys(values)
+        .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
+
+    for (const key of keys) {
+      let text = values[key];
+      if (!text) { text = ''; }
+      // The value from the server being used to choose which item is
+      // selected is in integer form, so this must be converted.
+      valuesArr.push({value: parseInt(key, 10), text});
+    }
+    return valuesArr;
+  }
+
+  /**
+   * @param {!Array} rules
+   * @return {!Object} Object with groups with rues as keys, and true as
+   *    value.
+   */
+  _computeGroupsWithRules(rules) {
+    const groups = {};
+    for (const rule of rules) {
+      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(
+        this._groupFilter,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(response => {
+          const groups = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            groups.push({
+              name: key,
+              value: response[key],
             });
-          });
-    },
+          }
+          // Does not return groups in which we already have rules for.
+          return groups
+              .filter(group => !this._groupsWithRules[group.value.id]);
+        });
+  }
 
-    /**
-     * Handles adding a skeleton item to the dom-repeat.
-     * gr-rule-editor handles setting the default values.
-     */
-    _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, ' ');
-      // 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
-      // have looked at using MutableData:
-      // https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data
-      this.permission.value.rules[groupId] = {};
+  /**
+   * Handles adding a skeleton item to the dom-repeat.
+   * gr-rule-editor handles setting the default values.
+   */
+  _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, ' ');
+    // 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
+    // have looked at using MutableData:
+    // https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data
+    this.permission.value.rules[groupId] = {};
 
-      // Purposely don't recompute sorted array so that the newly added rule
-      // is the last item of the array.
-      this.push('_rules', {
-        id: groupId,
-      });
+    // Purposely don't recompute sorted array so that the newly added rule
+    // is the last item of the array.
+    this.push('_rules', {
+      id: groupId,
+    });
 
-      // Add the new group name to the groups object so the name renders
-      // correctly.
-      if (this.groups && !this.groups[groupId]) {
-        this.groups[groupId] = {name: this.$.groupAutocomplete.text};
-      }
+    // Add the new group name to the groups object so the name renders
+    // correctly.
+    if (this.groups && !this.groups[groupId]) {
+      this.groups[groupId] = {name: this.$.groupAutocomplete.text};
+    }
 
-      // Wait for new rule to get value populated via gr-rule-editor, and then
-      // add to permission values as well, so that the change gets propogated
-      // back to the section. Since the rule is inside a dom-repeat, a flush
-      // is needed.
-      Polymer.dom.flush();
-      const value = this._rules[this._rules.length - 1].value;
-      value.added = true;
-      // See comment above for why we cannot use "this.set(...)" here.
-      this.permission.value.rules[groupId] = value;
-      this.dispatchEvent(
-          new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+    // Wait for new rule to get value populated via gr-rule-editor, and then
+    // add to permission values as well, so that the change gets propogated
+    // back to the section. Since the rule is inside a dom-repeat, a flush
+    // is needed.
+    flush();
+    const value = this._rules[this._rules.length - 1].value;
+    value.added = true;
+    // See comment above for why we cannot use "this.set(...)" here.
+    this.permission.value.rules[groupId] = value;
+    this.dispatchEvent(
+        new CustomEvent('access-modified', {bubbles: true, composed: true}));
+  }
 
-    _computeHasRange(name) {
-      if (!name) { return false; }
+  _computeHasRange(name) {
+    if (!name) { return false; }
 
-      return RANGE_NAMES.includes(name.toUpperCase());
-    },
-  });
-})();
+    return RANGE_NAMES.includes(name.toUpperCase());
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapExclusiveToggle(e) {
+    e.preventDefault();
+  }
+}
+
+customElements.define(GrPermission.is, GrPermission);
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
new file mode 100644
index 0000000..ed4f64a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
@@ -0,0 +1,144 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-m);
+    }
+    .header {
+      align-items: baseline;
+      display: flex;
+      justify-content: space-between;
+      margin: var(--spacing-s) var(--spacing-m);
+    }
+    .rules {
+      background: var(--table-header-background-color);
+      border: 1px solid var(--border-color);
+      border-bottom: 0;
+    }
+    .editing .rules {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .title {
+      margin-bottom: var(--spacing-s);
+    }
+    #addRule,
+    #removeBtn {
+      display: none;
+    }
+    .right {
+      display: flex;
+      align-items: center;
+    }
+    .editing #removeBtn {
+      display: block;
+      margin-left: var(--spacing-xl);
+    }
+    .editing #addRule {
+      display: block;
+      padding: var(--spacing-m);
+    }
+    #deletedContainer,
+    .deleted #mainContainer {
+      display: none;
+    }
+    .deleted #deletedContainer {
+      align-items: baseline;
+      border: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m);
+    }
+    #mainContainer {
+      display: block;
+    }
+  </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)]]"
+  >
+    <div id="mainContainer">
+      <div class="header">
+        <span class="title">[[name]]</span>
+        <div class="right">
+          <template
+            is="dom-if"
+            if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]"
+          >
+            <paper-toggle-button
+              id="exclusiveToggle"
+              checked="{{permission.value.exclusive}}"
+              on-change="_handleValueChange"
+              disabled$="[[!editing]]"
+              on-tap="_onTapExclusiveToggle"
+            ></paper-toggle-button
+            >Exclusive
+          </template>
+          <gr-button link="" id="removeBtn" on-click="_handleRemovePermission"
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+      <!-- end header -->
+      <div class="rules">
+        <template is="dom-repeat" items="{{_rules}}" as="rule">
+          <gr-rule-editor
+            has-range="[[_computeHasRange(name)]]"
+            label="[[_label]]"
+            editing="[[editing]]"
+            group-id="[[rule.id]]"
+            group-name="[[_computeGroupName(groups, rule.id)]]"
+            permission="[[permission.id]]"
+            rule="{{rule}}"
+            section="[[section]]"
+            on-added-rule-removed="_handleAddedRuleRemoved"
+          ></gr-rule-editor>
+        </template>
+        <div id="addRule">
+          <gr-autocomplete
+            id="groupAutocomplete"
+            text="{{_groupFilter}}"
+            query="[[_query]]"
+            placeholder="Add group"
+            on-commit="_handleAddRuleItem"
+          >
+          </gr-autocomplete>
+        </div>
+        <!-- end addRule -->
+      </div>
+      <!-- end rules -->
+    </div>
+    <!-- end mainContainer -->
+    <div id="deletedContainer">
+      <span>[[name]] was deleted</span>
+      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
+        >Undo</gr-button
+      >
+    </div>
+    <!-- end deletedContainer -->
+  </section>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..1ce492e 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-permission.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,392 +32,403 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-permission tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-permission.js';
+suite('gr-permission tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      sandbox.stub(element.$.restAPI, 'getSuggestedGroups').returns(
-          Promise.resolve({
-            'Administrators': {
-              id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    sandbox.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+        Promise.resolve({
+          'Administrators': {
+            id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+          },
+          'Anonymous Users': {
+            id: 'global%3AAnonymous-Users',
+          },
+        }));
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('unit tests', () => {
+    test('_sortPermission', () => {
+      const permission = {
+        id: 'submit',
+        value: {
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
             },
-            'Anonymous Users': {
-              id: 'global%3AAnonymous-Users',
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
             },
-          }));
+          },
+        },
+      };
+
+      const expectedRules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+          value: {action: 'ALLOW', force: false},
+        },
+        {
+          id: 'global:Project-Owners',
+          value: {action: 'ALLOW', force: false},
+        },
+      ];
+
+      element._sortPermission(permission);
+      assert.deepEqual(element._rules, expectedRules);
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_computeLabel and _computeLabelValues', () => {
+      const labels = {
+        'Code-Review': {
+          default_value: 0,
+          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',
+          },
+        },
+      };
+      let permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+
+      const expectedLabelValues = [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: 0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ];
+
+      const expectedLabel = {
+        name: 'Code-Review',
+        values: expectedLabelValues,
+      };
+
+      assert.deepEqual(element._computeLabelValues(
+          labels['Code-Review'].values), expectedLabelValues);
+
+      assert.deepEqual(element._computeLabel(permission, labels),
+          expectedLabel);
+
+      permission = {
+        id: 'label-reviewDB',
+        value: {
+          label: 'reviewDB',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+            },
+          },
+        },
+      };
+
+      assert.isNotOk(element._computeLabel(permission, labels));
     });
 
-    suite('unit tests', () => {
-      test('_sortPermission', () => {
-        const permission = {
-          id: 'submit',
-          value: {
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-              },
-            },
-          },
-        };
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
 
-        const expectedRules = [
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, deleted),
+          'editing deleted');
+    });
+
+    test('_computeGroupName', () => {
+      const groups = {
+        abc123: {name: 'test group'},
+        bcd234: {},
+      };
+      assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
+      assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
+    });
+
+    test('_computeGroupsWithRules', () => {
+      const rules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+          value: {action: 'ALLOW', force: false},
+        },
+        {
+          id: 'global:Project-Owners',
+          value: {action: 'ALLOW', force: false},
+        },
+      ];
+      const groupsWithRules = {
+        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
+        'global:Project-Owners': true,
+      };
+      assert.deepEqual(element._computeGroupsWithRules(rules),
+          groupsWithRules);
+    });
+
+    test('_getGroupSuggestions without existing rules', done => {
+      element._groupsWithRules = {};
+
+      element._getGroupSuggestions().then(groups => {
+        assert.deepEqual(groups, [
           {
-            id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-            value: {action: 'ALLOW', force: false},
-          },
-          {
-            id: 'global:Project-Owners',
-            value: {action: 'ALLOW', force: false},
-          },
-        ];
-
-        element._sortPermission(permission);
-        assert.deepEqual(element._rules, expectedRules);
-      });
-
-      test('_computeLabel and _computeLabelValues', () => {
-        const labels = {
-          'Code-Review': {
-            default_value: 0,
-            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',
-            },
-          },
-        };
-        let permission = {
-          id: 'label-Code-Review',
-          value: {
-            label: 'Code-Review',
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-            },
-          },
-        };
-
-        const expectedLabelValues = [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: 0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ];
-
-        const expectedLabel = {
-          name: 'Code-Review',
-          values: expectedLabelValues,
-        };
-
-        assert.deepEqual(element._computeLabelValues(
-            labels['Code-Review'].values), expectedLabelValues);
-
-        assert.deepEqual(element._computeLabel(permission, labels),
-            expectedLabel);
-
-        permission = {
-          id: 'label-reviewDB',
-          value: {
-            label: 'reviewDB',
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-              },
-            },
-          },
-        };
-
-        assert.isNotOk(element._computeLabel(permission, labels));
-      });
-
-      test('_computeSectionClass', () => {
-        let deleted = true;
-        let editing = false;
-        assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-        deleted = false;
-        assert.equal(element._computeSectionClass(editing, deleted), '');
-
-        editing = true;
-        assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-        deleted = true;
-        assert.equal(element._computeSectionClass(editing, deleted),
-            'editing deleted');
-      });
-
-      test('_computeGroupName', () => {
-        const groups = {
-          abc123: {name: 'test group'},
-          bcd234: {},
-        };
-        assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
-        assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
-      });
-
-      test('_computeGroupsWithRules', () => {
-        const rules = [
-          {
-            id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-            value: {action: 'ALLOW', force: false},
-          },
-          {
-            id: 'global:Project-Owners',
-            value: {action: 'ALLOW', force: false},
-          },
-        ];
-        const groupsWithRules = {
-          '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
-          'global:Project-Owners': true,
-        };
-        assert.deepEqual(element._computeGroupsWithRules(rules),
-            groupsWithRules);
-      });
-
-      test('_getGroupSuggestions without existing rules', done => {
-        element._groupsWithRules = {};
-
-        element._getGroupSuggestions().then(groups => {
-          assert.deepEqual(groups, [
-            {
-              name: 'Administrators',
-              value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
-            }, {
-              name: 'Anonymous Users',
-              value: {id: 'global%3AAnonymous-Users'},
-            },
-          ]);
-          done();
-        });
-      });
-
-      test('_getGroupSuggestions with existing rules filters them', done => {
-        element._groupsWithRules = {
-          '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
-        };
-
-        element._getGroupSuggestions().then(groups => {
-          assert.deepEqual(groups, [{
+            name: 'Administrators',
+            value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
+          }, {
             name: 'Anonymous Users',
             value: {id: 'global%3AAnonymous-Users'},
-          }]);
-          done();
-        });
-      });
-
-      test('_handleRemovePermission', () => {
-        element.editing = true;
-        element.permission = {value: {rules: {}}};
-        element._handleRemovePermission();
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.permission.value.deleted);
-
-        element.editing = false;
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.permission.value.deleted);
-      });
-
-      test('_handleUndoRemove', () => {
-        element.permission = {value: {deleted: true, rules: {}}};
-        element._handleUndoRemove();
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.permission.value.deleted);
-      });
-
-      test('_computeHasRange', () => {
-        assert.isTrue(element._computeHasRange('Query Limit'));
-
-        assert.isTrue(element._computeHasRange('Batch Changes Limit'));
-
-        assert.isFalse(element._computeHasRange('test'));
+          },
+        ]);
+        done();
       });
     });
 
-    suite('interactions', () => {
-      setup(() => {
-        sandbox.spy(element, '_computeLabel');
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.labels = {
-          'Code-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',
-            },
-            default_value: 0,
-          },
-        };
-        element.permission = {
-          id: 'label-Code-Review',
-          value: {
-            label: 'Code-Review',
-            rules: {
-              'global:Project-Owners': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-                action: 'ALLOW',
-                force: false,
-                min: -2,
-                max: 2,
-              },
-            },
-          },
-        };
-        element._setupValues();
-        flushAsynchronousOperations();
+    test('_getGroupSuggestions with existing rules filters them', done => {
+      element._groupsWithRules = {
+        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
+      };
+
+      element._getGroupSuggestions().then(groups => {
+        assert.deepEqual(groups, [{
+          name: 'Anonymous Users',
+          value: {id: 'global%3AAnonymous-Users'},
+        }]);
+        done();
       });
+    });
 
-      test('adding a rule', () => {
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.groups = {};
-        element.$.groupAutocomplete.text = 'ldap/tests te.st';
-        const e = {
-          detail: {
-            value: {
-              id: 'ldap:CN=test+te.st',
-            },
-          },
-        };
-        element.editing = true;
-        assert.equal(element._rules.length, 2);
-        assert.equal(Object.keys(element._groupsWithRules).length, 2);
-        element._handleAddRuleItem(e);
-        flushAsynchronousOperations();
-        assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
-          name: 'ldap/tests te.st'}});
-        assert.equal(element._rules.length, 3);
-        assert.equal(Object.keys(element._groupsWithRules).length, 3);
-        assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
-            {action: 'ALLOW', min: -2, max: 2, added: true});
-        // New rule should be removed if cancel from editing.
-        element.editing = false;
-        assert.equal(element._rules.length, 2);
-        assert.equal(Object.keys(element.permission.value.rules).length, 2);
-      });
+    test('_handleRemovePermission', () => {
+      element.editing = true;
+      element.permission = {value: {rules: {}}};
+      element._handleRemovePermission();
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.permission.value.deleted);
 
-      test('removing an added rule', () => {
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.groups = {};
-        element.$.groupAutocomplete.text = 'new group name';
-        assert.equal(element._rules.length, 2);
-        element.$$('gr-rule-editor').fire('added-rule-removed');
-        flushAsynchronousOperations();
-        assert.equal(element._rules.length, 1);
-      });
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
 
-      test('removing an added permission', () => {
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-permission-removed', removeStub);
-        element.editing = true;
-        element.name = 'Priority';
-        element.section = 'refs/*';
-        element.permission.value.added = true;
-        MockInteractions.tap(element.$.removeBtn);
-        assert.isTrue(removeStub.called);
-      });
+    test('_handleUndoRemove', () => {
+      element.permission = {value: {deleted: true, rules: {}}};
+      element._handleUndoRemove();
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
 
-      test('removing the permission', () => {
-        element.editing = true;
-        element.name = 'Priority';
-        element.section = 'refs/*';
+    test('_computeHasRange', () => {
+      assert.isTrue(element._computeHasRange('Query Limit'));
 
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-permission-removed', removeStub);
+      assert.isTrue(element._computeHasRange('Batch Changes Limit'));
 
-        assert.isFalse(element.$.permission.classList.contains('deleted'));
-        assert.isFalse(element._deleted);
-        MockInteractions.tap(element.$.removeBtn);
-        assert.isTrue(element.$.permission.classList.contains('deleted'));
-        assert.isTrue(element._deleted);
-        MockInteractions.tap(element.$.undoRemoveBtn);
-        assert.isFalse(element.$.permission.classList.contains('deleted'));
-        assert.isFalse(element._deleted);
-        assert.isFalse(removeStub.called);
-      });
-
-      test('modify a permission', () => {
-        element.editing = true;
-        element.name = 'Priority';
-        element.section = 'refs/*';
-
-        assert.isFalse(element._originalExclusiveValue);
-        assert.isNotOk(element.permission.value.modified);
-        MockInteractions.tap(element.$$('#exclusiveToggle'));
-        flushAsynchronousOperations();
-        assert.isTrue(element.permission.value.exclusive);
-        assert.isTrue(element.permission.value.modified);
-        assert.isFalse(element._originalExclusiveValue);
-        element.editing = false;
-        assert.isFalse(element.permission.value.exclusive);
-      });
-
-      test('_handleValueChange', () => {
-        const modifiedHandler = sandbox.stub();
-        element.permission = {value: {rules: {}}};
-        element.addEventListener('access-modified', modifiedHandler);
-        assert.isNotOk(element.permission.value.modified);
-        element._handleValueChange();
-        assert.isTrue(element.permission.value.modified);
-        assert.isTrue(modifiedHandler.called);
-      });
-
-      test('Exclusive hidden for owner permission', () => {
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'flex');
-        element.set(['permission', 'id'], 'owner');
-        flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'none');
-      });
-
-      test('Exclusive hidden for any global permissions', () => {
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'flex');
-        element.section = 'GLOBAL_CAPABILITIES';
-        flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'none');
-      });
+      assert.isFalse(element._computeHasRange('test'));
     });
   });
+
+  suite('interactions', () => {
+    setup(() => {
+      sandbox.spy(element, '_computeLabel');
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.labels = {
+        'Code-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',
+          },
+          default_value: 0,
+        },
+      };
+      element.permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+      element._setupValues();
+      flushAsynchronousOperations();
+    });
+
+    test('adding a rule', () => {
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.groups = {};
+      element.$.groupAutocomplete.text = 'ldap/tests te.st';
+      const e = {
+        detail: {
+          value: {
+            id: 'ldap:CN=test+te.st',
+          },
+        },
+      };
+      element.editing = true;
+      assert.equal(element._rules.length, 2);
+      assert.equal(Object.keys(element._groupsWithRules).length, 2);
+      element._handleAddRuleItem(e);
+      flushAsynchronousOperations();
+      assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
+        name: 'ldap/tests te.st'}});
+      assert.equal(element._rules.length, 3);
+      assert.equal(Object.keys(element._groupsWithRules).length, 3);
+      assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
+          {action: 'ALLOW', min: -2, max: 2, added: true});
+      // New rule should be removed if cancel from editing.
+      element.editing = false;
+      assert.equal(element._rules.length, 2);
+      assert.equal(Object.keys(element.permission.value.rules).length, 2);
+    });
+
+    test('removing an added rule', () => {
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.groups = {};
+      element.$.groupAutocomplete.text = 'new group name';
+      assert.equal(element._rules.length, 2);
+      element.shadowRoot
+          .querySelector('gr-rule-editor').dispatchEvent(
+              new CustomEvent('added-rule-removed', {
+                composed: true, bubbles: true,
+              }));
+      flushAsynchronousOperations();
+      assert.equal(element._rules.length, 1);
+    });
+
+    test('removing an added permission', () => {
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.permission.value.added = true;
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(removeStub.called);
+    });
+
+    test('removing the permission', () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+
+      assert.isFalse(element.$.permission.classList.contains('deleted'));
+      assert.isFalse(element._deleted);
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(element.$.permission.classList.contains('deleted'));
+      assert.isTrue(element._deleted);
+      MockInteractions.tap(element.$.undoRemoveBtn);
+      assert.isFalse(element.$.permission.classList.contains('deleted'));
+      assert.isFalse(element._deleted);
+      assert.isFalse(removeStub.called);
+    });
+
+    test('modify a permission', () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+
+      assert.isFalse(element._originalExclusiveValue);
+      assert.isNotOk(element.permission.value.modified);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#exclusiveToggle'));
+      flushAsynchronousOperations();
+      assert.isTrue(element.permission.value.exclusive);
+      assert.isTrue(element.permission.value.modified);
+      assert.isFalse(element._originalExclusiveValue);
+      element.editing = false;
+      assert.isFalse(element.permission.value.exclusive);
+    });
+
+    test('_handleValueChange', () => {
+      const modifiedHandler = sandbox.stub();
+      element.permission = {value: {rules: {}}};
+      element.addEventListener('access-modified', modifiedHandler);
+      assert.isNotOk(element.permission.value.modified);
+      element._handleValueChange();
+      assert.isTrue(element.permission.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('Exclusive hidden for owner permission', () => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'flex');
+      element.set(['permission', 'id'], 'owner');
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'none');
+    });
+
+    test('Exclusive hidden for any global permissions', () => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'flex');
+      element.section = 'GLOBAL_CAPABILITIES';
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'none');
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index 2761526..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
+++ /dev/null
@@ -1,104 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-
-<dom-module id="gr-plugin-config-array-editor">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      .wrapper {
-        width: 30em;
-      }
-      .existingItems {
-        background: var(--table-header-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-      }
-      gr-button {
-        float: right;
-        margin-left: var(--spacing-m);
-        width: 4.5em;
-      }
-      .row {
-        align-items: center;
-        display: flex;
-        justify-content: space-between;
-        padding: var(--spacing-m) 0;
-        width: 100%;
-      }
-      .existingItems .row {
-        padding: var(--spacing-m);
-      }
-      .existingItems .row:not(:first-of-type) {
-        border-top: 1px solid var(--border-color);
-      }
-      input {
-        flex-grow: 1;
-      }
-      .hide {
-        display: none;
-      }
-      .placeholder {
-        color: var(--deemphasized-text-color);
-        padding-top: var(--spacing-m);
-      }
-    </style>
-    <div class="wrapper gr-form-styles">
-      <template is="dom-if" if="[[pluginOption.info.values.length]]">
-        <div class="existingItems">
-          <template is="dom-repeat" items="[[pluginOption.info.values]]">
-            <div class="row">
-              <span>[[item]]</span>
-              <gr-button
-                  link
-                  disabled$="[[disabled]]"
-                  data-item$="[[item]]"
-                  on-click="_handleDelete">Delete</gr-button>
-            </div>
-          </template>
-        </div>
-      </template>
-      <template is="dom-if" if="[[!pluginOption.info.values.length]]">
-        <div class="row placeholder">None configured.</div>
-      </template>
-      <div class$="row [[_computeShowInputRow(disabled)]]">
-        <iron-input
-            on-keydown="_handleInputKeydown"
-            bind-value="{{_newValue}}">
-          <input
-              is="iron-input"
-              id="input"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newValue}}">
-        </iron-input>
-        <gr-button
-            id="addButton"
-            disabled$="[[!_newValue.length]]"
-            link
-            on-click="_handleAddTap">Add</gr-button>
-      </div>
-    </div>
-  </template>
-  <script src="gr-plugin-config-array-editor.js"></script>
-</dom-module>
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..318c2c3 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
@@ -14,22 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-plugin-config-array-editor',
+import '@polymer/iron-input/iron-input.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-config-array-editor_html.js';
 
-    /**
-     * Fired when the plugin config option changes.
-     *
-     * @event plugin-config-option-changed
-     */
+/** @extends Polymer.Element */
+class GrPluginConfigArrayEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /** @type {?} */
+  static get is() { return 'gr-plugin-config-array-editor'; }
+  /**
+   * Fired when the plugin config option changes.
+   *
+   * @event plugin-config-option-changed
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
       pluginOption: Object,
-      /** @type {Boolean} */
+      /** @type {boolean} */
       disabled: {
         type: Boolean,
         computed: '_computeDisabled(pluginOption.*)',
@@ -39,52 +54,55 @@
         type: String,
         value: '',
       },
-    },
+    };
+  }
 
-    _computeDisabled(record) {
-      return !(record && record.base && record.base.info &&
-          record.base.info.editable);
-    },
+  _computeDisabled(record) {
+    return !(record && record.base && record.base.info &&
+        record.base.info.editable);
+  }
 
-    _handleAddTap(e) {
+  _handleAddTap(e) {
+    e.preventDefault();
+    this._handleAdd();
+  }
+
+  _handleInputKeydown(e) {
+    // Enter.
+    if (e.keyCode === 13) {
       e.preventDefault();
       this._handleAdd();
-    },
+    }
+  }
 
-    _handleInputKeydown(e) {
-      // Enter.
-      if (e.keyCode === 13) {
-        e.preventDefault();
-        this._handleAdd();
-      }
-    },
+  _handleAdd() {
+    if (!this._newValue.length) { return; }
+    this._dispatchChanged(
+        this.pluginOption.info.values.concat([this._newValue]));
+    this._newValue = '';
+  }
 
-    _handleAdd() {
-      if (!this._newValue.length) { return; }
-      this._dispatchChanged(
-          this.pluginOption.info.values.concat([this._newValue]));
-      this._newValue = '';
-    },
+  _handleDelete(e) {
+    const value = dom(e).localTarget.dataset.item;
+    this._dispatchChanged(
+        this.pluginOption.info.values.filter(str => str !== value));
+  }
 
-    _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;
+    const detail = {
+      _key,
+      info: Object.assign(info, {values}, {}),
+      notifyPath: `${_key}.values`,
+    };
+    this.dispatchEvent(
+        new CustomEvent('plugin-config-option-changed', {detail}));
+  }
 
-    _dispatchChanged(values) {
-      const {_key, info} = this.pluginOption;
-      const detail = {
-        _key,
-        info: Object.assign(info, {values}, {}),
-        notifyPath: `${_key}.values`,
-      };
-      this.dispatchEvent(
-          new CustomEvent('plugin-config-option-changed', {detail}));
-    },
+  _computeShowInputRow(disabled) {
+    return disabled ? 'hide' : '';
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
new file mode 100644
index 0000000..be35035
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    .existingItems {
+      background: var(--table-header-background-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+    }
+    gr-button {
+      float: right;
+      margin-left: var(--spacing-m);
+      width: 4.5em;
+    }
+    .row {
+      align-items: center;
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) 0;
+      width: 100%;
+    }
+    .existingItems .row {
+      padding: var(--spacing-m);
+    }
+    .existingItems .row:not(:first-of-type) {
+      border-top: 1px solid var(--border-color);
+    }
+    input {
+      flex-grow: 1;
+    }
+    .hide {
+      display: none;
+    }
+    .placeholder {
+      color: var(--deemphasized-text-color);
+      padding-top: var(--spacing-m);
+    }
+  </style>
+  <div class="wrapper gr-form-styles">
+    <template is="dom-if" if="[[pluginOption.info.values.length]]">
+      <div class="existingItems">
+        <template is="dom-repeat" items="[[pluginOption.info.values]]">
+          <div class="row">
+            <span>[[item]]</span>
+            <gr-button
+              link=""
+              disabled$="[[disabled]]"
+              data-item$="[[item]]"
+              on-click="_handleDelete"
+              >Delete</gr-button
+            >
+          </div>
+        </template>
+      </div>
+    </template>
+    <template is="dom-if" if="[[!pluginOption.info.values.length]]">
+      <div class="row placeholder">None configured.</div>
+    </template>
+    <div class$="row [[_computeShowInputRow(disabled)]]">
+      <iron-input on-keydown="_handleInputKeydown" bind-value="{{_newValue}}">
+        <input
+          is="iron-input"
+          id="input"
+          on-keydown="_handleInputKeydown"
+          bind-value="{{_newValue}}"
+        />
+      </iron-input>
+      <gr-button
+        id="addButton"
+        disabled$="[[!_newValue.length]]"
+        link=""
+        on-click="_handleAddTap"
+        >Add</gr-button
+      >
+    </div>
+  </div>
+`;
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..5eff42d 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-plugin-config-array-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,111 +31,114 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-config-array-editor tests', () => {
-    let element;
-    let sandbox;
-    let dispatchStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-plugin-config-array-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-plugin-config-array-editor tests', () => {
+  let element;
+  let sandbox;
+  let dispatchStub;
 
-    const getAll = str => Polymer.dom(element.root).querySelectorAll(str);
+  const getAll = str => dom(element.root).querySelectorAll(str);
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.pluginOption = {
+      _key: 'test-key',
+      info: {
+        values: [],
+      },
+    };
+  });
+
+  teardown(() => sandbox.restore());
+
+  test('_computeShowInputRow', () => {
+    assert.equal(element._computeShowInputRow(true), 'hide');
+    assert.equal(element._computeShowInputRow(false), '');
+  });
+
+  test('_computeDisabled', () => {
+    assert.isTrue(element._computeDisabled({}));
+    assert.isTrue(element._computeDisabled({base: {}}));
+    assert.isTrue(element._computeDisabled({base: {info: {}}}));
+    assert.isTrue(
+        element._computeDisabled({base: {info: {editable: false}}}));
+    assert.isFalse(
+        element._computeDisabled({base: {info: {editable: true}}}));
+  });
+
+  suite('adding', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.pluginOption = {
-        _key: 'test-key',
-        info: {
-          values: [],
-        },
-      };
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('_computeShowInputRow', () => {
-      assert.equal(element._computeShowInputRow(true), 'hide');
-      assert.equal(element._computeShowInputRow(false), '');
-    });
-
-    test('_computeDisabled', () => {
-      assert.isTrue(element._computeDisabled({}));
-      assert.isTrue(element._computeDisabled({base: {}}));
-      assert.isTrue(element._computeDisabled({base: {info: {}}}));
-      assert.isTrue(
-          element._computeDisabled({base: {info: {editable: false}}}));
-      assert.isFalse(
-          element._computeDisabled({base: {info: {editable: true}}}));
-    });
-
-    suite('adding', () => {
-      setup(() => {
-        dispatchStub = sandbox.stub(element, '_dispatchChanged');
-      });
-
-      test('with enter', () => {
-        element._newValue = '';
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-        flushAsynchronousOperations();
-
-        assert.isFalse(dispatchStub.called);
-        element._newValue = 'test';
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-        flushAsynchronousOperations();
-
-        assert.isTrue(dispatchStub.called);
-        assert.equal(dispatchStub.lastCall.args[0], 'test');
-        assert.equal(element._newValue, '');
-      });
-
-      test('with add btn', () => {
-        element._newValue = '';
-        MockInteractions.tap(element.$.addButton);
-        flushAsynchronousOperations();
-
-        assert.isFalse(dispatchStub.called);
-        element._newValue = 'test';
-        MockInteractions.tap(element.$.addButton);
-        flushAsynchronousOperations();
-
-        assert.isTrue(dispatchStub.called);
-        assert.equal(dispatchStub.lastCall.args[0], 'test');
-        assert.equal(element._newValue, '');
-      });
-    });
-
-    test('deleting', () => {
       dispatchStub = sandbox.stub(element, '_dispatchChanged');
-      element.pluginOption = {info: {values: ['test', 'test2']}};
-      flushAsynchronousOperations();
+    });
 
-      const rows = getAll('.existingItems .row');
-      assert.equal(rows.length, 2);
-      const button = rows[0].querySelector('gr-button');
-
-      MockInteractions.tap(button);
+    test('with enter', () => {
+      element._newValue = '';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
       flushAsynchronousOperations();
 
       assert.isFalse(dispatchStub.called);
-      element.pluginOption.info.editable = true;
-      element.notifyPath('pluginOption.info.editable');
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(button);
+      element._newValue = 'test';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
       flushAsynchronousOperations();
 
       assert.isTrue(dispatchStub.called);
-      assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element._newValue, '');
     });
 
-    test('_dispatchChanged', () => {
-      const eventStub = sandbox.stub(element, 'dispatchEvent');
-      element._dispatchChanged(['new-test-value']);
+    test('with add btn', () => {
+      element._newValue = '';
+      MockInteractions.tap(element.$.addButton);
+      flushAsynchronousOperations();
 
-      assert.isTrue(eventStub.called);
-      const {detail} = eventStub.lastCall.args[0];
-      assert.equal(detail._key, 'test-key');
-      assert.deepEqual(detail.info, {values: ['new-test-value']});
-      assert.equal(detail.notifyPath, 'test-key.values');
+      assert.isFalse(dispatchStub.called);
+      element._newValue = 'test';
+      MockInteractions.tap(element.$.addButton);
+      flushAsynchronousOperations();
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element._newValue, '');
     });
   });
+
+  test('deleting', () => {
+    dispatchStub = sandbox.stub(element, '_dispatchChanged');
+    element.pluginOption = {info: {values: ['test', 'test2']}};
+    flushAsynchronousOperations();
+
+    const rows = getAll('.existingItems .row');
+    assert.equal(rows.length, 2);
+    const button = rows[0].querySelector('gr-button');
+
+    MockInteractions.tap(button);
+    flushAsynchronousOperations();
+
+    assert.isFalse(dispatchStub.called);
+    element.pluginOption.info.editable = true;
+    element.notifyPath('pluginOption.info.editable');
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(button);
+    flushAsynchronousOperations();
+
+    assert.isTrue(dispatchStub.called);
+    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+  });
+
+  test('_dispatchChanged', () => {
+    const eventStub = sandbox.stub(element, 'dispatchEvent');
+    element._dispatchChanged(['new-test-value']);
+
+    assert.isTrue(eventStub.called);
+    const {detail} = eventStub.lastCall.args[0];
+    assert.equal(detail._key, 'test-key');
+    assert.deepEqual(detail.info, {values: ['new-test-value']});
+    assert.equal(detail.notifyPath, 'test-key.values');
+  });
+});
 </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
deleted file mode 100644
index ee5dd83..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
+++ /dev/null
@@ -1,87 +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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-plugin-list">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles">
-      .placeholder {
-        color: var(--deemphasized-text-color);
-      }
-    </style>
-    <gr-list-view
-        filter="[[_filter]]"
-        items-per-page="[[_pluginsPerPage]]"
-        items="[[_plugins]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        path="[[_path]]">
-      <table id="list" class="genericList">
-        <tr class="headerRow">
-          <th class="name topHeader">Plugin Name</th>
-          <th class="version topHeader">Version</th>
-          <th class="apiVersion topHeader">API Version</th>
-          <th class="status topHeader">Status</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
-          <template is="dom-repeat" items="[[_shownPlugins]]">
-            <tr class="table">
-              <td class="name">
-                <template is="dom-if" if="[[item.index_url]]">
-                  <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
-                </template>
-                <template is="dom-if" if="[[!item.index_url]]">
-                  [[item.id]]
-                </template>
-              </td>
-              <td class="version">
-                <template is="dom-if" if="[[item.version]]">
-                  [[item.version]]
-                </template>
-                <template is="dom-if" if="[[!item.version]]">
-                  <span class="placeholder">--</span>
-                </template>
-              </td>
-              <td class="apiVersion">
-                <template is="dom-if" if="[[item.api_version]]">
-                  [[item.api_version]]
-                </template>
-                <template is="dom-if" if="[[!item.api_version]]">
-                  <span class="placeholder">--</span>
-                </template>
-              </td>
-              <td class="status">[[_status(item)]]</td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </gr-list-view>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-plugin-list.js"></script>
-</dom-module>
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..154af6e 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
@@ -14,16 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-plugin-list',
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-list_html.js';
+import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
 
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
+/**
+ * @appliesMixin ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrPluginList extends mixinBehaviors( [
+  ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-plugin-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -61,52 +82,57 @@
         type: String,
         value: '',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.ListViewBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'Plugins'},
+      composed: true, bubbles: true,
+    }));
+  }
 
-    attached() {
-      this.fire('title-change', {title: 'Plugins'});
-    },
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
 
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
+    return this._getPlugins(this._filter, this._pluginsPerPage,
+        this._offset);
+  }
 
-      return this._getPlugins(this._filter, this._pluginsPerPage,
-          this._offset);
-    },
+  _getPlugins(filter, pluginsPerPage, offset) {
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
+    return this.$.restAPI.getPlugins(filter, pluginsPerPage, offset, errFn)
+        .then(plugins => {
+          if (!plugins) {
+            this._plugins = [];
+            return;
+          }
+          this._plugins = Object.keys(plugins)
+              .map(key => {
+                const plugin = plugins[key];
+                plugin.name = key;
+                return plugin;
+              });
+          this._loading = false;
+        });
+  }
 
-    _getPlugins(filter, pluginsPerPage, offset) {
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-      return this.$.restAPI.getPlugins(filter, pluginsPerPage, offset, errFn)
-          .then(plugins => {
-            if (!plugins) {
-              this._plugins = [];
-              return;
-            }
-            this._plugins = Object.keys(plugins)
-                .map(key => {
-                  const plugin = plugins[key];
-                  plugin.name = key;
-                  return plugin;
-                });
-            this._loading = false;
-          });
-    },
+  _status(item) {
+    return item.disabled === true ? 'Disabled' : 'Enabled';
+  }
 
-    _status(item) {
-      return item.disabled === true ? 'Disabled' : 'Enabled';
-    },
+  _computePluginUrl(id) {
+    return this.getUrl('/', id);
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
new file mode 100644
index 0000000..bd2bea3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    .placeholder {
+      color: var(--deemphasized-text-color);
+    }
+  </style>
+  <gr-list-view
+    filter="[[_filter]]"
+    items-per-page="[[_pluginsPerPage]]"
+    items="[[_plugins]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Plugin Name</th>
+          <th class="version topHeader">Version</th>
+          <th class="apiVersion topHeader">API Version</th>
+          <th class="status topHeader">Status</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownPlugins]]">
+          <tr class="table">
+            <td class="name">
+              <template is="dom-if" if="[[item.index_url]]">
+                <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
+              </template>
+              <template is="dom-if" if="[[!item.index_url]]">
+                [[item.id]]
+              </template>
+            </td>
+            <td class="version">
+              <template is="dom-if" if="[[item.version]]">
+                [[item.version]]
+              </template>
+              <template is="dom-if" if="[[!item.version]]">
+                <span class="placeholder">--</span>
+              </template>
+            </td>
+            <td class="apiVersion">
+              <template is="dom-if" if="[[item.api_version]]">
+                [[item.api_version]]
+              </template>
+              <template is="dom-if" if="[[!item.api_version]]">
+                <span class="placeholder">--</span>
+              </template>
+            </td>
+            <td class="status">[[_status(item)]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 2485687..6ca8afa4 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-plugin-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,174 +32,178 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const pluginGenerator = () => {
-    const plugin = {
-      id: `test${++counter}`,
-      disabled: false,
-    };
-
-    if (counter !== 2) {
-      plugin.index_url = `plugins/test${counter}/`;
-    }
-    if (counter !== 3) {
-      plugin.version = `version-${counter}`;
-    }
-    if (counter !== 4) {
-      plugin.api_version = `api-version-${counter}`;
-    }
-    return plugin;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-plugin-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+let counter;
+const pluginGenerator = () => {
+  const plugin = {
+    id: `test${++counter}`,
+    disabled: false,
   };
 
-  suite('gr-plugin-list tests', () => {
-    let element;
-    let plugins;
-    let sandbox;
-    let value;
+  if (counter !== 2) {
+    plugin.index_url = `plugins/test${counter}/`;
+  }
+  if (counter !== 3) {
+    plugin.version = `version-${counter}`;
+  }
+  if (counter !== 4) {
+    plugin.api_version = `api-version-${counter}`;
+  }
+  return plugin;
+};
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      counter = 0;
-    });
+suite('gr-plugin-list tests', () => {
+  let element;
+  let plugins;
+  let sandbox;
+  let value;
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    counter = 0;
+  });
 
-    suite('list with plugins', () => {
-      setup(done => {
-        plugins = _.times(26, pluginGenerator);
+  teardown(() => {
+    sandbox.restore();
+  });
 
-        stub('gr-rest-api-interface', {
-          getPlugins(num, offset) {
-            return Promise.resolve(plugins);
-          },
-        });
+  suite('list with plugins', () => {
+    setup(done => {
+      plugins = _.times(26, pluginGenerator);
 
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('plugin in the list is formatted correctly', done => {
-        flush(() => {
-          assert.equal(element._plugins[4].id, 'test5');
-          assert.equal(element._plugins[4].index_url, 'plugins/test5/');
-          assert.equal(element._plugins[4].version, 'version-5');
-          assert.equal(element._plugins[4].api_version, 'api-version-5');
-          assert.equal(element._plugins[4].disabled, false);
-          done();
-        });
-      });
-
-      test('with and without urls', done => {
-        flush(() => {
-          const names = Polymer.dom(element.root).querySelectorAll('.name');
-          assert.isOk(names[1].querySelector('a'));
-          assert.equal(names[1].querySelector('a').innerText, 'test1');
-          assert.isNotOk(names[2].querySelector('a'));
-          assert.equal(names[2].innerText, 'test2');
-          done();
-        });
-      });
-
-      test('versions', done => {
-        flush(() => {
-          const versions = Polymer.dom(element.root).querySelectorAll('.version');
-          assert.equal(versions[2].innerText, 'version-2');
-          assert.equal(versions[3].innerText, '--');
-          done();
-        });
-      });
-
-      test('api versions', done => {
-        flush(() => {
-          const apiVersions = Polymer.dom(element.root).querySelectorAll(
-              '.apiVersion');
-          assert.equal(apiVersions[3].innerText, 'api-version-3');
-          assert.equal(apiVersions[4].innerText, '--');
-          done();
-        });
-      });
-
-      test('_shownPlugins', () => {
-        assert.equal(element._shownPlugins.length, 25);
-      });
-    });
-
-    suite('list with less then 26 plugins', () => {
-      setup(done => {
-        plugins = _.times(25, pluginGenerator);
-
-        stub('gr-rest-api-interface', {
-          getPlugins(num, offset) {
-            return Promise.resolve(plugins);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('_shownPlugins', () => {
-        assert.equal(element._shownPlugins.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getPlugins', () => {
+      stub('gr-rest-api-interface', {
+        getPlugins(num, offset) {
           return Promise.resolve(plugins);
-        });
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value).then(() => {
-          assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
-              25);
-          assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
-              25);
-          done();
-        });
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('plugin in the list is formatted correctly', done => {
+      flush(() => {
+        assert.equal(element._plugins[4].id, 'test5');
+        assert.equal(element._plugins[4].index_url, 'plugins/test5/');
+        assert.equal(element._plugins[4].version, 'version-5');
+        assert.equal(element._plugins[4].api_version, 'api-version-5');
+        assert.equal(element._plugins[4].disabled, false);
+        done();
       });
     });
 
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._plugins = _.times(25, pluginGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    test('with and without urls', done => {
+      flush(() => {
+        const names = dom(element.root).querySelectorAll('.name');
+        assert.isOk(names[1].querySelector('a'));
+        assert.equal(names[1].querySelector('a').innerText, 'test1');
+        assert.isNotOk(names[2].querySelector('a'));
+        assert.equal(names[2].innerText, 'test2');
+        done();
       });
     });
 
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(element.$.restAPI, 'getPlugins',
-            (filter, pluginsPerPage, opt_offset, errFn) => {
-              errFn(response);
-            });
+    test('versions', done => {
+      flush(() => {
+        const versions = Polymer.dom(element.root).querySelectorAll('.version');
+        assert.equal(versions[2].innerText, 'version-2');
+        assert.equal(versions[3].innerText, '--');
+        done();
+      });
+    });
 
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
+    test('api versions', done => {
+      flush(() => {
+        const apiVersions = Polymer.dom(element.root).querySelectorAll(
+            '.apiVersion');
+        assert.equal(apiVersions[3].innerText, 'api-version-3');
+        assert.equal(apiVersions[4].innerText, '--');
+        done();
+      });
+    });
 
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value);
+    test('_shownPlugins', () => {
+      assert.equal(element._shownPlugins.length, 25);
+    });
+  });
+
+  suite('list with less then 26 plugins', () => {
+    setup(done => {
+      plugins = _.times(25, pluginGenerator);
+
+      stub('gr-rest-api-interface', {
+        getPlugins(num, offset) {
+          return Promise.resolve(plugins);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('_shownPlugins', () => {
+      assert.equal(element._shownPlugins.length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    test('_paramsChanged', done => {
+      sandbox.stub(
+          element.$.restAPI,
+          'getPlugins',
+          () => Promise.resolve(plugins));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
+            'test');
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
+            25);
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
+            25);
+        done();
       });
     });
   });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._plugins = _.times(25, pluginGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      const response = {status: 404};
+      sandbox.stub(element.$.restAPI, 'getPlugins',
+          (filter, pluginsPerPage, opt_offset, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value);
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index ea12908..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
+++ /dev/null
@@ -1,135 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-access-section/gr-access-section.html">
-
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-repo-access">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-subpage-styles">
-      gr-button,
-      #inheritsFrom,
-      #editInheritFromInput,
-      .editing #inheritFromName,
-      .weblinks,
-      .editing .invisible{
-        display: none;
-      }
-      #inheritsFrom.show {
-        display: flex;
-        min-height: 2em;
-        align-items: center;
-      }
-      .weblink {
-        margin-right: var(--spacing-xs);
-      }
-      .weblinks.show,
-      .referenceContainer {
-        display: block;
-      }
-      .rightsText {
-        margin-right: var(--spacing-s);
-      }
-
-      .editing gr-button,
-      .admin #editBtn {
-        display: inline-block;
-        margin: var(--spacing-l) 0;
-      }
-      .editing #editInheritFromInput {
-        display: inline-block;
-      }
-    </style>
-    <style include="gr-menu-page-styles"></style>
-    <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-        Loading...
-      </div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <h3 id="inheritsFrom" class$="[[_computeShowInherit(_inheritsFrom)]]">
-          <span class="rightsText">Rights Inherit From</span>
-          <a
-              href$="[[_computeParentHref(_inheritsFrom.name)]]"
-              rel="noopener"
-              id="inheritFromName">
-            [[_inheritsFrom.name]]</a>
-          <gr-autocomplete
-              id="editInheritFromInput"
-              text="{{_inheritFromFilter}}"
-              query="[[_query]]"
-              on-commit="_handleUpdateInheritFrom"></gr-autocomplete>
-        </h3>
-        <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
-          History:
-          <template is="dom-repeat" items="[[_weblinks]]" as="link">
-            <a href="[[link.url]]" class="weblink" rel="noopener" target="[[link.target]]">
-              [[link.name]]
-            </a>
-          </template>
-        </div>
-        <gr-button id="editBtn"
-            on-click="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
-        <gr-button id="saveBtn"
-            primary
-            class$="[[_computeSaveBtnClass(_ownerOf)]]"
-            on-click="_handleSave"
-            disabled$="[[!_modified]]">Save</gr-button>
-        <gr-button id="saveReviewBtn"
-            primary
-            class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
-            on-click="_handleSaveForReview"
-            disabled$="[[!_modified]]">Save for review</gr-button>
-        <template
-            is="dom-repeat"
-            items="{{_sections}}"
-            initial-count="5"
-            target-framerate="60"
-            as="section">
-          <gr-access-section
-              capabilities="[[_capabilities]]"
-              section="{{section}}"
-              labels="[[_labels]]"
-              can-upload="[[_canUpload]]"
-              editing="[[_editing]]"
-              owner-of="[[_ownerOf]]"
-              groups="[[_groups]]"
-              on-added-section-removed="_handleAddedSectionRemoved"></gr-access-section>
-        </template>
-        <div class="referenceContainer">
-          <gr-button id="addReferenceBtn"
-              on-click="_handleCreateSection">Add Reference</gr-button>
-        </div>
-      </div>
-    </main>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-access.js"></script>
-</dom-module>
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..9aa81f8 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
@@ -14,64 +14,90 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const Defs = {};
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-access-section/gr-access-section.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-access_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  const NOTHING_TO_SAVE = 'No changes to save.';
+const Defs = {};
 
-  const MAX_AUTOCOMPLETE_RESULTS = 50;
+const NOTHING_TO_SAVE = 'No changes to save.';
 
-  /**
-   * Fired when save is a no-op
-   *
-   * @event show-alert
-   */
+const MAX_AUTOCOMPLETE_RESULTS = 50;
 
-  /**
-   * @typedef {{
-   *    value: !Object,
-   * }}
-   */
-  Defs.rule;
+/**
+ * Fired when save is a no-op
+ *
+ * @event show-alert
+ */
 
-  /**
-   * @typedef {{
-   *    rules: !Object<string, Defs.rule>
-   * }}
-   */
-  Defs.permission;
+/**
+ * @typedef {{
+ *    value: !Object,
+ * }}
+ */
+Defs.rule;
 
-  /**
-   * Can be an empty object or consist of permissions.
-   *
-   * @typedef {{
-   *    permissions: !Object<string, Defs.permission>
-   * }}
-   */
-  Defs.permissions;
+/**
+ * @typedef {{
+ *    rules: !Object<string, Defs.rule>
+ * }}
+ */
+Defs.permission;
 
-  /**
-   * Can be an empty object or consist of permissions.
-   *
-   * @typedef {!Object<string, Defs.permissions>}
-   */
-  Defs.sections;
+/**
+ * Can be an empty object or consist of permissions.
+ *
+ * @typedef {{
+ *    permissions: !Object<string, Defs.permission>
+ * }}
+ */
+Defs.permissions;
 
-  /**
-   * @typedef {{
-   *    remove: !Defs.sections,
-   *    add: !Defs.sections,
-   * }}
-   */
-  Defs.projectAccessInput;
+/**
+ * Can be an empty object or consist of permissions.
+ *
+ * @typedef {!Object<string, Defs.permissions>}
+ */
+Defs.sections;
 
+/**
+ * @typedef {{
+ *    remove: !Defs.sections,
+ *    add: !Defs.sections,
+ * }}
+ */
+Defs.projectAccessInput;
 
-  Polymer({
-    is: 'gr-repo-access',
+/**
+ * @extends Polymer.Element
+ */
+class GrRepoAccess extends mixinBehaviors( [
+  AccessBehavior,
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-repo-access'; }
+
+  static get properties() {
+    return {
       repo: {
         type: String,
         observer: '_repoChanged',
@@ -112,364 +138,387 @@
         type: Boolean,
         value: true,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.AccessBehavior,
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-modified',
+        () =>
+          this._handleAccessModified());
+  }
 
-    listeners: {
-      'access-modified': '_handleAccessModified',
-    },
+  _handleAccessModified() {
+    this._modified = true;
+  }
 
-    _handleAccessModified() {
-      this._modified = true;
-    },
+  /**
+   * @param {string} repo
+   * @return {!Promise}
+   */
+  _repoChanged(repo) {
+    this._loading = true;
 
-    /**
-     * @param {string} repo
-     * @return {!Promise}
-     */
-    _repoChanged(repo) {
-      this._loading = true;
+    if (!repo) { return Promise.resolve(); }
 
-      if (!repo) { return Promise.resolve(); }
+    return this._reload(repo);
+  }
 
-      return this._reload(repo);
-    },
+  _reload(repo) {
+    const promises = [];
 
-    _reload(repo) {
-      const promises = [];
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    this._editing = false;
 
-      this._editing = false;
+    // Always reset sections when a project changes.
+    this._sections = [];
+    promises.push(this.$.restAPI.getRepoAccessRights(repo, errFn)
+        .then(res => {
+          if (!res) { return Promise.resolve(); }
 
-      // Always reset sections when a project changes.
-      this._sections = [];
-      promises.push(this.$.restAPI.getRepoAccessRights(repo, errFn)
-          .then(res => {
-            if (!res) { return Promise.resolve(); }
+          // Keep a copy of the original inherit from values separate from
+          // the ones data bound to gr-autocomplete, so the original value
+          // can be restored if the user cancels.
+          this._inheritsFrom = res.inherits_from ? Object.assign({},
+              res.inherits_from) : null;
+          this._originalInheritsFrom = res.inherits_from ? Object.assign({},
+              res.inherits_from) : null;
+          // Initialize the filter value so when the user clicks edit, the
+          // current value appears. If there is no parent repo, it is
+          // initialized as an empty string.
+          this._inheritFromFilter = res.inherits_from ?
+            this._inheritsFrom.name : '';
+          this._local = res.local;
+          this._groups = res.groups;
+          this._weblinks = res.config_web_links || [];
+          this._canUpload = res.can_upload;
+          this._ownerOf = res.owner_of || [];
+          return this.toSortedArray(this._local);
+        }));
 
-            // Keep a copy of the original inherit from values separate from
-            // the ones data bound to gr-autocomplete, so the original value
-            // can be restored if the user cancels.
-            this._inheritsFrom = res.inherits_from ? Object.assign({},
-                res.inherits_from) : null;
-            this._originalInheritsFrom = res.inherits_from ? Object.assign({},
-                res.inherits_from) : null;
-            // Initialize the filter value so when the user clicks edit, the
-            // current value appears. If there is no parent repo, it is
-            // initialized as an empty string.
-            this._inheritFromFilter = res.inherits_from ?
-              this._inheritsFrom.name : '';
-            this._local = res.local;
-            this._groups = res.groups;
-            this._weblinks = res.config_web_links || [];
-            this._canUpload = res.can_upload;
-            this._ownerOf = res.owner_of || [];
-            return this.toSortedArray(this._local);
-          }));
+    promises.push(this.$.restAPI.getCapabilities(errFn)
+        .then(res => {
+          if (!res) { return Promise.resolve(); }
 
-      promises.push(this.$.restAPI.getCapabilities(errFn)
-          .then(res => {
-            if (!res) { return Promise.resolve(); }
+          return res;
+        }));
 
-            return res;
-          }));
+    promises.push(this.$.restAPI.getRepo(repo, errFn)
+        .then(res => {
+          if (!res) { return Promise.resolve(); }
 
-      promises.push(this.$.restAPI.getRepo(repo, errFn)
-          .then(res => {
-            if (!res) { return Promise.resolve(); }
+          return res.labels;
+        }));
 
-            return res.labels;
-          }));
+    return Promise.all(promises).then(([sections, capabilities, labels]) => {
+      this._capabilities = capabilities;
+      this._labels = labels;
+      this._sections = sections;
+      this._loading = false;
+    });
+  }
 
-      return Promise.all(promises).then(([sections, capabilities, labels]) => {
-        this._capabilities = capabilities;
-        this._labels = labels;
-        this._sections = sections;
-        this._loading = false;
-      });
-    },
+  _handleUpdateInheritFrom(e) {
+    if (!this._inheritsFrom) {
+      this._inheritsFrom = {};
+    }
+    this._inheritsFrom.id = e.detail.value;
+    this._inheritsFrom.name = this._inheritFromFilter;
+    this._handleAccessModified();
+  }
 
-    _handleUpdateInheritFrom(e) {
-      if (!this._inheritsFrom) {
-        this._inheritsFrom = {};
+  _getInheritFromSuggestions() {
+    return this.$.restAPI.getRepos(
+        this._inheritFromFilter,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(response => {
+          const projects = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            projects.push({
+              name: response[key].name,
+              value: response[key].id,
+            });
+          }
+          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.
+    if (!editingOld || editing) { return; }
+    // Remove any unsaved but added refs.
+    if (this._sections) {
+      this._sections = this._sections.filter(p => !p.value.added);
+    }
+    // Restore inheritFrom.
+    if (this._inheritsFrom) {
+      this._inheritsFrom = Object.assign({}, this._originalInheritsFrom);
+      this._inheritFromFilter = this._inheritsFrom.name;
+    }
+    for (const key of Object.keys(this._local)) {
+      if (this._local[key].added) {
+        delete this._local[key];
       }
-      this._inheritsFrom.id = e.detail.value;
-      this._inheritsFrom.name = this._inheritFromFilter;
-      this._handleAccessModified();
-    },
+    }
+  }
 
-    _getInheritFromSuggestions() {
-      return this.$.restAPI.getRepos(
-          this._inheritFromFilter,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(response => {
-            const projects = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              projects.push({
-                name: response[key].name,
-                value: response[key].id,
-              });
-            }
-            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.
-      if (!editingOld || editing) { return; }
-      // Remove any unsaved but added refs.
-      if (this._sections) {
-        this._sections = this._sections.filter(p => !p.value.added);
-      }
-      // Restore inheritFrom.
-      if (this._inheritsFrom) {
-        this._inheritsFrom = Object.assign({}, this._originalInheritsFrom);
-        this._inheritFromFilter = this._inheritsFrom.name;
-      }
-      for (const key of Object.keys(this._local)) {
-        if (this._local[key].added) {
-          delete this._local[key];
-        }
-      }
-    },
-
-    /**
-     * @param {!Defs.projectAccessInput} addRemoveObj
-     * @param {!Array} path
-     * @param {string} type add or remove
-     * @param {!Object=} opt_value value to add if the type is 'add'
-     * @return {!Defs.projectAccessInput}
-     */
-    _updateAddRemoveObj(addRemoveObj, path, type, opt_value) {
-      let curPos = addRemoveObj[type];
-      for (const item of path) {
-        if (!curPos[item]) {
-          if (item === path[path.length - 1] && type === 'remove') {
-            if (path[path.length - 2] === 'permissions') {
-              curPos[item] = {rules: {}};
-            } else if (path.length === 1) {
-              curPos[item] = {permissions: {}};
-            } else {
-              curPos[item] = {};
-            }
-          } else if (item === path[path.length - 1] && type === 'add') {
-            curPos[item] = opt_value;
+  /**
+   * @param {!Defs.projectAccessInput} addRemoveObj
+   * @param {!Array} path
+   * @param {string} type add or remove
+   * @param {!Object=} opt_value value to add if the type is 'add'
+   * @return {!Defs.projectAccessInput}
+   */
+  _updateAddRemoveObj(addRemoveObj, path, type, opt_value) {
+    let curPos = addRemoveObj[type];
+    for (const item of path) {
+      if (!curPos[item]) {
+        if (item === path[path.length - 1] && type === 'remove') {
+          if (path[path.length - 2] === 'permissions') {
+            curPos[item] = {rules: {}};
+          } else if (path.length === 1) {
+            curPos[item] = {permissions: {}};
           } else {
             curPos[item] = {};
           }
+        } else if (item === path[path.length - 1] && type === 'add') {
+          curPos[item] = opt_value;
+        } else {
+          curPos[item] = {};
         }
-        curPos = curPos[item];
       }
-      return addRemoveObj;
-    },
+      curPos = curPos[item];
+    }
+    return addRemoveObj;
+  }
 
-    /**
-     * Used to recursively remove any objects with a 'deleted' bit.
-     */
-    _recursivelyRemoveDeleted(obj) {
-      for (const k in obj) {
-        if (!obj.hasOwnProperty(k)) { continue; }
+  /**
+   * Used to recursively remove any objects with a 'deleted' bit.
+   */
+  _recursivelyRemoveDeleted(obj) {
+    for (const k in obj) {
+      if (!obj.hasOwnProperty(k)) { continue; }
 
-        if (typeof obj[k] == 'object') {
-          if (obj[k].deleted) {
-            delete obj[k];
-            return;
+      if (typeof obj[k] == 'object') {
+        if (obj[k].deleted) {
+          delete obj[k];
+          return;
+        }
+        this._recursivelyRemoveDeleted(obj[k]);
+      }
+    }
+  }
+
+  _recursivelyUpdateAddRemoveObj(obj, addRemoveObj, path = []) {
+    for (const k in obj) {
+      if (!obj.hasOwnProperty(k)) { continue; }
+      if (typeof obj[k] == 'object') {
+        const updatedId = obj[k].updatedId;
+        const ref = updatedId ? updatedId : k;
+        if (obj[k].deleted) {
+          this._updateAddRemoveObj(addRemoveObj,
+              path.concat(k), 'remove');
+          continue;
+        } else if (obj[k].modified) {
+          this._updateAddRemoveObj(addRemoveObj,
+              path.concat(k), 'remove');
+          this._updateAddRemoveObj(addRemoveObj, path.concat(ref), 'add',
+              obj[k]);
+          /* Special case for ref changes because they need to be added and
+           removed in a different way. The new ref needs to include all
+           changes but also the initial state. To do this, instead of
+           continuing with the same recursion, just remove anything that is
+           deleted in the current state. */
+          if (updatedId && updatedId !== k) {
+            this._recursivelyRemoveDeleted(addRemoveObj.add[updatedId]);
           }
-          this._recursivelyRemoveDeleted(obj[k]);
+          continue;
+        } else if (obj[k].added) {
+          this._updateAddRemoveObj(addRemoveObj,
+              path.concat(ref), 'add', obj[k]);
+          /**
+           * As add / delete both can happen in the new section,
+           * so here to make sure it will remove the deleted ones.
+           *
+           * @see Issue 11339
+           */
+          this._recursivelyRemoveDeleted(addRemoveObj.add[k]);
+          continue;
         }
+        this._recursivelyUpdateAddRemoveObj(obj[k], addRemoveObj,
+            path.concat(k));
       }
-    },
+    }
+  }
 
-    _recursivelyUpdateAddRemoveObj(obj, addRemoveObj, path = []) {
-      for (const k in obj) {
-        if (!obj.hasOwnProperty(k)) { continue; }
-        if (typeof obj[k] == 'object') {
-          const updatedId = obj[k].updatedId;
-          const ref = updatedId ? updatedId : k;
-          if (obj[k].deleted) {
-            this._updateAddRemoveObj(addRemoveObj,
-                path.concat(k), 'remove');
-            continue;
-          } else if (obj[k].modified) {
-            this._updateAddRemoveObj(addRemoveObj,
-                path.concat(k), 'remove');
-            this._updateAddRemoveObj(addRemoveObj, path.concat(ref), 'add',
-                obj[k]);
-            /* Special case for ref changes because they need to be added and
-             removed in a different way. The new ref needs to include all
-             changes but also the initial state. To do this, instead of
-             continuing with the same recursion, just remove anything that is
-             deleted in the current state. */
-            if (updatedId && updatedId !== k) {
-              this._recursivelyRemoveDeleted(addRemoveObj.add[updatedId]);
-            }
-            continue;
-          } else if (obj[k].added) {
-            this._updateAddRemoveObj(addRemoveObj,
-                path.concat(ref), 'add', obj[k]);
-            /**
-             * As add / delete both can happen in the new section,
-             * so here to make sure it will remove the deleted ones.
-             *
-             * @see Issue 11339
-             */
-            this._recursivelyRemoveDeleted(addRemoveObj.add[k]);
-            continue;
+  /**
+   * Returns an object formatted for saving or submitting access changes for
+   * review
+   *
+   * @return {!Defs.projectAccessInput}
+   */
+  _computeAddAndRemove() {
+    const addRemoveObj = {
+      add: {},
+      remove: {},
+    };
+
+    const originalInheritsFromId = this._originalInheritsFrom ?
+      this.singleDecodeURL(this._originalInheritsFrom.id) :
+      null;
+    const inheritsFromId = this._inheritsFrom ?
+      this.singleDecodeURL(this._inheritsFrom.id) :
+      null;
+
+    const inheritFromChanged =
+        // Inherit from changed
+        (originalInheritsFromId &&
+            originalInheritsFromId !== inheritsFromId) ||
+        // Inherit from added (did not have one initially);
+        (!originalInheritsFromId && inheritsFromId);
+
+    this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
+
+    if (inheritFromChanged) {
+      addRemoveObj.parent = inheritsFromId;
+    }
+    return addRemoveObj;
+  }
+
+  _handleCreateSection() {
+    let newRef = 'refs/for/*';
+    // Avoid using an already used key for the placeholder, since it
+    // immediately gets added to an object.
+    while (this._local[newRef]) {
+      newRef = `${newRef}*`;
+    }
+    const section = {permissions: {}, added: true};
+    this.push('_sections', {id: newRef, value: section});
+    this.set(['_local', newRef], section);
+    flush();
+    dom(this.root).querySelector('gr-access-section:last-of-type')
+        .editReference();
+  }
+
+  _getObjforSave() {
+    const addRemoveObj = this._computeAddAndRemove();
+    // If there are no changes, don't actually save.
+    if (!Object.keys(addRemoveObj.add).length &&
+        !Object.keys(addRemoveObj.remove).length &&
+        !addRemoveObj.parent) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: NOTHING_TO_SAVE},
+        bubbles: true,
+        composed: true,
+      }));
+      return;
+    }
+    const obj = {
+      add: addRemoveObj.add,
+      remove: addRemoveObj.remove,
+    };
+    if (addRemoveObj.parent) {
+      obj.parent = addRemoveObj.parent;
+    }
+    return obj;
+  }
+
+  _handleSave(e) {
+    const obj = this._getObjforSave();
+    if (!obj) { return; }
+    const button = e && e.target;
+    if (button) {
+      button.loading = true;
+    }
+    return this.$.restAPI.setRepoAccessRights(this.repo, obj)
+        .then(() => {
+          this._reload(this.repo);
+        })
+        .finally(() => {
+          this._modified = false;
+          if (button) {
+            button.loading = false;
           }
-          this._recursivelyUpdateAddRemoveObj(obj[k], addRemoveObj,
-              path.concat(k));
-        }
-      }
-    },
+        });
+  }
 
-    /**
-     * Returns an object formatted for saving or submitting access changes for
-     * review
-     *
-     * @return {!Defs.projectAccessInput}
-     */
-    _computeAddAndRemove() {
-      const addRemoveObj = {
-        add: {},
-        remove: {},
-      };
+  _handleSaveForReview(e) {
+    const obj = this._getObjforSave();
+    if (!obj) { return; }
+    const button = e && e.target;
+    if (button) {
+      button.loading = true;
+    }
+    return this.$.restAPI
+        .setRepoAccessRightsForReview(this.repo, obj)
+        .then(change => {
+          GerritNav.navigateToChange(change);
+        })
+        .finally(() => {
+          this._modified = false;
+          if (button) {
+            button.loading = false;
+          }
+        });
+  }
 
-      const originalInheritsFromId = this._originalInheritsFrom ?
-        this.singleDecodeURL(this._originalInheritsFrom.id) :
-        null;
-      const inheritsFromId = this._inheritsFrom ?
-        this.singleDecodeURL(this._inheritsFrom.id) :
-        null;
+  _computeSaveReviewBtnClass(canUpload) {
+    return !canUpload ? 'invisible' : '';
+  }
 
-      const inheritFromChanged =
-          // Inherit from changed
-          (originalInheritsFromId
-              && originalInheritsFromId !== inheritsFromId) ||
-          // Inherit from added (did not have one initially);
-          (!originalInheritsFromId && inheritsFromId);
+  _computeSaveBtnClass(ownerOf) {
+    return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
+  }
 
-      this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
+  _computeMainClass(ownerOf, canUpload, editing) {
+    const classList = [];
+    if (ownerOf && ownerOf.length > 0 || canUpload) {
+      classList.push('admin');
+    }
+    if (editing) {
+      classList.push('editing');
+    }
+    return classList.join(' ');
+  }
 
-      if (inheritFromChanged) {
-        addRemoveObj.parent = inheritsFromId;
-      }
-      return addRemoveObj;
-    },
+  _computeParentHref(repoName) {
+    return this.getBaseUrl() +
+        `/admin/repos/${this.encodeURL(repoName, true)},access`;
+  }
+}
 
-    _handleCreateSection() {
-      let newRef = 'refs/for/*';
-      // Avoid using an already used key for the placeholder, since it
-      // immediately gets added to an object.
-      while (this._local[newRef]) {
-        newRef = `${newRef}*`;
-      }
-      const section = {permissions: {}, added: true};
-      this.push('_sections', {id: newRef, value: section});
-      this.set(['_local', newRef], section);
-      Polymer.dom.flush();
-      Polymer.dom(this.root).querySelector('gr-access-section:last-of-type')
-          .editReference();
-    },
-
-    _getObjforSave() {
-      const addRemoveObj = this._computeAddAndRemove();
-      // If there are no changes, don't actually save.
-      if (!Object.keys(addRemoveObj.add).length &&
-          !Object.keys(addRemoveObj.remove).length &&
-          !addRemoveObj.parent) {
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
-        }));
-        return;
-      }
-      const obj = {
-        add: addRemoveObj.add,
-        remove: addRemoveObj.remove,
-      };
-      if (addRemoveObj.parent) {
-        obj.parent = addRemoveObj.parent;
-      }
-      return obj;
-    },
-
-    _handleSave() {
-      const obj = this._getObjforSave();
-      if (!obj) { return; }
-      return this.$.restAPI.setRepoAccessRights(this.repo, obj)
-          .then(() => {
-            this._reload(this.repo);
-          });
-    },
-
-    _handleSaveForReview() {
-      const obj = this._getObjforSave();
-      if (!obj) { return; }
-      return this.$.restAPI.setRepoAccessRightsForReview(this.repo, obj)
-          .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 = [];
-      if (ownerOf && ownerOf.length > 0 || canUpload) {
-        classList.push('admin');
-      }
-      if (editing) {
-        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_html.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
new file mode 100644
index 0000000..5f0739a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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,
+    #editInheritFromInput,
+    .editing #inheritFromName,
+    .weblinks,
+    .editing .invisible {
+      display: none;
+    }
+    #inheritsFrom.show {
+      display: flex;
+      min-height: 2em;
+      align-items: center;
+    }
+    .weblink {
+      margin-right: var(--spacing-xs);
+    }
+    .weblinks.show,
+    .referenceContainer {
+      display: block;
+    }
+    .rightsText {
+      margin-right: var(--spacing-s);
+    }
+
+    .editing gr-button,
+    .admin #editBtn {
+      display: inline-block;
+      margin: var(--spacing-l) 0;
+    }
+    .editing #editInheritFromInput {
+      display: inline-block;
+    }
+  </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...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h3 id="inheritsFrom" class$="[[_computeShowInherit(_inheritsFrom)]]">
+        <span class="rightsText">Rights Inherit From</span>
+        <a
+          href$="[[_computeParentHref(_inheritsFrom.name)]]"
+          rel="noopener"
+          id="inheritFromName"
+        >
+          [[_inheritsFrom.name]]</a
+        >
+        <gr-autocomplete
+          id="editInheritFromInput"
+          text="{{_inheritFromFilter}}"
+          query="[[_query]]"
+          on-commit="_handleUpdateInheritFrom"
+        ></gr-autocomplete>
+      </h3>
+      <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
+        History:
+        <template is="dom-repeat" items="[[_weblinks]]" as="link">
+          <a
+            href="[[link.url]]"
+            class="weblink"
+            rel="noopener"
+            target="[[link.target]]"
+          >
+            [[link.name]]
+          </a>
+        </template>
+      </div>
+      <gr-button id="editBtn" on-click="_handleEdit"
+        >[[_editOrCancel(_editing)]]</gr-button
+      >
+      <gr-button
+        id="saveBtn"
+        primary=""
+        class$="[[_computeSaveBtnClass(_ownerOf)]]"
+        on-click="_handleSave"
+        disabled="[[!_modified]]"
+        >Save</gr-button
+      >
+      <gr-button
+        id="saveReviewBtn"
+        primary=""
+        class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
+        on-click="_handleSaveForReview"
+        disabled="[[!_modified]]"
+        >Save for review</gr-button
+      >
+      <template
+        is="dom-repeat"
+        items="{{_sections}}"
+        initial-count="5"
+        target-framerate="60"
+        as="section"
+      >
+        <gr-access-section
+          capabilities="[[_capabilities]]"
+          section="{{section}}"
+          labels="[[_labels]]"
+          can-upload="[[_canUpload]]"
+          editing="[[_editing]]"
+          owner-of="[[_ownerOf]]"
+          groups="[[_groups]]"
+          on-added-section-removed="_handleAddedSectionRemoved"
+        ></gr-access-section>
+      </template>
+      <div class="referenceContainer">
+        <gr-button id="addReferenceBtn" on-click="_handleCreateSection"
+          >Add Reference</gr-button
+        >
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..7d66cb0 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-repo-access.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,389 +32,450 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-access tests', () => {
-    let element;
-    let sandbox;
-    let repoStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-access.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    const accessRes = {
-      local: {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-                123: {action: 'DENY'},
-              },
+suite('gr-repo-access tests', () => {
+  let element;
+  let sandbox;
+  let repoStub;
+
+  const accessRes = {
+    local: {
+      'refs/*': {
+        permissions: {
+          owner: {
+            rules: {
+              234: {action: 'ALLOW'},
+              123: {action: 'DENY'},
             },
-            read: {
-              rules: {
-                234: {action: 'ALLOW'},
+          },
+          read: {
+            rules: {
+              234: {action: 'ALLOW'},
+            },
+          },
+        },
+      },
+    },
+    groups: {
+      Administrators: {
+        name: 'Administrators',
+      },
+      Maintainers: {
+        name: 'Maintainers',
+      },
+    },
+    config_web_links: [{
+      name: 'gitiles',
+      target: '_blank',
+      url: 'https://my/site/+log/123/project.config',
+    }],
+    can_upload: true,
+  };
+  const accessRes2 = {
+    local: {
+      GLOBAL_CAPABILITIES: {
+        permissions: {
+          accessDatabase: {
+            rules: {
+              group1: {
+                action: 'ALLOW',
               },
             },
           },
         },
       },
-      groups: {
-        Administrators: {
-          name: 'Administrators',
-        },
-        Maintainers: {
-          name: 'Maintainers',
+    },
+  };
+  const repoRes = {
+    labels: {
+      'Code-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',
         },
       },
-      config_web_links: [{
-        name: 'gitiles',
-        target: '_blank',
-        url: 'https://my/site/+log/123/project.config',
-      }],
-      can_upload: true,
-    };
-    const accessRes2 = {
-      local: {
-        GLOBAL_CAPABILITIES: {
-          permissions: {
-            accessDatabase: {
-              rules: {
-                group1: {
-                  action: 'ALLOW',
-                },
-              },
-            },
-          },
-        },
-      },
-    };
-    const repoRes = {
-      labels: {
-        'Code-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',
-          },
-        },
-      },
-    };
+    },
+  };
+  const capabilitiesRes = {
+    accessDatabase: {
+      id: 'accessDatabase',
+      name: 'Access Database',
+    },
+    createAccount: {
+      id: 'createAccount',
+      name: 'Create Account',
+    },
+  };
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+    });
+    repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
+        Promise.resolve(repoRes));
+    element._loading = false;
+    element._ownerOf = [];
+    element._canUpload = false;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_repoChanged called when repo name changes', () => {
+    sandbox.stub(element, '_repoChanged');
+    element.repo = 'New Repo';
+    assert.isTrue(element._repoChanged.called);
+  });
+
+  test('_repoChanged', done => {
+    const accessStub = sandbox.stub(element.$.restAPI,
+        'getRepoAccessRights');
+
+    accessStub.withArgs('New Repo').returns(
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+    accessStub.withArgs('Another New Repo')
+        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = sandbox.stub(element.$.restAPI,
+        'getCapabilities');
+    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
+
+    element._repoChanged('New Repo').then(() => {
+      assert.isTrue(accessStub.called);
+      assert.isTrue(capabilitiesStub.called);
+      assert.isTrue(repoStub.called);
+      assert.isNotOk(element._inheritsFrom);
+      assert.deepEqual(element._local, accessRes.local);
+      assert.deepEqual(element._sections,
+          element.toSortedArray(accessRes.local));
+      assert.deepEqual(element._labels, repoRes.labels);
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('.weblinks')).display,
+      'block');
+      return element._repoChanged('Another New Repo');
+    })
+        .then(() => {
+          assert.deepEqual(element._sections,
+              element.toSortedArray(accessRes2.local));
+          assert.equal(getComputedStyle(element.shadowRoot
+              .querySelector('.weblinks')).display,
+          'none');
+          done();
+        });
+  });
+
+  test('_repoChanged when repo changes to undefined returns', done => {
     const capabilitiesRes = {
       accessDatabase: {
         id: 'accessDatabase',
         name: 'Access Database',
       },
-      createAccount: {
-        id: 'createAccount',
-        name: 'Create Account',
-      },
     };
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
-      repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
-          Promise.resolve(repoRes));
-      element._loading = false;
-      element._ownerOf = [];
-      element._canUpload = false;
+    const accessStub = sandbox.stub(element.$.restAPI, 'getRepoAccessRights')
+        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = sandbox.stub(element.$.restAPI,
+        'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+
+    element._repoChanged().then(() => {
+      assert.isFalse(accessStub.called);
+      assert.isFalse(capabilitiesStub.called);
+      assert.isFalse(repoStub.called);
+      done();
+    });
+  });
+
+  test('_computeParentHref', () => {
+    const repoName = 'test-repo';
+    assert.equal(element._computeParentHref(repoName),
+        '/admin/repos/test-repo,access');
+  });
+
+  test('_computeMainClass', () => {
+    let ownerOf = ['refs/*'];
+    const editing = true;
+    const canUpload = false;
+    assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
+        'admin editing');
+    ownerOf = [];
+    assert.equal(element._computeMainClass(ownerOf, canUpload), '');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
+        'editing');
+  });
+
+  test('inherit section', () => {
+    element._local = {};
+    element._ownerOf = [];
+    sandbox.stub(element, '_computeParentHref');
+    // Nothing should appear when no inherit from and not in edit mode.
+    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    // The autocomplete should be hidden, and the link should be  displayed.
+    assert.isFalse(element._computeParentHref.called);
+    // When it edit mode, the autocomplete should appear.
+    element._editing = true;
+    // When editing, the autocomplete should still not be shown.
+    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    element._editing = false;
+    element._inheritsFrom = {
+      name: 'another-repo',
+    };
+    // When there is a parent project, the link should be displayed.
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
+        'none');
+    assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
+        'none');
+    assert.isTrue(element._computeParentHref.called);
+    element._editing = true;
+    // When editing, the autocomplete should be shown.
+    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
+    assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
+        'none');
+  });
+
+  test('_handleUpdateInheritFrom', () => {
+    element._inheritFromFilter = 'foo bar baz';
+    element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+    assert.isOk(element._inheritsFrom);
+    assert.equal(element._inheritsFrom.id, 'abc+123');
+    assert.equal(element._inheritsFrom.name, 'foo bar baz');
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('fires page-error', done => {
+    const response = {status: 404};
+
+    sandbox.stub(
+        element.$.restAPI, 'getRepoAccessRights', (repoName, errFn) => {
+          errFn(response);
+        });
+
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element.repo = 'test';
+  });
 
-    test('_repoChanged called when repo name changes', () => {
-      sandbox.stub(element, '_repoChanged');
-      element.repo = 'New Repo';
-      assert.isTrue(element._repoChanged.called);
-    });
-
-    test('_repoChanged', done => {
-      const accessStub = sandbox.stub(element.$.restAPI,
-          'getRepoAccessRights');
-
-      accessStub.withArgs('New Repo').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      accessStub.withArgs('Another New Repo')
-          .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-      const capabilitiesStub = sandbox.stub(element.$.restAPI,
-          'getCapabilities');
-      capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
-
-      element._repoChanged('New Repo').then(() => {
-        assert.isTrue(accessStub.called);
-        assert.isTrue(capabilitiesStub.called);
-        assert.isTrue(repoStub.called);
-        assert.isNotOk(element._inheritsFrom);
-        assert.deepEqual(element._local, accessRes.local);
-        assert.deepEqual(element._sections,
-            element.toSortedArray(accessRes.local));
-        assert.deepEqual(element._labels, repoRes.labels);
-        assert.equal(getComputedStyle(element.$$('.weblinks')).display,
-            'block');
-        return element._repoChanged('Another New Repo');
-      })
-          .then(() => {
-            assert.deepEqual(element._sections,
-                element.toSortedArray(accessRes2.local));
-            assert.equal(getComputedStyle(element.$$('.weblinks')).display,
-                'none');
-            done();
-          });
-    });
-
-    test('_repoChanged when repo changes to undefined returns', done => {
-      const capabilitiesRes = {
-        accessDatabase: {
-          id: 'accessDatabase',
-          name: 'Access Database',
-        },
-      };
-      const accessStub = sandbox.stub(element.$.restAPI, 'getRepoAccessRights')
-          .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-      const capabilitiesStub = sandbox.stub(element.$.restAPI,
-          'getCapabilities').returns(Promise.resolve(capabilitiesRes));
-
-      element._repoChanged().then(() => {
-        assert.isFalse(accessStub.called);
-        assert.isFalse(capabilitiesStub.called);
-        assert.isFalse(repoStub.called);
-        done();
-      });
-    });
-
-    test('_computeParentHref', () => {
-      const repoName = 'test-repo';
-      assert.equal(element._computeParentHref(repoName),
-          '/admin/repos/test-repo,access');
-    });
-
-    test('_computeMainClass', () => {
-      let ownerOf = ['refs/*'];
-      const editing = true;
-      const canUpload = false;
-      assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
-      assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-          'admin editing');
-      ownerOf = [];
-      assert.equal(element._computeMainClass(ownerOf, canUpload), '');
-      assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-          'editing');
-    });
-
-    test('inherit section', () => {
-      element._local = {};
-      element._ownerOf = [];
-      sandbox.stub(element, '_computeParentHref');
-      // Nothing should appear when no inherit from and not in edit mode.
-      assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      // The autocomplete should be hidden, and the link should be  displayed.
-      assert.isFalse(element._computeParentHref.called);
-      // When it edit mode, the autocomplete should appear.
-      element._editing = true;
-      // When editing, the autocomplete should still not be shown.
-      assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      element._editing = false;
-      element._inheritsFrom = {
-        name: 'another-repo',
-      };
-      // When there is a parent project, the link should be displayed.
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
-          'none');
+  suite('with defined sections', () => {
+    const testEditSaveCancelBtns = (shouldShowSave, shouldShowSaveReview) => {
+      // Edit button is visible and Save button is hidden.
+      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
+      assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
+      assert.equal(element.$.editBtn.innerText, 'EDIT');
       assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
           'none');
-      assert.isTrue(element._computeParentHref.called);
-      element._editing = true;
-      // When editing, the autocomplete should be shown.
-      assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-      assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
-          'none');
-    });
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
 
-    test('_handleUpdateInheritFrom', () => {
-      element._inheritFromFilter = 'foo bar baz';
-      element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
-      assert.isOk(element._inheritsFrom);
-      assert.equal(element._inheritsFrom.id, 'abc+123');
-      assert.equal(element._inheritsFrom.name, 'foo bar baz');
-    });
+      MockInteractions.tap(element.$.editBtn);
+      flushAsynchronousOperations();
 
-    test('_computeLoadingClass', () => {
-      assert.equal(element._computeLoadingClass(true), 'loading');
-      assert.equal(element._computeLoadingClass(false), '');
-    });
-
-    test('fires page-error', done => {
-      const response = {status: 404};
-
-      sandbox.stub(
-          element.$.restAPI, 'getRepoAccessRights', (repoName, errFn) => {
-            errFn(response);
-          });
-
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element.repo = 'test';
-    });
-
-    suite('with defined sections', () => {
-      const testEditSaveCancelBtns = (shouldShowSave, shouldShowSaveReview) => {
-        // Edit button is visible and Save button is hidden.
-        assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
-        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-        assert.equal(element.$.editBtn.innerText, 'EDIT');
-        assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
+      // Edit button changes to Cancel button, and Save button is visible but
+      // disabled.
+      assert.equal(element.$.editBtn.innerText, 'CANCEL');
+      if (shouldShowSaveReview) {
+        assert.notEqual(getComputedStyle(element.$.saveReviewBtn).display,
             'none');
-        element._inheritsFrom = {
-          id: 'test-project',
-        };
-        flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.$$('#editInheritFromInput'))
-            .display, 'none');
+        assert.isTrue(element.$.saveReviewBtn.disabled);
+      }
+      if (shouldShowSave) {
+        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
+        assert.isTrue(element.$.saveBtn.disabled);
+      }
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
 
-        MockInteractions.tap(element.$.editBtn);
-        flushAsynchronousOperations();
+      // Save button should be enabled after access is modified
+      element.dispatchEvent(
+          new CustomEvent('access-modified', {
+            composed: true, bubbles: true,
+          }));
+      if (shouldShowSaveReview) {
+        assert.isFalse(element.$.saveReviewBtn.disabled);
+      }
+      if (shouldShowSave) {
+        assert.isFalse(element.$.saveBtn.disabled);
+      }
+    };
 
-        // Edit button changes to Cancel button, and Save button is visible but
-        // disabled.
-        assert.equal(element.$.editBtn.innerText, 'CANCEL');
-        if (shouldShowSaveReview) {
-          assert.notEqual(getComputedStyle(element.$.saveReviewBtn).display,
-              'none');
-          assert.isTrue(element.$.saveReviewBtn.disabled);
-        }
-        if (shouldShowSave) {
-          assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
-          assert.isTrue(element.$.saveBtn.disabled);
-        }
-        assert.notEqual(getComputedStyle(element.$$('#editInheritFromInput'))
-            .display, 'none');
+    setup(() => {
+      // Create deep copies of these objects so the originals are not modified
+      // by any tests.
+      element._local = JSON.parse(JSON.stringify(accessRes.local));
+      element._ownerOf = [];
+      element._sections = element.toSortedArray(element._local);
+      element._groups = JSON.parse(JSON.stringify(accessRes.groups));
+      element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
+      element._labels = JSON.parse(JSON.stringify(repoRes.labels));
+      flushAsynchronousOperations();
+    });
 
-        // Save button should be enabled after access is modified
-        element.fire('access-modified');
-        if (shouldShowSaveReview) {
-          assert.isFalse(element.$.saveReviewBtn.disabled);
-        }
-        if (shouldShowSave) {
-          assert.isFalse(element.$.saveBtn.disabled);
-        }
+    test('removing an added section', () => {
+      element.editing = true;
+      assert.equal(element._sections.length, 1);
+      element.shadowRoot
+          .querySelector('gr-access-section').dispatchEvent(
+              new CustomEvent('added-section-removed', {
+                composed: true, bubbles: true,
+              }));
+      flushAsynchronousOperations();
+      assert.equal(element._sections.length, 0);
+    });
+
+    test('button visibility for non ref owner', () => {
+      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
+    });
+
+    test('button visibility for non ref owner with upload privilege', () => {
+      element._canUpload = true;
+      testEditSaveCancelBtns(false, true);
+    });
+
+    test('button visibility for ref owner', () => {
+      element._ownerOf = ['refs/for/*'];
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('button visibility for ref owner and upload', () => {
+      element._ownerOf = ['refs/for/*'];
+      element._canUpload = true;
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('_handleAccessModified called with event fired', () => {
+      sandbox.spy(element, '_handleAccessModified');
+      element.dispatchEvent(
+          new CustomEvent('access-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleAccessModified.called);
+    });
+
+    test('_handleAccessModified called when parent changes', () => {
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      flushAsynchronousOperations();
+      element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
+          new CustomEvent('commit', {
+            detail: {},
+            composed: true, bubbles: true,
+          }));
+      sandbox.spy(element, '_handleAccessModified');
+      element.dispatchEvent(
+          new CustomEvent('access-modified', {
+            detail: {},
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleAccessModified.called);
+    });
+
+    test('_handleSaveForReview', () => {
+      const saveStub =
+          sandbox.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
+      sandbox.stub(element, '_computeAddAndRemove').returns({
+        add: {},
+        remove: {},
+      });
+      element._handleSaveForReview();
+      assert.isFalse(saveStub.called);
+    });
+
+    test('_recursivelyRemoveDeleted', () => {
+      const obj = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+                123: {action: 'DENY', deleted: true},
+              },
+            },
+            read: {
+              deleted: true,
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      const expectedResult = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      element._recursivelyRemoveDeleted(obj);
+      assert.deepEqual(obj, expectedResult);
+    });
+
+    test('_recursivelyUpdateAddRemoveObj on new added section', () => {
+      const obj = {
+        'refs/for/*': {
+          permissions: {
+            'label-Code-Review': {
+              rules: {
+                e798fed07afbc9173a587f876ef8760c78d240c1: {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+            'labelAs-Code-Review': {
+              rules: {
+                'ldap:gerritcodereview-eng': {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                  deleted: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+          },
+          added: true,
+        },
       };
 
-      setup(() => {
-        // Create deep copies of these objects so the originals are not modified
-        // by any tests.
-        element._local = JSON.parse(JSON.stringify(accessRes.local));
-        element._ownerOf = [];
-        element._sections = element.toSortedArray(element._local);
-        element._groups = JSON.parse(JSON.stringify(accessRes.groups));
-        element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
-        element._labels = JSON.parse(JSON.stringify(repoRes.labels));
-        flushAsynchronousOperations();
-      });
-
-      test('removing an added section', () => {
-        element.editing = true;
-        assert.equal(element._sections.length, 1);
-        element.$$('gr-access-section').fire('added-section-removed');
-        flushAsynchronousOperations();
-        assert.equal(element._sections.length, 0);
-      });
-
-      test('button visibility for non ref owner', () => {
-        assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-
-      test('button visibility for non ref owner with upload privilege', () => {
-        element._canUpload = true;
-        testEditSaveCancelBtns(false, true);
-      });
-
-      test('button visibility for ref owner', () => {
-        element._ownerOf = ['refs/for/*'];
-        testEditSaveCancelBtns(true, false);
-      });
-
-      test('button visibility for ref owner and upload', () => {
-        element._ownerOf = ['refs/for/*'];
-        element._canUpload = true;
-        testEditSaveCancelBtns(true, false);
-      });
-
-      test('_handleAccessModified called with event fired', () => {
-        sandbox.spy(element, '_handleAccessModified');
-        element.fire('access-modified');
-        assert.isTrue(element._handleAccessModified.called);
-      });
-
-      test('_handleAccessModified called when parent changes', () => {
-        element._inheritsFrom = {
-          id: 'test-project',
-        };
-        flushAsynchronousOperations();
-        element.$$('#editInheritFromInput').fire('commit');
-        sandbox.spy(element, '_handleAccessModified');
-        element.fire('access-modified');
-        assert.isTrue(element._handleAccessModified.called);
-      });
-
-      test('_handleSaveForReview', () => {
-        const saveStub =
-            sandbox.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
-        sandbox.stub(element, '_computeAddAndRemove').returns({
-          add: {},
-          remove: {},
-        });
-        element._handleSaveForReview();
-        assert.isFalse(saveStub.called);
-      });
-
-      test('_recursivelyRemoveDeleted', () => {
-        const obj = {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY', deleted: true},
-                },
-              },
-              read: {
-                deleted: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        };
-        const expectedResult = {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        };
-        element._recursivelyRemoveDeleted(obj);
-        assert.deepEqual(obj, expectedResult);
-      });
-
-      test('_recursivelyUpdateAddRemoveObj on new added section', () => {
-        const obj = {
+      const expectedResult = {
+        add: {
           'refs/for/*': {
             permissions: {
               'label-Code-Review': {
@@ -433,737 +491,764 @@
                 label: 'Code-Review',
               },
               'labelAs-Code-Review': {
-                rules: {
-                  'ldap:gerritcodereview-eng': {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                    deleted: true,
-                  },
-                },
+                rules: {},
                 added: true,
                 label: 'Code-Review',
               },
             },
             added: true,
           },
-        };
+        },
+        remove: {},
+      };
+      const updateObj = {add: {}, remove: {}};
+      element._recursivelyUpdateAddRemoveObj(obj, updateObj);
+      assert.deepEqual(updateObj, expectedResult);
+    });
 
-        const expectedResult = {
-          add: {
-            'refs/for/*': {
-              permissions: {
-                'label-Code-Review': {
-                  rules: {
-                    e798fed07afbc9173a587f876ef8760c78d240c1: {
-                      min: -2,
-                      max: 2,
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                  added: true,
-                  label: 'Code-Review',
-                },
-                'labelAs-Code-Review': {
-                  rules: {},
-                  added: true,
-                  label: 'Code-Review',
-                },
-              },
-              added: true,
-            },
-          },
-          remove: {},
-        };
-        const updateObj = {add: {}, remove: {}};
-        element._recursivelyUpdateAddRemoveObj(obj, updateObj);
-        assert.deepEqual(updateObj, expectedResult);
+    test('_handleSaveForReview with no changes', () => {
+      assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+    });
+
+    test('_handleSaveForReview parent change', () => {
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      element._originalInheritsFrom = {
+        id: 'test-project-original',
+      };
+      assert.deepEqual(element._computeAddAndRemove(), {
+        parent: 'test-project', add: {}, remove: {},
       });
+    });
 
-      test('_handleSaveForReview with no changes', () => {
-        assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+    test('_handleSaveForReview new parent with spaces', () => {
+      element._inheritsFrom = {id: 'spaces+in+project+name'};
+      element._originalInheritsFrom = {id: 'old-project'};
+      assert.deepEqual(element._computeAddAndRemove(), {
+        parent: 'spaces in project name', add: {}, remove: {},
       });
+    });
 
-      test('_handleSaveForReview parent change', () => {
-        element._inheritsFrom = {
-          id: 'test-project',
-        };
-        element._originalInheritsFrom = {
-          id: 'test-project-original',
-        };
-        assert.deepEqual(element._computeAddAndRemove(), {
-          parent: 'test-project', add: {}, remove: {},
-        });
+    test('_handleSaveForReview rules', () => {
+      // Delete a rule.
+      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Undo deleting a rule.
+      delete element._local['refs/*'].permissions.owner.rules[123].deleted;
+
+      // Modify a rule.
+      element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove permissions', () => {
+      // Add a new rule to a permission.
+      let expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      element.shadowRoot
+          .querySelector('gr-access-section').shadowRoot
+          .querySelector('gr-permission')
+          ._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Remove the added rule.
+      delete element._local['refs/*'].permissions.owner.rules.Maintainers;
+
+      // Delete a permission.
+      element._local['refs/*'].permissions.owner.deleted = true;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Undo delete permission.
+      delete element._local['refs/*'].permissions.owner.deleted;
+
+      // Modify a permission.
+      element._local['refs/*'].permissions.owner.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove sections', () => {
+      // Add a new permission to a section
+      let expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      element.shadowRoot
+          .querySelector('gr-access-section')._handleAddPermission();
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a new rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newPermission =
+          dom(element.shadowRoot
+              .querySelector('gr-access-section').root).querySelectorAll(
+              'gr-permission')[2];
+      newPermission._handleAddRuleItem(
+          {detail: {value: {id: 'Maintainers'}}});
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify a section reference.
+      element._local['refs/*'].updatedId = 'refs/for/bar';
+      element._local['refs/*'].modified = true;
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              'owner': {
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+              'read': {
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Delete a section.
+      element._local['refs/*'].deleted = true;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove new section', () => {
+      // Add a new permission to a section
+      let expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {},
+          },
+        },
+        remove: {},
+      };
+      MockInteractions.tap(element.$.addReferenceBtn);
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[1];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify a the reference from the default value.
+      element._local['refs/for/*'].updatedId = 'refs/for/new';
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove combinations', () => {
+      // Modify rule and delete permission that it is inside of.
+      element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      element._local['refs/*'].permissions.owner.deleted = true;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      // Delete rule and delete permission that it is inside of.
+      element._local['refs/*'].permissions.owner.rules[123].modified = false;
+      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Also modify a different rule inside of another permission.
+      element._local['refs/*'].permissions.read.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      // Modify both permissions with an exclusive bit. Owner is still
+      // deleted.
+      element._local['refs/*'].permissions.owner.exclusive = true;
+      element._local['refs/*'].permissions.owner.modified = true;
+      element._local['refs/*'].permissions.read.exclusive = true;
+      element._local['refs/*'].permissions.read.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a rule to the existing permission;
+      const readPermission =
+          dom(element.shadowRoot
+              .querySelector('gr-access-section').root).querySelectorAll(
+              'gr-permission')[1];
+      readPermission._handleAddRuleItem(
+          {detail: {value: {id: 'Maintainers'}}});
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Change one of the refs
+      element._local['refs/*'].updatedId = 'refs/for/bar';
+      element._local['refs/*'].modified = true;
+
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      element._local['refs/*'].deleted = true;
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a new section.
+      MockInteractions.tap(element.$.addReferenceBtn);
+      let newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[1];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+      // Modify a the reference from the default value.
+      element._local['refs/for/*'].updatedId = 'refs/for/new';
+
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify newly added rule inside new ref.
+      element._local['refs/for/*'].permissions['label-Code-Review'].
+          rules['Maintainers'].modified = true;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a second new section.
+      MockInteractions.tap(element.$.addReferenceBtn);
+      newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[2];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+      // Modify a the reference from the default value.
+      element._local['refs/for/**'].updatedId = 'refs/for/new2';
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+          'refs/for/new2': {
+            added: true,
+            updatedId: 'refs/for/new2',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('Unsaved added refs are discarded when edit cancelled', () => {
+      // Unsaved changes are discarded when editing is cancelled.
+      MockInteractions.tap(element.$.editBtn);
+      assert.equal(element._sections.length, 1);
+      assert.equal(Object.keys(element._local).length, 1);
+      MockInteractions.tap(element.$.addReferenceBtn);
+      assert.equal(element._sections.length, 2);
+      assert.equal(Object.keys(element._local).length, 2);
+      MockInteractions.tap(element.$.editBtn);
+      assert.equal(element._sections.length, 1);
+      assert.equal(Object.keys(element._local).length, 1);
+    });
+
+    test('_handleSave', done => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sandbox.stub(GerritNav, 'navigateToChange');
+      let resolver;
+      const saveStub = sandbox.stub(element.$.restAPI,
+          'setRepoAccessRights')
+          .returns(new Promise(r => resolver = r));
+
+      element.repo = 'test-repo';
+      sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+
+      element._modified = true;
+      MockInteractions.tap(element.$.saveBtn);
+      assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
+      resolver({_number: 1});
+      flush(() => {
+        assert.isTrue(saveStub.called);
+        assert.isTrue(GerritNav.navigateToChange.notCalled);
+        done();
       });
+    });
 
-      test('_handleSaveForReview new parent with spaces', () => {
-        element._inheritsFrom = {id: 'spaces+in+project+name'};
-        element._originalInheritsFrom = {id: 'old-project'};
-        assert.deepEqual(element._computeAddAndRemove(), {
-          parent: 'spaces in project name', add: {}, remove: {},
-        });
-      });
-
-      test('_handleSaveForReview rules', () => {
-        // Delete a rule.
-        element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-        let expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {},
-                  },
+    test('_handleSaveForReview', done => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
                 },
               },
             },
           },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Undo deleting a rule.
-        delete element._local['refs/*'].permissions.owner.rules[123].deleted;
-
-        // Modify a rule.
-        element._local['refs/*'].permissions.owner.rules[123].modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {action: 'DENY', modified: true},
-                  },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
                 },
               },
             },
           },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {},
-                  },
-                },
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
+        },
+      };
+      sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sandbox.stub(GerritNav, 'navigateToChange');
+      let resolver;
+      const saveForReviewStub = sandbox.stub(element.$.restAPI,
+          'setRepoAccessRightsForReview')
+          .returns(new Promise(r => resolver = r));
 
-      test('_computeAddAndRemove permissions', () => {
-        // Add a new rule to a permission.
-        let expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                },
-              },
-            },
-          },
-          remove: {},
-        };
+      element.repo = 'test-repo';
+      sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
 
-        element.$$('gr-access-section').$$('gr-permission')._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Remove the added rule.
-        delete element._local['refs/*'].permissions.owner.rules.Maintainers;
-
-        // Delete a permission.
-        element._local['refs/*'].permissions.owner.deleted = true;
-        expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Undo delete permission.
-        delete element._local['refs/*'].permissions.owner.deleted;
-
-        // Modify a permission.
-        element._local['refs/*'].permissions.owner.modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    123: {action: 'DENY'},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('_computeAddAndRemove sections', () => {
-        // Add a new permission to a section
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {},
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        element.$$('gr-access-section')._handleAddPermission();
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a new rule to the new permission.
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      min: -2,
-                      max: 2,
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        const newPermission =
-            Polymer.dom(element.$$('gr-access-section').root).querySelectorAll(
-                'gr-permission')[2];
-        newPermission._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Modify a section reference.
-        element._local['refs/*'].updatedId = 'refs/for/bar';
-        element._local['refs/*'].modified = true;
-        expectedInput = {
-          add: {
-            'refs/for/bar': {
-              modified: true,
-              updatedId: 'refs/for/bar',
-              permissions: {
-                'owner': {
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    123: {action: 'DENY'},
-                  },
-                },
-                'read': {
-                  rules: {
-                    234: {action: 'ALLOW'},
-                  },
-                },
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      min: -2,
-                      max: 2,
-                      action: 'ALLOW',
-                      added: true,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Delete a section.
-        element._local['refs/*'].deleted = true;
-        expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('_computeAddAndRemove new section', () => {
-        // Add a new permission to a section
-        expectedInput = {
-          add: {
-            'refs/for/*': {
-              added: true,
-              permissions: {},
-            },
-          },
-          remove: {},
-        };
-        MockInteractions.tap(element.$.addReferenceBtn);
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        expectedInput = {
-          add: {
-            'refs/for/*': {
-              added: true,
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {},
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        const newSection = Polymer.dom(element.root)
-            .querySelectorAll('gr-access-section')[1];
-        newSection._handleAddPermission();
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add rule to the new permission.
-        expectedInput = {
-          add: {
-            'refs/for/*': {
-              added: true,
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-
-        newSection.$$('gr-permission')._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-
-        flushAsynchronousOperations();
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Modify a the reference from the default value.
-        element._local['refs/for/*'].updatedId = 'refs/for/new';
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {},
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('_computeAddAndRemove combinations', () => {
-        // Modify rule and delete permission that it is inside of.
-        element._local['refs/*'].permissions.owner.rules[123].modified = true;
-        element._local['refs/*'].permissions.owner.deleted = true;
-        let expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-        // Delete rule and delete permission that it is inside of.
-        element._local['refs/*'].permissions.owner.rules[123].modified = false;
-        element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Also modify a different rule inside of another permission.
-        element._local['refs/*'].permissions.read.modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                read: {
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-                read: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-        // Modify both permissions with an exclusive bit. Owner is still
-        // deleted.
-        element._local['refs/*'].permissions.owner.exclusive = true;
-        element._local['refs/*'].permissions.owner.modified = true;
-        element._local['refs/*'].permissions.read.exclusive = true;
-        element._local['refs/*'].permissions.read.modified = true;
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                read: {
-                  exclusive: true,
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-                read: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a rule to the existing permission;
-        const readPermission =
-            Polymer.dom(element.$$('gr-access-section').root).querySelectorAll(
-                'gr-permission')[1];
-        readPermission._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-
-        expectedInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                read: {
-                  exclusive: true,
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    Maintainers: {action: 'ALLOW', added: true},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {rules: {}},
-                read: {rules: {}},
-              },
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Change one of the refs
-        element._local['refs/*'].updatedId = 'refs/for/bar';
-        element._local['refs/*'].modified = true;
-
-        expectedInput = {
-          add: {
-            'refs/for/bar': {
-              modified: true,
-              updatedId: 'refs/for/bar',
-              permissions: {
-                read: {
-                  exclusive: true,
-                  modified: true,
-                  rules: {
-                    234: {action: 'ALLOW'},
-                    Maintainers: {action: 'ALLOW', added: true},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        expectedInput = {
-          add: {},
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        element._local['refs/*'].deleted = true;
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a new section.
-        MockInteractions.tap(element.$.addReferenceBtn);
-        let newSection = Polymer.dom(element.root)
-            .querySelectorAll('gr-access-section')[1];
-        newSection._handleAddPermission();
-        flushAsynchronousOperations();
-        newSection.$$('gr-permission')._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-        // Modify a the reference from the default value.
-        element._local['refs/for/*'].updatedId = 'refs/for/new';
-
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Modify newly added rule inside new ref.
-        element._local['refs/for/*'].permissions['label-Code-Review'].
-            rules['Maintainers'].modified = true;
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      modified: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-        // Add a second new section.
-        MockInteractions.tap(element.$.addReferenceBtn);
-        newSection = Polymer.dom(element.root)
-            .querySelectorAll('gr-access-section')[2];
-        newSection._handleAddPermission();
-        flushAsynchronousOperations();
-        newSection.$$('gr-permission')._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
-        // Modify a the reference from the default value.
-        element._local['refs/for/**'].updatedId = 'refs/for/new2';
-        expectedInput = {
-          add: {
-            'refs/for/new': {
-              added: true,
-              updatedId: 'refs/for/new',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      modified: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-            'refs/for/new2': {
-              added: true,
-              updatedId: 'refs/for/new2',
-              permissions: {
-                'label-Code-Review': {
-                  added: true,
-                  rules: {
-                    Maintainers: {
-                      action: 'ALLOW',
-                      added: true,
-                      max: 2,
-                      min: -2,
-                    },
-                  },
-                  label: 'Code-Review',
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {},
-            },
-          },
-        };
-        assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      });
-
-      test('Unsaved added refs are discarded when edit cancelled', () => {
-        // Unsaved changes are discarded when editing is cancelled.
-        MockInteractions.tap(element.$.editBtn);
-        assert.equal(element._sections.length, 1);
-        assert.equal(Object.keys(element._local).length, 1);
-        MockInteractions.tap(element.$.addReferenceBtn);
-        assert.equal(element._sections.length, 2);
-        assert.equal(Object.keys(element._local).length, 2);
-        MockInteractions.tap(element.$.editBtn);
-        assert.equal(element._sections.length, 1);
-        assert.equal(Object.keys(element._local).length, 1);
-      });
-
-      test('_handleSaveForReview', done => {
-        const repoAccessInput = {
-          add: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {action: 'DENY', modified: true},
-                  },
-                },
-              },
-            },
-          },
-          remove: {
-            'refs/*': {
-              permissions: {
-                owner: {
-                  rules: {
-                    123: {},
-                  },
-                },
-              },
-            },
-          },
-        };
-        sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
-            Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange');
-        const saveForReviewStub = sandbox.stub(element.$.restAPI,
-            'setRepoAccessRightsForReview')
-            .returns(Promise.resolve({_number: 1}));
-
-        element.repo = 'test-repo';
-        sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-        element._handleSaveForReview().then(() => {
-          assert.isTrue(saveForReviewStub.called);
-          assert.isTrue(Gerrit.Nav.navigateToChange
-              .lastCall.calledWithExactly({_number: 1}));
-          done();
-        });
+      element._modified = true;
+      MockInteractions.tap(element.$.saveReviewBtn);
+      assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
+      resolver({_number: 1});
+      flush(() => {
+        assert.isTrue(saveForReviewStub.called);
+        assert.isTrue(GerritNav.navigateToChange
+            .lastCall.calledWithExactly({_number: 1}));
+        done();
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
deleted file mode 100644
index 29bc02d..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.html
+++ /dev/null
@@ -1,39 +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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-
-<dom-module id="gr-repo-command">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-xxl);
-      }
-    </style>
-    <h3>[[title]]</h3>
-    <gr-button
-        title$="[[tooltip]]"
-        disabled$="[[disabled]]"
-        on-click
-        ="_onCommandTap">
-      [[title]]
-    </gr-button>
-  </template>
-  <script src="gr-repo-command.js"></script>
-</dom-module>
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..53b4989 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
@@ -14,27 +14,41 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-repo-command',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-command_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrRepoCommand extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-command'; }
+
+  static get properties() {
+    return {
       title: String,
       disabled: Boolean,
       tooltip: String,
-    },
+    };
+  }
 
-    /**
-     * Fired when command button is tapped.
-     *
-     * @event command-tap
-     */
+  /**
+   * Fired when command button is tapped.
+   *
+   * @event command-tap
+   */
 
-    _onCommandTap() {
-      this.dispatchEvent(
-          new CustomEvent('command-tap', {bubbles: true, composed: true}));
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
new file mode 100644
index 0000000..cf934b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <h3>[[title]]</h3>
+  <gr-button
+    title$="[[tooltip]]"
+    disabled$="[[disabled]]"
+    on-click="_onCommandTap"
+  >
+    [[title]]
+  </gr-button>
+`;
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..a73f071 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-command.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,20 +31,23 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-command tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-command.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-repo-command tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('dispatched command-tap on button tap', done => {
-      element.addEventListener('command-tap', () => {
-        done();
-      });
-      MockInteractions.tap(
-          Polymer.dom(element.root).querySelector('gr-button'));
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('dispatched command-tap on button tap', done => {
+    element.addEventListener('command-tap', () => {
+      done();
+    });
+    MockInteractions.tap(
+        dom(element.root).querySelector('gr-button'));
+  });
+});
 </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
deleted file mode 100644
index 5089f34..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
+++ /dev/null
@@ -1,88 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.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="../../plugins/gr-endpoint-param/gr-endpoint-param.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-create-change-dialog/gr-create-change-dialog.html">
-<link rel="import" href="../gr-repo-command/gr-repo-command.html">
-
-<dom-module id="gr-repo-commands">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-subpage-styles"></style>
-    <style include="gr-form-styles"></style>
-    <main class="gr-form-styles read-only">
-      <h1 id="Title">Repository Commands</h1>
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <h2 id="options">Command</h2>
-        <div id="form">
-          <gr-repo-command
-              title="Create change"
-              on-command-tap="_createNewChange">
-          </gr-repo-command>
-          <gr-repo-command
-              id="editRepoConfig"
-              title="Edit repo config"
-              on-command-tap="_handleEditRepoConfig">
-          </gr-repo-command>
-          <gr-repo-command
-              title="[[_repoConfig.actions.gc.label]]"
-              tooltip="[[_repoConfig.actions.gc.title]]"
-              hidden$="[[!_repoConfig.actions.gc.enabled]]"
-              on-command-tap="_handleRunningGC">
-          </gr-repo-command>
-          <gr-endpoint-decorator name="repo-command">
-            <gr-endpoint-param name="config" value="[[_repoConfig]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="repoName" value="[[repo]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </div>
-      </div>
-    </main>
-    <gr-overlay id="createChangeOverlay" with-backdrop>
-      <gr-dialog
-          id="createChangeDialog"
-          confirm-label="Create"
-          disabled="[[!_canCreate]]"
-          on-confirm="_handleCreateChange"
-          on-cancel="_handleCloseCreateChange">
-        <div class="header" slot="header">
-          Create Change
-        </div>
-        <div class="main" slot="main">
-          <gr-create-change-dialog
-              id="createNewChangeModal"
-              can-create="{{_canCreate}}"
-              repo-name="[[repo]]"></gr-create-change-dialog>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-commands.js"></script>
-</dom-module>
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..1f1dc5b 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
@@ -14,22 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const GC_MESSAGE = 'Garbage collection completed successfully.';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-change-dialog/gr-create-change-dialog.js';
+import '../gr-repo-command/gr-repo-command.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-commands_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  const CONFIG_BRANCH = 'refs/meta/config';
-  const CONFIG_PATH = 'project.config';
-  const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
-  const INITIAL_PATCHSET = 1;
-  const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
-  const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
+const GC_MESSAGE = 'Garbage collection completed successfully.';
 
-  Polymer({
-    is: 'gr-repo-commands',
+const CONFIG_BRANCH = 'refs/meta/config';
+const CONFIG_PATH = 'project.config';
+const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
+const INITIAL_PATCHSET = 1;
+const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
+const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrRepoCommands extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-commands'; }
+
+  static get properties() {
+    return {
       params: Object,
       repo: String,
       _loading: {
@@ -39,78 +62,84 @@
       /** @type {?} */
       _repoConfig: Object,
       _canCreate: Boolean,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
 
-    attached() {
-      this._loadRepo();
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'Repo Commands'},
+      composed: true, bubbles: true,
+    }));
+  }
 
-      this.fire('title-change', {title: 'Repo Commands'});
-    },
+  _loadRepo() {
+    if (!this.repo) { return Promise.resolve(); }
 
-    _loadRepo() {
-      if (!this.repo) { return Promise.resolve(); }
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    return this.$.restAPI.getProjectConfig(this.repo, errFn)
+        .then(config => {
+          if (!config) { return Promise.resolve(); }
 
-      return this.$.restAPI.getProjectConfig(this.repo, errFn)
-          .then(config => {
-            if (!config) { return Promise.resolve(); }
+          this._repoConfig = config;
+          this._loading = false;
+        });
+  }
 
-            this._repoConfig = config;
-            this._loading = false;
-          });
-    },
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
 
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
 
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    },
+  _handleRunningGC() {
+    return this.$.restAPI.runRepoGC(this.repo).then(response => {
+      if (response.status === 200) {
+        this.dispatchEvent(new CustomEvent(
+            'show-alert',
+            {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
+      }
+    });
+  }
 
-    _handleRunningGC() {
-      return this.$.restAPI.runRepoGC(this.repo).then(response => {
-        if (response.status === 200) {
-          this.dispatchEvent(new CustomEvent(
-              'show-alert',
-              {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
-        }
-      });
-    },
+  _createNewChange() {
+    this.$.createChangeOverlay.open();
+  }
 
-    _createNewChange() {
-      this.$.createChangeOverlay.open();
-    },
+  _handleCreateChange() {
+    this.$.createNewChangeModal.handleCreateChange();
+    this._handleCloseCreateChange();
+  }
 
-    _handleCreateChange() {
-      this.$.createNewChangeModal.handleCreateChange();
-      this._handleCloseCreateChange();
-    },
+  _handleCloseCreateChange() {
+    this.$.createChangeOverlay.close();
+  }
 
-    _handleCloseCreateChange() {
-      this.$.createChangeOverlay.close();
-    },
+  _handleEditRepoConfig() {
+    return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
+        EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
+      const message = change ?
+        CREATE_CHANGE_SUCCEEDED_MESSAGE :
+        CREATE_CHANGE_FAILED_MESSAGE;
+      this.dispatchEvent(new CustomEvent('show-alert',
+          {detail: {message}, bubbles: true, composed: true}));
+      if (!change) { return; }
 
-    _handleEditRepoConfig() {
-      return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
-          EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
-        const message = change ?
-          CREATE_CHANGE_SUCCEEDED_MESSAGE :
-          CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message}, bubbles: true, composed: true}));
-        if (!change) { return; }
+      GerritNav.navigateToRelativeUrl(GerritNav.getEditUrlForDiff(
+          change, CONFIG_PATH, INITIAL_PATCHSET));
+    });
+  }
+}
 
-        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_html.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
new file mode 100644
index 0000000..b27c36b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h2 id="options">Command</h2>
+      <div id="form">
+        <gr-repo-command
+          title="Create change"
+          on-command-tap="_createNewChange"
+        >
+        </gr-repo-command>
+        <gr-repo-command
+          id="editRepoConfig"
+          title="Edit repo config"
+          on-command-tap="_handleEditRepoConfig"
+        >
+        </gr-repo-command>
+        <gr-repo-command
+          title="[[_repoConfig.actions.gc.label]]"
+          tooltip="[[_repoConfig.actions.gc.title]]"
+          hidden$="[[!_repoConfig.actions.gc.enabled]]"
+          on-command-tap="_handleRunningGC"
+        >
+        </gr-repo-command>
+        <gr-endpoint-decorator name="repo-command">
+          <gr-endpoint-param name="config" value="[[_repoConfig]]">
+          </gr-endpoint-param>
+          <gr-endpoint-param name="repoName" value="[[repo]]">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </div>
+  </main>
+  <gr-overlay id="createChangeOverlay" with-backdrop="">
+    <gr-dialog
+      id="createChangeDialog"
+      confirm-label="Create"
+      disabled="[[!_canCreate]]"
+      on-confirm="_handleCreateChange"
+      on-cancel="_handleCloseCreateChange"
+    >
+      <div class="header" slot="header">
+        Create Change
+      </div>
+      <div class="main" slot="main">
+        <gr-create-change-dialog
+          id="createNewChangeModal"
+          can-create="{{_canCreate}}"
+          repo-name="[[repo]]"
+        ></gr-create-change-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..db2bfcf 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-repo-commands.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,107 +32,120 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-commands tests', () => {
-    let element;
-    let sandbox;
-    let repoStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-commands.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+suite('gr-repo-commands tests', () => {
+  let element;
+  let sandbox;
+  let repoStub;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    repoStub = sandbox.stub(
+        element.$.restAPI,
+        'getProjectConfig',
+        () => Promise.resolve({}));
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('create new change dialog', () => {
+    test('_createNewChange opens modal', () => {
+      const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
+      element._createNewChange();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateChange called when confirm fired', () => {
+      sandbox.stub(element, '_handleCreateChange');
+      element.$.createChangeDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateChange.called);
+    });
+
+    test('_handleCloseCreateChange called when cancel fired', () => {
+      sandbox.stub(element, '_handleCloseCreateChange');
+      element.$.createChangeDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreateChange.called);
+    });
+  });
+
+  suite('edit repo config', () => {
+    let createChangeStub;
+    let urlStub;
+    let handleSpy;
+    let alertStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      repoStub = sandbox.stub(element.$.restAPI, 'getProjectConfig', () => {
-        return Promise.resolve({});
+      createChangeStub = sandbox.stub(element.$.restAPI, 'createChange');
+      urlStub = sandbox.stub(GerritNav, 'getEditUrlForDiff');
+      sandbox.stub(GerritNav, 'navigateToRelativeUrl');
+      handleSpy = sandbox.spy(element, '_handleEditRepoConfig');
+      alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+    });
+
+    test('successful creation of change', () => {
+      const change = {_number: '1'};
+      createChangeStub.returns(Promise.resolve(change));
+      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
+          .querySelector('gr-button'));
+      return handleSpy.lastCall.returnValue.then(() => {
+        flushAsynchronousOperations();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(alertStub.lastCall.args[0].detail.message,
+            'Navigating to change');
+        assert.isTrue(urlStub.called);
+        assert.deepEqual(urlStub.lastCall.args,
+            [change, 'project.config', 1]);
       });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    test('unsuccessful creation of change', () => {
+      createChangeStub.returns(Promise.resolve(null));
+      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
+          .querySelector('gr-button'));
+      return handleSpy.lastCall.returnValue.then(() => {
+        flushAsynchronousOperations();
 
-    suite('create new change dialog', () => {
-      test('_createNewChange opens modal', () => {
-        const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
-        element._createNewChange();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateChange called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateChange');
-        element.$.createChangeDialog.fire('confirm');
-        assert.isTrue(element._handleCreateChange.called);
-      });
-
-      test('_handleCloseCreateChange called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreateChange');
-        element.$.createChangeDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreateChange.called);
-      });
-    });
-
-    suite('edit repo config', () => {
-      let createChangeStub;
-      let urlStub;
-      let handleSpy;
-      let alertStub;
-
-      setup(() => {
-        createChangeStub = sandbox.stub(element.$.restAPI, 'createChange');
-        urlStub = sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff');
-        sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
-        handleSpy = sandbox.spy(element, '_handleEditRepoConfig');
-        alertStub = sandbox.stub();
-        element.addEventListener('show-alert', alertStub);
-      });
-
-      test('successful creation of change', () => {
-        const change = {_number: '1'};
-        createChangeStub.returns(Promise.resolve(change));
-        MockInteractions.tap(element.$.editRepoConfig.$$('gr-button'));
-        return handleSpy.lastCall.returnValue.then(() => {
-          flushAsynchronousOperations();
-
-          assert.isTrue(alertStub.called);
-          assert.equal(alertStub.lastCall.args[0].detail.message,
-              'Navigating to change');
-          assert.isTrue(urlStub.called);
-          assert.deepEqual(urlStub.lastCall.args,
-              [change, 'project.config', 1]);
-        });
-      });
-
-      test('unsuccessful creation of change', () => {
-        createChangeStub.returns(Promise.resolve(null));
-        MockInteractions.tap(element.$.editRepoConfig.$$('gr-button'));
-        return handleSpy.lastCall.returnValue.then(() => {
-          flushAsynchronousOperations();
-
-          assert.isTrue(alertStub.called);
-          assert.equal(alertStub.lastCall.args[0].detail.message,
-              'Failed to create change.');
-          assert.isFalse(urlStub.called);
-        });
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        repoStub.restore();
-
-        element.repo = 'test';
-
-        const response = {status: 404};
-        sandbox.stub(
-            element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
-              errFn(response);
-            });
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        element._loadRepo();
+        assert.isTrue(alertStub.called);
+        assert.equal(alertStub.lastCall.args[0].detail.message,
+            'Failed to create change.');
+        assert.isFalse(urlStub.called);
       });
     });
   });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      repoStub.restore();
+
+      element.repo = 'test';
+
+      const response = {status: 404};
+      sandbox.stub(
+          element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
+            errFn(response);
+          });
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._loadRepo();
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index 8af3a92..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
+++ /dev/null
@@ -1,70 +0,0 @@
-<!--
-@license
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-repo-dashboards">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-xxl);
-      }
-      .loading #dashboards,
-      #loadingContainer {
-        display: none;
-      }
-      .loading #loadingContainer {
-        display: block;
-      }
-    </style>
-    <style include="gr-table-styles"></style>
-    <table id="list" class$="genericList [[_computeLoadingClass(_loading)]]">
-      <tr class="headerRow">
-        <th class="topHeader">Dashboard name</th>
-        <th class="topHeader">Dashboard title</th>
-        <th class="topHeader">Dashboard description</th>
-        <th class="topHeader">Inherited from</th>
-        <th class="topHeader">Default</th>
-      </tr>
-      <tr id="loadingContainer">
-        <td>Loading...</td>
-      </tr>
-      <tbody id="dashboards">
-        <template is="dom-repeat" items="[[_dashboards]]">
-          <tr class="groupHeader">
-            <td colspan="5">[[item.section]]</td>
-          </tr>
-          <template is="dom-repeat" items="[[item.dashboards]]">
-            <tr class="table">
-              <td class="name"><a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a></td>
-              <td class="title">[[item.title]]</td>
-              <td class="desc">[[item.description]]</td>
-              <td class="inherited">[[_computeInheritedFrom(item.project, item.defining_project)]]</td>
-              <td class="default">[[_computeIsDefault(item.is_default)]]</td>
-            </tr>
-          </template>
-        </template>
-      </tbody>
-    </table>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-dashboards.js"></script>
-</dom-module>
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..072fc721 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
+ * 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.
@@ -14,13 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-repo-dashboards',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-dashboards_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrRepoDashboards extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-dashboards'; }
+
+  static get properties() {
+    return {
       repo: {
         type: String,
         observer: '_repoChanged',
@@ -30,64 +45,66 @@
         value: true,
       },
       _dashboards: Array,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  _repoChanged(repo) {
+    this._loading = true;
+    if (!repo) { return Promise.resolve(); }
 
-    _repoChanged(repo) {
-      this._loading = true;
-      if (!repo) { return Promise.resolve(); }
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
+    this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
+      if (!res) { return Promise.resolve(); }
 
-      this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
-        if (!res) { return Promise.resolve(); }
-
-        // Group by ref and sort by id.
-        const dashboards = res.concat.apply([], res).sort((a, b) =>
-          a.id < b.id ? -1 : 1);
-        const dashboardsByRef = {};
-        dashboards.forEach(d => {
-          if (!dashboardsByRef[d.ref]) {
-            dashboardsByRef[d.ref] = [];
-          }
-          dashboardsByRef[d.ref].push(d);
-        });
-
-        const dashboardBuilder = [];
-        Object.keys(dashboardsByRef).sort().forEach(ref => {
-          dashboardBuilder.push({
-            section: ref,
-            dashboards: dashboardsByRef[ref],
-          });
-        });
-
-        this._dashboards = dashboardBuilder;
-        this._loading = false;
-        Polymer.dom.flush();
+      // Group by ref and sort by id.
+      const dashboards = res.concat.apply([], res).sort((a, b) =>
+        (a.id < b.id ? -1 : 1));
+      const dashboardsByRef = {};
+      dashboards.forEach(d => {
+        if (!dashboardsByRef[d.ref]) {
+          dashboardsByRef[d.ref] = [];
+        }
+        dashboardsByRef[d.ref].push(d);
       });
-    },
 
-    _getUrl(project, id) {
-      if (!project || !id) { return ''; }
+      const dashboardBuilder = [];
+      Object.keys(dashboardsByRef).sort()
+          .forEach(ref => {
+            dashboardBuilder.push({
+              section: ref,
+              dashboards: dashboardsByRef[ref],
+            });
+          });
 
-      return Gerrit.Nav.getUrlForRepoDashboard(project, id);
-    },
+      this._dashboards = dashboardBuilder;
+      this._loading = false;
+      flush();
+    });
+  }
 
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
+  _getUrl(project, id) {
+    if (!project || !id) { return ''; }
 
-    _computeInheritedFrom(project, definingProject) {
-      return project === definingProject ? '' : definingProject;
-    },
+    return GerritNav.getUrlForRepoDashboard(project, id);
+  }
 
-    _computeIsDefault(isDefault) {
-      return isDefault ? '✓' : '';
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
new file mode 100644
index 0000000..8ce69df
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+    .loading #dashboards,
+    #loadingContainer {
+      display: none;
+    }
+    .loading #loadingContainer {
+      display: block;
+    }
+  </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)]]">
+    <tbody>
+      <tr class="headerRow">
+        <th class="topHeader">Dashboard name</th>
+        <th class="topHeader">Dashboard title</th>
+        <th class="topHeader">Dashboard description</th>
+        <th class="topHeader">Inherited from</th>
+        <th class="topHeader">Default</th>
+      </tr>
+      <tr id="loadingContainer">
+        <td>Loading...</td>
+      </tr>
+    </tbody>
+    <tbody id="dashboards">
+      <template is="dom-repeat" items="[[_dashboards]]">
+        <tr class="groupHeader">
+          <td colspan="5">[[item.section]]</td>
+        </tr>
+        <template is="dom-repeat" items="[[item.dashboards]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a>
+            </td>
+            <td class="title">[[item.title]]</td>
+            <td class="desc">[[item.description]]</td>
+            <td class="inherited">
+              [[_computeInheritedFrom(item.project, item.defining_project)]]
+            </td>
+            <td class="default">[[_computeIsDefault(item.is_default)]]</td>
+          </tr>
+        </template>
+      </template>
+    </tbody>
+  </table>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..dc12eff 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-dashboards.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,127 +31,131 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-dashboards tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-dashboards.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
+suite('gr-repo-dashboards tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('dashboard table', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
+          Promise.resolve([
+            {
+              id: 'default:contributor',
+              project: 'gerrit',
+              defining_project: 'gerrit',
+              ref: 'default',
+              path: 'contributor',
+              description: 'Own contributions.',
+              foreach: 'owner:self',
+              url: '/dashboard/?params',
+              title: 'Contributor Dashboard',
+              sections: [
+                {
+                  name: 'Mine To Rebase',
+                  query: 'is:open -is:mergeable',
+                },
+                {
+                  name: 'My Recently Merged',
+                  query: 'is:merged limit:10',
+                },
+              ],
+            },
+            {
+              id: 'custom:custom2',
+              project: 'gerrit',
+              defining_project: 'Public-Projects',
+              ref: 'custom',
+              path: 'open',
+              description: 'Recent open changes.',
+              url: '/dashboard/?params',
+              title: 'Open Changes',
+              sections: [
+                {
+                  name: 'Open Changes',
+                  query: 'status:open project:${project} -age:7w',
+                },
+              ],
+            },
+            {
+              id: 'default:abc',
+              project: 'gerrit',
+              ref: 'default',
+            },
+            {
+              id: 'custom:custom1',
+              project: 'gerrit',
+              ref: 'custom',
+            },
+          ]));
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('dashboard table', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              {
-                id: 'default:contributor',
-                project: 'gerrit',
-                defining_project: 'gerrit',
-                ref: 'default',
-                path: 'contributor',
-                description: 'Own contributions.',
-                foreach: 'owner:self',
-                url: '/dashboard/?params',
-                title: 'Contributor Dashboard',
-                sections: [
-                  {
-                    name: 'Mine To Rebase',
-                    query: 'is:open -is:mergeable',
-                  },
-                  {
-                    name: 'My Recently Merged',
-                    query: 'is:merged limit:10',
-                  },
-                ],
-              },
-              {
-                id: 'custom:custom2',
-                project: 'gerrit',
-                defining_project: 'Public-Projects',
-                ref: 'custom',
-                path: 'open',
-                description: 'Recent open changes.',
-                url: '/dashboard/?params',
-                title: 'Open Changes',
-                sections: [
-                  {
-                    name: 'Open Changes',
-                    query: 'status:open project:${project} -age:7w',
-                  },
-                ],
-              },
-              {
-                id: 'default:abc',
-                project: 'gerrit',
-                ref: 'default',
-              },
-              {
-                id: 'custom:custom1',
-                project: 'gerrit',
-                ref: 'custom',
-              },
-            ]));
-      });
-
-      test('loading, sections, and ordering', done => {
-        assert.isTrue(element._loading);
-        assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
+    test('loading, sections, and ordering', done => {
+      assert.isTrue(element._loading);
+      assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
+          'none');
+      assert.equal(getComputedStyle(element.$.dashboards).display,
+          'none');
+      element.repo = 'test';
+      flush(() => {
+        assert.equal(getComputedStyle(element.$.loadingContainer).display,
             'none');
-        assert.equal(getComputedStyle(element.$.dashboards).display,
+        assert.notEqual(getComputedStyle(element.$.dashboards).display,
             'none');
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(getComputedStyle(element.$.loadingContainer).display,
-              'none');
-          assert.notEqual(getComputedStyle(element.$.dashboards).display,
-              'none');
 
-          assert.equal(element._dashboards.length, 2);
-          assert.equal(element._dashboards[0].section, 'custom');
-          assert.equal(element._dashboards[1].section, 'default');
+        assert.equal(element._dashboards.length, 2);
+        assert.equal(element._dashboards[0].section, 'custom');
+        assert.equal(element._dashboards[1].section, 'default');
 
-          const dashboards = element._dashboards[0].dashboards;
-          assert.equal(dashboards.length, 2);
-          assert.equal(dashboards[0].id, 'custom:custom1');
-          assert.equal(dashboards[1].id, 'custom:custom2');
+        const dashboards = element._dashboards[0].dashboards;
+        assert.equal(dashboards.length, 2);
+        assert.equal(dashboards[0].id, 'custom:custom1');
+        assert.equal(dashboards[1].id, 'custom:custom2');
 
-          done();
-        });
-      });
-    });
-
-    suite('test url', () => {
-      test('_getUrl', () => {
-        sandbox.stub(Gerrit.Nav, 'getUrlForRepoDashboard',
-            () => '/r/dashboard/test');
-
-        assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
-
-        assert.equal(element._getUrl(undefined, undefined), '');
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(
-            element.$.restAPI, 'getRepoDashboards', (repo, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        element.repo = 'test';
+        done();
       });
     });
   });
+
+  suite('test url', () => {
+    test('_getUrl', () => {
+      sandbox.stub(GerritNav, 'getUrlForRepoDashboard',
+          () => '/r/dashboard/test');
+
+      assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
+
+      assert.equal(element._getUrl(undefined, undefined), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      const response = {status: 404};
+      sandbox.stub(
+          element.$.restAPI, 'getRepoDashboards', (repo, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element.repo = 'test';
+    });
+  });
+});
 </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
deleted file mode 100644
index 2f244f8..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
+++ /dev/null
@@ -1,218 +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.
--->
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.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-create-pointer-dialog/gr-create-pointer-dialog.html">
-<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
-
-<dom-module id="gr-repo-detail-list">
-  <template>
-    <style include="gr-form-styles"></style>
-    <style include="gr-table-styles"></style>
-    <style include="shared-styles">
-      .tags td.name {
-        min-width: 25em;
-      }
-      td.name,
-      td.revision,
-      td.message {
-        word-break: break-word;
-      }
-      td.revision.tags {
-        width: 27em;
-      }
-      td.message,
-      td.tagger {
-        max-width: 15em;
-      }
-      .editing .editItem {
-        display: inherit;
-      }
-      .editItem,
-      .editing .editBtn,
-      .canEdit .revisionNoEditing,
-      .editing .revisionWithEditing,
-      .revisionEdit,
-      .hideItem {
-        display: none;
-      }
-      .revisionEdit gr-button {
-        margin-left: var(--spacing-m);
-      }
-      .editBtn {
-        margin-left: var(--spacing-l);
-      }
-      .canEdit .revisionEdit{
-        align-items: center;
-        display: flex;
-      }
-      .deleteButton:not(.show) {
-        display: none;
-      }
-      .tagger.hide {
-        display: none;
-      }
-    </style>
-    <style include="gr-table-styles"></style>
-    <gr-list-view
-        create-new="[[_loggedIn]]"
-        filter="[[_filter]]"
-        items-per-page="[[_itemsPerPage]]"
-        items="[[_items]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_getPath(_repo, detailType)]]">
-      <table id="list" class="genericList gr-form-styles">
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="revision topHeader">Revision</th>
-          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
-            Message</th>
-          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
-            Tagger</th>
-          <th class="repositoryBrowser topHeader">
-            Repository Browser</th>
-          <th class="delete topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
-          <template is="dom-repeat" items="[[_shownItems]]">
-            <tr class="table">
-              <td class$="[[detailType]] name">[[_stripRefs(item.ref, detailType)]]</td>
-              <td class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]">
-                <span class="revisionNoEditing">
-                  [[item.revision]]
-                </span>
-                <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
-                  <span class="revisionWithEditing">
-                    [[item.revision]]
-                  </span>
-                  <gr-button
-                      link
-                      on-click="_handleEditRevision"
-                      class="editBtn">
-                    edit
-                  </gr-button>
-                  <iron-input
-                      bind-value="{{_revisedRef}}"
-                      class="editItem">
-                    <input
-                        is="iron-input"
-                        bind-value="{{_revisedRef}}">
-                  </iron-input>
-                  <gr-button
-                      link
-                      on-click="_handleCancelRevision"
-                      class="cancelBtn editItem">
-                    Cancel
-                  </gr-button>
-                  <gr-button
-                      link
-                      on-click="_handleSaveRevision"
-                      class="saveBtn editItem"
-                      disabled="[[!_revisedRef]]">
-                    Save
-                  </gr-button>
-                </span>
-              </td>
-              <td class$="message [[_hideIfBranch(detailType)]]">
-                [[_computeMessage(item.message)]]
-              </td>
-              <td class$="tagger [[_hideIfBranch(detailType)]]">
-                <div class$="tagger [[_computeHideTagger(item.tagger)]]">
-                  <gr-account-link
-                      account="[[item.tagger]]">
-                  </gr-account-link>
-                  (<gr-date-formatter
-                      has-tooltip
-                      date-str="[[item.tagger.date]]">
-                  </gr-date-formatter>)
-                </div>
-              </td>
-              <td class="repositoryBrowser">
-                <template is="dom-repeat"
-                    items="[[_computeWeblink(item)]]" as="link">
-                  <a href$="[[link.url]]"
-                      class="webLink"
-                      rel="noopener"
-                      target="_blank">
-                    ([[link.name]])
-                  </a>
-                </template>
-              </td>
-              <td class="delete">
-                <gr-button
-                    link
-                    class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
-                    on-click="_handleDeleteItem">
-                  Delete
-                </gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="overlay" with-backdrop>
-        <gr-confirm-delete-item-dialog
-            class="confirmDialog"
-            on-confirm="_handleDeleteItemConfirm"
-            on-cancel="_handleConfirmDialogCancel"
-            item="[[_refName]]"
-            item-type="[[detailType]]"></gr-confirm-delete-item-dialog>
-      </gr-overlay>
-    </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          id="createDialog"
-          disabled="[[!_hasNewItemName]]"
-          confirm-label="Create"
-          on-confirm="_handleCreateItem"
-          on-cancel="_handleCloseCreate">
-        <div class="header" slot="header">
-          Create [[_computeItemName(detailType)]]
-        </div>
-        <div class="main" slot="main">
-          <gr-create-pointer-dialog
-              id="createNewModal"
-              detail-type="[[_computeItemName(detailType)]]"
-              has-new-item-name="{{_hasNewItemName}}"
-              item-detail="[[detailType]]"
-              repo-name="[[_repo]]"></gr-create-pointer-dialog>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-detail-list.js"></script>
-</dom-module>
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..e8b3d9a 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
@@ -14,23 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  const DETAIL_TYPES = {
-    BRANCHES: 'branches',
-    TAGS: 'tags',
-  };
+import '@polymer/iron-input/iron-input.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-pointer-dialog/gr-create-pointer-dialog.js';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-detail-list_html.js';
+import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
-  const PGP_START = '-----BEGIN PGP SIGNATURE-----';
+const DETAIL_TYPES = {
+  BRANCHES: 'branches',
+  TAGS: 'tags',
+};
 
-  Polymer({
-    is: 'gr-repo-detail-list',
+const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
+/**
+ * @appliesMixin ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrRepoDetailList extends mixinBehaviors( [
+  ListViewBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-detail-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -80,203 +113,202 @@
       _hasNewItemName: Boolean,
       _isEditing: Boolean,
       _revisedRef: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.ListViewBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  _determineIfOwner(repo) {
+    return this.$.restAPI.getRepoAccess(repo)
+        .then(access =>
+          this._isOwner = access && !!access[repo].is_owner);
+  }
 
-    _determineIfOwner(repo) {
-      return this.$.restAPI.getRepoAccess(repo)
-          .then(access =>
-            this._isOwner = access && !!access[repo].is_owner);
-    },
+  _paramsChanged(params) {
+    if (!params || !params.repo) { return; }
 
-    _paramsChanged(params) {
-      if (!params || !params.repo) { return; }
+    this._repo = params.repo;
 
-      this._repo = params.repo;
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this._determineIfOwner(this._repo);
+      }
+    });
 
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this._determineIfOwner(this._repo);
-        }
+    this.detailType = params.detail;
+
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+
+    return this._getItems(this._filter, this._repo,
+        this._itemsPerPage, this._offset, this.detailType);
+  }
+
+  _getItems(filter, repo, itemsPerPage, offset, detailType) {
+    this._loading = true;
+    this._items = [];
+    flush();
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
+    if (detailType === DETAIL_TYPES.BRANCHES) {
+      return this.$.restAPI.getRepoBranches(
+          filter, repo, itemsPerPage, offset, errFn).then(items => {
+        if (!items) { return; }
+        this._items = items;
+        this._loading = false;
       });
-
-      this.detailType = params.detail;
-
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
-
-      return this._getItems(this._filter, this._repo,
-          this._itemsPerPage, this._offset, this.detailType);
-    },
-
-    _getItems(filter, repo, itemsPerPage, offset, detailType) {
-      this._loading = true;
-      this._items = [];
-      Polymer.dom.flush();
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return this.$.restAPI.getRepoBranches(
-            filter, repo, itemsPerPage, offset, errFn).then(items => {
-          if (!items) { return; }
-          this._items = items;
-          this._loading = false;
-        });
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return this.$.restAPI.getRepoTags(
-            filter, repo, itemsPerPage, offset, errFn).then(items => {
-          if (!items) { return; }
-          this._items = items;
-          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) {
-        return item.replace('refs/heads/', '');
-      } 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 => {
-        if (res.status < 400) {
-          this._isEditing = false;
-          e.model.set('item.revision', ref);
-          // This is needed to refresh _items property with fresh data,
-          // specifically can_delete from the json response.
-          this._getItems(
-              this._filter, this._repo, this._itemsPerPage,
-              this._offset, this.detailType);
-        }
+    } else if (detailType === DETAIL_TYPES.TAGS) {
+      return this.$.restAPI.getRepoTags(
+          filter, repo, itemsPerPage, offset, errFn).then(items => {
+        if (!items) { return; }
+        this._items = items;
+        this._loading = false;
       });
-    },
+    }
+  }
 
-    _computeItemName(detailType) {
-      if (detailType === DETAIL_TYPES.BRANCHES) {
-        return 'Branch';
-      } else if (detailType === DETAIL_TYPES.TAGS) {
-        return 'Tag';
+  _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) {
+      return item.replace('refs/heads/', '');
+    } 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 => {
+      if (res.status < 400) {
+        this._isEditing = false;
+        e.model.set('item.revision', ref);
+        // This is needed to refresh _items property with fresh data,
+        // specifically can_delete from the json response.
+        this._getItems(
+            this._filter, this._repo, this._itemsPerPage,
+            this._offset, this.detailType);
       }
-    },
+    });
+  }
 
-    _handleDeleteItemConfirm() {
-      this.$.overlay.close();
-      if (this.detailType === DETAIL_TYPES.BRANCHES) {
-        return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this._getItems(
-                    this._filter, this._repo, this._itemsPerPage,
-                    this._offset, this.detailType);
-              }
-            });
-      } else if (this.detailType === DETAIL_TYPES.TAGS) {
-        return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
-            .then(itemDeleted => {
-              if (itemDeleted.status === 204) {
-                this._getItems(
-                    this._filter, this._repo, this._itemsPerPage,
-                    this._offset, this.detailType);
-              }
-            });
-      }
-    },
+  _computeItemName(detailType) {
+    if (detailType === DETAIL_TYPES.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === DETAIL_TYPES.TAGS) {
+      return 'Tag';
+    }
+  }
 
-    _handleConfirmDialogCancel() {
-      this.$.overlay.close();
-    },
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    if (this.detailType === DETAIL_TYPES.BRANCHES) {
+      return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204) {
+              this._getItems(
+                  this._filter, this._repo, this._itemsPerPage,
+                  this._offset, this.detailType);
+            }
+          });
+    } else if (this.detailType === DETAIL_TYPES.TAGS) {
+      return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
+          .then(itemDeleted => {
+            if (itemDeleted.status === 204) {
+              this._getItems(
+                  this._filter, this._repo, this._itemsPerPage,
+                  this._offset, this.detailType);
+            }
+          });
+    }
+  }
 
-    _handleDeleteItem(e) {
-      const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
-      if (!name) { return; }
-      this._refName = name;
-      this.$.overlay.open();
-    },
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
 
-    _computeHideDeleteClass(owner, canDelete) {
-      if (canDelete || owner) {
-        return 'show';
-      }
+  _handleDeleteItem(e) {
+    const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
+    if (!name) { return; }
+    this._refName = name;
+    this.$.overlay.open();
+  }
 
-      return '';
-    },
+  _computeHideDeleteClass(owner, canDelete) {
+    if (canDelete || owner) {
+      return 'show';
+    }
 
-    _handleCreateItem() {
-      this.$.createNewModal.handleCreateItem();
-      this._handleCloseCreate();
-    },
+    return '';
+  }
 
-    _handleCloseCreate() {
-      this.$.createOverlay.close();
-    },
+  _handleCreateItem() {
+    this.$.createNewModal.handleCreateItem();
+    this._handleCloseCreate();
+  }
 
-    _handleCreateClicked() {
-      this.$.createOverlay.open();
-    },
+  _handleCloseCreate() {
+    this.$.createOverlay.close();
+  }
 
-    _hideIfBranch(type) {
-      if (type === DETAIL_TYPES.BRANCHES) {
-        return 'hideItem';
-      }
+  _handleCreateClicked() {
+    this.$.createOverlay.open();
+  }
 
-      return '';
-    },
+  _hideIfBranch(type) {
+    if (type === DETAIL_TYPES.BRANCHES) {
+      return 'hideItem';
+    }
 
-    _computeHideTagger(tagger) {
-      return tagger ? '' : 'hide';
-    },
-  });
-})();
+    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_html.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
new file mode 100644
index 0000000..21971e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
@@ -0,0 +1,222 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    td.name,
+    td.revision,
+    td.message {
+      word-break: break-word;
+    }
+    td.revision.tags {
+      width: 27em;
+    }
+    td.message,
+    td.tagger {
+      max-width: 15em;
+    }
+    .editing .editItem {
+      display: inherit;
+    }
+    .editItem,
+    .editing .editBtn,
+    .canEdit .revisionNoEditing,
+    .editing .revisionWithEditing,
+    .revisionEdit,
+    .hideItem {
+      display: none;
+    }
+    .revisionEdit gr-button {
+      margin-left: var(--spacing-m);
+    }
+    .editBtn {
+      margin-left: var(--spacing-l);
+    }
+    .canEdit .revisionEdit {
+      align-items: center;
+      display: flex;
+    }
+    .deleteButton:not(.show) {
+      display: none;
+    }
+    .tagger.hide {
+      display: none;
+    }
+  </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]]"
+    items-per-page="[[_itemsPerPage]]"
+    items="[[_items]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    on-create-clicked="_handleCreateClicked"
+    path="[[_getPath(_repo, detailType)]]"
+  >
+    <table id="list" class="genericList gr-form-styles">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="revision topHeader">Revision</th>
+          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
+            Message
+          </th>
+          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
+            Tagger
+          </th>
+          <th class="repositoryBrowser topHeader">
+            Repository Browser
+          </th>
+          <th class="delete topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownItems]]">
+          <tr class="table">
+            <td class$="[[detailType]] name">
+              [[_stripRefs(item.ref, detailType)]]
+            </td>
+            <td
+              class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
+            >
+              <span class="revisionNoEditing">
+                [[item.revision]]
+              </span>
+              <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
+                <span class="revisionWithEditing">
+                  [[item.revision]]
+                </span>
+                <gr-button
+                  link=""
+                  on-click="_handleEditRevision"
+                  class="editBtn"
+                >
+                  edit
+                </gr-button>
+                <iron-input bind-value="{{_revisedRef}}" class="editItem">
+                  <input is="iron-input" bind-value="{{_revisedRef}}" />
+                </iron-input>
+                <gr-button
+                  link=""
+                  on-click="_handleCancelRevision"
+                  class="cancelBtn editItem"
+                >
+                  Cancel
+                </gr-button>
+                <gr-button
+                  link=""
+                  on-click="_handleSaveRevision"
+                  class="saveBtn editItem"
+                  disabled="[[!_revisedRef]]"
+                >
+                  Save
+                </gr-button>
+              </span>
+            </td>
+            <td class$="message [[_hideIfBranch(detailType)]]">
+              [[_computeMessage(item.message)]]
+            </td>
+            <td class$="tagger [[_hideIfBranch(detailType)]]">
+              <div class$="tagger [[_computeHideTagger(item.tagger)]]">
+                <gr-account-link account="[[item.tagger]]"> </gr-account-link>
+                (<gr-date-formatter
+                  has-tooltip=""
+                  date-str="[[item.tagger.date]]"
+                >
+                </gr-date-formatter
+                >)
+              </div>
+            </td>
+            <td class="repositoryBrowser">
+              <template
+                is="dom-repeat"
+                items="[[_computeWeblink(item)]]"
+                as="link"
+              >
+                <a
+                  href$="[[link.url]]"
+                  class="webLink"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  ([[link.name]])
+                </a>
+              </template>
+            </td>
+            <td class="delete">
+              <gr-button
+                link=""
+                class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
+                on-click="_handleDeleteItem"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+    <gr-overlay id="overlay" with-backdrop="">
+      <gr-confirm-delete-item-dialog
+        class="confirmDialog"
+        on-confirm="_handleDeleteItemConfirm"
+        on-cancel="_handleConfirmDialogCancel"
+        item="[[_refName]]"
+        item-type="[[detailType]]"
+      ></gr-confirm-delete-item-dialog>
+    </gr-overlay>
+  </gr-list-view>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      id="createDialog"
+      disabled="[[!_hasNewItemName]]"
+      confirm-label="Create"
+      on-confirm="_handleCreateItem"
+      on-cancel="_handleCloseCreate"
+    >
+      <div class="header" slot="header">
+        Create [[_computeItemName(detailType)]]
+      </div>
+      <div class="main" slot="main">
+        <gr-create-pointer-dialog
+          id="createNewModal"
+          detail-type="[[_computeItemName(detailType)]]"
+          has-new-item-name="{{_hasNewItemName}}"
+          item-detail="[[detailType]]"
+          repo-name="[[_repo]]"
+        ></gr-create-pointer-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..9d7bba4 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-repo-detail-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,40 +32,45 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const branchGenerator = () => {
-    return {
-      ref: `refs/heads/test${++counter}`,
-      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
-        },
-      ],
-    };
-  };
-  const tagGenerator = () => {
-    return {
-      ref: `refs/tags/test${++counter}`,
-      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
-        },
-      ],
-      message: 'Annotated tag',
-      tagger: {
-        name: 'Test User',
-        email: 'test.user@gmail.com',
-        date: '2017-09-19 14:54:00.000000000',
-        tz: 540,
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-detail-list.js';
+import page from 'page/page.mjs';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+let counter;
+const branchGenerator = () => {
+  return {
+    ref: `refs/heads/test${++counter}`,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
       },
-    };
+    ],
   };
+};
+const tagGenerator = () => {
+  return {
+    ref: `refs/tags/test${++counter}`,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
+      },
+    ],
+    message: 'Annotated tag',
+    tagger: {
+      name: 'Test User',
+      email: 'test.user@gmail.com',
+      date: '2017-09-19 14:54:00.000000000',
+      tz: 540,
+    },
+  };
+};
 
+suite('gr-repo-detail-list', () => {
   suite('Branches', () => {
     let element;
     let branches;
@@ -142,21 +144,21 @@
             }));
         element._determineIfOwner('test').then(() => {
           assert.equal(element._isOwner, false);
-          assert.equal(getComputedStyle(Polymer.dom(element.root)
+          assert.equal(getComputedStyle(dom(element.root)
               .querySelector('.revisionNoEditing')).display, 'inline');
-          assert.equal(getComputedStyle(Polymer.dom(element.root)
+          assert.equal(getComputedStyle(dom(element.root)
               .querySelector('.revisionEdit')).display, 'none');
           done();
         });
       });
 
       test('Edit HEAD button admin', done => {
-        const saveBtn = Polymer.dom(element.root).querySelector('.saveBtn');
-        const cancelBtn = Polymer.dom(element.root).querySelector('.cancelBtn');
-        const editBtn = Polymer.dom(element.root).querySelector('.editBtn');
-        const revisionNoEditing = Polymer.dom(element.root)
+        const saveBtn = dom(element.root).querySelector('.saveBtn');
+        const cancelBtn = dom(element.root).querySelector('.cancelBtn');
+        const editBtn = dom(element.root).querySelector('.editBtn');
+        const revisionNoEditing = dom(element.root)
             .querySelector('.revisionNoEditing');
-        const revisionWithEditing = Polymer.dom(element.root)
+        const revisionWithEditing = dom(element.root)
             .querySelector('.revisionWithEditing');
 
         sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
@@ -171,7 +173,7 @@
           assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
 
           // The revision container for editing enabled row is visible.
-          assert.notEqual(getComputedStyle(Polymer.dom(element.root)
+          assert.notEqual(getComputedStyle(dom(element.root)
               .querySelector('.revisionEdit')).display, 'none');
 
           // The revision and edit button are visible.
@@ -180,7 +182,7 @@
           assert.notEqual(getComputedStyle(editBtn).display, 'none');
 
           // The input, cancel, and save buttons are not visible.
-          const hiddenElements = Polymer.dom(element.root)
+          const hiddenElements = dom(element.root)
               .querySelectorAll('.canEdit .editItem');
 
           for (const item of hiddenElements) {
@@ -194,7 +196,7 @@
           assert.equal(getComputedStyle(editBtn).display, 'none');
 
           // The input, cancel, and save buttons are not visible.
-          for (item of hiddenElements) {
+          for (const item of hiddenElements) {
             assert.notEqual(getComputedStyle(item).display, 'none');
           }
 
@@ -297,9 +299,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 +483,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',
@@ -506,7 +510,11 @@
     suite('create new', () => {
       test('_handleCreateClicked called when create-click fired', () => {
         sandbox.stub(element, '_handleCreateClicked');
-        element.$$('gr-list-view').fire('create-clicked');
+        element.shadowRoot
+            .querySelector('gr-list-view').dispatchEvent(
+                new CustomEvent('create-clicked', {
+                  composed: true, bubbles: true,
+                }));
         assert.isTrue(element._handleCreateClicked.called);
       });
 
@@ -518,13 +526,19 @@
 
       test('_handleCreateItem called when confirm fired', () => {
         sandbox.stub(element, '_handleCreateItem');
-        element.$.createDialog.fire('confirm');
+        element.$.createDialog.dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
         assert.isTrue(element._handleCreateItem.called);
       });
 
       test('_handleCloseCreate called when cancel fired', () => {
         sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.fire('cancel');
+        element.$.createDialog.dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
         assert.isTrue(element._handleCloseCreate.called);
       });
     });
@@ -558,4 +572,5 @@
       assert.deepEqual(element._computeHideDeleteClass(false, false), '');
     });
   });
+});
 </script>
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
deleted file mode 100644
index 5e82c1e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
+++ /dev/null
@@ -1,113 +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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.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-list-view/gr-list-view.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-create-repo-dialog/gr-create-repo-dialog.html">
-
-<dom-module id="gr-repo-list">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
-    <style>
-      .genericList tr td:last-of-type {
-        text-align: left;
-      }
-      .genericList tr th:last-of-type {
-        text-align: left;
-      }
-      .readOnly {
-        text-align: center;
-      }
-      .changesLink, .name, .repositoryBrowser, .readOnly {
-        white-space:nowrap;
-      }
-    </style>
-    <gr-list-view
-        create-new=[[_createNewCapability]]
-        filter="[[_filter]]"
-        items-per-page="[[_reposPerPage]]"
-        items="[[_repos]]"
-        loading="[[_loading]]"
-        offset="[[_offset]]"
-        on-create-clicked="_handleCreateClicked"
-        path="[[_path]]">
-      <table id="list" class="genericList">
-        <tr class="headerRow">
-          <th class="name topHeader">Repository Name</th>
-          <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="changesLink topHeader">Changes</th>
-          <th class="topHeader readOnly">Read only</th>
-          <th class="description topHeader">Repository Description</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
-          <template is="dom-repeat" items="[[_shownRepos]]">
-            <tr class="table">
-              <td class="name">
-                <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
-              </td>
-              <td class="repositoryBrowser">
-                <template is="dom-repeat"
-                    items="[[_computeWeblink(item)]]" as="link">
-                  <a href$="[[link.url]]"
-                      class="webLink"
-                      rel="noopener"
-                      target="_blank">
-                    [[link.name]]
-                  </a>
-                </template>
-              </td>
-              <td class="changesLink"><a href$="[[_computeChangesLink(item.name)]]">view all</a></td>
-              <td class="readOnly">[[_readOnly(item)]]</td>
-              <td class="description">[[item.description]]</td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </gr-list-view>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          id="createDialog"
-          class="confirmDialog"
-          disabled="[[!_hasNewRepoName]]"
-          confirm-label="Create"
-          on-confirm="_handleCreateRepo"
-          on-cancel="_handleCloseCreate">
-        <div class="header" slot="header">
-          Create Repository
-        </div>
-        <div class="main" slot="main">
-          <gr-create-repo-dialog
-              has-new-repo-name="{{_hasNewRepoName}}"
-              params="[[params]]"
-              id="createNewModal"></gr-create-repo-dialog>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-list.js"></script>
-</dom-module>
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 5de844f..1f33b25 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
@@ -14,16 +14,41 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-repo-list',
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-repo-dialog/gr-create-repo-dialog.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-list_html.js';
+import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
+/**
+ * @appliesMixin ListViewMixin
+ * @extends Polymer.Element
+ */
+class GrRepoList extends mixinBehaviors( [
+  ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-list'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -67,97 +92,100 @@
         type: String,
         value: '',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.ListViewBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this._getCreateRepoCapability();
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'Repos'},
+      composed: true, bubbles: true,
+    }));
+    this._maybeOpenCreateOverlay(this.params);
+  }
 
-    attached() {
-      this._getCreateRepoCapability();
-      this.fire('title-change', {title: 'Repos'});
-      this._maybeOpenCreateOverlay(this.params);
-    },
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
 
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
-      this._offset = this.getOffsetValue(params);
+    return this._getRepos(this._filter, this._reposPerPage,
+        this._offset);
+  }
 
-      return this._getRepos(this._filter, this._reposPerPage,
-          this._offset);
-    },
-
-    /**
-     * Opens the create overlay if the route has a hash 'create'
-     *
-     * @param {!Object} params
-     */
-    _maybeOpenCreateOverlay(params) {
-      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 => {
-        if (!account) { return; }
-        return this.$.restAPI.getAccountCapabilities(['createProject'])
-            .then(capabilities => {
-              if (capabilities.createProject) {
-                this._createNewCapability = true;
-              }
-            });
-      });
-    },
-
-    _getRepos(filter, reposPerPage, offset) {
-      this._repos = [];
-      return this.$.restAPI.getRepos(filter, reposPerPage, offset)
-          .then(repos => {
-            // Late response.
-            if (filter !== this._filter || !repos) { return; }
-            this._repos = repos.filter(repo => repo.name.includes(filter));
-            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() {
+  /**
+   * Opens the create overlay if the route has a hash 'create'
+   *
+   * @param {!Object} params
+   */
+  _maybeOpenCreateOverlay(params) {
+    if (params && params.openCreateModal) {
       this.$.createOverlay.open();
-    },
+    }
+  }
 
-    _readOnly(item) {
-      return item.state === 'READ_ONLY' ? 'Y' : '';
-    },
+  _computeRepoUrl(name) {
+    return this.getUrl(this._path + '/', name);
+  }
 
-    _computeWeblink(repo) {
-      if (!repo.web_links) { return ''; }
-      const webLinks = repo.web_links;
-      return webLinks.length ? webLinks : null;
-    },
-  });
-})();
+  _computeChangesLink(name) {
+    return GerritNav.getUrlForProjectChanges(name);
+  }
+
+  _getCreateRepoCapability() {
+    return this.$.restAPI.getAccount().then(account => {
+      if (!account) { return; }
+      return this.$.restAPI.getAccountCapabilities(['createProject'])
+          .then(capabilities => {
+            if (capabilities.createProject) {
+              this._createNewCapability = true;
+            }
+          });
+    });
+  }
+
+  _getRepos(filter, reposPerPage, offset) {
+    this._repos = [];
+    return this.$.restAPI.getRepos(filter, reposPerPage, offset)
+        .then(repos => {
+          // Late response.
+          if (filter !== this._filter || !repos) { return; }
+          this._repos = repos.filter(repo => repo.name.includes(filter));
+          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_html.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
new file mode 100644
index 0000000..3681399
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    .genericList tr th:last-of-type {
+      text-align: left;
+    }
+    .readOnly {
+      text-align: center;
+    }
+    .changesLink,
+    .name,
+    .repositoryBrowser,
+    .readOnly {
+      white-space: nowrap;
+    }
+  </style>
+  <gr-list-view
+    create-new="[[_createNewCapability]]"
+    filter="[[_filter]]"
+    items-per-page="[[_reposPerPage]]"
+    items="[[_repos]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    on-create-clicked="_handleCreateClicked"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Repository Name</th>
+          <th class="repositoryBrowser topHeader">Repository Browser</th>
+          <th class="changesLink topHeader">Changes</th>
+          <th class="topHeader readOnly">Read only</th>
+          <th class="description topHeader">Repository Description</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownRepos]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
+            </td>
+            <td class="repositoryBrowser">
+              <template
+                is="dom-repeat"
+                items="[[_computeWeblink(item)]]"
+                as="link"
+              >
+                <a
+                  href$="[[link.url]]"
+                  class="webLink"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  [[link.name]]
+                </a>
+              </template>
+            </td>
+            <td class="changesLink">
+              <a href$="[[_computeChangesLink(item.name)]]">view all</a>
+            </td>
+            <td class="readOnly">[[_readOnly(item)]]</td>
+            <td class="description">[[item.description]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      id="createDialog"
+      class="confirmDialog"
+      disabled="[[!_hasNewRepoName]]"
+      confirm-label="Create"
+      on-confirm="_handleCreateRepo"
+      on-cancel="_handleCloseCreate"
+    >
+      <div class="header" slot="header">
+        Create Repository
+      </div>
+      <div class="main" slot="main">
+        <gr-create-repo-dialog
+          has-new-repo-name="{{_hasNewRepoName}}"
+          params="[[params]]"
+          id="createNewModal"
+        ></gr-create-repo-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 625312d..e848980 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-repo-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,166 +32,179 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const repoGenerator = () => {
-    return {
-      id: `test${++counter}`,
-      name: `test`,
-      state: 'ACTIVE',
-      web_links: [
-        {
-          name: 'diffusion',
-          url: `https://phabricator.example.org/r/project/test${counter}`,
-        },
-      ],
-    };
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-list.js';
+import page from 'page/page.mjs';
+
+let counter;
+const repoGenerator = () => {
+  return {
+    id: `test${++counter}`,
+    name: `test`,
+    state: 'ACTIVE',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://phabricator.example.org/r/project/test${counter}`,
+      },
+    ],
   };
+};
 
-  suite('gr-repo-list tests', () => {
-    let element;
-    let repos;
-    let sandbox;
-    let value;
+suite('gr-repo-list tests', () => {
+  let element;
+  let repos;
+  let sandbox;
+  let value;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(page, 'show');
-      element = fixture('basic');
-      counter = 0;
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(page, 'show');
+    element = fixture('basic');
+    counter = 0;
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    suite('list with repos', () => {
-      setup(done => {
-        repos = _.times(26, repoGenerator);
-        stub('gr-rest-api-interface', {
-          getRepos(num, offset) {
-            return Promise.resolve(repos);
-          },
-        });
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('test for test repo in the list', done => {
-        flush(() => {
-          assert.equal(element._repos[1].id, 'test2');
-          done();
-        });
-      });
-
-      test('_shownRepos', () => {
-        assert.equal(element._shownRepos.length, 25);
-      });
-
-      test('_maybeOpenCreateOverlay', () => {
-        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
-        element._maybeOpenCreateOverlay();
-        assert.isFalse(overlayOpen.called);
-        const params = {};
-        element._maybeOpenCreateOverlay(params);
-        assert.isFalse(overlayOpen.called);
-        params.openCreateModal = true;
-        element._maybeOpenCreateOverlay(params);
-        assert.isTrue(overlayOpen.called);
-      });
-    });
-
-    suite('list with less then 25 repos', () => {
-      setup(done => {
-        repos = _.times(25, repoGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepos(num, offset) {
-            return Promise.resolve(repos);
-          },
-        });
-
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('_shownRepos', () => {
-        assert.equal(element._shownRepos.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      setup(() => {
-        repos = _.times(25, repoGenerator);
-        reposFiltered = _.times(1, repoGenerator);
-      });
-
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getRepos', () => {
+  suite('list with repos', () => {
+    setup(done => {
+      repos = _.times(26, repoGenerator);
+      stub('gr-rest-api-interface', {
+        getRepos(num, offset) {
           return Promise.resolve(repos);
-        });
-        const value = {
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(value).then(() => {
-          assert.isTrue(element.$.restAPI.getRepos.lastCall
-              .calledWithExactly('test', 25, 25));
-          done();
-        });
+        },
       });
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
 
-      test('latest repos requested are always set', done => {
-        const repoStub = sandbox.stub(element.$.restAPI, 'getRepos');
-        repoStub.withArgs('test').returns(Promise.resolve(repos));
-        repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
-        element._filter = 'test';
-
-        // Repos are not set because the element._filter differs.
-        element._getRepos('filter', 25, 0).then(() => {
-          assert.deepEqual(element._repos, []);
-          done();
-        });
+    test('test for test repo in the list', done => {
+      flush(() => {
+        assert.equal(element._repos[1].id, 'test2');
+        done();
       });
     });
 
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
+    test('_shownRepos', () => {
+      assert.equal(element._shownRepos.length, 25);
+    });
 
-        element._loading = false;
-        element._repos = _.times(25, repoGenerator);
+    test('_maybeOpenCreateOverlay', () => {
+      const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+      element._maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      const params = {};
+      element._maybeOpenCreateOverlay(params);
+      assert.isFalse(overlayOpen.called);
+      params.openCreateModal = true;
+      element._maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
 
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+  suite('list with less then 25 repos', () => {
+    setup(done => {
+      repos = _.times(25, repoGenerator);
+
+      stub('gr-rest-api-interface', {
+        getRepos(num, offset) {
+          return Promise.resolve(repos);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('_shownRepos', () => {
+      assert.equal(element._shownRepos.length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    let reposFiltered;
+    setup(() => {
+      repos = _.times(25, repoGenerator);
+      reposFiltered = _.times(1, repoGenerator);
+    });
+
+    test('_paramsChanged', done => {
+      sandbox.stub(element.$.restAPI, 'getRepos', () => Promise.resolve(repos));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getRepos.lastCall
+            .calledWithExactly('test', 25, 25));
+        done();
       });
     });
 
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sandbox.stub(element, '_handleCreateClicked');
-        element.$$('gr-list-view').fire('create-clicked');
-        assert.isTrue(element._handleCreateClicked.called);
-      });
+    test('latest repos requested are always set', done => {
+      const repoStub = sandbox.stub(element.$.restAPI, 'getRepos');
+      repoStub.withArgs('test').returns(Promise.resolve(repos));
+      repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
+      element._filter = 'test';
 
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sandbox.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateRepo called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateRepo');
-        element.$.createDialog.fire('confirm');
-        assert.isTrue(element._handleCreateRepo.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.fire('cancel');
-        assert.isTrue(element._handleCloseCreate.called);
+      // Repos are not set because the element._filter differs.
+      element._getRepos('filter', 25, 0).then(() => {
+        assert.deepEqual(element._repos, []);
+        done();
       });
     });
   });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._repos = _.times(25, repoGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sandbox.stub(element, '_handleCreateClicked');
+      element.shadowRoot
+          .querySelector('gr-list-view').dispatchEvent(
+              new CustomEvent('create-clicked', {
+                composed: true, bubbles: true,
+              }));
+      assert.isTrue(element._handleCreateClicked.called);
+    });
+
+    test('_handleCreateClicked opens modal', () => {
+      const openStub = sandbox.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateRepo called when confirm fired', () => {
+      sandbox.stub(element, '_handleCreateRepo');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateRepo.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sandbox.stub(element, '_handleCloseCreate');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index d2093e4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
+++ /dev/null
@@ -1,115 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-
-<link rel="import" href="../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../gr-plugin-config-array-editor/gr-plugin-config-array-editor.html">
-
-<dom-module id="gr-repo-plugin-config">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles"></style>
-    <style include="gr-subpage-styles">
-      .inherited {
-        color: var(--deemphasized-text-color);
-        margin-left: var(--spacing-m);
-      }
-      section.section:not(.ARRAY) .title {
-        align-items: center;
-        display: flex;
-      }
-      section.section.ARRAY .title {
-        padding-top: var(--spacing-m);
-      }
-    </style>
-    <div class="gr-form-styles">
-      <fieldset>
-        <h4>[[pluginData.name]]</h4>
-        <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
-          <section class$="section [[option.info.type]]">
-            <span class="title">
-              <gr-tooltip-content
-                  has-tooltip="[[option.info.description]]"
-                  show-icon="[[option.info.description]]"
-                  title="[[option.info.description]]">
-                <span>[[option.info.display_name]]</span>
-              </gr-tooltip-content>
-            </span>
-            <span class="value">
-              <template is="dom-if" if="[[_isArray(option.info.type)]]">
-                <gr-plugin-config-array-editor
-                    on-plugin-config-option-changed="_handleArrayChange"
-                    plugin-option="[[option]]"></gr-plugin-config-array-editor>
-              </template>
-              <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
-                <paper-toggle-button
-                    checked="[[_computeChecked(option.info.value)]]"
-                    on-change="_handleBooleanChange"
-                    data-option-key$="[[option._key]]"
-                    disabled$="[[_computeDisabled(option.info.editable)]]"></paper-toggle-button>
-              </template>
-              <template is="dom-if" if="[[_isList(option.info.type)]]">
-                <gr-select
-                    bind-value$="[[option.info.value]]"
-                    on-change="_handleListChange">
-                  <select
-                      data-option-key$="[[option._key]]"
-                      disabled$="[[_computeDisabled(option.info.editable)]]">
-                    <template is="dom-repeat"
-                        items="[[option.info.permitted_values]]"
-                        as="value">
-                      <option value$="[[value]]">[[value]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </template>
-              <template is="dom-if" if="[[_isString(option.info.type)]]">
-                <iron-input
-                    bind-value="[[option.info.value]]"
-                    on-input="_handleStringChange"
-                    data-option-key$="[[option._key]]"
-                    disabled$="[[_computeDisabled(option.info.editable)]]">
-                  <input
-                      is="iron-input"
-                      value="[[option.info.value]]"
-                      on-input="_handleStringChange"
-                      data-option-key$="[[option._key]]"
-                      disabled$="[[_computeDisabled(option.info.editable)]]">
-                </iron-input>
-              </template>
-              <template is="dom-if" if="[[option.info.inherited_value]]">
-                <span class="inherited">
-                  (Inherited: [[option.info.inherited_value]])
-                </span>
-              </template>
-            </span>
-          </section>
-        </template>
-      </fieldset>
-    </div>
-  </template>
-  <script src="gr-repo-plugin-config.js"></script>
-</dom-module>
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..8a01f93 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
@@ -14,120 +14,151 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-repo-plugin-config',
+import '@polymer/iron-input/iron-input.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-plugin-config_html.js';
+import {RepoPluginConfig} from '../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js';
 
-    /**
-     * Fired when the plugin config changes.
-     *
-     * @event plugin-config-changed
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrRepoPluginConfig extends mixinBehaviors( [
+  RepoPluginConfig,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /** @type {?} */
+  static get is() { return 'gr-repo-plugin-config'; }
+  /**
+   * Fired when the plugin config changes.
+   *
+   * @event plugin-config-changed
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
       pluginData: Object,
       /** @type {Array} */
       _pluginConfigOptions: {
         type: Array,
         computed: '_computePluginConfigOptions(pluginData.*)',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.RepoPluginConfig,
-    ],
+  _computePluginConfigOptions(dataRecord) {
+    if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
+      return [];
+    }
+    const {config} = dataRecord.base;
+    return Object.keys(config)
+        .map(_key => { return {_key, info: config[_key]}; });
+  }
 
-    _computePluginConfigOptions(dataRecord) {
-      if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
-        return [];
-      }
-      const {config} = dataRecord.base;
-      return Object.keys(config).map(_key => ({_key, info: config[_key]}));
-    },
+  _isArray(type) {
+    return type === this.ENTRY_TYPES.ARRAY;
+  }
 
-    _isArray(type) {
-      return type === this.ENTRY_TYPES.ARRAY;
-    },
+  _isBoolean(type) {
+    return type === this.ENTRY_TYPES.BOOLEAN;
+  }
 
-    _isBoolean(type) {
-      return type === this.ENTRY_TYPES.BOOLEAN;
-    },
+  _isList(type) {
+    return type === this.ENTRY_TYPES.LIST;
+  }
 
-    _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;
+  }
 
-    _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';
+  }
 
-    _computeDisabled(editable) {
-      return editable === 'false';
-    },
+  /**
+   * @param {string} value - fallback to 'false' if undefined
+   */
+  _computeChecked(value = 'false') {
+    return JSON.parse(value);
+  }
 
-    /**
-     * @param {string} value - fallback to 'false' if undefined
-     */
-    _computeChecked(value = 'false') {
-      return JSON.parse(value);
-    },
+  _handleStringChange(e) {
+    const el = dom(e).localTarget;
+    const _key = el.getAttribute('data-option-key');
+    const configChangeInfo =
+        this._buildConfigChangeInfo(el.value, _key);
+    this._handleChange(configChangeInfo);
+  }
 
-    _handleStringChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const _key = el.getAttribute('data-option-key');
-      const configChangeInfo =
-          this._buildConfigChangeInfo(el.value, _key);
-      this._handleChange(configChangeInfo);
-    },
+  _handleListChange(e) {
+    const el = dom(e).localTarget;
+    const _key = el.getAttribute('data-option-key');
+    const configChangeInfo =
+        this._buildConfigChangeInfo(el.value, _key);
+    this._handleChange(configChangeInfo);
+  }
 
-    _handleListChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const _key = el.getAttribute('data-option-key');
-      const configChangeInfo =
-          this._buildConfigChangeInfo(el.value, _key);
-      this._handleChange(configChangeInfo);
-    },
+  _handleBooleanChange(e) {
+    const el = dom(e).localTarget;
+    const _key = el.getAttribute('data-option-key');
+    const configChangeInfo =
+        this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
+    this._handleChange(configChangeInfo);
+  }
 
-    _handleBooleanChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const _key = el.getAttribute('data-option-key');
-      const configChangeInfo =
-          this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
-      this._handleChange(configChangeInfo);
-    },
+  _buildConfigChangeInfo(value, _key) {
+    const info = this.pluginData.config[_key];
+    info.value = value;
+    return {
+      _key,
+      info,
+      notifyPath: `${_key}.value`,
+    };
+  }
 
-    _buildConfigChangeInfo(value, _key) {
-      const info = this.pluginData.config[_key];
-      info.value = value;
-      return {
-        _key,
-        info,
-        notifyPath: `${_key}.value`,
-      };
-    },
+  _handleArrayChange({detail}) {
+    this._handleChange(detail);
+  }
 
-    _handleArrayChange({detail}) {
-      this._handleChange(detail);
-    },
+  _handleChange({_key, info, notifyPath}) {
+    const {name, config} = this.pluginData;
 
-    _handleChange({_key, info, notifyPath}) {
-      const {name, config} = this.pluginData;
+    /** @type {Object} */
+    const detail = {
+      name,
+      config: Object.assign(config, {[_key]: info}, {}),
+      notifyPath: `${name}.${notifyPath}`,
+    };
 
-      /** @type {Object} */
-      const detail = {
-        name,
-        config: Object.assign(config, {[_key]: info}, {}),
-        notifyPath: `${name}.${notifyPath}`,
-      };
+    this.dispatchEvent(new CustomEvent(
+        this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
+  }
 
-      this.dispatchEvent(new CustomEvent(
-          this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
-    },
-  });
-})();
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapPluginBoolean(e) {
+    e.preventDefault();
+  }
+}
+
+customElements.define(GrRepoPluginConfig.is, GrRepoPluginConfig);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
new file mode 100644
index 0000000..ee633463
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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);
+      margin-left: var(--spacing-m);
+    }
+    section.section:not(.ARRAY) .title {
+      align-items: center;
+      display: flex;
+    }
+    section.section.ARRAY .title {
+      padding-top: var(--spacing-m);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset>
+      <h4>[[pluginData.name]]</h4>
+      <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
+        <section class$="section [[option.info.type]]">
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip="[[option.info.description]]"
+              show-icon="[[option.info.description]]"
+              title="[[option.info.description]]"
+            >
+              <span>[[option.info.display_name]]</span>
+            </gr-tooltip-content>
+          </span>
+          <span class="value">
+            <template is="dom-if" if="[[_isArray(option.info.type)]]">
+              <gr-plugin-config-array-editor
+                on-plugin-config-option-changed="_handleArrayChange"
+                plugin-option="[[option]]"
+              ></gr-plugin-config-array-editor>
+            </template>
+            <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
+              <paper-toggle-button
+                checked="[[_computeChecked(option.info.value)]]"
+                on-change="_handleBooleanChange"
+                data-option-key$="[[option._key]]"
+                disabled$="[[_computeDisabled(option.info.editable)]]"
+                on-tap="_onTapPluginBoolean"
+              ></paper-toggle-button>
+            </template>
+            <template is="dom-if" if="[[_isList(option.info.type)]]">
+              <gr-select
+                bind-value$="[[option.info.value]]"
+                on-change="_handleListChange"
+              >
+                <select
+                  data-option-key$="[[option._key]]"
+                  disabled$="[[_computeDisabled(option.info.editable)]]"
+                >
+                  <template
+                    is="dom-repeat"
+                    items="[[option.info.permitted_values]]"
+                    as="value"
+                  >
+                    <option value$="[[value]]">[[value]]</option>
+                  </template>
+                </select>
+              </gr-select>
+            </template>
+            <template is="dom-if" if="[[_isString(option.info.type)]]">
+              <iron-input
+                bind-value="[[option.info.value]]"
+                on-input="_handleStringChange"
+                data-option-key$="[[option._key]]"
+                disabled$="[[_computeDisabled(option.info.editable)]]"
+              >
+                <input
+                  is="iron-input"
+                  value="[[option.info.value]]"
+                  on-input="_handleStringChange"
+                  data-option-key$="[[option._key]]"
+                  disabled$="[[_computeDisabled(option.info.editable)]]"
+                />
+              </iron-input>
+            </template>
+            <template is="dom-if" if="[[option.info.inherited_value]]">
+              <span class="inherited">
+                (Inherited: [[option.info.inherited_value]])
+              </span>
+            </template>
+          </span>
+        </section>
+      </template>
+    </fieldset>
+  </div>
+`;
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..a2370d9 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-plugin-config.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,145 +31,151 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-plugin-config tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-plugin-config.js';
+suite('gr-repo-plugin-config tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => sandbox.restore());
+
+  test('_computePluginConfigOptions', () => {
+    assert.deepEqual(element._computePluginConfigOptions(), []);
+    assert.deepEqual(element._computePluginConfigOptions({}), []);
+    assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
+    assert.deepEqual(element._computePluginConfigOptions(
+        {base: {config: {}}}), []);
+    assert.deepEqual(element._computePluginConfigOptions(
+        {base: {config: {testKey: 'testInfo'}}}),
+    [{_key: 'testKey', info: 'testInfo'}]);
+  });
+
+  test('_computeDisabled', () => {
+    assert.isFalse(element._computeDisabled('true'));
+    assert.isTrue(element._computeDisabled('false'));
+  });
+
+  test('_handleChange', () => {
+    const eventStub = sandbox.stub(element, 'dispatchEvent');
+    element.pluginData = {
+      name: 'testName',
+      config: {plugin: {value: 'test'}},
+    };
+    element._handleChange({
+      _key: 'plugin',
+      info: {value: 'newTest'},
+      notifyPath: 'plugin.value',
+    });
+
+    assert.isTrue(eventStub.called);
+
+    const {detail} = eventStub.lastCall.args[0];
+    assert.equal(detail.name, 'testName');
+    assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
+    assert.equal(detail.notifyPath, 'testName.plugin.value');
+  });
+
+  suite('option types', () => {
+    let changeStub;
+    let buildStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      changeStub = sandbox.stub(element, '_handleChange');
+      buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
     });
 
-    teardown(() => sandbox.restore());
-
-    test('_computePluginConfigOptions', () => {
-      assert.deepEqual(element._computePluginConfigOptions(), []);
-      assert.deepEqual(element._computePluginConfigOptions({}), []);
-      assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
-      assert.deepEqual(element._computePluginConfigOptions(
-          {base: {config: {}}}), []);
-      assert.deepEqual(element._computePluginConfigOptions(
-          {base: {config: {testKey: 'testInfo'}}}),
-      [{_key: 'testKey', info: 'testInfo'}]);
-    });
-
-    test('_computeDisabled', () => {
-      assert.isFalse(element._computeDisabled('true'));
-      assert.isTrue(element._computeDisabled('false'));
-    });
-
-    test('_handleChange', () => {
-      const eventStub = sandbox.stub(element, 'dispatchEvent');
+    test('ARRAY type option', () => {
       element.pluginData = {
         name: 'testName',
-        config: {plugin: {value: 'test'}},
+        config: {plugin: {value: 'test', type: 'ARRAY'}},
       };
-      element._handleChange({
-        _key: 'plugin',
-        info: {value: 'newTest'},
-        notifyPath: 'plugin.value',
-      });
+      flushAsynchronousOperations();
 
-      assert.isTrue(eventStub.called);
-
-      const {detail} = eventStub.lastCall.args[0];
-      assert.equal(detail.name, 'testName');
-      assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
-      assert.equal(detail.notifyPath, 'testName.plugin.value');
+      const editor = element.shadowRoot
+          .querySelector('gr-plugin-config-array-editor');
+      assert.ok(editor);
+      element._handleArrayChange({detail: 'test'});
+      assert.isTrue(changeStub.called);
+      assert.equal(changeStub.lastCall.args[0], 'test');
     });
 
-    suite('option types', () => {
-      let changeStub;
-      let buildStub;
-
-      setup(() => {
-        changeStub = sandbox.stub(element, '_handleChange');
-        buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
-      });
-
-      test('ARRAY type option', () => {
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'test', type: 'ARRAY'}},
-        };
-        flushAsynchronousOperations();
-
-        const editor = element.$$('gr-plugin-config-array-editor');
-        assert.ok(editor);
-        element._handleArrayChange({detail: 'test'});
-        assert.isTrue(changeStub.called);
-        assert.equal(changeStub.lastCall.args[0], 'test');
-      });
-
-      test('BOOLEAN type option', () => {
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'true', type: 'BOOLEAN'}},
-        };
-        flushAsynchronousOperations();
-
-        const toggle = element.$$('paper-toggle-button');
-        assert.ok(toggle);
-        toggle.click();
-        flushAsynchronousOperations();
-
-        assert.isTrue(buildStub.called);
-        assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
-
-        assert.isTrue(changeStub.called);
-      });
-
-      test('INT/LONG/STRING type option', () => {
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'test', type: 'STRING'}},
-        };
-        flushAsynchronousOperations();
-
-        const input = element.$$('input');
-        assert.ok(input);
-        input.value = 'newTest';
-        input.dispatchEvent(new Event('input'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(buildStub.called);
-        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-        assert.isTrue(changeStub.called);
-      });
-
-      test('LIST type option', () => {
-        const permitted_values = ['test', 'newTest'];
-        element.pluginData = {
-          name: 'testName',
-          config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
-        };
-        flushAsynchronousOperations();
-
-        const select = element.$$('select');
-        assert.ok(select);
-        select.value = 'newTest';
-        select.dispatchEvent(new Event(
-            'change', {bubbles: true, composed: true}));
-        flushAsynchronousOperations();
-
-        assert.isTrue(buildStub.called);
-        assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-        assert.isTrue(changeStub.called);
-      });
-    });
-
-    test('_buildConfigChangeInfo', () => {
+    test('BOOLEAN type option', () => {
       element.pluginData = {
         name: 'testName',
-        config: {plugin: {value: 'test'}},
+        config: {plugin: {value: 'true', type: 'BOOLEAN'}},
       };
-      const detail = element._buildConfigChangeInfo('newTest', 'plugin');
-      assert.equal(detail._key, 'plugin');
-      assert.deepEqual(detail.info, {value: 'newTest'});
-      assert.equal(detail.notifyPath, 'plugin.value');
+      flushAsynchronousOperations();
+
+      const toggle = element.shadowRoot
+          .querySelector('paper-toggle-button');
+      assert.ok(toggle);
+      toggle.click();
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('INT/LONG/STRING type option', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'STRING'}},
+      };
+      flushAsynchronousOperations();
+
+      const input = element.shadowRoot
+          .querySelector('input');
+      assert.ok(input);
+      input.value = 'newTest';
+      input.dispatchEvent(new Event('input'));
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('LIST type option', () => {
+      const permitted_values = ['test', 'newTest'];
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
+      };
+      flushAsynchronousOperations();
+
+      const select = element.shadowRoot
+          .querySelector('select');
+      assert.ok(select);
+      select.value = 'newTest';
+      select.dispatchEvent(new Event(
+          'change', {bubbles: true, composed: true}));
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
     });
   });
+
+  test('_buildConfigChangeInfo', () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {plugin: {value: 'test'}},
+    };
+    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
+    assert.equal(detail._key, 'plugin');
+    assert.deepEqual(detail.info, {value: 'newTest'});
+    assert.equal(detail.notifyPath, 'plugin.value');
+  });
+});
 </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
deleted file mode 100644
index 5de77b9..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ /dev/null
@@ -1,380 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-subpage-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-repo-plugin-config/gr-repo-plugin-config.html">
-
-<dom-module id="gr-repo">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-subpage-styles">
-      h2.edited:after {
-        color: var(--deemphasized-text-color);
-        content: ' *';
-      }
-      .loading,
-      .hide {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
-      #options .repositorySettings {
-        display: none;
-      }
-      #options .repositorySettings.showConfig {
-        display: block;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <main class="gr-form-styles read-only">
-      <style include="shared-styles"></style>
-      <div class="info">
-        <h1 id="Title" class$="name">
-          [[repo]]
-          <hr/>
-        </h1>
-        <div>
-          <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
-        </div>
-      </div>
-      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
-      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-        <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
-          <h2 id="download">Download</h2>
-          <fieldset>
-            <gr-download-commands
-                id="downloadCommands"
-                commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
-                schemes="[[_schemes]]"
-                selected-scheme="{{_selectedScheme}}"></gr-download-commands>
-          </fieldset>
-        </div>
-        <h2 id="configurations"
-            class$="[[_computeHeaderClass(_configChanged)]]">Configurations</h2>
-        <div id="form">
-          <fieldset>
-            <h3 id="Description">Description</h3>
-            <fieldset>
-              <iron-autogrow-textarea
-                  id="descriptionInput"
-                  class="description"
-                  autocomplete="on"
-                  placeholder="<Insert repo description here>"
-                  bind-value="{{_repoConfig.description}}"
-                  disabled$="[[_readOnly]]"></iron-autogrow-textarea>
-            </fieldset>
-            <h3 id="Options">Repository Options</h3>
-            <fieldset id="options">
-              <section>
-                <span class="title">State</span>
-                <span class="value">
-                  <gr-select
-                      id="stateSelect"
-                      bind-value="{{_repoConfig.state}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat" items=[[_states]]>
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Submit type</span>
-                <span class="value">
-                  <gr-select
-                      id="submitTypeSelect"
-                      bind-value="{{_repoConfig.submit_type}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatSubmitTypeSelect(_repoConfig)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Allow content merges</span>
-                <span class="value">
-                  <gr-select
-                      id="contentMergeSelect"
-                      bind-value="{{_repoConfig.use_content_merge.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Create a new change for every commit not in the target branch
-                </span>
-                <span class="value">
-                  <gr-select
-                      id="newChangeSelect"
-                      bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Require Change-Id in commit message</span>
-                <span class="value">
-                  <gr-select
-                      id="requireChangeIdSelect"
-                      bind-value="{{_repoConfig.require_change_id.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section
-                   id="enableSignedPushSettings"
-                   class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]">
-                <span class="title">Enable signed push</span>
-                <span class="value">
-                  <gr-select
-                      id="enableSignedPush"
-                      bind-value="{{_repoConfig.enable_signed_push.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section
-                   id="requireSignedPushSettings"
-                   class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]">
-                <span class="title">Require signed push</span>
-                <span class="value">
-                  <gr-select
-                      id="requireSignedPush"
-                      bind-value="{{_repoConfig.require_signed_push.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Reject implicit merges when changes are pushed for review</span>
-                <span class="value">
-                  <gr-select
-                      id="rejectImplicitMergesSelect"
-                      bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Enable adding unregistered users as reviewers and CCs on changes</span>
-                <span class="value">
-                  <gr-select
-                      id="unRegisteredCcSelect"
-                      bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                  </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Set all new changes private by default</span>
-                <span class="value">
-                  <gr-select
-                      id="setAllnewChangesPrivateByDefaultSelect"
-                      bind-value="{{_repoConfig.private_by_default.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">
-                  Set new changes to "work in progress" by default</span>
-                <span class="value">
-                  <gr-select
-                      id="setAllNewChangesWorkInProgressByDefaultSelect"
-                      bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Maximum Git object size limit</span>
-                <span class="value">
-                  <iron-input
-                      id="maxGitObjSizeIronInput"
-                      bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                      type="text"
-                      disabled$="[[_readOnly]]">
-                    <input
-                        id="maxGitObjSizeInput"
-                        bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                        is="iron-input"
-                        type="text"
-                        disabled$="[[_readOnly]]">
-                  </iron-input>
-                  <template is="dom-if" if="[[_repoConfig.max_object_size_limit.value]]">
-                    effective: [[_repoConfig.max_object_size_limit.value]] bytes
-                  </template>
-                </span>
-              </section>
-              <section>
-                <span class="title">Match authored date with committer date upon submit</span>
-                <span class="value">
-                  <gr-select
-                      id="matchAuthoredDateWithCommitterDateSelect"
-                      bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                  </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Reject empty commit upon submit</span>
-                <span class="value">
-                  <gr-select
-                      id="rejectEmptyCommitSelect"
-                      bind-value="{{_repoConfig.reject_empty_commit.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                                items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                  </select>
-                  </gr-select>
-                </span>
-              </section>
-            </fieldset>
-            <h3 id="Options">Contributor Agreements</h3>
-            <fieldset id="agreements">
-              <section>
-                <span class="title">
-                  Require a valid contributor agreement to upload</span>
-                <span class="value">
-                  <gr-select
-                      id="contributorAgreementSelect"
-                      bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat"
-                        items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-              <section>
-                <span class="title">Require Signed-off-by in commit message</span>
-                <span class="value">
-                  <gr-select
-                        id="useSignedOffBySelect"
-                        bind-value="{{_repoConfig.use_signed_off_by.configured_value}}">
-                    <select disabled$="[[_readOnly]]">
-                      <template is="dom-repeat"
-                          items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]">
-                        <option value="[[item.value]]">[[item.label]]</option>
-                      </template>
-                    </select>
-                  </gr-select>
-                </span>
-              </section>
-            </fieldset>
-            <div
-                class$="pluginConfig [[_computeHideClass(_pluginData)]]"
-                on-plugin-config-changed="_handlePluginConfigChanged">
-              <h3>Plugins</h3>
-              <template is="dom-repeat" items="[[_pluginData]]" as="data">
-                <gr-repo-plugin-config
-                    plugin-data="[[data]]"></gr-repo-plugin-config>
-              </template>
-            </div>
-            <gr-button
-                on-click="_handleSaveRepoConfig"
-                disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
-          </fieldset>
-          <gr-endpoint-decorator name="repo-config">
-            <gr-endpoint-param name="repoName" value="[[repo]]"></gr-endpoint-param>
-            <gr-endpoint-param name="readOnly" value="[[_readOnly]]"></gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </div>
-      </div>
-    </main>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo.js"></script>
-</dom-module>
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..05ae73d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -14,47 +14,70 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const STATES = {
-    active: {value: 'ACTIVE', label: 'Active'},
-    readOnly: {value: 'READ_ONLY', label: 'Read Only'},
-    hidden: {value: 'HIDDEN', label: 'Hidden'},
-  };
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-download-commands/gr-download-commands.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-subpage-styles.js';
+import '../../../styles/shared-styles.js';
+import '../gr-repo-plugin-config/gr-repo-plugin-config.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  const SUBMIT_TYPES = {
-    // Exclude INHERIT, which is handled specially.
-    mergeIfNecessary: {
-      value: 'MERGE_IF_NECESSARY',
-      label: 'Merge if necessary',
-    },
-    fastForwardOnly: {
-      value: 'FAST_FORWARD_ONLY',
-      label: 'Fast forward only',
-    },
-    rebaseAlways: {
-      value: 'REBASE_ALWAYS',
-      label: 'Rebase Always',
-    },
-    rebaseIfNecessary: {
-      value: 'REBASE_IF_NECESSARY',
-      label: 'Rebase if necessary',
-    },
-    mergeAlways: {
-      value: 'MERGE_ALWAYS',
-      label: 'Merge always',
-    },
-    cherryPick: {
-      value: 'CHERRY_PICK',
-      label: 'Cherry pick',
-    },
-  };
+const STATES = {
+  active: {value: 'ACTIVE', label: 'Active'},
+  readOnly: {value: 'READ_ONLY', label: 'Read Only'},
+  hidden: {value: 'HIDDEN', label: 'Hidden'},
+};
 
-  Polymer({
-    is: 'gr-repo',
+const SUBMIT_TYPES = {
+  // Exclude INHERIT, which is handled specially.
+  mergeIfNecessary: {
+    value: 'MERGE_IF_NECESSARY',
+    label: 'Merge if necessary',
+  },
+  fastForwardOnly: {
+    value: 'FAST_FORWARD_ONLY',
+    label: 'Fast forward only',
+  },
+  rebaseAlways: {
+    value: 'REBASE_ALWAYS',
+    label: 'Rebase Always',
+  },
+  rebaseIfNecessary: {
+    value: 'REBASE_IF_NECESSARY',
+    label: 'Rebase if necessary',
+  },
+  mergeAlways: {
+    value: 'MERGE_ALWAYS',
+    label: 'Merge always',
+  },
+  cherryPick: {
+    value: 'CHERRY_PICK',
+    label: 'Cherry pick',
+  },
+};
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrRepo extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo'; }
+
+  static get properties() {
+    return {
       params: Object,
       repo: String,
 
@@ -106,244 +129,252 @@
       },
       _selectedScheme: String,
       _schemesObj: Object,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_handleConfigChanged(_repoConfig.*)',
-    ],
+    ];
+  }
 
-    attached() {
-      this._loadRepo();
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
 
-      this.fire('title-change', {title: this.repo});
-    },
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: this.repo},
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _computePluginData(configRecord) {
-      if (!configRecord ||
-          !configRecord.base) { return []; }
+  _computePluginData(configRecord) {
+    if (!configRecord ||
+        !configRecord.base) { return []; }
 
-      const pluginConfig = configRecord.base;
-      return Object.keys(pluginConfig)
-          .map(name => ({name, config: pluginConfig[name]}));
-    },
+    const pluginConfig = configRecord.base;
+    return Object.keys(pluginConfig)
+        .map(name => { return {name, config: pluginConfig[name]}; });
+  }
 
-    _loadRepo() {
-      if (!this.repo) { return Promise.resolve(); }
+  _loadRepo() {
+    if (!this.repo) { return Promise.resolve(); }
 
-      const promises = [];
+    const promises = [];
 
-      const errFn = response => {
-        this.fire('page-error', {response});
-      };
-
-      promises.push(this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this.$.restAPI.getRepoAccess(this.repo).then(access => {
-            if (!access) { return Promise.resolve(); }
-
-            // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[this.repo].is_owner;
-          });
-        }
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
       }));
+    };
 
-      promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
-          .then(config => {
-            if (!config) { return Promise.resolve(); }
+    promises.push(this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.$.restAPI.getRepoAccess(this.repo).then(access => {
+          if (!access) { return Promise.resolve(); }
 
-            if (config.default_submit_type) {
-              // The gr-select is bound to submit_type, which needs to be the
-              // *configured* submit type. When default_submit_type is
-              // present, the server reports the *effective* submit type in
-              // submit_type, so we need to overwrite it before storing the
-              // config in this.
-              config.submit_type =
-                  config.default_submit_type.configured_value;
-            }
-            if (!config.state) {
-              config.state = STATES.active.value;
-            }
-            this._repoConfig = config;
-            this._loading = false;
-          }));
-
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        if (!config) { return Promise.resolve(); }
-
-        this._schemesObj = config.download.schemes;
-      }));
-
-      return Promise.all(promises);
-    },
-
-    _computeLoadingClass(loading) {
-      return loading ? 'loading' : '';
-    },
-
-    _computeHideClass(arr) {
-      return !arr || !arr.length ? 'hide' : '';
-    },
-
-    _loggedInChanged(_loggedIn) {
-      if (!_loggedIn) { return; }
-      this.$.restAPI.getPreferences().then(prefs => {
-        if (prefs.download_scheme) {
-          // Note (issue 5180): normalize the download scheme with lower-case.
-          this._selectedScheme = prefs.download_scheme.toLowerCase();
-        }
-      });
-    },
-
-    _formatBooleanSelect(item) {
-      if (!item) { return; }
-      let inheritLabel = 'Inherit';
-      if (!(item.inherited_value === undefined)) {
-        inheritLabel = `Inherit (${item.inherited_value})`;
-      }
-      return [
-        {
-          label: inheritLabel,
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ];
-    },
-
-    _formatSubmitTypeSelect(projectConfig) {
-      if (!projectConfig) { return; }
-      const allValues = Object.values(SUBMIT_TYPES);
-      const type = projectConfig.default_submit_type;
-      if (!type) {
-        // Server is too old to report default_submit_type, so assume INHERIT
-        // is not a valid value.
-        return allValues;
-      }
-
-      let inheritLabel = 'Inherit';
-      if (type.inherited_value) {
-        let inherited = type.inherited_value;
-        for (const val of allValues) {
-          if (val.value === type.inherited_value) {
-            inherited = val.label;
-            break;
-          }
-        }
-        inheritLabel = `Inherit (${inherited})`;
-      }
-      return [
-        {
-          label: inheritLabel,
-          value: 'INHERIT',
-        },
-        ...allValues,
-      ];
-    },
-
-    _isLoading() {
-      return this._loading || this._loading === undefined;
-    },
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
-
-    _formatRepoConfigForSave(repoConfig) {
-      const configInputObj = {};
-      for (const key in repoConfig) {
-        if (repoConfig.hasOwnProperty(key)) {
-          if (key === 'default_submit_type') {
-            // default_submit_type is not in the input type, and the
-            // configured value was already copied to submit_type by
-            // _loadProject. Omit this property when saving.
-            continue;
-          }
-          if (key === 'plugin_config') {
-            configInputObj.plugin_config_values = repoConfig[key];
-          } else if (typeof repoConfig[key] === 'object') {
-            configInputObj[key] = repoConfig[key].configured_value;
-          } else {
-            configInputObj[key] = repoConfig[key];
-          }
-        }
-      }
-      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) {
-        return [];
-      }
-      const commands = [];
-      let commandObj;
-      if (schemesObj.hasOwnProperty(_selectedScheme)) {
-        commandObj = schemesObj[_selectedScheme].clone_commands;
-      }
-      for (const title in commandObj) {
-        if (!commandObj.hasOwnProperty(title)) { continue; }
-        commands.push({
-          title,
-          command: commandObj[title]
-              .replace(/\$\{project\}/gi, encodeURI(repo))
-              .replace(/\$\{project-base-name\}/gi,
-                  encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
+          // If the user is not an owner, is_owner is not a property.
+          this._readOnly = !access[this.repo].is_owner;
         });
       }
-      return commands;
-    },
+    }));
 
-    _computeRepositoriesClass(config) {
-      return config ? 'showConfig': '';
-    },
+    promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
+        .then(config => {
+          if (!config) { return Promise.resolve(); }
 
-    _computeChangesUrl(name) {
-      return Gerrit.Nav.getUrlForProjectChanges(name);
-    },
+          if (config.default_submit_type) {
+            // The gr-select is bound to submit_type, which needs to be the
+            // *configured* submit type. When default_submit_type is
+            // present, the server reports the *effective* submit type in
+            // submit_type, so we need to overwrite it before storing the
+            // config in this.
+            config.submit_type =
+                config.default_submit_type.configured_value;
+          }
+          if (!config.state) {
+            config.state = STATES.active.value;
+          }
+          this._repoConfig = config;
+          this._loading = false;
+        }));
 
-    _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
-      this._repoConfig.plugin_config[name] = config;
-      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
-    },
-  });
-})();
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      if (!config) { return Promise.resolve(); }
+
+      this._schemesObj = config.download.schemes;
+    }));
+
+    return Promise.all(promises);
+  }
+
+  _computeLoadingClass(loading) {
+    return loading ? 'loading' : '';
+  }
+
+  _computeHideClass(arr) {
+    return !arr || !arr.length ? 'hide' : '';
+  }
+
+  _loggedInChanged(_loggedIn) {
+    if (!_loggedIn) { return; }
+    this.$.restAPI.getPreferences().then(prefs => {
+      if (prefs.download_scheme) {
+        // Note (issue 5180): normalize the download scheme with lower-case.
+        this._selectedScheme = prefs.download_scheme.toLowerCase();
+      }
+    });
+  }
+
+  _formatBooleanSelect(item) {
+    if (!item) { return; }
+    let inheritLabel = 'Inherit';
+    if (!(item.inherited_value === undefined)) {
+      inheritLabel = `Inherit (${item.inherited_value})`;
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ];
+  }
+
+  _formatSubmitTypeSelect(projectConfig) {
+    if (!projectConfig) { return; }
+    const allValues = Object.values(SUBMIT_TYPES);
+    const type = projectConfig.default_submit_type;
+    if (!type) {
+      // Server is too old to report default_submit_type, so assume INHERIT
+      // is not a valid value.
+      return allValues;
+    }
+
+    let inheritLabel = 'Inherit';
+    if (type.inherited_value) {
+      let inherited = type.inherited_value;
+      for (const val of allValues) {
+        if (val.value === type.inherited_value) {
+          inherited = val.label;
+          break;
+        }
+      }
+      inheritLabel = `Inherit (${inherited})`;
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      ...allValues,
+    ];
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _formatRepoConfigForSave(repoConfig) {
+    const configInputObj = {};
+    for (const key in repoConfig) {
+      if (repoConfig.hasOwnProperty(key)) {
+        if (key === 'default_submit_type') {
+          // default_submit_type is not in the input type, and the
+          // configured value was already copied to submit_type by
+          // _loadProject. Omit this property when saving.
+          continue;
+        }
+        if (key === 'plugin_config') {
+          configInputObj.plugin_config_values = repoConfig[key];
+        } else if (typeof repoConfig[key] === 'object') {
+          configInputObj[key] = repoConfig[key].configured_value;
+        } else {
+          configInputObj[key] = repoConfig[key];
+        }
+      }
+    }
+    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) {
+      return [];
+    }
+    const commands = [];
+    let commandObj;
+    if (schemesObj.hasOwnProperty(_selectedScheme)) {
+      commandObj = schemesObj[_selectedScheme].clone_commands;
+    }
+    for (const title in commandObj) {
+      if (!commandObj.hasOwnProperty(title)) { continue; }
+      commands.push({
+        title,
+        command: commandObj[title]
+            .replace(/\$\{project\}/gi, encodeURI(repo))
+            .replace(/\$\{project-base-name\}/gi,
+                encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
+      });
+    }
+    return commands;
+  }
+
+  _computeRepositoriesClass(config) {
+    return config ? 'showConfig': '';
+  }
+
+  _computeChangesUrl(name) {
+    return GerritNav.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_html.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
new file mode 100644
index 0000000..de36e73
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
@@ -0,0 +1,437 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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);
+      content: ' *';
+    }
+    .loading,
+    .hide {
+      display: none;
+    }
+    #loading.loading {
+      display: block;
+    }
+    #loading:not(.loading) {
+      display: none;
+    }
+    #options .repositorySettings {
+      display: none;
+    }
+    #options .repositorySettings.showConfig {
+      display: block;
+    }
+  </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">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <div class="info">
+      <h1 id="Title" class$="name">
+        [[repo]]
+        <hr />
+      </h1>
+      <div>
+        <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+      </div>
+    </div>
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
+        <h2 id="download">Download</h2>
+        <fieldset>
+          <gr-download-commands
+            id="downloadCommands"
+            commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
+            schemes="[[_schemes]]"
+            selected-scheme="{{_selectedScheme}}"
+          ></gr-download-commands>
+        </fieldset>
+      </div>
+      <h2 id="configurations" class$="[[_computeHeaderClass(_configChanged)]]">
+        Configurations
+      </h2>
+      <div id="form">
+        <fieldset>
+          <h3 id="Description">Description</h3>
+          <fieldset>
+            <iron-autogrow-textarea
+              id="descriptionInput"
+              class="description"
+              autocomplete="on"
+              placeholder="<Insert repo description here>"
+              bind-value="{{_repoConfig.description}}"
+              disabled$="[[_readOnly]]"
+            ></iron-autogrow-textarea>
+          </fieldset>
+          <h3 id="Options">Repository Options</h3>
+          <fieldset id="options">
+            <section>
+              <span class="title">State</span>
+              <span class="value">
+                <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
+                  <select disabled$="[[_readOnly]]">
+                    <template is="dom-repeat" items="[[_states]]">
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Submit type</span>
+              <span class="value">
+                <gr-select
+                  id="submitTypeSelect"
+                  bind-value="{{_repoConfig.submit_type}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatSubmitTypeSelect(_repoConfig)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Allow content merges</span>
+              <span class="value">
+                <gr-select
+                  id="contentMergeSelect"
+                  bind-value="{{_repoConfig.use_content_merge.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Create a new change for every commit not in the target branch
+              </span>
+              <span class="value">
+                <gr-select
+                  id="newChangeSelect"
+                  bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Require Change-Id in commit message</span>
+              <span class="value">
+                <gr-select
+                  id="requireChangeIdSelect"
+                  bind-value="{{_repoConfig.require_change_id.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section
+              id="enableSignedPushSettings"
+              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]"
+            >
+              <span class="title">Enable signed push</span>
+              <span class="value">
+                <gr-select
+                  id="enableSignedPush"
+                  bind-value="{{_repoConfig.enable_signed_push.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section
+              id="requireSignedPushSettings"
+              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]"
+            >
+              <span class="title">Require signed push</span>
+              <span class="value">
+                <gr-select
+                  id="requireSignedPush"
+                  bind-value="{{_repoConfig.require_signed_push.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Reject implicit merges when changes are pushed for review</span
+              >
+              <span class="value">
+                <gr-select
+                  id="rejectImplicitMergesSelect"
+                  bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Enable adding unregistered users as reviewers and CCs on
+                changes</span
+              >
+              <span class="value">
+                <gr-select
+                  id="unRegisteredCcSelect"
+                  bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Set all new changes private by default</span>
+              <span class="value">
+                <gr-select
+                  id="setAllnewChangesPrivateByDefaultSelect"
+                  bind-value="{{_repoConfig.private_by_default.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Set new changes to "work in progress" by default</span
+              >
+              <span class="value">
+                <gr-select
+                  id="setAllNewChangesWorkInProgressByDefaultSelect"
+                  bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Maximum Git object size limit</span>
+              <span class="value">
+                <iron-input
+                  id="maxGitObjSizeIronInput"
+                  bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
+                  type="text"
+                  disabled$="[[_readOnly]]"
+                >
+                  <input
+                    id="maxGitObjSizeInput"
+                    bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
+                    is="iron-input"
+                    type="text"
+                    disabled$="[[_readOnly]]"
+                  />
+                </iron-input>
+                <template
+                  is="dom-if"
+                  if="[[_repoConfig.max_object_size_limit.value]]"
+                >
+                  effective: [[_repoConfig.max_object_size_limit.value]] bytes
+                </template>
+              </span>
+            </section>
+            <section>
+              <span class="title"
+                >Match authored date with committer date upon submit</span
+              >
+              <span class="value">
+                <gr-select
+                  id="matchAuthoredDateWithCommitterDateSelect"
+                  bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Reject empty commit upon submit</span>
+              <span class="value">
+                <gr-select
+                  id="rejectEmptyCommitSelect"
+                  bind-value="{{_repoConfig.reject_empty_commit.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+          </fieldset>
+          <h3 id="Options">Contributor Agreements</h3>
+          <fieldset id="agreements">
+            <section>
+              <span class="title">
+                Require a valid contributor agreement to upload</span
+              >
+              <span class="value">
+                <gr-select
+                  id="contributorAgreementSelect"
+                  bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Require Signed-off-by in commit message</span>
+              <span class="value">
+                <gr-select
+                  id="useSignedOffBySelect"
+                  bind-value="{{_repoConfig.use_signed_off_by.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+          </fieldset>
+          <div
+            class$="pluginConfig [[_computeHideClass(_pluginData)]]"
+            on-plugin-config-changed="_handlePluginConfigChanged"
+          >
+            <h3>Plugins</h3>
+            <template is="dom-repeat" items="[[_pluginData]]" as="data">
+              <gr-repo-plugin-config
+                plugin-data="[[data]]"
+              ></gr-repo-plugin-config>
+            </template>
+          </div>
+          <gr-button
+            on-click="_handleSaveRepoConfig"
+            disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]"
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <gr-endpoint-decorator name="repo-config">
+          <gr-endpoint-param
+            name="repoName"
+            value="[[repo]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="readOnly"
+            value="[[_readOnly]]"
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..58b488a 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,365 +31,370 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo tests', () => {
-    let element;
-    let sandbox;
-    let repoStub;
-    const repoConf = {
-      description: 'Access inherited by all other projects.',
-      use_contributor_agreements: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      use_content_merge: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      use_signed_off_by: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      create_new_change_for_all_not_in_target: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      require_change_id: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      enable_signed_push: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      require_signed_push: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      reject_implicit_merges: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      private_by_default: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      match_author_to_committer_date: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      reject_empty_commit: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      enable_reviewer_by_email: {
-        value: false,
-        configured_value: 'FALSE',
-      },
-      max_object_size_limit: {},
-      submit_type: 'MERGE_IF_NECESSARY',
-      default_submit_type: {
-        value: 'MERGE_IF_NECESSARY',
-        configured_value: 'INHERIT',
-        inherited_value: 'MERGE_IF_NECESSARY',
-      },
-    };
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+suite('gr-repo tests', () => {
+  let element;
+  let sandbox;
+  let repoStub;
+  const repoConf = {
+    description: 'Access inherited by all other projects.',
+    use_contributor_agreements: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    use_content_merge: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    use_signed_off_by: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    create_new_change_for_all_not_in_target: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    require_change_id: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    enable_signed_push: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    require_signed_push: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    reject_implicit_merges: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    private_by_default: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    match_author_to_committer_date: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    reject_empty_commit: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    enable_reviewer_by_email: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    max_object_size_limit: {},
+    submit_type: 'MERGE_IF_NECESSARY',
+    default_submit_type: {
+      value: 'MERGE_IF_NECESSARY',
+      configured_value: 'INHERIT',
+      inherited_value: 'MERGE_IF_NECESSARY',
+    },
+  };
 
-    const REPO = 'test-repo';
-    const SCHEMES = {http: {}, repo: {}, ssh: {}};
+  const REPO = 'test-repo';
+  const SCHEMES = {http: {}, repo: {}, ssh: {}};
 
-    function getFormFields() {
-      const selects = Array.from(
-          Polymer.dom(element.root).querySelectorAll('select'));
-      const textareas = Array.from(
-          Polymer.dom(element.root).querySelectorAll('iron-autogrow-textarea'));
-      const inputs = Array.from(
-          Polymer.dom(element.root).querySelectorAll('input'));
-      return inputs.concat(textareas).concat(selects);
-    }
+  function getFormFields() {
+    const selects = Array.from(
+        dom(element.root).querySelectorAll('select'));
+    const textareas = Array.from(
+        dom(element.root).querySelectorAll('iron-autogrow-textarea'));
+    const inputs = Array.from(
+        dom(element.root).querySelectorAll('input'));
+    return inputs.concat(textareas).concat(selects);
+  }
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getConfig() {
-          return Promise.resolve({download: {}});
-        },
-      });
-      element = fixture('basic');
-      repoStub = sandbox.stub(element.$.restAPI, 'getProjectConfig', () => {
-        return Promise.resolve(repoConf);
-      });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getConfig() {
+        return Promise.resolve({download: {}});
+      },
     });
+    element = fixture('basic');
+    repoStub = sandbox.stub(
+        element.$.restAPI,
+        'getProjectConfig',
+        () => Promise.resolve(repoConf));
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('_computePluginData', () => {
-      assert.deepEqual(element._computePluginData(), []);
-      assert.deepEqual(element._computePluginData({}), []);
-      assert.deepEqual(element._computePluginData({base: {}}), []);
-      assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
-          [{name: 'plugin', config: 'data'}]);
-    });
+  test('_computePluginData', () => {
+    assert.deepEqual(element._computePluginData(), []);
+    assert.deepEqual(element._computePluginData({}), []);
+    assert.deepEqual(element._computePluginData({base: {}}), []);
+    assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
+        [{name: 'plugin', config: 'data'}]);
+  });
 
-    test('_handlePluginConfigChanged', () => {
-      const notifyStub = sandbox.stub(element, 'notifyPath');
-      element._repoConfig = {plugin_config: {}};
-      element._handlePluginConfigChanged({detail: {
-        name: 'test',
-        config: 'data',
-        notifyPath: 'path',
-      }});
-      flushAsynchronousOperations();
+  test('_handlePluginConfigChanged', () => {
+    const notifyStub = sandbox.stub(element, 'notifyPath');
+    element._repoConfig = {plugin_config: {}};
+    element._handlePluginConfigChanged({detail: {
+      name: 'test',
+      config: 'data',
+      notifyPath: 'path',
+    }});
+    flushAsynchronousOperations();
 
-      assert.equal(element._repoConfig.plugin_config.test, 'data');
-      assert.equal(notifyStub.lastCall.args[0],
-          '_repoConfig.plugin_config.path');
-    });
+    assert.equal(element._repoConfig.plugin_config.test, 'data');
+    assert.equal(notifyStub.lastCall.args[0],
+        '_repoConfig.plugin_config.path');
+  });
 
-    test('loading displays before repo config is loaded', () => {
-      assert.isTrue(element.$.loading.classList.contains('loading'));
-      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-      assert.isTrue(getComputedStyle(element.$.loadedContent)
-          .display === 'none');
-    });
+  test('loading displays before repo config is loaded', () => {
+    assert.isTrue(element.$.loading.classList.contains('loading'));
+    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+    assert.isTrue(getComputedStyle(element.$.loadedContent)
+        .display === 'none');
+  });
 
-    test('download commands visibility', () => {
-      element._loading = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.downloadContent.classList.contains('hide'));
-      assert.isTrue(getComputedStyle(element.$.downloadContent)
-          .display == 'none');
-      element._schemesObj = SCHEMES;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.downloadContent.classList.contains('hide'));
-      assert.isFalse(getComputedStyle(element.$.downloadContent)
-          .display == 'none');
-    });
+  test('download commands visibility', () => {
+    element._loading = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
+    assert.isTrue(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+    element._schemesObj = SCHEMES;
+    flushAsynchronousOperations();
+    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
+    assert.isFalse(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+  });
 
-    test('form defaults to read only', () => {
+  test('form defaults to read only', () => {
+    assert.isTrue(element._readOnly);
+  });
+
+  test('form defaults to read only when not logged in', done => {
+    element.repo = REPO;
+    element._loadRepo().then(() => {
       assert.isTrue(element._readOnly);
+      done();
+    });
+  });
+
+  test('form defaults to read only when logged in and not admin', done => {
+    element.repo = 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();
+    });
+  });
+
+  test('all form elements are disabled when not admin', done => {
+    element.repo = REPO;
+    element._loadRepo().then(() => {
+      flushAsynchronousOperations();
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isTrue(field.hasAttribute('disabled'));
+      }
+      done();
+    });
+  });
+
+  test('_formatBooleanSelect', () => {
+    let item = {inherited_value: true};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit (true)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    item = {inherited_value: false};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit (false)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    // For items without inherited values
+    item = {};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+  });
+
+  test('fires page-error', done => {
+    repoStub.restore();
+
+    element.repo = 'test';
+
+    const response = {status: 404};
+    sandbox.stub(
+        element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
     });
 
-    test('form defaults to read only when not logged in', done => {
+    element._loadRepo();
+  });
+
+  suite('admin', () => {
+    setup(() => {
       element.repo = REPO;
-      element._loadRepo().then(() => {
-        assert.isTrue(element._readOnly);
-        done();
-      });
+      sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
+      sandbox.stub(
+          element.$.restAPI,
+          'getRepoAccess',
+          () => Promise.resolve({'test-repo': {is_owner: true}}));
     });
 
-    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': {}});
-      });
-      element._loadRepo().then(() => {
-        assert.isTrue(element._readOnly);
-        done();
-      });
-    });
-
-    test('all form elements are disabled when not admin', done => {
-      element.repo = REPO;
+    test('all form elements are enabled', done => {
       element._loadRepo().then(() => {
         flushAsynchronousOperations();
         const formFields = getFormFields();
         for (const field of formFields) {
-          assert.isTrue(field.hasAttribute('disabled'));
+          assert.isFalse(field.hasAttribute('disabled'));
         }
+        assert.isFalse(element._loading);
         done();
       });
     });
 
-    test('_formatBooleanSelect', () => {
-      let item = {inherited_value: true};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit (true)',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
-
-      item = {inherited_value: false};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit (false)',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
-
-      // For items without inherited values
-      item = {};
-      assert.deepEqual(element._formatBooleanSelect(item), [
-        {
-          label: 'Inherit',
-          value: 'INHERIT',
-        },
-        {
-          label: 'True',
-          value: 'TRUE',
-        }, {
-          label: 'False',
-          value: 'FALSE',
-        },
-      ]);
+    test('state gets set correctly', done => {
+      element._loadRepo().then(() => {
+        assert.equal(element._repoConfig.state, 'ACTIVE');
+        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
+        done();
+      });
     });
 
-    test('fires page-error', done => {
-      repoStub.restore();
-
-      element.repo = 'test';
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
-            errFn(response);
+    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();
           });
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._loadRepo();
     });
 
-    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}});
-        });
-      });
+    test('fields update and save correctly', () => {
+      const configInputObj = {
+        description: 'new description',
+        use_contributor_agreements: 'TRUE',
+        use_content_merge: 'TRUE',
+        use_signed_off_by: 'TRUE',
+        create_new_change_for_all_not_in_target: 'TRUE',
+        require_change_id: 'TRUE',
+        enable_signed_push: 'TRUE',
+        require_signed_push: 'TRUE',
+        reject_implicit_merges: 'TRUE',
+        private_by_default: 'TRUE',
+        match_author_to_committer_date: 'TRUE',
+        reject_empty_commit: 'TRUE',
+        max_object_size_limit: 10,
+        submit_type: 'FAST_FORWARD_ONLY',
+        state: 'READ_ONLY',
+        enable_reviewer_by_email: 'TRUE',
+      };
 
-      test('all form elements are enabled', done => {
-        element._loadRepo().then(() => {
-          flushAsynchronousOperations();
-          const formFields = getFormFields();
-          for (const field of formFields) {
-            assert.isFalse(field.hasAttribute('disabled'));
-          }
-          assert.isFalse(element._loading);
-          done();
-        });
-      });
+      const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
+          , () => Promise.resolve({}));
 
-      test('state gets set correctly', done => {
-        element._loadRepo().then(() => {
-          assert.equal(element._repoConfig.state, 'ACTIVE');
-          assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-          done();
-        });
-      });
+      const button = dom(element.root).querySelector('gr-button');
 
-      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)');
-        });
-      });
+      return element._loadRepo().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        element.$.descriptionInput.bindValue = configInputObj.description;
+        element.$.stateSelect.bindValue = configInputObj.state;
+        element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+        element.$.contentMergeSelect.bindValue =
+            configInputObj.use_content_merge;
+        element.$.newChangeSelect.bindValue =
+            configInputObj.create_new_change_for_all_not_in_target;
+        element.$.requireChangeIdSelect.bindValue =
+            configInputObj.require_change_id;
+        element.$.enableSignedPush.bindValue =
+            configInputObj.enable_signed_push;
+        element.$.requireSignedPush.bindValue =
+            configInputObj.require_signed_push;
+        element.$.rejectImplicitMergesSelect.bindValue =
+            configInputObj.reject_implicit_merges;
+        element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+            configInputObj.private_by_default;
+        element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+            configInputObj.match_author_to_committer_date;
+        const inputElement = PolymerElement ?
+          element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+        inputElement.bindValue = configInputObj.max_object_size_limit;
+        element.$.contributorAgreementSelect.bindValue =
+            configInputObj.use_contributor_agreements;
+        element.$.useSignedOffBySelect.bindValue =
+            configInputObj.use_signed_off_by;
+        element.$.rejectEmptyCommitSelect.bindValue =
+            configInputObj.reject_empty_commit;
+        element.$.unRegisteredCcSelect.bindValue =
+            configInputObj.enable_reviewer_by_email;
 
-      test('fields update and save correctly', () => {
-        const configInputObj = {
-          description: 'new description',
-          use_contributor_agreements: 'TRUE',
-          use_content_merge: 'TRUE',
-          use_signed_off_by: 'TRUE',
-          create_new_change_for_all_not_in_target: 'TRUE',
-          require_change_id: 'TRUE',
-          enable_signed_push: 'TRUE',
-          require_signed_push: 'TRUE',
-          reject_implicit_merges: 'TRUE',
-          private_by_default: 'TRUE',
-          match_author_to_committer_date: 'TRUE',
-          reject_empty_commit: 'TRUE',
-          max_object_size_limit: 10,
-          submit_type: 'FAST_FORWARD_ONLY',
-          state: 'READ_ONLY',
-          enable_reviewer_by_email: 'TRUE',
-        };
+        assert.isFalse(button.hasAttribute('disabled'));
+        assert.isTrue(element.$.configurations.classList.contains('edited'));
 
-        const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
-            , () => {
-              return Promise.resolve({});
-            });
+        const formattedObj =
+            element._formatRepoConfigForSave(element._repoConfig);
+        assert.deepEqual(formattedObj, configInputObj);
 
-        const button = Polymer.dom(element.root).querySelector('gr-button');
-
-        return element._loadRepo().then(() => {
+        return element._handleSaveRepoConfig().then(() => {
           assert.isTrue(button.hasAttribute('disabled'));
           assert.isFalse(element.$.Title.classList.contains('edited'));
-          element.$.descriptionInput.bindValue = configInputObj.description;
-          element.$.stateSelect.bindValue = configInputObj.state;
-          element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-          element.$.contentMergeSelect.bindValue =
-              configInputObj.use_content_merge;
-          element.$.newChangeSelect.bindValue =
-              configInputObj.create_new_change_for_all_not_in_target;
-          element.$.requireChangeIdSelect.bindValue =
-              configInputObj.require_change_id;
-          element.$.enableSignedPush.bindValue =
-              configInputObj.enable_signed_push;
-          element.$.requireSignedPush.bindValue =
-              configInputObj.require_signed_push;
-          element.$.rejectImplicitMergesSelect.bindValue =
-              configInputObj.reject_implicit_merges;
-          element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-              configInputObj.private_by_default;
-          element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-              configInputObj.match_author_to_committer_date;
-          const inputElement = Polymer.Element ?
-            element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-          inputElement.bindValue = configInputObj.max_object_size_limit;
-          element.$.contributorAgreementSelect.bindValue =
-              configInputObj.use_contributor_agreements;
-          element.$.useSignedOffBySelect.bindValue =
-              configInputObj.use_signed_off_by;
-          element.$.rejectEmptyCommitSelect.bindValue =
-              configInputObj.reject_empty_commit;
-          element.$.unRegisteredCcSelect.bindValue =
-              configInputObj.enable_reviewer_by_email;
-
-          assert.isFalse(button.hasAttribute('disabled'));
-          assert.isTrue(element.$.configurations.classList.contains('edited'));
-
-          const formattedObj =
-              element._formatRepoConfigForSave(element._repoConfig);
-          assert.deepEqual(formattedObj, configInputObj);
-
-          return element._handleSaveRepoConfig().then(() => {
-            assert.isTrue(button.hasAttribute('disabled'));
-            assert.isFalse(element.$.Title.classList.contains('edited'));
-            assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-                configInputObj));
-          });
+          assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
+              configInputObj));
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
deleted file mode 100644
index 9820e31..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
+++ /dev/null
@@ -1,164 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-rule-editor">
-  <template>
-    <style include="shared-styles">
-      :host {
-        border-bottom: 1px solid var(--border-color);
-        padding: var(--spacing-m);
-        display: block;
-      }
-      #removeBtn {
-        display: none;
-      }
-      .editing #removeBtn  {
-        display: flex;
-      }
-      #options {
-        align-items: baseline;
-        display: flex;
-      }
-      #options > * {
-        margin-right: var(--spacing-m);
-      }
-      #mainContainer {
-        align-items: baseline;
-        display: flex;
-        flex-wrap: nowrap;
-        justify-content: space-between;
-      }
-      #deletedContainer.deleted {
-        align-items: baseline;
-        display: flex;
-        justify-content: space-between;
-      }
-      #undoBtn,
-      #force,
-      #deletedContainer,
-      #mainContainer.deleted {
-        display: none;
-      }
-      #undoBtn.modified,
-      #force.force {
-        display: block;
-      }
-      .groupPath {
-        color: var(--deemphasized-text-color);
-      }
-    </style>
-    <style include="gr-form-styles">
-      iron-autogrow-textarea {
-        width: 14em;
-      }
-    </style>
-    <div id="mainContainer"
-        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
-      <div id="options">
-        <gr-select id="action"
-            bind-value="{{rule.value.action}}"
-            on-change="_handleValueChange">
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[_computeOptions(permission)]]">
-              <option value="[[item]]">[[item]]</option>
-            </template>
-          </select>
-        </gr-select>
-        <template is="dom-if" if="[[label]]">
-          <gr-select
-              id="labelMin"
-              bind-value="{{rule.value.min}}"
-              on-change="_handleValueChange">
-            <select disabled$="[[!editing]]">
-              <template is="dom-repeat" items="[[label.values]]">
-                <option value="[[item.value]]">[[item.value]]</option>
-              </template>
-            </select>
-          </gr-select>
-          <gr-select
-              id="labelMax"
-              bind-value="{{rule.value.max}}"
-              on-change="_handleValueChange">
-            <select disabled$="[[!editing]]">
-              <template is="dom-repeat" items="[[label.values]]">
-                <option value="[[item.value]]">[[item.value]]</option>
-              </template>
-            </select>
-          </gr-select>
-        </template>
-        <template is="dom-if" if="[[hasRange]]">
-          <iron-autogrow-textarea
-              id="minInput"
-              class="min"
-              autocomplete="on"
-              placeholder="Min value"
-              bind-value="{{rule.value.min}}"
-              disabled$="[[!editing]]"></iron-autogrow-textarea>
-          <iron-autogrow-textarea
-              id="maxInput"
-              class="max"
-              autocomplete="on"
-              placeholder="Max value"
-              bind-value="{{rule.value.max}}"
-              disabled$="[[!editing]]"></iron-autogrow-textarea>
-        </template>
-        <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
-          [[groupName]]
-        </a>
-        <gr-select
-            id="force"
-            class$="[[_computeForceClass(permission, rule.value.action)]]"
-            bind-value="{{rule.value.force}}"
-            on-change="_handleValueChange">
-          <select disabled$="[[!editing]]">
-            <template
-                is="dom-repeat"
-                items="[[_computeForceOptions(permission, rule.value.action)]]">
-              <option value="[[item.value]]">[[item.name]]</option>
-            </template>
-          </select>
-        </gr-select>
-      </div>
-      <gr-button
-          link
-          id="removeBtn"
-          on-click="_handleRemoveRule">Remove</gr-button>
-    </div>
-    <div
-        id="deletedContainer"
-        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
-      [[groupName]] was deleted
-      <gr-button link
-          id="undoRemoveBtn" on-click="_handleUndoRemove">Undo</gr-button>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-rule-editor.js"></script>
-</dom-module>
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..234015a 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
@@ -14,60 +14,86 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * Fired when the rule has been modified or removed.
-   *
-   * @event access-modified
-   */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-rule-editor_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
-  /**
-   * Fired when a rule that was previously added was removed.
-   *
-   * @event added-rule-removed
-   */
+/**
+ * Fired when the rule has been modified or removed.
+ *
+ * @event access-modified
+ */
 
-  const PRIORITY_OPTIONS = [
-    'BATCH',
-    'INTERACTIVE',
-  ];
+/**
+ * Fired when a rule that was previously added was removed.
+ *
+ * @event added-rule-removed
+ */
 
-  const Action = {
-    ALLOW: 'ALLOW',
-    DENY: 'DENY',
-    BLOCK: 'BLOCK',
-  };
+const PRIORITY_OPTIONS = [
+  'BATCH',
+  'INTERACTIVE',
+];
 
-  const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+const Action = {
+  ALLOW: 'ALLOW',
+  DENY: 'DENY',
+  BLOCK: 'BLOCK',
+};
 
-  const ForcePushOptions = {
-    ALLOW: [
-      {name: 'Allow pushing (but not force pushing)', value: false},
-      {name: 'Allow pushing with or without force', value: true},
-    ],
-    BLOCK: [
-      {name: 'Block pushing with or without force', value: false},
-      {name: 'Block force pushing', value: true},
-    ],
-  };
+const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
 
-  const FORCE_EDIT_OPTIONS = [
-    {
-      name: 'No Force Edit',
-      value: false,
-    },
-    {
-      name: 'Force Edit',
-      value: true,
-    },
-  ];
+const ForcePushOptions = {
+  ALLOW: [
+    {name: 'Allow pushing (but not force pushing)', value: false},
+    {name: 'Allow pushing with or without force', value: true},
+  ],
+  BLOCK: [
+    {name: 'Block pushing with or without force', value: false},
+    {name: 'Block force pushing', value: true},
+  ],
+};
 
-  Polymer({
-    is: 'gr-rule-editor',
+const FORCE_EDIT_OPTIONS = [
+  {
+    name: 'No Force Edit',
+    value: false,
+  },
+  {
+    name: 'Force Edit',
+    value: true,
+  },
+];
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrRuleEditor extends mixinBehaviors( [
+  AccessBehavior,
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-rule-editor'; }
+
+  static get properties() {
+    return {
       hasRange: Boolean,
       /** @type {?} */
       label: Object,
@@ -91,173 +117,173 @@
         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,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_handleValueChange(rule.value.*)',
-    ],
+    ];
+  }
 
-    listeners: {
-      'access-saved': '_handleAccessSaved',
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('access-saved',
+        () => this._handleAccessSaved());
+  }
 
-    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 */
+  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);
+  }
 
-    attached() {
-      if (!this.rule) { return; } // Check needed for test purposes.
-      if (!this._originalRuleValues) {
-        // Observer _handleValueChange is called after the ready()
-        // method finishes. Original values must be set later to
-        // 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 &&
-          action !== Action.DENY) {
-        return true;
-      }
-
-      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.
+  /** @override */
+  attached() {
+    super.attached();
+    if (!this.rule) { return; } // Check needed for test purposes.
+    if (!this._originalRuleValues) {
+      // Observer _handleValueChange is called after the ready()
+      // method finishes. Original values must be set later to
+      // avoid set .modified flag to true
       this._setOriginalRuleValues(this.rule.value);
-    },
+    }
+  }
 
-    _handleEditingChanged(editing, editingOld) {
-      // Ignore when editing gets set initially.
-      if (!editingOld) { return; }
-      // Restore original values if no longer editing.
-      if (!editing) {
-        this._handleUndoChange();
-      }
-    },
+  _setupValues(rule) {
+    if (!rule.value) {
+      this._setDefaultRuleValues();
+    }
+  }
 
-    _computeSectionClass(editing, deleted) {
-      const classList = [];
-      if (editing) {
-        classList.push('editing');
-      }
-      if (deleted) {
-        classList.push('deleted');
-      }
-      return classList.join(' ');
-    },
+  _computeForce(permission, action) {
+    if (this.permissionValues.push.id === permission &&
+        action !== Action.DENY) {
+      return true;
+    }
 
-    _computeForceOptions(permission, action) {
-      if (permission === this.permissionValues.push.id) {
-        if (action === Action.ALLOW) {
-          return ForcePushOptions.ALLOW;
-        } else if (action === Action.BLOCK) {
-          return ForcePushOptions.BLOCK;
-        } else {
-          return [];
-        }
-      } else if (permission === this.permissionValues.editTopicName.id) {
-        return FORCE_EDIT_OPTIONS;
-      }
-      return [];
-    },
+    return this.permissionValues.editTopicName.id === permission;
+  }
 
-    _getDefaultRuleValues(permission, label) {
-      const ruleAction = Action.ALLOW;
-      const value = {};
-      if (permission === 'priority') {
-        value.action = PRIORITY_OPTIONS[0];
-        return value;
-      } else if (label) {
-        value.min = label.values[0].value;
-        value.max = label.values[label.values.length - 1].value;
-      } else if (this._computeForce(permission, ruleAction)) {
-        value.force =
-            this._computeForceOptions(permission, ruleAction)[0].value;
+  _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.
+    if (!editingOld) { return; }
+    // Restore original values if no longer editing.
+    if (!editing) {
+      this._handleUndoChange();
+    }
+  }
+
+  _computeSectionClass(editing, deleted) {
+    const classList = [];
+    if (editing) {
+      classList.push('editing');
+    }
+    if (deleted) {
+      classList.push('deleted');
+    }
+    return classList.join(' ');
+  }
+
+  _computeForceOptions(permission, action) {
+    if (permission === this.permissionValues.push.id) {
+      if (action === Action.ALLOW) {
+        return ForcePushOptions.ALLOW;
+      } else if (action === Action.BLOCK) {
+        return ForcePushOptions.BLOCK;
+      } else {
+        return [];
       }
-      value.action = DROPDOWN_OPTIONS[0];
+    } else if (permission === this.permissionValues.editTopicName.id) {
+      return FORCE_EDIT_OPTIONS;
+    }
+    return [];
+  }
+
+  _getDefaultRuleValues(permission, label) {
+    const ruleAction = Action.ALLOW;
+    const value = {};
+    if (permission === 'priority') {
+      value.action = PRIORITY_OPTIONS[0];
       return value;
-    },
+    } else if (label) {
+      value.min = label.values[0].value;
+      value.max = label.values[label.values.length - 1].value;
+    } else if (this._computeForce(permission, ruleAction)) {
+      value.force =
+          this._computeForceOptions(permission, ruleAction)[0].value;
+    }
+    value.action = DROPDOWN_OPTIONS[0];
+    return value;
+  }
 
-    _setDefaultRuleValues() {
-      this.set('rule.value', this._getDefaultRuleValues(this.permission,
-          this.label));
-    },
+  _setDefaultRuleValues() {
+    this.set('rule.value', this._getDefaultRuleValues(this.permission,
+        this.label));
+  }
 
-    _computeOptions(permission) {
-      if (permission === 'priority') {
-        return PRIORITY_OPTIONS;
-      }
-      return DROPDOWN_OPTIONS;
-    },
+  _computeOptions(permission) {
+    if (permission === 'priority') {
+      return PRIORITY_OPTIONS;
+    }
+    return DROPDOWN_OPTIONS;
+  }
 
-    _handleRemoveRule() {
-      if (this.rule.value.added) {
-        this.dispatchEvent(new CustomEvent(
-            'added-rule-removed', {bubbles: true, composed: true}));
-      }
-      this._deleted = true;
-      this.rule.value.deleted = true;
-      this.dispatchEvent(
-          new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+  _handleRemoveRule() {
+    if (this.rule.value.added) {
+      this.dispatchEvent(new CustomEvent(
+          'added-rule-removed', {bubbles: true, composed: true}));
+    }
+    this._deleted = true;
+    this.rule.value.deleted = true;
+    this.dispatchEvent(
+        new CustomEvent('access-modified', {bubbles: true, composed: true}));
+  }
 
-    _handleUndoRemove() {
-      this._deleted = false;
-      delete this.rule.value.deleted;
-    },
+  _handleUndoRemove() {
+    this._deleted = false;
+    delete this.rule.value.deleted;
+  }
 
-    _handleUndoChange() {
-      // gr-permission will take care of removing rules that were added but
-      // unsaved. We need to keep the added bit for the filter.
-      if (this.rule.value.added) { return; }
-      this.set('rule.value', Object.assign({}, this._originalRuleValues));
-      this._deleted = false;
-      delete this.rule.value.deleted;
-      delete this.rule.value.modified;
-    },
+  _handleUndoChange() {
+    // gr-permission will take care of removing rules that were added but
+    // unsaved. We need to keep the added bit for the filter.
+    if (this.rule.value.added) { return; }
+    this.set('rule.value', Object.assign({}, this._originalRuleValues));
+    this._deleted = false;
+    delete this.rule.value.deleted;
+    delete this.rule.value.modified;
+  }
 
-    _handleValueChange() {
-      if (!this._originalRuleValues) { return; }
-      this.rule.value.modified = true;
-      // Allows overall access page to know a change has been made.
-      this.dispatchEvent(
-          new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+  _handleValueChange() {
+    if (!this._originalRuleValues) { return; }
+    this.rule.value.modified = true;
+    // 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);
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
new file mode 100644
index 0000000..3e4f9d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
@@ -0,0 +1,160 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      border-bottom: 1px solid var(--border-color);
+      padding: var(--spacing-m);
+      display: block;
+    }
+    #removeBtn {
+      display: none;
+    }
+    .editing #removeBtn {
+      display: flex;
+    }
+    #options {
+      align-items: baseline;
+      display: flex;
+    }
+    #options > * {
+      margin-right: var(--spacing-m);
+    }
+    #mainContainer {
+      align-items: baseline;
+      display: flex;
+      flex-wrap: nowrap;
+      justify-content: space-between;
+    }
+    #deletedContainer.deleted {
+      align-items: baseline;
+      display: flex;
+      justify-content: space-between;
+    }
+    #undoBtn,
+    #force,
+    #deletedContainer,
+    #mainContainer.deleted {
+      display: none;
+    }
+    #undoBtn.modified,
+    #force.force {
+      display: block;
+    }
+    .groupPath {
+      color: var(--deemphasized-text-color);
+    }
+  </style>
+  <style include="gr-form-styles">
+    iron-autogrow-textarea {
+      width: 14em;
+    }
+  </style>
+  <div
+    id="mainContainer"
+    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
+  >
+    <div id="options">
+      <gr-select
+        id="action"
+        bind-value="{{rule.value.action}}"
+        on-change="_handleValueChange"
+      >
+        <select disabled$="[[!editing]]">
+          <template is="dom-repeat" items="[[_computeOptions(permission)]]">
+            <option value="[[item]]">[[item]]</option>
+          </template>
+        </select>
+      </gr-select>
+      <template is="dom-if" if="[[label]]">
+        <gr-select
+          id="labelMin"
+          bind-value="{{rule.value.min}}"
+          on-change="_handleValueChange"
+        >
+          <select disabled$="[[!editing]]">
+            <template is="dom-repeat" items="[[label.values]]">
+              <option value="[[item.value]]">[[item.value]]</option>
+            </template>
+          </select>
+        </gr-select>
+        <gr-select
+          id="labelMax"
+          bind-value="{{rule.value.max}}"
+          on-change="_handleValueChange"
+        >
+          <select disabled$="[[!editing]]">
+            <template is="dom-repeat" items="[[label.values]]">
+              <option value="[[item.value]]">[[item.value]]</option>
+            </template>
+          </select>
+        </gr-select>
+      </template>
+      <template is="dom-if" if="[[hasRange]]">
+        <iron-autogrow-textarea
+          id="minInput"
+          class="min"
+          autocomplete="on"
+          placeholder="Min value"
+          bind-value="{{rule.value.min}}"
+          disabled$="[[!editing]]"
+        ></iron-autogrow-textarea>
+        <iron-autogrow-textarea
+          id="maxInput"
+          class="max"
+          autocomplete="on"
+          placeholder="Max value"
+          bind-value="{{rule.value.max}}"
+          disabled$="[[!editing]]"
+        ></iron-autogrow-textarea>
+      </template>
+      <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
+        [[groupName]]
+      </a>
+      <gr-select
+        id="force"
+        class$="[[_computeForceClass(permission, rule.value.action)]]"
+        bind-value="{{rule.value.force}}"
+        on-change="_handleValueChange"
+      >
+        <select disabled$="[[!editing]]">
+          <template
+            is="dom-repeat"
+            items="[[_computeForceOptions(permission, rule.value.action)]]"
+          >
+            <option value="[[item.value]]">[[item.name]]</option>
+          </template>
+        </select>
+      </gr-select>
+    </div>
+    <gr-button link="" id="removeBtn" on-click="_handleRemoveRule"
+      >Remove</gr-button
+    >
+  </div>
+  <div
+    id="deletedContainer"
+    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
+  >
+    [[groupName]] was deleted
+    <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
+      >Undo</gr-button
+    >
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..f096eed 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-rule-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,592 +32,595 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-rule-editor tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-rule-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-rule-editor tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    suite('unit tests', () => {
-      test('_computeForce, _computeForceClass, and _computeForceOptions',
-          () => {
-            const ForcePushOptions = {
-              ALLOW: [
-                {name: 'Allow pushing (but not force pushing)', value: false},
-                {name: 'Allow pushing with or without force', value: true},
-              ],
-              BLOCK: [
-                {name: 'Block pushing with or without force', value: false},
-                {name: 'Block force pushing', value: true},
-              ],
-            };
+  suite('unit tests', () => {
+    test('_computeForce, _computeForceClass, and _computeForceOptions',
+        () => {
+          const ForcePushOptions = {
+            ALLOW: [
+              {name: 'Allow pushing (but not force pushing)', value: false},
+              {name: 'Allow pushing with or without force', value: true},
+            ],
+            BLOCK: [
+              {name: 'Block pushing with or without force', value: false},
+              {name: 'Block force pushing', value: true},
+            ],
+          };
 
-            const FORCE_EDIT_OPTIONS = [
-              {
-                name: 'No Force Edit',
-                value: false,
-              },
-              {
-                name: 'Force Edit',
-                value: true,
-              },
-            ];
-            let permission = 'push';
-            let action = 'ALLOW';
-            assert.isTrue(element._computeForce(permission, action));
-            assert.equal(element._computeForceClass(permission, action),
-                'force');
-            assert.deepEqual(element._computeForceOptions(permission, action),
-                ForcePushOptions.ALLOW);
+          const FORCE_EDIT_OPTIONS = [
+            {
+              name: 'No Force Edit',
+              value: false,
+            },
+            {
+              name: 'Force Edit',
+              value: true,
+            },
+          ];
+          let permission = 'push';
+          let action = 'ALLOW';
+          assert.isTrue(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action),
+              'force');
+          assert.deepEqual(element._computeForceOptions(permission, action),
+              ForcePushOptions.ALLOW);
 
-            action = 'BLOCK';
-            assert.isTrue(element._computeForce(permission, action));
-            assert.equal(element._computeForceClass(permission, action),
-                'force');
-            assert.deepEqual(element._computeForceOptions(permission, action),
-                ForcePushOptions.BLOCK);
+          action = 'BLOCK';
+          assert.isTrue(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action),
+              'force');
+          assert.deepEqual(element._computeForceOptions(permission, action),
+              ForcePushOptions.BLOCK);
 
-            action = 'DENY';
-            assert.isFalse(element._computeForce(permission, action));
-            assert.equal(element._computeForceClass(permission, action), '');
-            assert.equal(
-                element._computeForceOptions(permission, action).length, 0);
-
-            permission = 'editTopicName';
-            assert.isTrue(element._computeForce(permission));
-            assert.equal(element._computeForceClass(permission), 'force');
-            assert.deepEqual(element._computeForceOptions(permission),
-                FORCE_EDIT_OPTIONS);
-            permission = 'submit';
-            assert.isFalse(element._computeForce(permission));
-            assert.equal(element._computeForceClass(permission), '');
-            assert.deepEqual(element._computeForceOptions(permission), []);
-          });
-
-      test('_computeSectionClass', () => {
-        let deleted = true;
-        let editing = false;
-        assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-        deleted = false;
-        assert.equal(element._computeSectionClass(editing, deleted), '');
-
-        editing = true;
-        assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-        deleted = true;
-        assert.equal(element._computeSectionClass(editing, deleted),
-            'editing deleted');
-      });
-
-      test('_getDefaultRuleValues', () => {
-        let permission = 'priority';
-        let label;
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'BATCH'});
-        permission = 'label-Code-Review';
-        label = {values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: -0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ]};
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'ALLOW', max: 2, min: -2});
-        permission = 'push';
-        label = undefined;
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'ALLOW', force: false});
-        permission = 'submit';
-        assert.deepEqual(element._getDefaultRuleValues(permission, label),
-            {action: 'ALLOW'});
-      });
-
-      test('_setDefaultRuleValues', () => {
-        element.rule = {id: 123};
-        const defaultValue = {action: 'ALLOW'};
-        sandbox.stub(element, '_getDefaultRuleValues').returns(defaultValue);
-        element._setDefaultRuleValues();
-        assert.isTrue(element._getDefaultRuleValues.called);
-        assert.equal(element.rule.value, defaultValue);
-      });
-
-      test('_computeOptions', () => {
-        const PRIORITY_OPTIONS = [
-          'BATCH',
-          'INTERACTIVE',
-        ];
-        const DROPDOWN_OPTIONS = [
-          'ALLOW',
-          'DENY',
-          'BLOCK',
-        ];
-        let permission = 'priority';
-        assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-        permission = 'submit';
-        assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
-      });
-
-      test('_handleValueChange', () => {
-        const modifiedHandler = sandbox.stub();
-        element.rule = {value: {}};
-        element.addEventListener('access-modified', modifiedHandler);
-        element._handleValueChange();
-        assert.isNotOk(element.rule.value.modified);
-        element._originalRuleValues = {};
-        element._handleValueChange();
-        assert.isTrue(element.rule.value.modified);
-        assert.isTrue(modifiedHandler.called);
-      });
-
-      test('_handleAccessSaved', () => {
-        const originalValue = {action: 'DENY'};
-        const newValue = {action: 'ALLOW'};
-        element._originalRuleValues = originalValue;
-        element.rule = {value: newValue};
-        element._handleAccessSaved();
-        assert.deepEqual(element._originalRuleValues, newValue);
-      });
-
-      test('_setOriginalRuleValues', () => {
-        const value = {
-          action: 'ALLOW',
-          force: false,
-        };
-        element._setOriginalRuleValues(value);
-        assert.deepEqual(element._originalRuleValues, value);
-      });
-    });
-
-    suite('already existing generic rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'submit';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: false,
-          },
-        };
-        element.section = 'refs/*';
-
-        // Typically called on ready since elements will have properies defined
-        // by the parent element.
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
-        assert.isFalse(element.$.force.classList.contains('force'));
-      });
-
-      test('modify and cancel restores original values', () => {
-        element.editing = true;
-        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = 'DENY';
-        assert.isTrue(element.rule.value.modified);
-        element.editing = false;
-        assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-        assert.equal(element.$.action.bindValue, 'ALLOW');
-        assert.isNotOk(element.rule.value.modified);
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = 'DENY';
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('all selects are disabled when not in edit mode', () => {
-        const selects = Polymer.dom(element.root).querySelectorAll('select');
-        for (select of selects) {
-          assert.isTrue(select.disabled);
-        }
-        element.editing = true;
-        for (select of selects) {
-          assert.isFalse(select.disabled);
-        }
-      });
-
-      test('remove rule and undo remove', () => {
-        element.editing = true;
-        element.rule = {id: 123, value: {action: 'ALLOW'}};
-        assert.isFalse(
-            element.$.deletedContainer.classList.contains('deleted'));
-        MockInteractions.tap(element.$.removeBtn);
-        assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.rule.value.deleted);
-
-        MockInteractions.tap(element.$.undoRemoveBtn);
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.rule.value.deleted);
-      });
-
-      test('remove rule and cancel', () => {
-        element.editing = true;
-        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.deletedContainer).display,
-            'none');
-
-        element.rule = {id: 123, value: {action: 'ALLOW'}};
-        MockInteractions.tap(element.$.removeBtn);
-        assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
-            'none');
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.rule.value.deleted);
-
-        element.editing = false;
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.rule.value.deleted);
-        assert.isNotOk(element.rule.value.modified);
-
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-        assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-        assert.equal(getComputedStyle(element.$.deletedContainer).display,
-            'none');
-      });
-
-      test('_computeGroupPath', () => {
-        const group = '123';
-        assert.equal(element._computeGroupPath(group),
-            `/admin/groups/123`);
-      });
-    });
-
-    suite('new edit rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'editTopicName';
-        element.rule = {
-          id: '123',
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        element.rule.value.added = true;
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        // Since the element does not already have default values, they should
-        // be set. The original values should be set to those too.
-        assert.isNotOk(element.rule.value.modified);
-        const expectedRuleValue = {
-          action: 'ALLOW',
-          force: false,
-          added: true,
-        };
-        assert.deepEqual(element.rule.value, expectedRuleValue);
-        test('values are set correctly', () => {
-          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-        });
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.force.bindValue = true;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('remove value', () => {
-        element.editing = true;
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-rule-removed', removeStub);
-        MockInteractions.tap(element.$.removeBtn);
-        flushAsynchronousOperations();
-        assert.isTrue(removeStub.called);
-      });
-    });
-
-    suite('already existing rule with labels', () => {
-      setup(done => {
-        element.label = {values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: -0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ]};
-        element.group = 'Group Name';
-        element.permission = 'label-Code-Review';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: false,
-            max: 2,
-            min: -2,
-          },
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#labelMin').bindValue,
-            element.rule.value.min);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#labelMax').bindValue,
-            element.rule.value.max);
-        assert.isFalse(element.$.force.classList.contains('force'));
-      });
-
-      test('modify value', () => {
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-rule-removed', removeStub);
-        assert.isNotOk(element.rule.value.modified);
-        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-        assert.isFalse(removeStub.called);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
-    });
-
-    suite('new rule with labels', () => {
-      setup(done => {
-        sandbox.spy(element, '_setDefaultRuleValues');
-        element.label = {values: [
-          {value: -2, text: 'This shall not be merged'},
-          {value: -1, text: 'I would prefer this is not merged as is'},
-          {value: -0, text: 'No score'},
-          {value: 1, text: 'Looks good to me, but someone else must approve'},
-          {value: 2, text: 'Looks good to me, approved'},
-        ]};
-        element.group = 'Group Name';
-        element.permission = 'label-Code-Review';
-        element.rule = {
-          id: '123',
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        element.rule.value.added = true;
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        // Since the element does not already have default values, they should
-        // be set. The original values should be set to those too.
-        assert.isNotOk(element.rule.value.modified);
-        assert.isTrue(element._setDefaultRuleValues.called);
-
-        const expectedRuleValue = {
-          max: element.label.values[element.label.values.length - 1].value,
-          min: element.label.values[0].value,
-          action: 'ALLOW',
-          added: true,
-        };
-        assert.deepEqual(element.rule.value, expectedRuleValue);
-        test('values are set correctly', () => {
+          action = 'DENY';
+          assert.isFalse(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action), '');
           assert.equal(
-              element.$.action.bindValue,
-              expectedRuleValue.action);
-          assert.equal(
-              Polymer.dom(element.root).querySelector('#labelMin').bindValue,
-              expectedRuleValue.min);
-          assert.equal(
-              Polymer.dom(element.root).querySelector('#labelMax').bindValue,
-              expectedRuleValue.max);
+              element._computeForceOptions(permission, action).length, 0);
+
+          permission = 'editTopicName';
+          assert.isTrue(element._computeForce(permission));
+          assert.equal(element._computeForceClass(permission), 'force');
+          assert.deepEqual(element._computeForceOptions(permission),
+              FORCE_EDIT_OPTIONS);
+          permission = 'submit';
+          assert.isFalse(element._computeForce(permission));
+          assert.equal(element._computeForceClass(permission), '');
+          assert.deepEqual(element._computeForceOptions(permission), []);
         });
-      });
 
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
 
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, deleted),
+          'editing deleted');
     });
 
-    suite('already existing push rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'push';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: true,
-          },
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
-
-      test('values are set correctly', () => {
-        assert.isTrue(element.$.force.classList.contains('force'));
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#force').bindValue,
-            element.rule.value.force);
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = false;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_getDefaultRuleValues', () => {
+      let permission = 'priority';
+      let label;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'BATCH'});
+      permission = 'label-Code-Review';
+      label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', max: 2, min: -2});
+      permission = 'push';
+      label = undefined;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', force: false});
+      permission = 'submit';
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW'});
     });
 
-    suite('new push rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'push';
-        element.rule = {
-          id: '123',
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        element.rule.value.added = true;
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
-
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        // Since the element does not already have default values, they should
-        // be set. The original values should be set to those too.
-        assert.isNotOk(element.rule.value.modified);
-        const expectedRuleValue = {
-          action: 'ALLOW',
-          force: false,
-          added: true,
-        };
-        assert.deepEqual(element.rule.value, expectedRuleValue);
-        test('values are set correctly', () => {
-          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-        });
-      });
-
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.force.bindValue = true;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_setDefaultRuleValues', () => {
+      element.rule = {id: 123};
+      const defaultValue = {action: 'ALLOW'};
+      sandbox.stub(element, '_getDefaultRuleValues').returns(defaultValue);
+      element._setDefaultRuleValues();
+      assert.isTrue(element._getDefaultRuleValues.called);
+      assert.equal(element.rule.value, defaultValue);
     });
 
-    suite('already existing edit rule', () => {
-      setup(done => {
-        element.group = 'Group Name';
-        element.permission = 'editTopicName';
-        element.rule = {
-          id: '123',
-          value: {
-            action: 'ALLOW',
-            force: true,
-          },
-        };
-        element.section = 'refs/*';
-        element._setupValues(element.rule);
-        flushAsynchronousOperations();
-        flush(() => {
-          element.attached();
-          done();
-        });
-      });
+    test('_computeOptions', () => {
+      const PRIORITY_OPTIONS = [
+        'BATCH',
+        'INTERACTIVE',
+      ];
+      const DROPDOWN_OPTIONS = [
+        'ALLOW',
+        'DENY',
+        'BLOCK',
+      ];
+      let permission = 'priority';
+      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+      permission = 'submit';
+      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+    });
 
-      test('_ruleValues and _originalRuleValues are set correctly', () => {
-        assert.deepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_handleValueChange', () => {
+      const modifiedHandler = sandbox.stub();
+      element.rule = {value: {}};
+      element.addEventListener('access-modified', modifiedHandler);
+      element._handleValueChange();
+      assert.isNotOk(element.rule.value.modified);
+      element._originalRuleValues = {};
+      element._handleValueChange();
+      assert.isTrue(element.rule.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
 
-      test('values are set correctly', () => {
-        assert.isTrue(element.$.force.classList.contains('force'));
-        assert.equal(element.$.action.bindValue, element.rule.value.action);
-        assert.equal(
-            Polymer.dom(element.root).querySelector('#force').bindValue,
-            element.rule.value.force);
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
-        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
-      });
+    test('_handleAccessSaved', () => {
+      const originalValue = {action: 'DENY'};
+      const newValue = {action: 'ALLOW'};
+      element._originalRuleValues = originalValue;
+      element.rule = {value: newValue};
+      element._handleAccessSaved();
+      assert.deepEqual(element._originalRuleValues, newValue);
+    });
 
-      test('modify value', () => {
-        assert.isNotOk(element.rule.value.modified);
-        element.$.action.bindValue = false;
-        flushAsynchronousOperations();
-        assert.isTrue(element.rule.value.modified);
-
-        // The original value should now differ from the rule values.
-        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-      });
+    test('_setOriginalRuleValues', () => {
+      const value = {
+        action: 'ALLOW',
+        force: false,
+      };
+      element._setOriginalRuleValues(value);
+      assert.deepEqual(element._originalRuleValues, value);
     });
   });
+
+  suite('already existing generic rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'submit';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      };
+      element.section = 'refs/*';
+
+      // Typically called on ready since elements will have properies defined
+      // by the parent element.
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+      assert.isFalse(element.$.force.classList.contains('force'));
+    });
+
+    test('modify and cancel restores original values', () => {
+      element.editing = true;
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = 'DENY';
+      assert.isTrue(element.rule.value.modified);
+      element.editing = false;
+      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+      assert.equal(element.$.action.bindValue, 'ALLOW');
+      assert.isNotOk(element.rule.value.modified);
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = 'DENY';
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('all selects are disabled when not in edit mode', () => {
+      const selects = dom(element.root).querySelectorAll('select');
+      for (const select of selects) {
+        assert.isTrue(select.disabled);
+      }
+      element.editing = true;
+      for (const select of selects) {
+        assert.isFalse(select.disabled);
+      }
+    });
+
+    test('remove rule and undo remove', () => {
+      element.editing = true;
+      element.rule = {id: 123, value: {action: 'ALLOW'}};
+      assert.isFalse(
+          element.$.deletedContainer.classList.contains('deleted'));
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule.value.deleted);
+
+      MockInteractions.tap(element.$.undoRemoveBtn);
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule.value.deleted);
+    });
+
+    test('remove rule and cancel', () => {
+      element.editing = true;
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+
+      element.rule = {id: 123, value: {action: 'ALLOW'}};
+      MockInteractions.tap(element.$.removeBtn);
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule.value.deleted);
+
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule.value.deleted);
+      assert.isNotOk(element.rule.value.modified);
+
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+    });
+
+    test('_computeGroupPath', () => {
+      const group = '123';
+      assert.equal(element._computeGroupPath(group),
+          `/admin/groups/123`);
+    });
+  });
+
+  suite('new edit rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'editTopicName';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.force.bindValue = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('remove value', () => {
+      element.editing = true;
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      MockInteractions.tap(element.$.removeBtn);
+      flushAsynchronousOperations();
+      assert.isTrue(removeStub.called);
+    });
+  });
+
+  suite('already existing rule with labels', () => {
+    setup(done => {
+      element.label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      element.group = 'Group Name';
+      element.permission = 'label-Code-Review';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: false,
+          max: 2,
+          min: -2,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#labelMin').bindValue,
+          element.rule.value.min);
+      assert.equal(
+          dom(element.root).querySelector('#labelMax').bindValue,
+          element.rule.value.max);
+      assert.isFalse(element.$.force.classList.contains('force'));
+    });
+
+    test('modify value', () => {
+      const removeStub = sandbox.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule.value.modified);
+      dom(element.root).querySelector('#labelMin').bindValue = 1;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+      assert.isFalse(removeStub.called);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('new rule with labels', () => {
+    setup(done => {
+      sandbox.spy(element, '_setDefaultRuleValues');
+      element.label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      element.group = 'Group Name';
+      element.permission = 'label-Code-Review';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      assert.isTrue(element._setDefaultRuleValues.called);
+
+      const expectedRuleValue = {
+        max: element.label.values[element.label.values.length - 1].value,
+        min: element.label.values[0].value,
+        action: 'ALLOW',
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+            element.$.action.bindValue,
+            expectedRuleValue.action);
+        assert.equal(
+            dom(element.root).querySelector('#labelMin').bindValue,
+            expectedRuleValue.min);
+        assert.equal(
+            dom(element.root).querySelector('#labelMax').bindValue,
+            expectedRuleValue.max);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      dom(element.root).querySelector('#labelMin').bindValue = 1;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing push rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'push';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(element.$.force.classList.contains('force'));
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('new push rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'push';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.force.bindValue = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'editTopicName';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(element.$.force.classList.contains('force'));
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+});
 </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
deleted file mode 100644
index a6c86bb..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ /dev/null
@@ -1,235 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-change-list-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
-<link rel="import" href="../../shared/gr-change-status/gr-change-status.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.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="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-
-<dom-module id="gr-change-list-item">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: table-row;
-        color: var(--reviewed-text-color);
-      }
-      :host(:focus) {
-        outline: none;
-      }
-      :host(:hover) {
-        background-color: var(--hover-background-color);
-      }
-      :host([needs-review]) {
-        font-weight: var(--font-weight-bold);
-        color: var(--primary-text-color);
-      }
-      :host([highlight]) {
-        background-color: var(--assignee-highlight-color);
-      }
-      .container {
-        position: relative;
-      }
-      .content {
-        overflow: hidden;
-        position: absolute;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        width: 100%;
-      }
-      .content a {
-        display: block;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        width: 100%;
-      }
-      .spacer {
-        height: 0;
-        overflow: hidden;
-      }
-      .status {
-        align-items: center;
-        display: inline-flex;
-      }
-      .status .comma {
-        padding-right: var(--spacing-xs);
-      }
-      /* Used to hide the leading separator comma for statuses. */
-      .status .comma:first-of-type {
-        display: none;
-      }
-      .size gr-tooltip-content {
-        margin: -.4rem -.6rem;
-        max-width: 2.5rem;
-        padding: var(--spacing-m) var(--spacing-l);
-      }
-      a {
-        color: inherit;
-        cursor: pointer;
-        text-decoration: none;
-      }
-      a:hover {
-        text-decoration: underline;
-      }
-      .u-monospace {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-      }
-      .u-green {
-        color: var(--vote-text-color-recommended);
-      }
-      .u-red {
-        color: var(--vote-text-color-disliked);
-      }
-      .u-gray-background {
-        background-color: var(--table-header-background-color);
-      }
-      .comma,
-      .placeholder {
-        color: var(--deemphasized-text-color);
-      }
-      .cell.label {
-        font-weight: normal;
-      }
-      @media only screen and (max-width: 50em) {
-        :host {
-          display: flex;
-        }
-      }
-    </style>
-    <style include="gr-change-list-styles"></style>
-    <td class="cell leftPadding"></td>
-    <td class="cell star" hidden$="[[!showStar]]" hidden>
-      <gr-change-star change="{{change}}"></gr-change-star>
-    </td>
-    <td class="cell number" hidden$="[[!showNumber]]" hidden>
-      <a href$="[[changeURL]]">[[change._number]]</a>
-    </td>
-    <td class="cell subject"
-        hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]">
-      <div class="container">
-        <div class="content">
-          <a title$="[[change.subject]]" href$="[[changeURL]]">
-            [[change.subject]]
-          </a>
-        </div>
-        <div class="spacer">
-           [[change.subject]]
-        </div>
-        <span>&nbsp;</span>
-      </div>
-    </td>
-    <td class="cell status"
-        hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]">
-      <template is="dom-repeat" items="[[statuses]]" as="status">
-        <div class="comma">,</div>
-        <gr-change-status flat status="[[status]]"></gr-change-status>
-      </template>
-      <template is="dom-if" if="[[!statuses.length]]">
-        <span class="placeholder">--</span>
-      </template>
-    </td>
-    <td class="cell owner"
-        hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]">
-      <gr-account-link
-          account="[[change.owner]]"></gr-account-link>
-    </td>
-    <td class="cell assignee"
-        hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]">
-      <template is="dom-if" if="[[change.assignee]]">
-        <gr-account-link
-            id="assigneeAccountLink"
-            account="[[change.assignee]]"></gr-account-link>
-      </template>
-      <template is="dom-if" if="[[!change.assignee]]">
-        <span class="placeholder">--</span>
-      </template>
-    </td>
-    <td class="cell repo"
-        hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]">
-      <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
-        [[_computeRepoDisplay(change)]]
-      </a>
-      <a
-          class="truncatedRepo"
-          href$="[[_computeRepoUrl(change)]]"
-          title$="[[_computeRepoDisplay(change)]]">
-        [[_computeRepoDisplay(change, 'true')]]
-      </a>
-    </td>
-    <td class="cell branch"
-        hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
-      <a href$="[[_computeRepoBranchURL(change)]]">
-        [[change.branch]]
-      </a>
-      <template is="dom-if" if="[[change.topic]]">
-        (<a href$="[[_computeTopicURL(change)]]"><!--
-       --><gr-limited-text limit="50" text="[[change.topic]]">
-          </gr-limited-text><!--
-     --></a>)
-      </template>
-    </td>
-    <td class="cell updated"
-        hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]">
-      <gr-date-formatter
-          has-tooltip
-          date-str="[[change.updated]]"></gr-date-formatter>
-    </td>
-    <td class="cell size"
-        hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]">
-      <gr-tooltip-content
-          has-tooltip
-          title="[[_computeSizeTooltip(change)]]">
-        <template is="dom-if" if="[[_changeSize]]">
-            <span>[[_changeSize]]</span>
-        </template>
-        <template is="dom-if" if="[[!_changeSize]]">
-            <span class="placeholder">--</span>
-        </template>
-      </gr-tooltip-content>
-    </td>
-    <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-      <td title$="[[_computeLabelTitle(change, labelName)]]"
-          class$="[[_computeLabelClass(change, labelName)]]">
-        [[_computeLabelValue(change, labelName)]]
-      </td>
-    </template>
-    <template is="dom-repeat" items="[[_dynamicCellEndpoints]]"
-      as="pluginEndpointName">
-      <td class="cell endpoint">
-        <gr-endpoint-decorator name$="[[pluginEndpointName]]">
-          <gr-endpoint-param name="change" value="[[change]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </td>
-    </template>
-  </template>
-  <script src="gr-change-list-item.js"></script>
-</dom-module>
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..da13492 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,20 +14,59 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  const CHANGE_SIZE = {
-    XS: 10,
-    SMALL: 50,
-    MEDIUM: 250,
-    LARGE: 1000,
-  };
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-change-list-styles.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-change-star/gr-change-star.js';
+import '../../shared/gr-change-status/gr-change-status.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-limited-text/gr-limited-text.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-list-item_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
+import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-  Polymer({
-    is: 'gr-change-list-item',
+const CHANGE_SIZE = {
+  XS: 10,
+  SMALL: 50,
+  MEDIUM: 250,
+  LARGE: 1000,
+};
 
-    properties: {
+/**
+ * @appliesMixin RESTClientMixin
+ * @extends Polymer.Element
+ */
+class GrChangeListItem extends mixinBehaviors( [
+  BaseUrlBehavior,
+  ChangeTableBehavior,
+  PathListBehavior,
+  RESTClientBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-list-item'; }
+
+  static get properties() {
+    return {
       visibleChangeTableColumns: Array,
       labelNames: {
         type: Array,
@@ -55,154 +94,156 @@
       _dynamicCellEndpoints: {
         type: Array,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.ChangeTableBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.RESTClientBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      this._dynamicCellEndpoints = pluginEndpoints.getDynamicEndpoints(
+          'change-list-item-cell');
+    });
+  }
 
-    attached() {
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicCellEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-list-item-cell');
-      });
-    },
+  _computeChangeURL(change) {
+    return GerritNav.getUrlForChange(change);
+  }
 
-    _computeChangeURL(change) {
-      return Gerrit.Nav.getUrlForChange(change);
-    },
+  _computeLabelTitle(change, labelName) {
+    const label = change.labels[labelName];
+    if (!label) { return 'Label not applicable'; }
+    const significantLabel = label.rejected || label.approved ||
+        label.disliked || label.recommended;
+    if (significantLabel && significantLabel.name) {
+      return labelName + '\nby ' + significantLabel.name;
+    }
+    return labelName;
+  }
 
-    _computeLabelTitle(change, labelName) {
-      const label = change.labels[labelName];
-      if (!label) { return 'Label not applicable'; }
-      const significantLabel = label.rejected || label.approved ||
-          label.disliked || label.recommended;
-      if (significantLabel && significantLabel.name) {
-        return labelName + '\nby ' + significantLabel.name;
-      }
-      return labelName;
-    },
-
-    _computeLabelClass(change, labelName) {
-      const label = change.labels[labelName];
-      // Mimic a Set.
-      const classes = {
-        cell: true,
-        label: true,
-      };
-      if (label) {
-        if (label.approved) {
-          classes['u-green'] = true;
-        }
-        if (label.value == 1) {
-          classes['u-monospace'] = true;
-          classes['u-green'] = true;
-        } else if (label.value == -1) {
-          classes['u-monospace'] = true;
-          classes['u-red'] = true;
-        }
-        if (label.rejected) {
-          classes['u-red'] = true;
-        }
-      } else {
-        classes['u-gray-background'] = true;
-      }
-      return Object.keys(classes).sort().join(' ');
-    },
-
-    _computeLabelValue(change, labelName) {
-      const label = change.labels[labelName];
-      if (!label) { return ''; }
+  _computeLabelClass(change, labelName) {
+    const label = change.labels[labelName];
+    // Mimic a Set.
+    const classes = {
+      cell: true,
+      label: true,
+    };
+    if (label) {
       if (label.approved) {
-        return '✓';
+        classes['u-green'] = true;
+      }
+      if (label.value == 1) {
+        classes['u-monospace'] = true;
+        classes['u-green'] = true;
+      } else if (label.value == -1) {
+        classes['u-monospace'] = true;
+        classes['u-red'] = true;
       }
       if (label.rejected) {
-        return '✕';
+        classes['u-red'] = true;
       }
-      if (label.value > 0) {
-        return '+' + label.value;
-      }
-      if (label.value < 0) {
-        return label.value;
-      }
-      return '';
-    },
+    } else {
+      classes['u-gray-background'] = true;
+    }
+    return Object.keys(classes).sort()
+        .join(' ');
+  }
 
-    _computeRepoUrl(change) {
-      return Gerrit.Nav.getUrlForProjectChanges(change.project, true,
-          change.internalHost);
-    },
+  _computeLabelValue(change, labelName) {
+    const label = change.labels[labelName];
+    if (!label) { return ''; }
+    if (label.approved) {
+      return '✓';
+    }
+    if (label.rejected) {
+      return '✕';
+    }
+    if (label.value > 0) {
+      return '+' + label.value;
+    }
+    if (label.value < 0) {
+      return label.value;
+    }
+    return '';
+  }
 
-    _computeRepoBranchURL(change) {
-      return Gerrit.Nav.getUrlForBranch(change.branch, change.project, null,
-          change.internalHost);
-    },
+  _computeRepoUrl(change) {
+    return GerritNav.getUrlForProjectChanges(change.project, true,
+        change.internalHost);
+  }
 
-    _computeTopicURL(change) {
-      if (!change.topic) { return ''; }
-      return Gerrit.Nav.getUrlForTopic(change.topic, change.internalHost);
-    },
+  _computeRepoBranchURL(change) {
+    return GerritNav.getUrlForBranch(change.branch, change.project, null,
+        change.internalHost);
+  }
 
-    /**
-     * Computes the display string for the project column. If there is a host
-     * specified in the change detail, the string will be prefixed with it.
-     *
-     * @param {!Object} change
-     * @param {string=} truncate whether or not the project name should be
-     *     truncated. If this value is truthy, the name will be truncated.
-     * @return {string}
-     */
-    _computeRepoDisplay(change, truncate) {
-      if (!change || !change.project) { return ''; }
-      let str = '';
-      if (change.internalHost) { str += change.internalHost + '/'; }
-      str += truncate ? this.truncatePath(change.project, 2) : change.project;
-      return str;
-    },
+  _computeTopicURL(change) {
+    if (!change.topic) { return ''; }
+    return GerritNav.getUrlForTopic(change.topic, change.internalHost);
+  }
 
-    _computeSizeTooltip(change) {
-      if (change.insertions + change.deletions === 0 ||
-          isNaN(change.insertions + change.deletions)) {
-        return 'Size unknown';
-      } else {
-        return `+${change.insertions}, -${change.deletions}`;
-      }
-    },
+  /**
+   * Computes the display string for the project column. If there is a host
+   * specified in the change detail, the string will be prefixed with it.
+   *
+   * @param {!Object} change
+   * @param {string=} truncate whether or not the project name should be
+   *     truncated. If this value is truthy, the name will be truncated.
+   * @return {string}
+   */
+  _computeRepoDisplay(change, truncate) {
+    if (!change || !change.project) { return ''; }
+    let str = '';
+    if (change.internalHost) { str += change.internalHost + '/'; }
+    str += truncate ? this.truncatePath(change.project, 2) : change.project;
+    return str;
+  }
 
-    /**
-     * TShirt sizing is based on the following paper:
-     * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
-     */
-    _computeChangeSize(change) {
-      const delta = change.insertions + change.deletions;
-      if (isNaN(delta) || delta === 0) {
-        return null; // Unknown
-      }
-      if (delta < CHANGE_SIZE.XS) {
-        return 'XS';
-      } else if (delta < CHANGE_SIZE.SMALL) {
-        return 'S';
-      } else if (delta < CHANGE_SIZE.MEDIUM) {
-        return 'M';
-      } else if (delta < CHANGE_SIZE.LARGE) {
-        return 'L';
-      } else {
-        return 'XL';
-      }
-    },
+  _computeSizeTooltip(change) {
+    if (change.insertions + change.deletions === 0 ||
+        isNaN(change.insertions + change.deletions)) {
+      return 'Size unknown';
+    } else {
+      return `+${change.insertions}, -${change.deletions}`;
+    }
+  }
 
-    toggleReviewed() {
-      const newVal = !this.change.reviewed;
-      this.set('change.reviewed', newVal);
-      this.dispatchEvent(new CustomEvent('toggle-reviewed', {
-        bubbles: true,
-        composed: true,
-        detail: {change: this.change, reviewed: newVal},
-      }));
-    },
-  });
-})();
+  _computeComments(unresolved_comment_count) {
+    if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
+    return `${unresolved_comment_count} unresolved`;
+  }
+
+  /**
+   * TShirt sizing is based on the following paper:
+   * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
+   */
+  _computeChangeSize(change) {
+    const delta = change.insertions + change.deletions;
+    if (isNaN(delta) || delta === 0) {
+      return null; // Unknown
+    }
+    if (delta < CHANGE_SIZE.XS) {
+      return 'XS';
+    } else if (delta < CHANGE_SIZE.SMALL) {
+      return 'S';
+    } else if (delta < CHANGE_SIZE.MEDIUM) {
+      return 'M';
+    } else if (delta < CHANGE_SIZE.LARGE) {
+      return 'L';
+    } else {
+      return 'XL';
+    }
+  }
+
+  toggleReviewed() {
+    const newVal = !this.change.reviewed;
+    this.set('change.reviewed', newVal);
+    this.dispatchEvent(new CustomEvent('toggle-reviewed', {
+      bubbles: true,
+      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_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
new file mode 100644
index 0000000..13b3c24
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
@@ -0,0 +1,278 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: table-row;
+      color: var(--primary-text-color);
+    }
+    :host(:focus) {
+      outline: none;
+    }
+    :host(:hover) {
+      background-color: var(--hover-background-color);
+    }
+    :host([needs-review]) {
+      font-weight: var(--font-weight-bold);
+      color: var(--primary-text-color);
+    }
+    :host([highlight]) {
+      background-color: var(--assignee-highlight-color);
+    }
+    .container {
+      position: relative;
+    }
+    .content {
+      overflow: hidden;
+      position: absolute;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      width: 100%;
+    }
+    .content a {
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      width: 100%;
+    }
+    .comments,
+    .reviewers {
+      white-space: nowrap;
+    }
+    .spacer {
+      height: 0;
+      overflow: hidden;
+    }
+    .status {
+      align-items: center;
+      display: inline-flex;
+    }
+    .status .comma {
+      padding-right: var(--spacing-xs);
+    }
+    /* Used to hide the leading separator comma for statuses. */
+    .status .comma:first-of-type {
+      display: none;
+    }
+    .size gr-tooltip-content {
+      margin: -0.4rem -0.6rem;
+      max-width: 2.5rem;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    a {
+      color: inherit;
+      cursor: pointer;
+      text-decoration: none;
+    }
+    a:hover {
+      text-decoration: underline;
+    }
+    .u-monospace {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    .u-green {
+      color: var(--vote-text-color-recommended);
+    }
+    .u-red {
+      color: var(--vote-text-color-disliked);
+    }
+    .u-gray-background {
+      background-color: var(--table-header-background-color);
+    }
+    .comma,
+    .placeholder {
+      color: var(--deemphasized-text-color);
+    }
+    .cell.label {
+      font-weight: var(--font-weight-normal);
+    }
+    .lastChildHidden:last-of-type {
+      display: none;
+    }
+    @media only screen and (max-width: 50em) {
+      :host {
+        display: flex;
+      }
+    }
+  </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>
+  </td>
+  <td class="cell number" hidden$="[[!showNumber]]" hidden="">
+    <a href$="[[changeURL]]">[[change._number]]</a>
+  </td>
+  <td
+    class="cell subject"
+    hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]"
+  >
+    <div class="container">
+      <div class="content">
+        <a title$="[[change.subject]]" href$="[[changeURL]]">
+          [[change.subject]]
+        </a>
+      </div>
+      <div class="spacer">
+        [[change.subject]]
+      </div>
+      <span>&nbsp;</span>
+    </div>
+  </td>
+  <td
+    class="cell status"
+    hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]"
+  >
+    <template is="dom-repeat" items="[[statuses]]" as="status">
+      <div class="comma">,</div>
+      <gr-change-status flat="" status="[[status]]"></gr-change-status>
+    </template>
+    <template is="dom-if" if="[[!statuses.length]]">
+      <span class="placeholder">--</span>
+    </template>
+  </td>
+  <td
+    class="cell owner"
+    hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
+  >
+    <gr-account-link account="[[change.owner]]"></gr-account-link>
+  </td>
+  <td
+    class="cell assignee"
+    hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]"
+  >
+    <template is="dom-if" if="[[change.assignee]]">
+      <gr-account-link
+        id="assigneeAccountLink"
+        account="[[change.assignee]]"
+      ></gr-account-link>
+    </template>
+    <template is="dom-if" if="[[!change.assignee]]">
+      <span class="placeholder">--</span>
+    </template>
+  </td>
+  <td
+    class="cell reviewers"
+    hidden$="[[isColumnHidden('Reviewers', visibleChangeTableColumns)]]"
+  >
+    <div>
+      <template
+        is="dom-repeat"
+        items="[[change.reviewers.REVIEWER]]"
+        as="reviewer"
+      >
+        <gr-account-link
+          hide-avatar=""
+          hide-status=""
+          account="[[reviewer]]"
+        ></gr-account-link
+        ><!--
+       --><span class="lastChildHidden">, </span>
+      </template>
+    </div>
+  </td>
+  <td
+    class="cell comments"
+    hidden$="[[isColumnHidden('Comments', visibleChangeTableColumns)]]"
+  >
+    <iron-icon
+      hidden$="[[!change.unresolved_comment_count]]"
+      icon="gr-icons:comment"
+    ></iron-icon>
+    <span>[[_computeComments(change.unresolved_comment_count)]]</span>
+  </td>
+  <td
+    class="cell repo"
+    hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]"
+  >
+    <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
+      [[_computeRepoDisplay(change)]]
+    </a>
+    <a
+      class="truncatedRepo"
+      href$="[[_computeRepoUrl(change)]]"
+      title$="[[_computeRepoDisplay(change)]]"
+    >
+      [[_computeRepoDisplay(change, 'true')]]
+    </a>
+  </td>
+  <td
+    class="cell branch"
+    hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]"
+  >
+    <a href$="[[_computeRepoBranchURL(change)]]">
+      [[change.branch]]
+    </a>
+    <template is="dom-if" if="[[change.topic]]">
+      (<a href$="[[_computeTopicURL(change)]]"
+        ><!--
+       --><gr-limited-text limit="50" text="[[change.topic]]"> </gr-limited-text
+        ><!--
+     --></a
+      >)
+    </template>
+  </td>
+  <td
+    class="cell updated"
+    hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      date-str="[[change.updated]]"
+    ></gr-date-formatter>
+  </td>
+  <td
+    class="cell size"
+    hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
+  >
+    <gr-tooltip-content has-tooltip="" title="[[_computeSizeTooltip(change)]]">
+      <template is="dom-if" if="[[_changeSize]]">
+        <span>[[_changeSize]]</span>
+      </template>
+      <template is="dom-if" if="[[!_changeSize]]">
+        <span class="placeholder">--</span>
+      </template>
+    </gr-tooltip-content>
+  </td>
+  <template is="dom-repeat" items="[[labelNames]]" as="labelName">
+    <td
+      title$="[[_computeLabelTitle(change, labelName)]]"
+      class$="[[_computeLabelClass(change, labelName)]]"
+    >
+      [[_computeLabelValue(change, labelName)]]
+    </td>
+  </template>
+  <template
+    is="dom-repeat"
+    items="[[_dynamicCellEndpoints]]"
+    as="pluginEndpointName"
+  >
+    <td class="cell endpoint">
+      <gr-endpoint-decorator name$="[[pluginEndpointName]]">
+        <gr-endpoint-param name="change" value="[[change]]">
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </td>
+  </template>
+`;
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..6b45618 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-change-list-item.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,235 +31,251 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-list-item tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-list-item.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
+suite('gr-change-list-item tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
     });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('computed fields', () => {
-      assert.equal(element._computeLabelClass({labels: {}}),
-          'cell label u-gray-background');
-      assert.equal(element._computeLabelClass(
-          {labels: {}}, 'Verified'), 'cell label u-gray-background');
-      assert.equal(element._computeLabelClass(
-          {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-      'cell label u-green u-monospace');
-      assert.equal(element._computeLabelClass(
-          {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-      'cell label u-monospace u-red');
-      assert.equal(element._computeLabelClass(
-          {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-      'cell label u-green u-monospace');
-      assert.equal(element._computeLabelClass(
-          {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-      'cell label u-monospace u-red');
-      assert.equal(element._computeLabelClass(
-          {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-      'cell label u-gray-background');
-
-      assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
-          'Label not applicable');
-      assert.equal(element._computeLabelTitle(
-          {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-      'Verified\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
-      'Label not applicable');
-      assert.equal(element._computeLabelTitle(
-          {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-      'Verified\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
-          'Code-Review'), 'Code-Review\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
-          'Code-Review'), 'Code-Review\nby Diffy');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-            rejected: {name: 'Admin'}}}}, 'Code-Review'),
-      'Code-Review\nby Admin');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {approved: {name: 'Diffy'},
-            rejected: {name: 'Admin'}}}}, 'Code-Review'),
-      'Code-Review\nby Admin');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-      'Code-Review\nby Admin');
-      assert.equal(element._computeLabelTitle(
-          {labels: {'Code-Review': {approved: {name: 'Diffy'},
-            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-      'Code-Review\nby Diffy');
-
-      assert.equal(element._computeLabelValue({labels: {}}), '');
-      assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
-      assert.equal(element._computeLabelValue(
-          {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
-    });
-
-    test('no hidden columns', () => {
-      element.visibleChangeTableColumns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-
-      flushAsynchronousOperations();
-
-      for (const column of element.columnNames) {
-        const elementClass = '.' + column.toLowerCase();
-        assert.isOk(element.$$(elementClass),
-            `Expect ${elementClass} element to be found`);
-        assert.isFalse(element.$$(elementClass).hidden);
-      }
-    });
-
-    test('repo column hidden', () => {
-      element.visibleChangeTableColumns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-
-      flushAsynchronousOperations();
-
-      for (const column of element.columnNames) {
-        const elementClass = '.' + column.toLowerCase();
-        if (column === 'Repo') {
-          assert.isTrue(element.$$(elementClass).hidden);
-        } else {
-          assert.isFalse(element.$$(elementClass).hidden);
-        }
-      }
-    });
-
-    test('random column does not exist', () => {
-      element.visibleChangeTableColumns = [
-        'Bad',
-      ];
-
-      flushAsynchronousOperations();
-      const elementClass = '.bad';
-      assert.isNotOk(element.$$(elementClass));
-    });
-
-    test('assignee only displayed if there is one', () => {
-      element.change = {};
-      flushAsynchronousOperations();
-      assert.isNotOk(element.$$('.assignee gr-account-link'));
-      assert.equal(element.$$('.assignee').textContent.trim(), '--');
-      element.change = {
-        assignee: {
-          name: 'test',
-          status: 'test',
-        },
-      };
-      flushAsynchronousOperations();
-      assert.isOk(element.$$('.assignee gr-account-link'));
-    });
-
-    test('TShirt sizing tooltip', () => {
-      assert.equal(element._computeSizeTooltip({
-        insertions: 'foo',
-        deletions: 'bar',
-      }), 'Size unknown');
-      assert.equal(element._computeSizeTooltip({
-        insertions: 0,
-        deletions: 0,
-      }), 'Size unknown');
-      assert.equal(element._computeSizeTooltip({
-        insertions: 1,
-        deletions: 2,
-      }), '+1, -2');
-    });
-
-    test('TShirt sizing', () => {
-      assert.equal(element._computeChangeSize({
-        insertions: 'foo',
-        deletions: 'bar',
-      }), null);
-      assert.equal(element._computeChangeSize({
-        insertions: 1,
-        deletions: 1,
-      }), 'XS');
-      assert.equal(element._computeChangeSize({
-        insertions: 9,
-        deletions: 1,
-      }), 'S');
-      assert.equal(element._computeChangeSize({
-        insertions: 10,
-        deletions: 200,
-      }), 'M');
-      assert.equal(element._computeChangeSize({
-        insertions: 99,
-        deletions: 900,
-      }), 'L');
-      assert.equal(element._computeChangeSize({
-        insertions: 99,
-        deletions: 999,
-      }), 'XL');
-    });
-
-    test('change params passed to gr-navigation', () => {
-      sandbox.stub(Gerrit.Nav);
-      const change = {
-        internalHost: 'test-host',
-        project: 'test-repo',
-        topic: 'test-topic',
-        branch: 'test-branch',
-      };
-      element.change = change;
-      flushAsynchronousOperations();
-
-      assert.deepEqual(Gerrit.Nav.getUrlForChange.lastCall.args, [change]);
-      assert.deepEqual(Gerrit.Nav.getUrlForProjectChanges.lastCall.args,
-          [change.project, true, change.internalHost]);
-      assert.deepEqual(Gerrit.Nav.getUrlForBranch.lastCall.args,
-          [change.branch, change.project, null, change.internalHost]);
-      assert.deepEqual(Gerrit.Nav.getUrlForTopic.lastCall.args,
-          [change.topic, change.internalHost]);
-    });
-
-    test('_computeRepoDisplay', () => {
-      const change = {
-        project: 'a/test/repo',
-        internalHost: 'host',
-      };
-      assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
-      assert.equal(element._computeRepoDisplay(change, true),
-          'host/…/test/repo');
-      delete change.internalHost;
-      assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
-      assert.equal(element._computeRepoDisplay(change, true),
-          '…/test/repo');
-    });
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('computed fields', () => {
+    assert.equal(element._computeLabelClass({labels: {}}),
+        'cell label u-gray-background');
+    assert.equal(element._computeLabelClass(
+        {labels: {}}, 'Verified'), 'cell label u-gray-background');
+    assert.equal(element._computeLabelClass(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+    'cell label u-green u-monospace');
+    assert.equal(element._computeLabelClass(
+        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
+    'cell label u-monospace u-red');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
+    'cell label u-green u-monospace');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
+    'cell label u-monospace u-red');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
+    'cell label u-gray-background');
+
+    assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
+        'Label not applicable');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
+    'Verified\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
+    'Label not applicable');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
+    'Verified\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
+        'Code-Review'), 'Code-Review\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
+        'Code-Review'), 'Code-Review\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+    'Code-Review\nby Diffy');
+
+    assert.equal(element._computeLabelValue({labels: {}}), '');
+    assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
+  });
+
+  test('no hidden columns', () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    flushAsynchronousOperations();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      assert.isOk(element.shadowRoot
+          .querySelector(elementClass),
+      `Expect ${elementClass} element to be found`);
+      assert.isFalse(element.shadowRoot
+          .querySelector(elementClass).hidden);
+    }
+  });
+
+  test('repo column hidden', () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    flushAsynchronousOperations();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      if (column === 'Repo') {
+        assert.isTrue(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      } else {
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      }
+    }
+  });
+
+  test('random column does not exist', () => {
+    element.visibleChangeTableColumns = [
+      'Bad',
+    ];
+
+    flushAsynchronousOperations();
+    const elementClass = '.bad';
+    assert.isNotOk(element.shadowRoot
+        .querySelector(elementClass));
+  });
+
+  test('assignee only displayed if there is one', () => {
+    element.change = {};
+    flushAsynchronousOperations();
+    assert.isNotOk(element.shadowRoot
+        .querySelector('.assignee gr-account-link'));
+    assert.equal(element.shadowRoot
+        .querySelector('.assignee').textContent.trim(), '--');
+    element.change = {
+      assignee: {
+        name: 'test',
+        status: 'test',
+      },
+    };
+    flushAsynchronousOperations();
+    assert.isOk(element.shadowRoot
+        .querySelector('.assignee gr-account-link'));
+  });
+
+  test('TShirt sizing tooltip', () => {
+    assert.equal(element._computeSizeTooltip({
+      insertions: 'foo',
+      deletions: 'bar',
+    }), 'Size unknown');
+    assert.equal(element._computeSizeTooltip({
+      insertions: 0,
+      deletions: 0,
+    }), 'Size unknown');
+    assert.equal(element._computeSizeTooltip({
+      insertions: 1,
+      deletions: 2,
+    }), '+1, -2');
+  });
+
+  test('TShirt sizing', () => {
+    assert.equal(element._computeChangeSize({
+      insertions: 'foo',
+      deletions: 'bar',
+    }), null);
+    assert.equal(element._computeChangeSize({
+      insertions: 1,
+      deletions: 1,
+    }), 'XS');
+    assert.equal(element._computeChangeSize({
+      insertions: 9,
+      deletions: 1,
+    }), 'S');
+    assert.equal(element._computeChangeSize({
+      insertions: 10,
+      deletions: 200,
+    }), 'M');
+    assert.equal(element._computeChangeSize({
+      insertions: 99,
+      deletions: 900,
+    }), 'L');
+    assert.equal(element._computeChangeSize({
+      insertions: 99,
+      deletions: 999,
+    }), 'XL');
+  });
+
+  test('change params passed to gr-navigation', () => {
+    sandbox.stub(GerritNav);
+    const change = {
+      internalHost: 'test-host',
+      project: 'test-repo',
+      topic: 'test-topic',
+      branch: 'test-branch',
+    };
+    element.change = change;
+    flushAsynchronousOperations();
+
+    assert.deepEqual(GerritNav.getUrlForChange.lastCall.args, [change]);
+    assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
+        [change.project, true, change.internalHost]);
+    assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
+        [change.branch, change.project, null, change.internalHost]);
+    assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
+        [change.topic, change.internalHost]);
+  });
+
+  test('_computeRepoDisplay', () => {
+    const change = {
+      project: 'a/test/repo',
+      internalHost: 'host',
+    };
+    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
+    assert.equal(element._computeRepoDisplay(change, true),
+        'host/…/test/repo');
+    delete change.internalHost;
+    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
+    assert.equal(element._computeRepoDisplay(change, true),
+        '…/test/repo');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
deleted file mode 100644
index 3ac1d5f..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ /dev/null
@@ -1,110 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-change-list/gr-change-list.html">
-<link rel="import" href="../gr-repo-header/gr-repo-header.html">
-<link rel="import" href="../gr-user-header/gr-user-header.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-list-view">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--view-background-color);
-        display: block;
-      }
-      .loading {
-        color: var(--deemphasized-text-color);
-        padding: var(--spacing-l);
-      }
-      gr-change-list {
-        width: 100%;
-      }
-      gr-user-header,
-      gr-repo-header {
-        border-bottom: 1px solid var(--border-color);
-      }
-      nav {
-        align-items: center;
-        background-color: var(--view-background-color);;
-        display: flex;
-        height: 3rem;
-        justify-content: flex-end;
-        margin-right: 20px;
-      }
-      nav,
-      iron-icon {
-        color: var(--deemphasized-text-color);
-      }
-      iron-icon {
-        height: 1.85rem;
-        margin-left: 16px;
-        width: 1.85rem;
-      }
-      .hide {
-        display: none;
-      }
-      @media only screen and (max-width: 50em) {
-        .loading,
-        .error {
-          padding: 0 var(--spacing-l);
-        }
-      }
-    </style>
-    <div class="loading" hidden$="[[!_loading]]" hidden>Loading...</div>
-    <div hidden$="[[_loading]]" hidden>
-      <gr-repo-header
-          repo="[[_repo]]"
-          class$="[[_computeHeaderClass(_repo)]]"></gr-repo-header>
-      <gr-user-header
-          user-id="[[_userId]]"
-          show-dashboard-link
-          logged-in="[[_loggedIn]]"
-          class$="[[_computeHeaderClass(_userId)]]"></gr-user-header>
-      <gr-change-list
-          account="[[account]]"
-          changes="{{_changes}}"
-          preferences="[[preferences]]"
-          selected-index="{{viewState.selectedChangeIndex}}"
-          show-star="[[_loggedIn]]"
-          on-toggle-star="_handleToggleStar"
-          on-toggle-reviewed="_handleToggleReviewed"></gr-change-list>
-      <nav class$="[[_computeNavClass(_loading)]]">
-          Page [[_computePage(_offset, _changesPerPage)]]
-          <a id="prevArrow"
-              href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-              class$="[[_computePrevArrowClass(_offset)]]">
-            <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-          </a>
-          <a id="nextArrow"
-              href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-              class$="[[_computeNextArrowClass(_changes)]]">
-            <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-          </a>
-      </nav>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-change-list-view.js"></script>
-</dom-module>
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 cf6da73..c416b11 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,40 +14,59 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  const LookupQueryPatterns = {
-    CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-    CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  };
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-change-list/gr-change-list.js';
+import '../gr-repo-header/gr-repo-header.js';
+import '../gr-user-header/gr-user-header.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-list-view_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import page from 'page/page.mjs';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+const LookupQueryPatterns = {
+  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
+  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+};
 
-  const REPO_QUERY_PATTERN =
-      /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
-  const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+const REPO_QUERY_PATTERN =
+    /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
 
-  Polymer({
-    is: 'gr-change-list-view',
+const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
+/**
+ * @extends Polymer.Element
+ */
+class GrChangeListView extends mixinBehaviors( [
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-list-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
+     * URL params passed from the router.
      */
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -114,165 +133,178 @@
         value: true,
       },
 
-      /** @type {?String} */
+      /** @type {?string} */
       _userId: {
         type: String,
         value: null,
       },
 
-      /** @type {?String} */
+      /** @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());
+  }
 
-    attached() {
-      this._loadPreferences();
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
 
-    _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.SEARCH) { return; }
+  _paramsChanged(value) {
+    if (value.view !== GerritNav.View.SEARCH) { return; }
 
-      this._loading = true;
-      this._query = value.query;
-      this._offset = value.offset || 0;
-      if (this.viewState.query != this._query ||
-          this.viewState.offset != this._offset) {
-        this.set('viewState.selectedChangeIndex', 0);
-        this.set('viewState.query', this._query);
-        this.set('viewState.offset', this._offset);
-      }
+    this._loading = true;
+    this._query = value.query;
+    this._offset = value.offset || 0;
+    if (this.viewState.query != this._query ||
+        this.viewState.offset != this._offset) {
+      this.set('viewState.selectedChangeIndex', 0);
+      this.set('viewState.query', this._query);
+      this.set('viewState.offset', this._offset);
+    }
 
-      // NOTE: This method may be called before attachment. Fire title-change
-      // in an async so that attachment to the DOM can take place first.
-      this.async(() => this.fire('title-change', {title: this._query}));
+    // NOTE: This method may be called before attachment. Fire title-change
+    // in an async so that attachment to the DOM can take place first.
+    this.async(() => this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: this._query},
+      composed: true, bubbles: true,
+    })));
 
-      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])) {
-              Gerrit.Nav.navigateToChange(changes[0]);
-              return;
+    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])) {
+                GerritNav.navigateToChange(changes[0]);
+                return;
+              }
             }
           }
-        }
-        this._changes = changes;
-        this._loading = false;
-      });
-    },
+          this._changes = changes;
+          this._loading = false;
+        });
+  }
 
-    _loadPreferences() {
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          this._getPreferences().then(preferences => {
-            this.preferences = preferences;
-          });
-        } else {
-          this.preferences = {};
-        }
-      });
-    },
-
-    _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);
-      if (!match) {
-        return defaultLimit;
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this._getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
       }
-      return parseInt(match[1], 10);
-    },
+    });
+  }
 
-    _computeNavLink(query, offset, direction, changesPerPage) {
-      // Offset could be a string when passed from the router.
-      offset = +(offset || 0);
-      const limit = this._limitFor(query, changesPerPage);
-      const newOffset = Math.max(0, offset + (limit * direction));
-      return Gerrit.Nav.getUrlForSearchQuery(query, newOffset);
-    },
+  _getChanges() {
+    return this.$.restAPI.getChanges(this._changesPerPage, this._query,
+        this._offset);
+  }
 
-    _computePrevArrowClass(offset) {
-      return offset === 0 ? 'hide' : '';
-    },
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
 
-    _computeNextArrowClass(changes) {
-      const more = changes.length && changes[changes.length - 1]._more_changes;
-      return more ? '' : 'hide';
-    },
+  _limitFor(query, defaultLimit) {
+    const match = query.match(LIMIT_OPERATOR_PATTERN);
+    if (!match) {
+      return defaultLimit;
+    }
+    return parseInt(match[1], 10);
+  }
 
-    _computeNavClass(loading) {
-      return loading || !this._changes || !this._changes.length ? 'hide' : '';
-    },
+  _computeNavLink(query, offset, direction, changesPerPage) {
+    // Offset could be a string when passed from the router.
+    offset = +(offset || 0);
+    const limit = this._limitFor(query, changesPerPage);
+    const newOffset = Math.max(0, offset + (limit * direction));
+    return GerritNav.getUrlForSearchQuery(query, newOffset);
+  }
 
-    _handleNextPage() {
-      if (this.$.nextArrow.hidden) { return; }
-      page.show(this._computeNavLink(
-          this._query, this._offset, 1, this._changesPerPage));
-    },
+  _computePrevArrowClass(offset) {
+    return offset === 0 ? 'hide' : '';
+  }
 
-    _handlePreviousPage() {
-      if (this.$.prevArrow.hidden) { return; }
-      page.show(this._computeNavLink(
-          this._query, this._offset, -1, this._changesPerPage));
-    },
+  _computeNextArrowClass(changes) {
+    const more = changes.length && changes[changes.length - 1]._more_changes;
+    return more ? '' : 'hide';
+  }
 
-    _changesChanged(changes) {
-      this._userId = null;
-      this._repo = null;
-      if (!changes || !changes.length) {
+  _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;
+    this._repo = null;
+    if (!changes || !changes.length) {
+      return;
+    }
+    if (USER_QUERY_PATTERN.test(this._query)) {
+      const owner = changes[0].owner;
+      const userId = owner._account_id ? owner._account_id : owner.email;
+      if (userId) {
+        this._userId = userId;
         return;
       }
-      if (USER_QUERY_PATTERN.test(this._query)) {
-        const owner = changes[0].owner;
-        const userId = owner._account_id ? owner._account_id : owner.email;
-        if (userId) {
-          this._userId = userId;
-          return;
-        }
-      }
-      if (REPO_QUERY_PATTERN.test(this._query)) {
-        this._repo = changes[0].project;
-      }
-    },
+    }
+    if (REPO_QUERY_PATTERN.test(this._query)) {
+      this._repo = changes[0].project;
+    }
+  }
 
-    _computeHeaderClass(id) {
-      return id ? '' : 'hide';
-    },
+  _computeHeaderClass(id) {
+    return id ? '' : 'hide';
+  }
 
-    _computePage(offset, changesPerPage) {
-      return offset / changesPerPage + 1;
-    },
+  _computePage(offset, changesPerPage) {
+    return offset / changesPerPage + 1;
+  }
 
-    _computeLoggedIn(account) {
-      return !!(account && Object.keys(account).length > 0);
-    },
+  _computeLoggedIn(account) {
+    return !!(account && Object.keys(account).length > 0);
+  }
 
-    _handleToggleStar(e) {
-      this.$.restAPI.saveChangeStarred(e.detail.change._number,
-          e.detail.starred);
-    },
+  _handleToggleStar(e) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number,
+        e.detail.starred);
+  }
 
-    _handleToggleReviewed(e) {
-      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-          e.detail.reviewed);
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
new file mode 100644
index 0000000..4add1da
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    .loading {
+      color: var(--deemphasized-text-color);
+      padding: var(--spacing-l);
+    }
+    gr-change-list {
+      width: 100%;
+    }
+    gr-user-header,
+    gr-repo-header {
+      border-bottom: 1px solid var(--border-color);
+    }
+    nav {
+      align-items: center;
+      display: flex;
+      height: 3rem;
+      justify-content: flex-end;
+      margin-right: 20px;
+    }
+    nav,
+    iron-icon {
+      color: var(--deemphasized-text-color);
+    }
+    iron-icon {
+      height: 1.85rem;
+      margin-left: 16px;
+      width: 1.85rem;
+    }
+    .hide {
+      display: none;
+    }
+    @media only screen and (max-width: 50em) {
+      .loading,
+      .error {
+        padding: 0 var(--spacing-l);
+      }
+    }
+  </style>
+  <div class="loading" hidden$="[[!_loading]]" hidden="">Loading...</div>
+  <div hidden$="[[_loading]]" hidden="">
+    <gr-repo-header
+      repo="[[_repo]]"
+      class$="[[_computeHeaderClass(_repo)]]"
+    ></gr-repo-header>
+    <gr-user-header
+      user-id="[[_userId]]"
+      show-dashboard-link=""
+      logged-in="[[_loggedIn]]"
+      class$="[[_computeHeaderClass(_userId)]]"
+    ></gr-user-header>
+    <gr-change-list
+      account="[[account]]"
+      changes="{{_changes}}"
+      preferences="[[preferences]]"
+      selected-index="{{viewState.selectedChangeIndex}}"
+      show-star="[[_loggedIn]]"
+      on-toggle-star="_handleToggleStar"
+      on-toggle-reviewed="_handleToggleReviewed"
+    ></gr-change-list>
+    <nav class$="[[_computeNavClass(_loading)]]">
+      Page [[_computePage(_offset, _changesPerPage)]]
+      <a
+        id="prevArrow"
+        href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
+        class$="[[_computePrevArrowClass(_offset)]]"
+      >
+        <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+      </a>
+      <a
+        id="nextArrow"
+        href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
+        class$="[[_computeNextArrowClass(_changes)]]"
+      >
+        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+      </a>
+    </nav>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 54885cc..58ec4e1 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-change-list-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,25 +32,172 @@
   </template>
 </test-fixture>
 
-<script>
-  const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-  const COMMIT_HASH = '12345678';
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-list-view.js';
+import page from 'page/page.mjs';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  suite('gr-change-list-view tests', () => {
-    let element;
-    let sandbox;
+const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+const COMMIT_HASH = '12345678';
 
+suite('gr-change-list-view tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getChanges(num, query) {
+        return Promise.resolve([]);
+      },
+      getAccountDetails() { return Promise.resolve({}); },
+      getAccountStatus() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(done => {
+    flush(() => {
+      sandbox.restore();
+      done();
+    });
+  });
+
+  test('_computePage', () => {
+    assert.equal(element._computePage(0, 25), 1);
+    assert.equal(element._computePage(50, 25), 3);
+  });
+
+  test('_limitFor', () => {
+    const defaultLimit = 25;
+    const _limitFor = q => element._limitFor(q, defaultLimit);
+    assert.equal(_limitFor(''), defaultLimit);
+    assert.equal(_limitFor('limit:10'), 10);
+    assert.equal(_limitFor('xlimit:10'), defaultLimit);
+    assert.equal(_limitFor('x(limit:10'), 10);
+  });
+
+  test('_computeNavLink', () => {
+    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForSearchQuery')
+        .returns('');
+    const query = 'status:open';
+    let offset = 0;
+    let direction = 1;
+    const changesPerPage = 5;
+
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 5);
+
+    direction = -1;
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 0);
+
+    offset = 5;
+    direction = 1;
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 10);
+  });
+
+  test('_computePrevArrowClass', () => {
+    let offset = 0;
+    assert.equal(element._computePrevArrowClass(offset), 'hide');
+    offset = 5;
+    assert.equal(element._computePrevArrowClass(offset), '');
+  });
+
+  test('_computeNextArrowClass', () => {
+    let changes = _.times(25, _.constant({_more_changes: true}));
+    assert.equal(element._computeNextArrowClass(changes), '');
+    changes = _.times(25, _.constant({}));
+    assert.equal(element._computeNextArrowClass(changes), 'hide');
+  });
+
+  test('_computeNavClass', () => {
+    let loading = true;
+    assert.equal(element._computeNavClass(loading), 'hide');
+    loading = false;
+    assert.equal(element._computeNavClass(loading), 'hide');
+    element._changes = [];
+    assert.equal(element._computeNavClass(loading), 'hide');
+    element._changes = _.times(5, _.constant({}));
+    assert.equal(element._computeNavClass(loading), '');
+  });
+
+  test('_handleNextPage', () => {
+    const showStub = sandbox.stub(page, 'show');
+    element.$.nextArrow.hidden = true;
+    element._handleNextPage();
+    assert.isFalse(showStub.called);
+    element.$.nextArrow.hidden = false;
+    element._handleNextPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('_handlePreviousPage', () => {
+    const showStub = sandbox.stub(page, 'show');
+    element.$.prevArrow.hidden = true;
+    element._handlePreviousPage();
+    assert.isFalse(showStub.called);
+    element.$.prevArrow.hidden = false;
+    element._handlePreviousPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('_userId query', done => {
+    assert.isNull(element._userId);
+    element._query = 'owner: foo@bar';
+    element._changes = [{owner: {email: 'foo@bar'}}];
+    flush(() => {
+      assert.equal(element._userId, 'foo@bar');
+
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._userId);
+
+      done();
+    });
+  });
+
+  test('_userId query without email', done => {
+    assert.isNull(element._userId);
+    element._query = 'owner: foo@bar';
+    element._changes = [{owner: {}}];
+    flush(() => {
+      assert.isNull(element._userId);
+      done();
+    });
+  });
+
+  test('_repo query', done => {
+    assert.isNull(element._repo);
+    element._query = 'project: test-repo';
+    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+    flush(() => {
+      assert.equal(element._repo, 'test-repo');
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._repo);
+      done();
+    });
+  });
+
+  test('_repo query with open status', done => {
+    assert.isNull(element._repo);
+    element._query = 'project:test-repo status:open';
+    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+    flush(() => {
+      assert.equal(element._repo, 'test-repo');
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._repo);
+      done();
+    });
+  });
+
+  suite('query based navigation', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getChanges(num, query) {
-          return Promise.resolve([]);
-        },
-        getAccountDetails() { return Promise.resolve({}); },
-        getAccountStatus() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
     });
 
     teardown(done => {
@@ -63,205 +207,63 @@
       });
     });
 
-    test('_computePage', () => {
-      assert.equal(element._computePage(0, 25), 1);
-      assert.equal(element._computePage(50, 25), 3);
-    });
-
-    test('_limitFor', () => {
-      const defaultLimit = 25;
-      const _limitFor = q => element._limitFor(q, defaultLimit);
-      assert.equal(_limitFor(''), defaultLimit);
-      assert.equal(_limitFor('limit:10'), 10);
-      assert.equal(_limitFor('xlimit:10'), defaultLimit);
-      assert.equal(_limitFor('x(limit:10'), 10);
-    });
-
-    test('_computeNavLink', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForSearchQuery')
-          .returns('');
-      const query = 'status:open';
-      let offset = 0;
-      let direction = 1;
-      const changesPerPage = 5;
-
-      element._computeNavLink(query, offset, direction, changesPerPage);
-      assert.equal(getUrlStub.lastCall.args[1], 5);
-
-      direction = -1;
-      element._computeNavLink(query, offset, direction, changesPerPage);
-      assert.equal(getUrlStub.lastCall.args[1], 0);
-
-      offset = 5;
-      direction = 1;
-      element._computeNavLink(query, offset, direction, changesPerPage);
-      assert.equal(getUrlStub.lastCall.args[1], 10);
-    });
-
-    test('_computePrevArrowClass', () => {
-      let offset = 0;
-      assert.equal(element._computePrevArrowClass(offset), 'hide');
-      offset = 5;
-      assert.equal(element._computePrevArrowClass(offset), '');
-    });
-
-    test('_computeNextArrowClass', () => {
-      let changes = _.times(25, _.constant({_more_changes: true}));
-      assert.equal(element._computeNextArrowClass(changes), '');
-      changes = _.times(25, _.constant({}));
-      assert.equal(element._computeNextArrowClass(changes), 'hide');
-    });
-
-    test('_computeNavClass', () => {
-      let loading = true;
-      assert.equal(element._computeNavClass(loading), 'hide');
-      loading = false;
-      assert.equal(element._computeNavClass(loading), 'hide');
-      element._changes = [];
-      assert.equal(element._computeNavClass(loading), 'hide');
-      element._changes = _.times(5, _.constant({}));
-      assert.equal(element._computeNavClass(loading), '');
-    });
-
-    test('_handleNextPage', () => {
-      const showStub = sandbox.stub(page, 'show');
-      element.$.nextArrow.hidden = true;
-      element._handleNextPage();
-      assert.isFalse(showStub.called);
-      element.$.nextArrow.hidden = false;
-      element._handleNextPage();
-      assert.isTrue(showStub.called);
-    });
-
-    test('_handlePreviousPage', () => {
-      const showStub = sandbox.stub(page, 'show');
-      element.$.prevArrow.hidden = true;
-      element._handlePreviousPage();
-      assert.isFalse(showStub.called);
-      element.$.prevArrow.hidden = false;
-      element._handlePreviousPage();
-      assert.isTrue(showStub.called);
-    });
-
-    test('_userId query', done => {
-      assert.isNull(element._userId);
-      element._query = 'owner: foo@bar';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      flush(() => {
-        assert.equal(element._userId, 'foo@bar');
-
-        element._query = 'foo bar baz';
-        element._changes = [{owner: {email: 'foo@bar'}}];
-        assert.isNull(element._userId);
-
+    test('Searching for a change ID redirects to change', done => {
+      const change = {_number: 1};
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sandbox.stub(GerritNav, 'navigateToChange', url => {
+        assert.equal(url, change);
         done();
       });
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
     });
 
-    test('_userId query without email', done => {
-      assert.isNull(element._userId);
-      element._query = 'owner: foo@bar';
-      element._changes = [{owner: {}}];
-      flush(() => {
-        assert.isNull(element._userId);
+    test('Searching for a change num redirects to change', done => {
+      const change = {_number: 1};
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sandbox.stub(GerritNav, 'navigateToChange', url => {
+        assert.equal(url, change);
         done();
       });
+
+      element.params = {view: GerritNav.View.SEARCH, query: '1'};
     });
 
-    test('_repo query', done => {
-      assert.isNull(element._repo);
-      element._query = 'project: test-repo';
-      element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-      flush(() => {
-        assert.equal(element._repo, 'test-repo');
-        element._query = 'foo bar baz';
-        element._changes = [{owner: {email: 'foo@bar'}}];
-        assert.isNull(element._repo);
+    test('Commit hash redirects to change', done => {
+      const change = {_number: 1};
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sandbox.stub(GerritNav, 'navigateToChange', url => {
+        assert.equal(url, change);
         done();
       });
+
+      element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
     });
 
-    test('_repo query with open status', done => {
-      assert.isNull(element._repo);
-      element._query = 'project:test-repo status:open';
-      element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-      flush(() => {
-        assert.equal(element._repo, 'test-repo');
-        element._query = 'foo bar baz';
-        element._changes = [{owner: {email: 'foo@bar'}}];
-        assert.isNull(element._repo);
-        done();
-      });
+    test('Searching for an invalid change ID searches', () => {
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([]));
+      const stub = sandbox.stub(GerritNav, 'navigateToChange');
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+      flushAsynchronousOperations();
+
+      assert.isFalse(stub.called);
     });
 
-    suite('query based navigation', () => {
-      setup(() => {
-      });
+    test('Change ID with multiple search results searches', () => {
+      sandbox.stub(element, '_getChanges')
+          .returns(Promise.resolve([{}, {}]));
+      const stub = sandbox.stub(GerritNav, 'navigateToChange');
 
-      teardown(done => {
-        flush(() => {
-          sandbox.restore();
-          done();
-        });
-      });
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+      flushAsynchronousOperations();
 
-      test('Searching for a change ID redirects to change', done => {
-        const change = {_number: 1};
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([change]));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
-          assert.equal(url, change);
-          done();
-        });
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
-      });
-
-      test('Searching for a change num redirects to change', done => {
-        const change = {_number: 1};
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([change]));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
-          assert.equal(url, change);
-          done();
-        });
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: '1'};
-      });
-
-      test('Commit hash redirects to change', done => {
-        const change = {_number: 1};
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([change]));
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', url => {
-          assert.equal(url, change);
-          done();
-        });
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: COMMIT_HASH};
-      });
-
-      test('Searching for an invalid change ID searches', () => {
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([]));
-        const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
-        flushAsynchronousOperations();
-
-        assert.isFalse(stub.called);
-      });
-
-      test('Change ID with multiple search results searches', () => {
-        sandbox.stub(element, '_getChanges')
-            .returns(Promise.resolve([{}, {}]));
-        const stub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-
-        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
-        flushAsynchronousOperations();
-
-        assert.isFalse(stub.called);
-      });
+      assert.isFalse(stub.called);
     });
   });
+});
 </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
deleted file mode 100644
index 699f07a..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ /dev/null
@@ -1,128 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-change-list-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../gr-change-list-item/gr-change-list-item.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-
-<dom-module id="gr-change-list">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-change-list-styles">
-      #changeList {
-        border-collapse: collapse;
-        width: 100%;
-      }
-      .section-count-label {
-        color: var(--deemphasized-text-color);
-      }
-      a.section-title:hover {
-        text-decoration: none;
-      }
-      a.section-title:hover .section-count-label {
-        text-decoration: none;
-      }
-      a.section-title:hover .section-name {
-        text-decoration: underline;
-      }
-    </style>
-    <table id="changeList">
-      <tr class="topHeader">
-        <th class="leftPadding"></th>
-        <th class="star" hidden$="[[!showStar]]" hidden></th>
-        <th class="number" hidden$="[[!showNumber]]" hidden>#</th>
-        <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
-          <th class$="[[_lowerCase(item)]]"
-              hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]">
-            [[item]]
-          </th>
-        </template>
-        <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-          <th class="label" title$="[[labelName]]">
-            [[_computeLabelShortcut(labelName)]]
-          </th>
-        </template>
-        <template is="dom-repeat" items="[[_dynamicHeaderEndpoints]]"
-          as="pluginHeader">
-          <th class="endpoint">
-            <gr-endpoint-decorator name$="[[pluginHeader]]">
-            </gr-endpoint-decorator>
-          </th>
-        </template>
-      </tr>
-      <template is="dom-repeat" items="[[sections]]" as="changeSection"
-          index-as="sectionIndex">
-        <template is="dom-if" if="[[changeSection.name]]">
-          <tr class="groupHeader">
-            <td class="leftPadding"></td>
-            <td class="star" hidden$="[[!showStar]]" hidden></td>
-            <td class="cell"
-                colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
-              <a href$="[[_sectionHref(changeSection.query)]]" class="section-title">
-                <span class="section-name">[[changeSection.name]]</span>
-                <span class="section-count-label">[[changeSection.countLabel]]</span>
-              </a>
-            </td>
-          </tr>
-        </template>
-        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
-          <tr class="noChanges">
-            <td class="leftPadding"></td>
-            <td class="star" hidden$="[[!showStar]]" hidden></td>
-            <td class="cell"
-                colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
-              <template is="dom-if" if="[[_isOutgoing(changeSection)]]">
-                <slot name="empty-outgoing"></slot>
-              </template>
-              <template is="dom-if" if="[[!_isOutgoing(changeSection)]]">
-                No changes
-              </template>
-            </td>
-          </tr>
-        </template>
-        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
-          <gr-change-list-item
-              selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-              highlight$="[[_computeItemHighlight(account, change)]]"
-              needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
-              change="[[change]]"
-              visible-change-table-columns="[[visibleChangeTableColumns]]"
-              show-number="[[showNumber]]"
-              show-star="[[showStar]]"
-              tabindex="0"
-              label-names="[[labelNames]]"></gr-change-list-item>
-        </template>
-      </template>
-    </table>
-    <gr-cursor-manager
-        id="cursor"
-        index="{{selectedIndex}}"
-        scroll-behavior="keep-visible"
-        focus-on-move></gr-cursor-manager>
-  </template>
-  <script src="gr-change-list.js"></script>
-</dom-module>
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..0a19ae1 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,38 +14,69 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  const NUMBER_FIXED_COLUMNS = 3;
-  const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-  const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-  const MAX_SHORTCUT_CHARS = 5;
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-change-list-styles.js';
+import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-change-list-item/gr-change-list-item.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-list_html.js';
+import {appContext} from '../../../services/app-context.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-  Polymer({
-    is: 'gr-change-list',
+const NUMBER_FIXED_COLUMNS = 3;
+const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
 
+/**
+ * @extends Polymer.Element
+ */
+class GrChangeList extends mixinBehaviors( [
+  BaseUrlBehavior,
+  ChangeTableBehavior,
+  KeyboardShortcutBehavior,
+  RESTClientBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-list'; }
+  /**
+   * Fired when next page key shortcut was pressed.
+   *
+   * @event next-page
+   */
+
+  /**
+   * Fired when previous page key shortcut was pressed.
+   *
+   * @event previous-page
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when next page key shortcut was pressed.
-     *
-     * @event next-page
+     * The logged-in user's account, or an empty object if no user is logged
+     * in.
      */
-
-    /**
-     * Fired when previous page key shortcut was pressed.
-     *
-     * @event previous-page
-     */
-
-    hostAttributes: {
-      tabindex: 0,
-    },
-
-    properties: {
-      /**
-       * The logged-in user's account, or an empty object if no user is logged
-       * in.
-       */
       account: {
         type: Object,
         value: null,
@@ -99,294 +130,314 @@
       changeTableColumns: Array,
       visibleChangeTableColumns: Array,
       preferences: Object,
-    },
+      _config: Object,
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.ChangeTableBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.RESTClientBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    listeners: {
-      keydown: '_scopedKeydownHandler',
-    },
-
-    observers: [
+  static get observers() {
+    return [
       '_sectionsChanged(sections.*)',
-      '_computePreferences(account, preferences)',
-    ],
+      '_computePreferences(account, preferences, _config)',
+    ];
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
-        [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
-        [this.Shortcut.NEXT_PAGE]: '_nextPage',
-        [this.Shortcut.PREV_PAGE]: '_prevPage',
-        [this.Shortcut.OPEN_CHANGE]: '_openChange',
-        [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
-        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
-        [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
-      };
-    },
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+      [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+      [this.Shortcut.NEXT_PAGE]: '_nextPage',
+      [this.Shortcut.PREV_PAGE]: '_prevPage',
+      [this.Shortcut.OPEN_CHANGE]: '_openChange',
+      [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+      [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+      [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+    };
+  }
 
-    attached() {
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-list-header');
-      });
-    },
+  constructor() {
+    super();
+    this.flagsService = appContext.flagsService;
+  }
 
-    /**
-     * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-     * events must be scoped to a component level (e.g. `enter`) in order to not
-     * override native browser functionality.
-     *
-     * Context: Issue 7294
-     */
-    _scopedKeydownHandler(e) {
-      if (e.keyCode === 13) {
-        // Enter.
-        this._openChange(e);
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown',
+        e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('tabindex', 0);
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      this._dynamicHeaderEndpoints = pluginEndpoints.getDynamicEndpoints(
+          'change-list-header');
+    });
+  }
+
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7294
+   */
+  _scopedKeydownHandler(e) {
+    if (e.keyCode === 13) {
+      // Enter.
+      this._openChange(e);
+    }
+  }
+
+  _lowerCase(column) {
+    return column.toLowerCase();
+  }
+
+  _computePreferences(account, preferences, config) {
+    // Polymer 2: check for undefined
+    if ([account, preferences, config].some(arg => arg === undefined)) {
+      return;
+    }
+
+    this.changeTableColumns = this.columnNames;
+    this.showNumber = false;
+    this.visibleChangeTableColumns = this.getEnabledColumns(this.columnNames,
+        config, this.flagsService.enabledExperiments);
+
+    if (account) {
+      this.showNumber = !!(preferences &&
+          preferences.legacycid_in_change_table);
+      if (preferences.change_table &&
+          preferences.change_table.length > 0) {
+        const prefColumns = this.getVisibleColumns(preferences.change_table);
+        this.visibleChangeTableColumns = this.getEnabledColumns(prefColumns,
+            config, this.flagsService.enabledExperiments);
       }
-    },
+    }
+  }
 
-    _lowerCase(column) {
-      return column.toLowerCase();
-    },
+  _computeColspan(changeTableColumns, labelNames) {
+    if (!changeTableColumns || !labelNames) return;
+    return changeTableColumns.length + labelNames.length +
+        NUMBER_FIXED_COLUMNS;
+  }
 
-    _computePreferences(account, preferences) {
-      // Polymer 2: check for undefined
-      if ([account, preferences].some(arg => arg === undefined)) {
-        return;
+  _computeLabelNames(sections) {
+    if (!sections) { return []; }
+    let labels = [];
+    const nonExistingLabel = function(item) {
+      return !labels.includes(item);
+    };
+    for (const section of sections) {
+      if (!section.results) { continue; }
+      for (const change of section.results) {
+        if (!change.labels) { continue; }
+        const currentLabels = Object.keys(change.labels);
+        labels = labels.concat(currentLabels.filter(nonExistingLabel));
       }
+    }
+    return labels.sort();
+  }
 
-      this.changeTableColumns = this.columnNames;
+  _computeLabelShortcut(labelName) {
+    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+    }
+    return labelName.split('-')
+        .reduce((a, i) => {
+          if (!i) { return a; }
+          return a + i[0].toUpperCase();
+        }, '')
+        .slice(0, MAX_SHORTCUT_CHARS);
+  }
 
-      if (account) {
-        this.showNumber = !!(preferences &&
-            preferences.legacycid_in_change_table);
-        this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
-          this.getVisibleColumns(preferences.change_table) : this.columnNames;
-      } else {
-        // Not logged in.
-        this.showNumber = false;
-        this.visibleChangeTableColumns = this.columnNames;
-      }
-    },
+  _changesChanged(changes) {
+    this.sections = changes ? [{results: changes}] : [];
+  }
 
-    _computeColspan(changeTableColumns, labelNames) {
-      if (!changeTableColumns || !labelNames) return;
-      return changeTableColumns.length + labelNames.length +
-          NUMBER_FIXED_COLUMNS;
-    },
+  _processQuery(query) {
+    let tokens = query.split(' ');
+    const invalidTokens = ['limit:', 'age:', '-age:'];
+    tokens = tokens.filter(token => !invalidTokens
+        .some(invalidToken => token.startsWith(invalidToken)));
+    return tokens.join(' ');
+  }
 
-    _computeLabelNames(sections) {
-      if (!sections) { return []; }
-      let labels = [];
-      const nonExistingLabel = function(item) {
-        return !labels.includes(item);
-      };
-      for (const section of sections) {
-        if (!section.results) { continue; }
-        for (const change of section.results) {
-          if (!change.labels) { continue; }
-          const currentLabels = Object.keys(change.labels);
-          labels = labels.concat(currentLabels.filter(nonExistingLabel));
-        }
-      }
-      return labels.sort();
-    },
+  _sectionHref(query) {
+    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+  }
 
-    _computeLabelShortcut(labelName) {
-      if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
-        labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
-      }
-      return labelName.split('-')
-          .reduce((a, i) => {
-            if (!i) { return a; }
-            return a + i[0].toUpperCase();
-          }, '')
-          .slice(0, MAX_SHORTCUT_CHARS);
-    },
+  /**
+   * Maps an index local to a particular section to the absolute index
+   * across all the changes on the page.
+   *
+   * @param {number} sectionIndex index of section
+   * @param {number} localIndex index of row within section
+   * @return {number} absolute index of row in the aggregate dashboard
+   */
+  _computeItemAbsoluteIndex(sectionIndex, localIndex) {
+    let idx = 0;
+    for (let i = 0; i < sectionIndex; i++) {
+      idx += this.sections[i].results.length;
+    }
+    return idx + localIndex;
+  }
 
-    _changesChanged(changes) {
-      this.sections = changes ? [{results: changes}] : [];
-    },
+  _computeItemSelected(sectionIndex, index, selectedIndex) {
+    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+    return idx == selectedIndex;
+  }
 
-    _processQuery(query) {
-      let tokens = query.split(' ');
-      const invalidTokens = ['limit:', 'age:', '-age:'];
-      tokens = tokens.filter(token => {
-        return !invalidTokens.some(invalidToken => {
-          return token.startsWith(invalidToken);
-        });
-      });
-      return tokens.join(' ');
-    },
+  _computeItemNeedsReview(account, change, showReviewedState) {
+    return showReviewedState && !change.reviewed &&
+        !change.work_in_progress &&
+        this.changeIsOpen(change) &&
+        (!account || account._account_id != change.owner._account_id);
+  }
 
-    _sectionHref(query) {
-      return Gerrit.Nav.getUrlForSearchQuery(this._processQuery(query));
-    },
+  _computeItemHighlight(account, change) {
+    // Do not show the assignee highlight if the change is not open.
+    if (!change ||!change.assignee ||
+        !account ||
+        CLOSED_STATUS.indexOf(change.status) !== -1) {
+      return false;
+    }
+    return account._account_id === change.assignee._account_id;
+  }
 
-    /**
-     * Maps an index local to a particular section to the absolute index
-     * across all the changes on the page.
-     *
-     * @param {number} sectionIndex index of section
-     * @param {number} localIndex index of row within section
-     * @return {number} absolute index of row in the aggregate dashboard
-     */
-    _computeItemAbsoluteIndex(sectionIndex, localIndex) {
-      let idx = 0;
-      for (let i = 0; i < sectionIndex; i++) {
-        idx += this.sections[i].results.length;
-      }
-      return idx + localIndex;
-    },
+  _nextChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _computeItemSelected(sectionIndex, index, selectedIndex) {
-      const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
-      return idx == selectedIndex;
-    },
+    e.preventDefault();
+    this.$.cursor.next();
+  }
 
-    _computeItemNeedsReview(account, change, showReviewedState) {
-      return showReviewedState && !change.reviewed &&
-          !change.work_in_progress &&
-          this.changeIsOpen(change) &&
-          (!account || account._account_id != change.owner._account_id);
-    },
+  _prevChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _computeItemHighlight(account, change) {
-      // Do not show the assignee highlight if the change is not open.
-      if (!change ||!change.assignee ||
-          !account ||
-          CLOSED_STATUS.indexOf(change.status) !== -1) {
-        return false;
-      }
-      return account._account_id === change.assignee._account_id;
-    },
+    e.preventDefault();
+    this.$.cursor.previous();
+  }
 
-    _nextChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+  _openChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-      e.preventDefault();
-      this.$.cursor.next();
-    },
+    e.preventDefault();
+    GerritNav.navigateToChange(this._changeForIndex(this.selectedIndex));
+  }
 
-    _prevChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+  _nextPage(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+      return;
+    }
 
-      e.preventDefault();
-      this.$.cursor.previous();
-    },
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('next-page', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _openChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+  _prevPage(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+      return;
+    }
 
-      e.preventDefault();
-      Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex));
-    },
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('previous-page', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _nextPage(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-        return;
-      }
+  _toggleChangeReviewed(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-      e.preventDefault();
-      this.fire('next-page');
-    },
+    e.preventDefault();
+    this._toggleReviewedForIndex(this.selectedIndex);
+  }
 
-    _prevPage(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-        return;
-      }
+  _toggleReviewedForIndex(index) {
+    const changeEls = this._getListItems();
+    if (index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
 
-      e.preventDefault();
-      this.fire('previous-page');
-    },
+    const changeEl = changeEls[index];
+    changeEl.toggleReviewed();
+  }
 
-    _toggleChangeReviewed(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+  _refreshChangeList(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-      e.preventDefault();
-      this._toggleReviewedForIndex(this.selectedIndex);
-    },
+    e.preventDefault();
+    this._reloadWindow();
+  }
 
-    _toggleReviewedForIndex(index) {
-      const changeEls = this._getListItems();
-      if (index >= changeEls.length || !changeEls[index]) {
-        return;
-      }
+  _reloadWindow() {
+    window.location.reload();
+  }
 
-      const changeEl = changeEls[index];
-      changeEl.toggleReviewed();
-    },
+  _toggleChangeStar(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _refreshChangeList(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    e.preventDefault();
+    this._toggleStarForIndex(this.selectedIndex);
+  }
 
-      e.preventDefault();
-      this._reloadWindow();
-    },
+  _toggleStarForIndex(index) {
+    const changeEls = this._getListItems();
+    if (index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
 
-    _reloadWindow() {
-      window.location.reload();
-    },
+    const changeEl = changeEls[index];
+    changeEl.shadowRoot
+        .querySelector('gr-change-star').toggleStar();
+  }
 
-    _toggleChangeStar(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+  _changeForIndex(index) {
+    const changeEls = this._getListItems();
+    if (index < changeEls.length && changeEls[index]) {
+      return changeEls[index].change;
+    }
+    return null;
+  }
 
-      e.preventDefault();
-      this._toggleStarForIndex(this.selectedIndex);
-    },
+  _getListItems() {
+    return Array.from(
+        dom(this.root).querySelectorAll('gr-change-list-item'));
+  }
 
-    _toggleStarForIndex(index) {
-      const changeEls = this._getListItems();
-      if (index >= changeEls.length || !changeEls[index]) {
-        return;
-      }
+  _sectionsChanged() {
+    // Flush DOM operations so that the list item elements will be loaded.
+    afterNextRender(this, () => {
+      this.$.cursor.stops = this._getListItems();
+      this.$.cursor.moveToStart();
+    });
+  }
 
-      const changeEl = changeEls[index];
-      changeEl.$$('gr-change-star').toggleStar();
-    },
+  _isOutgoing(section) {
+    return !!section.isOutgoing;
+  }
 
-    _changeForIndex(index) {
-      const changeEls = this._getListItems();
-      if (index < changeEls.length && changeEls[index]) {
-        return changeEls[index].change;
-      }
-      return null;
-    },
+  _isEmpty(section) {
+    return !section.results.length;
+  }
+}
 
-    _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.
-      Polymer.RenderStatus.afterNextRender(this, () => {
-        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_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
new file mode 100644
index 0000000..17150df
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+      width: 100%;
+    }
+    .section-count-label {
+      color: var(--deemphasized-text-color);
+      font-family: var(--font-family);
+      font-size: var(--font-size-small);
+      font-weight: var(--font-weight-normal);
+      line-height: var(--line-height-small);
+    }
+    a.section-title:hover {
+      text-decoration: none;
+    }
+    a.section-title:hover .section-count-label {
+      text-decoration: none;
+    }
+    a.section-title:hover .section-name {
+      text-decoration: underline;
+    }
+  </style>
+  <table id="changeList">
+    <template
+      is="dom-repeat"
+      items="[[sections]]"
+      as="changeSection"
+      index-as="sectionIndex"
+    >
+      <template is="dom-if" if="[[changeSection.name]]">
+        <tbody>
+          <tr class="groupHeader">
+            <td class="leftPadding"></td>
+            <td class="star" hidden$="[[!showStar]]" hidden=""></td>
+            <td
+              class="cell"
+              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+            >
+              <a
+                href$="[[_sectionHref(changeSection.query)]]"
+                class="section-title"
+              >
+                <span class="section-name">[[changeSection.name]]</span>
+                <span class="section-count-label"
+                  >[[changeSection.countLabel]]</span
+                >
+              </a>
+            </td>
+          </tr>
+        </tbody>
+      </template>
+      <tbody class="groupContent">
+        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
+          <tr class="noChanges">
+            <td class="leftPadding"></td>
+            <td class="star" hidden$="[[!showStar]]" hidden=""></td>
+            <td
+              class="cell"
+              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+            >
+              <template is="dom-if" if="[[_isOutgoing(changeSection)]]">
+                <slot name="empty-outgoing"></slot>
+              </template>
+              <template is="dom-if" if="[[!_isOutgoing(changeSection)]]">
+                No changes
+              </template>
+            </td>
+          </tr>
+        </template>
+        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
+          <tr class="groupTitle">
+            <td class="leftPadding"></td>
+            <td class="star" hidden$="[[!showStar]]" hidden=""></td>
+            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
+            <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
+              <td
+                class$="[[_lowerCase(item)]]"
+                hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]"
+              >
+                [[item]]
+              </td>
+            </template>
+            <template is="dom-repeat" items="[[labelNames]]" as="labelName">
+              <td class="label" title$="[[labelName]]">
+                [[_computeLabelShortcut(labelName)]]
+              </td>
+            </template>
+            <template
+              is="dom-repeat"
+              items="[[_dynamicHeaderEndpoints]]"
+              as="pluginHeader"
+            >
+              <td class="endpoint">
+                <gr-endpoint-decorator name$="[[pluginHeader]]">
+                </gr-endpoint-decorator>
+              </td>
+            </template>
+          </tr>
+        </template>
+        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
+          <gr-change-list-item
+            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
+            highlight$="[[_computeItemHighlight(account, change)]]"
+            needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
+            change="[[change]]"
+            visible-change-table-columns="[[visibleChangeTableColumns]]"
+            show-number="[[showNumber]]"
+            show-star="[[showStar]]"
+            tabindex="0"
+            label-names="[[labelNames]]"
+          ></gr-change-list-item>
+        </template>
+      </tbody>
+    </template>
+  </table>
+  <gr-cursor-manager
+    id="cursor"
+    index="{{selectedIndex}}"
+    scroll-behavior="keep-visible"
+    focus-on-move=""
+  ></gr-cursor-manager>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..62763d9 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
@@ -17,18 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-change-list.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -42,394 +38,428 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-list basic tests', () => {
-    // Define keybindings before attaching other fixtures.
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k');
-    kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o');
-    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n');
-    kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p');
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    let element;
-    let sandbox;
+suite('gr-change-list basic tests', () => {
+  // Define keybindings before attaching other fixtures.
+  const kb = KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+  kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k');
+  kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o');
+  kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+  kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n');
+  kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p');
 
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  suite('test show change number not logged in', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
       element = fixture('basic');
+      element.account = null;
+      element.preferences = null;
+      element._config = {};
     });
 
-    teardown(() => { sandbox.restore(); });
-
-    suite('test show change number not logged in', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.account = null;
-        element.preferences = null;
-      });
-
-      test('show number disabled', () => {
-        assert.isFalse(element.showNumber);
-      });
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
     });
+  });
 
-    suite('test show change number preference enabled', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [],
-        };
-        element.account = {_account_id: 1001};
-        flushAsynchronousOperations();
-      });
-
-      test('show number enabled', () => {
-        assert.isTrue(element.showNumber);
-      });
-    });
-
-    suite('test show change number preference disabled', () => {
-      setup(() => {
-        element = fixture('basic');
-        // legacycid_in_change_table is not set when false.
-        element.preferences = {
-          time_format: 'HHMM_12',
-          change_table: [],
-        };
-        element.account = {_account_id: 1001};
-        flushAsynchronousOperations();
-      });
-
-      test('show number disabled', () => {
-        assert.isFalse(element.showNumber);
-      });
-    });
-
-    test('computed fields', () => {
-      assert.equal(element._computeLabelNames(
-          [{results: [{_number: 0, labels: {}}]}]).length, 0);
-      assert.equal(element._computeLabelNames([
-        {results: [
-          {_number: 0, labels: {Verified: {approved: {}}}},
-          {
-            _number: 1,
-            labels: {
-              'Verified': {approved: {}},
-              'Code-Review': {approved: {}},
-            },
-          },
-          {
-            _number: 2,
-            labels: {
-              'Verified': {approved: {}},
-              'Library-Compliance': {approved: {}},
-            },
-          },
-        ]},
-      ]).length, 3);
-
-      assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
-      assert.equal(element._computeLabelShortcut('Verified'), 'V');
-      assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
-      assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
-      assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
-      assert.equal(element._computeLabelShortcut(
-          'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
-      assert.equal(element._computeLabelShortcut(
-          'Some-Special-Label-7'), 'SSL7');
-      assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
-          'TMD');
-      assert.equal(element._computeLabelShortcut(
-          'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
-    });
-
-    test('colspans', () => {
-      const thItemCount = Polymer.dom(element.root).querySelectorAll(
-          'th').length;
-
-      const changeTableColumns = [];
-      const labelNames = [];
-      assert.equal(thItemCount, element._computeColspan(
-          changeTableColumns, labelNames));
-    });
-
-    test('keyboard shortcuts', done => {
-      sandbox.stub(element, '_computeLabelNames');
-      element.sections = [
-        {results: new Array(1)},
-        {results: new Array(2)},
-      ];
-      element.selectedIndex = 0;
-      element.changes = [
-        {_number: 0},
-        {_number: 1},
-        {_number: 2},
-      ];
+  suite('test show change number preference enabled', () => {
+    setup(() => {
+      element = fixture('basic');
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      element._config = {};
       flushAsynchronousOperations();
-      Polymer.RenderStatus.afterNextRender(element, () => {
-        const elementItems = Polymer.dom(element.root).querySelectorAll(
-            'gr-change-list-item');
-        assert.equal(elementItems.length, 3);
-
-        assert.isTrue(elementItems[0].hasAttribute('selected'));
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.selectedIndex, 1);
-        assert.isTrue(elementItems[1].hasAttribute('selected'));
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.selectedIndex, 2);
-        assert.isTrue(elementItems[2].hasAttribute('selected'));
-
-        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-        assert.equal(element.selectedIndex, 2);
-        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-            'Should navigate to /c/2/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-            'Should navigate to /c/1/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.selectedIndex, 0);
-
-        const reloadStub = sandbox.stub(element, '_reloadWindow');
-        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-        assert.isTrue(reloadStub.called);
-
-        done();
-      });
     });
 
-    test('changes needing review', () => {
-      element.changes = [
-        {
-          _number: 0,
-          status: 'NEW',
-          reviewed: true,
-          owner: {_account_id: 0},
-        },
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference disabled', () => {
+    setup(() => {
+      element = fixture('basic');
+      // legacycid_in_change_table is not set when false.
+      element.preferences = {
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      element._config = {};
+      flushAsynchronousOperations();
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  test('computed fields', () => {
+    assert.equal(element._computeLabelNames(
+        [{results: [{_number: 0, labels: {}}]}]).length, 0);
+    assert.equal(element._computeLabelNames([
+      {results: [
+        {_number: 0, labels: {Verified: {approved: {}}}},
         {
           _number: 1,
-          status: 'NEW',
-          owner: {_account_id: 0},
+          labels: {
+            'Verified': {approved: {}},
+            'Code-Review': {approved: {}},
+          },
         },
         {
           _number: 2,
-          status: 'MERGED',
-          owner: {_account_id: 0},
+          labels: {
+            'Verified': {approved: {}},
+            'Library-Compliance': {approved: {}},
+          },
         },
-        {
-          _number: 3,
-          status: 'ABANDONED',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 4,
-          status: 'NEW',
-          work_in_progress: true,
-          owner: {_account_id: 0},
-        },
+      ]},
+    ]).length, 3);
+
+    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
+    assert.equal(element._computeLabelShortcut('Verified'), 'V');
+    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
+    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
+    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
+    assert.equal(element._computeLabelShortcut(
+        'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
+    assert.equal(element._computeLabelShortcut(
+        'Some-Special-Label-7'), 'SSL7');
+    assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
+        'TMD');
+    assert.equal(element._computeLabelShortcut(
+        'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
+  });
+
+  test('colspans', () => {
+    element.sections = [
+      {results: [{}]},
+    ];
+    flushAsynchronousOperations();
+    const tdItemCount = dom(element.root).querySelectorAll(
+        'td').length;
+
+    const changeTableColumns = [];
+    const labelNames = [];
+    assert.equal(tdItemCount, element._computeColspan(
+        changeTableColumns, labelNames));
+  });
+
+  test('keyboard shortcuts', done => {
+    sandbox.stub(element, '_computeLabelNames');
+    element.sections = [
+      {results: new Array(1)},
+      {results: new Array(2)},
+    ];
+    element.selectedIndex = 0;
+    element.changes = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    flushAsynchronousOperations();
+    afterNextRender(element, () => {
+      const elementItems = dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 3);
+
+      assert.isTrue(elementItems[0].hasAttribute('selected'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 1);
+      assert.isTrue(elementItems[1].hasAttribute('selected'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 2);
+      assert.isTrue(elementItems[2].hasAttribute('selected'));
+
+      const navStub = sandbox.stub(GerritNav, 'navigateToChange');
+      assert.equal(element.selectedIndex, 2);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 0);
+
+      const reloadStub = sandbox.stub(element, '_reloadWindow');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isTrue(reloadStub.called);
+
+      done();
+    });
+  });
+
+  test('changes needing review', () => {
+    element.changes = [
+      {
+        _number: 0,
+        status: 'NEW',
+        reviewed: true,
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 1,
+        status: 'NEW',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 2,
+        status: 'MERGED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 3,
+        status: 'ABANDONED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 4,
+        status: 'NEW',
+        work_in_progress: true,
+        owner: {_account_id: 0},
+      },
+    ];
+    flushAsynchronousOperations();
+    let elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    for (let i = 0; i < elementItems.length; i++) {
+      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+    }
+
+    element.showReviewedState = true;
+    elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+    element.account = {_account_id: 42};
+    elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+  });
+
+  test('no changes', () => {
+    element.changes = [];
+    flushAsynchronousOperations();
+    const listItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg =
+        dom(element.root).querySelector('.noChanges');
+    assert.ok(noChangesMsg);
+  });
+
+  test('empty sections', () => {
+    element.sections = [{results: []}, {results: []}];
+    flushAsynchronousOperations();
+    const listItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg = dom(element.root).querySelectorAll(
+        '.noChanges');
+    assert.equal(noChangesMsg.length, 2);
+  });
+
+  suite('empty outgoing', () => {
+    test('not shown on empty non-outgoing sections', () => {
+      const section = {results: []};
+      assert.isTrue(element._isEmpty(section));
+      assert.isFalse(element._isOutgoing(section));
+    });
+
+    test('shown on empty outgoing sections', () => {
+      const section = {results: [], isOutgoing: true};
+      assert.isTrue(element._isEmpty(section));
+      assert.isTrue(element._isOutgoing(section));
+    });
+
+    test('not shown on non-empty outgoing sections', () => {
+      const section = {isOutgoing: true, results: [
+        {_number: 0, labels: {Verified: {approved: {}}}}]};
+      assert.isFalse(element._isEmpty(section));
+      assert.isTrue(element._isOutgoing(section));
+    });
+  });
+
+  test('_isOutgoing', () => {
+    assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
+    assert.isFalse(element._isOutgoing({results: []}));
+  });
+
+  suite('empty column preference', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+      element.sections = [
+        {results: [{}]},
       ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element._config = {};
       flushAsynchronousOperations();
-      let elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      for (let i = 0; i < elementItems.length; i++) {
-        assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.columnNames) {
+        const elementClass = '.' + element._lowerCase(column);
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
       }
-
-      element.showReviewedState = true;
-      elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
-      element.account = {_account_id: 42};
-      elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
     });
+  });
 
-    test('no changes', () => {
-      element.changes = [];
+  suite('full column preference', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Assignee',
+          'Reviewers',
+          'Comments',
+          'Repo',
+          'Branch',
+          'Updated',
+          'Size',
+        ],
+      };
+      element._config = {};
       flushAsynchronousOperations();
-      const listItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(listItems.length, 0);
-      const noChangesMsg =
-          Polymer.dom(element.root).querySelector('.noChanges');
-      assert.ok(noChangesMsg);
     });
 
-    test('empty sections', () => {
-      element.sections = [{results: []}, {results: []}];
+    test('all columns visible', () => {
+      for (const column of element.changeTableColumns) {
+        const elementClass = '.' + element._lowerCase(column);
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      }
+    });
+  });
+
+  suite('partial column preference', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Assignee',
+          'Reviewers',
+          'Comments',
+          'Branch',
+          'Updated',
+          'Size',
+        ],
+      };
+      element._config = {};
       flushAsynchronousOperations();
-      const listItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(listItems.length, 0);
-      const noChangesMsg = Polymer.dom(element.root).querySelectorAll(
-          '.noChanges');
-      assert.equal(noChangesMsg.length, 2);
     });
 
-    suite('empty outgoing', () => {
-      test('not shown on empty non-outgoing sections', () => {
-        const section = {results: []};
-        assert.isTrue(element._isEmpty(section));
-        assert.isFalse(element._isOutgoing(section));
-      });
-
-      test('shown on empty outgoing sections', () => {
-        const section = {results: [], isOutgoing: true};
-        assert.isTrue(element._isEmpty(section));
-        assert.isTrue(element._isOutgoing(section));
-      });
-
-      test('not shown on non-empty outgoing sections', () => {
-        const section = {isOutgoing: true, results: [
-          {_number: 0, labels: {Verified: {approved: {}}}}]};
-        assert.isFalse(element._isEmpty(section));
-        assert.isTrue(element._isOutgoing(section));
-      });
-    });
-
-    test('_isOutgoing', () => {
-      assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
-      assert.isFalse(element._isOutgoing({results: []}));
-    });
-
-    suite('empty column preference', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('show number enabled', () => {
-        assert.isTrue(element.showNumber);
-      });
-
-      test('all columns visible', () => {
-        for (const column of element.columnNames) {
-          const elementClass = '.' + element._lowerCase(column);
-          assert.isFalse(element.$$(elementClass).hidden);
+    test('all columns except repo visible', () => {
+      for (const column of element.changeTableColumns) {
+        const elementClass = '.' + column.toLowerCase();
+        if (column === 'Repo') {
+          assert.isTrue(element.shadowRoot
+              .querySelector(elementClass).hidden);
+        } else {
+          assert.isFalse(element.shadowRoot
+              .querySelector(elementClass).hidden);
         }
-      });
+      }
+    });
+  });
+
+  suite('random column does not exist', () => {
+    let element;
+
+    /* This would only exist if somebody manually updated the config
+    file. */
+    setup(() => {
+      element = fixture('basic');
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Bad',
+        ],
+      };
+      flushAsynchronousOperations();
     });
 
-    suite('full column preference', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [
-            'Subject',
-            'Status',
-            'Owner',
-            'Assignee',
-            'Repo',
-            'Branch',
-            'Updated',
-            'Size',
-          ],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('all columns visible', () => {
-        for (const column of element.changeTableColumns) {
-          const elementClass = '.' + element._lowerCase(column);
-          assert.isFalse(element.$$(elementClass).hidden);
-        }
-      });
-    });
-
-    suite('partial column preference', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [
-            'Subject',
-            'Status',
-            'Owner',
-            'Assignee',
-            'Branch',
-            'Updated',
-            'Size',
-          ],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('all columns except repo visible', () => {
-        for (const column of element.changeTableColumns) {
-          const elementClass = '.' + column.toLowerCase();
-          if (column === 'Repo') {
-            assert.isTrue(element.$$(elementClass).hidden);
-          } else {
-            assert.isFalse(element.$$(elementClass).hidden);
-          }
-        }
-      });
-    });
-
-    suite('random column does not exist', () => {
-      let element;
-
-      /* This would only exist if somebody manually updated the config
-      file. */
-      setup(() => {
-        element = fixture('basic');
-        element.account = {_account_id: 1001};
-        element.preferences = {
-          legacycid_in_change_table: true,
-          time_format: 'HHMM_12',
-          change_table: [
-            'Bad',
-          ],
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('bad column does not exist', () => {
-        const elementClass = '.bad';
-        assert.isNotOk(element.$$(elementClass));
-      });
+    test('bad column does not exist', () => {
+      const elementClass = '.bad';
+      assert.isNotOk(element.shadowRoot
+          .querySelector(elementClass));
     });
   });
 
@@ -523,8 +553,8 @@
         },
       ];
       flushAsynchronousOperations();
-      Polymer.RenderStatus.afterNextRender(element, () => {
-        const elementItems = Polymer.dom(element.root).querySelectorAll(
+      afterNextRender(element, () => {
+        const elementItems = dom(element.root).querySelectorAll(
             'gr-change-list-item');
         assert.equal(elementItems.length, 9);
 
@@ -532,7 +562,7 @@
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
 
-        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+        const navStub = sandbox.stub(GerritNav, 'navigateToChange');
         assert.equal(element.selectedIndex, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
@@ -619,4 +649,5 @@
       assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
deleted file mode 100644
index e88368d..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-
-<dom-module id="gr-create-change-help">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      #graphic,
-      #help {
-        display: inline-block;
-        margin: var(--spacing-m);
-      }
-      #graphic #circle {
-        align-items: center;
-        background-color: var(--chip-background-color);
-        border-radius: 50%;
-        display: flex;
-        height: 10em;
-        justify-content: center;
-        width: 10em;
-      }
-      #graphic iron-icon {
-        color: #9e9e9e;
-        height: 5em;
-        width: 5em;
-      }
-      #graphic p {
-        color: var(--deemphasized-text-color);
-        text-align: center;
-      }
-      #help {
-        padding-top: var(--spacing-xl);
-        vertical-align: top;
-      }
-      #help h1 {
-        font-size: var(--font-size-h3);
-      }
-      #help p {
-        margin-bottom: var(--spacing-m);
-        max-width: 35em;
-      }
-      @media only screen and (max-width: 50em) {
-        #graphic {
-          display: none;
-        }
-      }
-    </style>
-    <div id="graphic">
-      <div id="circle">
-        <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
-      </div>
-      <p>
-        No outgoing changes yet
-      </p>
-    </div>
-    <div id="help">
-      <h1>Push your first change for code review</h1>
-      <p>
-        Pushing a change for review is easy, but a little different from
-        other git code review tools. Click on the `Create Change' button
-        and follow the step by step instructions.
-      </p>
-      <gr-button on-click="_handleCreateTap">Create Change</gr-button>
-    </div>
-  </template>
-  <script src="gr-create-change-help.js"></script>
-</dom-module>
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..3758a78 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
@@ -14,22 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-create-change-help',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-change-help_html.js';
 
-    /**
-     * Fired when the "Create change" button is tapped.
-     *
-     * @event create-tap
-     */
+/** @extends Polymer.Element */
+class GrCreateChangeHelp extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    _handleCreateTap(e) {
-      e.preventDefault();
-      this.dispatchEvent(
-          new CustomEvent('create-tap', {bubbles: true, composed: true}));
-    },
-  });
-})();
+  static get is() { return 'gr-create-change-help'; }
+
+  /**
+   * Fired when the "Create change" button is tapped.
+   *
+   * @event create-tap
+   */
+
+  _handleCreateTap(e) {
+    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_html.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
new file mode 100644
index 0000000..4a357af
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    #graphic,
+    #help {
+      display: inline-block;
+      margin: var(--spacing-m);
+    }
+    #graphic #circle {
+      align-items: center;
+      background-color: var(--chip-background-color);
+      border-radius: 50%;
+      display: flex;
+      height: 10em;
+      justify-content: center;
+      width: 10em;
+    }
+    #graphic iron-icon {
+      color: #9e9e9e;
+      height: 5em;
+      width: 5em;
+    }
+    #graphic p {
+      color: var(--deemphasized-text-color);
+      text-align: center;
+    }
+    #help {
+      padding-top: var(--spacing-xl);
+      vertical-align: top;
+    }
+    #help h1 {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+    }
+    #help p {
+      margin-bottom: var(--spacing-m);
+      max-width: 35em;
+    }
+    @media only screen and (max-width: 50em) {
+      #graphic {
+        display: none;
+      }
+    }
+  </style>
+  <div id="graphic">
+    <div id="circle">
+      <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
+    </div>
+    <p>
+      No outgoing changes yet
+    </p>
+  </div>
+  <div id="help">
+    <h1>Push your first change for code review</h1>
+    <p>
+      Pushing a change for review is easy, but a little different from other git
+      code review tools. Click on the \`Create Change' button and follow the
+      step by step instructions.
+    </p>
+    <gr-button on-click="_handleCreateTap">Create Change</gr-button>
+  </div>
+`;
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..9b8ed29 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-create-change-help.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,17 +31,20 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-change-help tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-create-change-help.js';
+suite('gr-create-change-help tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('Create change tap', done => {
-      element.addEventListener('create-tap', () => done());
-      MockInteractions.tap(element.$$('gr-button'));
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('Create change tap', done => {
+    element.addEventListener('create-tap', () => done());
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html
deleted file mode 100644
index 9e86058..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.html
+++ /dev/null
@@ -1,87 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.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-shell-command/gr-shell-command.html">
-
-<dom-module id="gr-create-commands-dialog">
-  <template>
-    <style include="shared-styles">
-      ol {
-        list-style: decimal;
-        margin-left: var(--spacing-l);
-      }
-      p {
-        margin-bottom: var(--spacing-m);
-      }
-      #commandsDialog {
-        max-width: 40em;
-      }
-    </style>
-    <gr-overlay id="commandsOverlay" with-backdrop>
-      <gr-dialog
-          id="commandsDialog"
-          confirm-label="Done"
-          cancel-label=""
-          confirm-on-enter
-          on-confirm="_handleClose">
-        <div class="header" slot="header">
-          Create change commands
-        </div>
-        <div class="main" slot="main">
-          <ol>
-            <li>
-              <p>
-                Make the changes to the files on your machine
-              </p>
-            </li>
-            <li>
-              <p>
-                If you are making a new commit use
-              </p>
-              <gr-shell-command command="[[_createNewCommitCommand]]"></gr-shell-command>
-              <p>
-                Or to amend an existing commit use
-              </p>
-              <gr-shell-command command="[[_amendExistingCommitCommand]]"></gr-shell-command>
-              <p>
-                Please make sure you add a commit message as it becomes the
-                description for your change.
-              </p>
-            </li>
-            <li>
-              <p>
-                Push the change for code review
-              </p>
-              <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
-            </li>
-            <li>
-              <p>
-                Close this dialog and you should be able to see your recently
-                created change in the 'Outgoing changes' section on the
-                'Your changes' page.
-              </p>
-            </li>
-          </ol>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-  </template>
-  <script src="gr-create-commands-dialog.js"></script>
-</dom-module>
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..7e5e749 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
@@ -14,19 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const Commands = {
-    CREATE: 'git commit',
-    AMEND: 'git commit --amend',
-    PUSH_PREFIX: 'git push origin HEAD:refs/for/',
-  };
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-shell-command/gr-shell-command.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-commands-dialog_html.js';
 
-  Polymer({
-    is: 'gr-create-commands-dialog',
+const Commands = {
+  CREATE: 'git commit',
+  AMEND: 'git commit --amend',
+  PUSH_PREFIX: 'git push origin HEAD:refs/for/',
+};
 
-    properties: {
+/** @extends Polymer.Element */
+class GrCreateCommandsDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-create-commands-dialog'; }
+
+  static get properties() {
+    return {
       branch: String,
       _createNewCommitCommand: {
         type: String,
@@ -42,18 +55,20 @@
         type: String,
         computed: '_computePushCommand(branch)',
       },
-    },
+    };
+  }
 
-    open() {
-      this.$.commandsOverlay.open();
-    },
+  open() {
+    this.$.commandsOverlay.open();
+  }
 
-    _handleClose() {
-      this.$.commandsOverlay.close();
-    },
+  _handleClose() {
+    this.$.commandsOverlay.close();
+  }
 
-    _computePushCommand(branch) {
-      return Commands.PUSH_PREFIX + branch;
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
new file mode 100644
index 0000000..d2a1af9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    ol {
+      list-style: decimal;
+      margin-left: var(--spacing-l);
+    }
+    p {
+      margin-bottom: var(--spacing-m);
+    }
+    #commandsDialog {
+      max-width: 40em;
+    }
+  </style>
+  <gr-overlay id="commandsOverlay" with-backdrop="">
+    <gr-dialog
+      id="commandsDialog"
+      confirm-label="Done"
+      cancel-label=""
+      confirm-on-enter=""
+      on-confirm="_handleClose"
+    >
+      <div class="header" slot="header">
+        Create change commands
+      </div>
+      <div class="main" slot="main">
+        <ol>
+          <li>
+            <p>
+              Make the changes to the files on your machine
+            </p>
+          </li>
+          <li>
+            <p>
+              If you are making a new commit use
+            </p>
+            <gr-shell-command
+              command="[[_createNewCommitCommand]]"
+            ></gr-shell-command>
+            <p>
+              Or to amend an existing commit use
+            </p>
+            <gr-shell-command
+              command="[[_amendExistingCommitCommand]]"
+            ></gr-shell-command>
+            <p>
+              Please make sure you add a commit message as it becomes the
+              description for your change.
+            </p>
+          </li>
+          <li>
+            <p>
+              Push the change for code review
+            </p>
+            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+          </li>
+          <li>
+            <p>
+              Close this dialog and you should be able to see your recently
+              created change in the 'Outgoing changes' section on the 'Your
+              changes' page.
+            </p>
+          </li>
+        </ol>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+`;
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..e6cd587 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-create-commands-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,22 +31,24 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-create-commands-dialog tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-create-commands-dialog.js';
+suite('gr-create-commands-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('_computePushCommand', () => {
-      element.branch = 'master';
-      assert.equal(element._pushCommand,
-          'git push origin HEAD:refs/for/master');
-
-      element.branch = 'stable-2.15';
-      assert.equal(element._pushCommand,
-          'git push origin HEAD:refs/for/stable-2.15');
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('_computePushCommand', () => {
+    element.branch = 'master';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/master');
+
+    element.branch = 'stable-2.15';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/stable-2.15');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html
deleted file mode 100644
index def5228..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.html
+++ /dev/null
@@ -1,48 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.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-repo-branch-picker/gr-repo-branch-picker.html">
-
-<dom-module id="gr-create-destination-dialog">
-  <template>
-    <style include="shared-styles">
-    </style>
-    <gr-overlay id="createOverlay" with-backdrop>
-      <gr-dialog
-          confirm-label="View commands"
-          on-confirm="_pickerConfirm"
-          on-cancel="_handleClose"
-          disabled="[[!_repoAndBranchSelected]]">
-        <div class="header" slot="header">
-          Create change
-        </div>
-        <div class="main" slot="main">
-          <gr-repo-branch-picker
-              repo="{{_repo}}"
-              branch="{{_branch}}"></gr-repo-branch-picker>
-          <p>
-            If you haven't done so, you will need to clone the repository.
-          </p>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-  </template>
-  <script src="gr-create-destination-dialog.js"></script>
-</dom-module>
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..f8757ba 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
@@ -14,20 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * Fired when a destination has been picked. Event details contain the repo
-   * name and the branch name.
-   *
-   * @event confirm
-   */
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-create-destination-dialog_html.js';
 
-  Polymer({
-    is: 'gr-create-destination-dialog',
+/**
+ * Fired when a destination has been picked. Event details contain the repo
+ * name and the branch name.
+ *
+ * @event confirm
+ * @extends Polymer.Element
+ */
+class GrCreateDestinationDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-create-destination-dialog'; }
+
+  static get properties() {
+    return {
       _repo: String,
       _branch: String,
       _repoAndBranchSelected: {
@@ -35,29 +47,33 @@
         value: false,
         computed: '_computeRepoAndBranchSelected(_repo, _branch)',
       },
-    },
-    open() {
-      this._repo = '';
-      this._branch = '';
-      this.$.createOverlay.open();
-    },
+    };
+  }
 
-    _handleClose() {
-      this.$.createOverlay.close();
-    },
+  open() {
+    this._repo = '';
+    this._branch = '';
+    this.$.createOverlay.open();
+  }
 
-    _pickerConfirm(e) {
-      this.$.createOverlay.close();
-      const detail = {repo: this._repo, branch: this._branch};
-      // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
-      // 'confirm' event here, so let's stop propagation of the bare event.
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
-    },
+  _handleClose() {
+    this.$.createOverlay.close();
+  }
 
-    _computeRepoAndBranchSelected(repo, branch) {
-      return !!(repo && branch);
-    },
-  });
-})();
+  _pickerConfirm(e) {
+    this.$.createOverlay.close();
+    const detail = {repo: this._repo, branch: this._branch};
+    // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
+    // 'confirm' event here, so let's stop propagation of the bare event.
+    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-create-destination-dialog/gr-create-destination-dialog_html.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
new file mode 100644
index 0000000..c7cd647
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles"></style>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      confirm-label="View commands"
+      on-confirm="_pickerConfirm"
+      on-cancel="_handleClose"
+      disabled="[[!_repoAndBranchSelected]]"
+    >
+      <div class="header" slot="header">
+        Create change
+      </div>
+      <div class="main" slot="main">
+        <gr-repo-branch-picker
+          repo="{{_repo}}"
+          branch="{{_branch}}"
+        ></gr-repo-branch-picker>
+        <p>
+          If you haven't done so, you will need to clone the repository.
+        </p>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
deleted file mode 100644
index 41475a0..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ /dev/null
@@ -1,134 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.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-create-commands-dialog/gr-create-commands-dialog.html">
-<link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
-<link rel="import" href="../gr-create-destination-dialog/gr-create-destination-dialog.html">
-<link rel="import" href="../gr-user-header/gr-user-header.html">
-
-<dom-module id="gr-dashboard-view">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--view-background-color);
-        display: block;
-      }
-      .loading {
-        color: var(--deemphasized-text-color);
-        padding: var(--spacing-l);
-      }
-      gr-change-list {
-        width: 100%;
-      }
-      gr-user-header {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .banner {
-        align-items: center;
-        background-color: var(--comment-background-color);
-        border-bottom: 1px solid var(--border-color);
-        display: flex;
-        justify-content: space-between;
-        padding: var(--spacing-xs) var(--spacing-l);
-      }
-      .banner gr-button {
-        --gr-button: {
-          color: var(--primary-text-color);
-        }
-      }
-      .hide {
-        display: none;
-      }
-      #emptyOutgoing {
-        display: block;
-      }
-      @media only screen and (max-width: 50em) {
-        .loading {
-          padding: 0 var(--spacing-l);
-        }
-      }
-    </style>
-    <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
-      <div>
-        You have draft comments on closed changes.
-        <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank">(view all)</a>
-      </div>
-      <div>
-        <gr-button
-            class="delete"
-            link
-            on-click="_handleOpenDeleteDialog">Delete All</gr-button>
-      </div>
-    </div>
-    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <div hidden$="[[_loading]]" hidden>
-      <gr-user-header
-          user-id="[[params.user]]"
-          class$="[[_computeUserHeaderClass(params)]]"></gr-user-header>
-      <gr-change-list
-          show-star
-          show-reviewed-state
-          account="[[account]]"
-          preferences="[[preferences]]"
-          selected-index="{{viewState.selectedChangeIndex}}"
-          sections="[[_results]]"
-          on-toggle-star="_handleToggleStar"
-          on-toggle-reviewed="_handleToggleReviewed">
-        <div id="emptyOutgoing" slot="empty-outgoing">
-          <template is="dom-if" if="[[_showNewUserHelp]]">
-            <gr-create-change-help on-create-tap="createChangeTap"></gr-create-change-help>
-          </template>
-          <template is="dom-if" if="[[!_showNewUserHelp]]">
-            No changes
-          </template>
-        </div>
-      </gr-change-list>
-    </div>
-    <gr-overlay id="confirmDeleteOverlay" with-backdrop>
-      <gr-dialog
-          id="confirmDeleteDialog"
-          confirm-label="Delete"
-          on-confirm="_handleConfirmDelete"
-          on-cancel="_closeConfirmDeleteOverlay">
-        <div class="header" slot="header">
-          Delete comments
-        </div>
-        <div class="main" slot="main">
-          Are you sure you want to delete all your draft comments in closed changes? This action
-          cannot be undone.
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-    <gr-create-destination-dialog
-        id="destinationDialog"
-        on-confirm="_handleDestinationConfirm"></gr-create-destination-dialog>
-    <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-dashboard-view.js"></script>
-</dom-module>
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..8b8b981 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,21 +14,48 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+import '../../../styles/shared-styles.js';
+import '../gr-change-list/gr-change-list.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-create-commands-dialog/gr-create-commands-dialog.js';
+import '../gr-create-change-help/gr-create-change-help.js';
+import '../gr-create-destination-dialog/gr-create-destination-dialog.js';
+import '../gr-user-header/gr-user-header.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dashboard-view_html.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-dashboard-view',
+const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
 
-    /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrDashboardView extends mixinBehaviors( [
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-dashboard-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  static get properties() {
+    return {
       account: {
         type: Object,
         value: null,
@@ -68,236 +95,242 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_paramsChanged(params.*)',
-    ],
+    ];
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
 
-    get options() {
-      return this.listChangesOptionsToHex(
-          this.ListChangesOption.LABELS,
-          this.ListChangesOption.DETAILED_ACCOUNTS,
-          this.ListChangesOption.REVIEWED
-      );
-    },
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
 
-    attached() {
-      this._loadPreferences();
-    },
-
-    _loadPreferences() {
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          this.$.restAPI.getPreferences().then(preferences => {
-            this.preferences = preferences;
-          });
-        } else {
-          this.preferences = {};
-        }
-      });
-    },
-
-    _getProjectDashboard(project, dashboard) {
-      const errFn = response => {
-        this.fire('page-error', {response});
+  _getProjectDashboard(project, dashboard) {
+    const errFn = response => {
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
+    return this.$.restAPI.getDashboard(
+        project, dashboard, errFn).then(response => {
+      if (!response) {
+        return;
+      }
+      return {
+        title: response.title,
+        sections: response.sections.map(section => {
+          const suffix = response.foreach ? ' ' + response.foreach : '';
+          return {
+            name: section.name,
+            query: (section.query + suffix).replace(
+                PROJECT_PLACEHOLDER_PATTERN, project),
+          };
+        }),
       };
-      return this.$.restAPI.getDashboard(
-          project, dashboard, errFn).then(response => {
-        if (!response) {
-          return;
-        }
-        return {
-          title: response.title,
-          sections: response.sections.map(section => {
-            const suffix = response.foreach ? ' ' + response.foreach : '';
-            return {
-              name: section.name,
-              query: (section.query + suffix).replace(
-                  PROJECT_PLACEHOLDER_PATTERN, project),
-            };
-          }),
-        };
-      });
-    },
+    });
+  }
 
-    _computeTitle(user) {
-      if (!user || user === 'self') {
-        return 'My Reviews';
-      }
-      return 'Dashboard for ' + user;
-    },
+  _computeTitle(user) {
+    if (!user || user === 'self') {
+      return 'My Reviews';
+    }
+    return 'Dashboard for ' + user;
+  }
 
-    _isViewActive(params) {
-      return params.view === Gerrit.Nav.View.DASHBOARD;
-    },
+  _isViewActive(params) {
+    return params.view === GerritNav.View.DASHBOARD;
+  }
 
-    _paramsChanged(paramsChangeRecord) {
-      const params = paramsChangeRecord.base;
+  _paramsChanged(paramsChangeRecord) {
+    const params = paramsChangeRecord.base;
 
-      if (!this._isViewActive(params)) {
-        return Promise.resolve();
-      }
+    if (!this._isViewActive(params)) {
+      return Promise.resolve();
+    }
 
-      return this._reload();
-    },
+    return this._reload();
+  }
 
-    /**
-     * Reloads the element.
-     *
-     * @return {Promise<!Object>}
-     */
-    _reload() {
-      this._loading = true;
-      const {project, dashboard, title, user, sections} = this.params;
-      const dashboardPromise = project ?
-        this._getProjectDashboard(project, dashboard) :
-        Promise.resolve(Gerrit.Nav.getUserDashboard(
-            user,
-            sections,
-            title || this._computeTitle(user)));
+  /**
+   * Reloads the element.
+   *
+   * @return {Promise<!Object>}
+   */
+  _reload() {
+    this._loading = true;
+    const {project, dashboard, title, user, sections} = this.params;
+    const dashboardPromise = project ?
+      this._getProjectDashboard(project, dashboard) :
+      Promise.resolve(GerritNav.getUserDashboard(
+          user,
+          sections,
+          title || this._computeTitle(user)));
 
-      const checkForNewUser = !project && user === 'self';
-      return dashboardPromise
-          .then(res => {
-            if (res && res.title) {
-              this.fire('title-change', {title: res.title});
-            }
-            return this._fetchDashboardChanges(res, checkForNewUser);
-          })
-          .then(() => {
-            this._maybeShowDraftsBanner();
-            this.$.reporting.dashboardDisplayed();
-          }).catch(err => {
-            this.fire('title-change', {
+    const checkForNewUser = !project && user === 'self';
+    return dashboardPromise
+        .then(res => {
+          if (res && res.title) {
+            this.dispatchEvent(new CustomEvent('title-change', {
+              detail: {title: res.title},
+              composed: true, bubbles: true,
+            }));
+          }
+          return this._fetchDashboardChanges(res, checkForNewUser);
+        })
+        .then(() => {
+          this._maybeShowDraftsBanner();
+          this.$.reporting.dashboardDisplayed();
+        })
+        .catch(err => {
+          this.dispatchEvent(new CustomEvent('title-change', {
+            detail: {
               title: title || this._computeTitle(user),
-            });
-            console.warn(err);
-          }).then(() => { this._loading = false; });
-    },
+            },
+            composed: true, bubbles: true,
+          }));
+          console.warn(err);
+        })
+        .then(() => { this._loading = false; });
+  }
 
-    /**
-     * Fetches the changes for each dashboard section and sets this._results
-     * with the response.
-     *
-     * @param {!Object} res
-     * @param {boolean} checkForNewUser
-     * @return {Promise}
-     */
-    _fetchDashboardChanges(res, checkForNewUser) {
-      if (!res) { return Promise.resolve(); }
+  /**
+   * Fetches the changes for each dashboard section and sets this._results
+   * with the response.
+   *
+   * @param {!Object} res
+   * @param {boolean} checkForNewUser
+   * @return {Promise}
+   */
+  _fetchDashboardChanges(res, checkForNewUser) {
+    if (!res) { return Promise.resolve(); }
 
-      const queries = res.sections
-          .map(section => section.suffixForDashboard ?
-            section.query + ' ' + section.suffixForDashboard :
-            section.query);
+    const queries = res.sections
+        .map(section => (section.suffixForDashboard ?
+          section.query + ' ' + section.suffixForDashboard :
+          section.query));
 
-      if (checkForNewUser) {
-        queries.push('owner:self limit:1');
-      }
+    if (checkForNewUser) {
+      queries.push('owner:self limit:1');
+    }
 
-      return this.$.restAPI.getChanges(null, queries, null, this.options)
-          .then(changes => {
-            if (checkForNewUser) {
-              // Last set of results is not meant for dashboard display.
-              const lastResultSet = changes.pop();
-              this._showNewUserHelp = lastResultSet.length == 0;
-            }
-            this._results = changes.map((results, i) => ({
+    return this.$.restAPI.getChanges(null, queries)
+        .then(changes => {
+          if (checkForNewUser) {
+            // Last set of results is not meant for dashboard display.
+            const lastResultSet = changes.pop();
+            this._showNewUserHelp = lastResultSet.length == 0;
+          }
+          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));
-          });
-    },
+            };
+          }).filter((section, i) => i < res.sections.length && (
+            !res.sections[i].hideIfEmpty ||
+              section.results.length));
+        });
+  }
 
-    _computeSectionCountLabel(changes) {
-      if (!changes || !changes.length || changes.length == 0) {
-        return '';
-      }
-      const more = changes[changes.length - 1]._more_changes;
-      const numChanges = changes.length;
-      const andMore = more ? ' and more' : '';
-      return `(${numChanges}${andMore})`;
-    },
-
-    _computeUserHeaderClass(params) {
-      if (!params || !!params.project || !params.user
-          || params.user === 'self') {
-        return 'hide';
-      }
+  _computeSectionCountLabel(changes) {
+    if (!changes || !changes.length || changes.length == 0) {
       return '';
-    },
+    }
+    const more = changes[changes.length - 1]._more_changes;
+    const numChanges = changes.length;
+    const andMore = more ? ' and more' : '';
+    return `(${numChanges}${andMore})`;
+  }
 
-    _handleToggleStar(e) {
-      this.$.restAPI.saveChangeStarred(e.detail.change._number,
-          e.detail.starred);
-    },
+  _computeUserHeaderClass(params) {
+    if (!params || !!params.project || !params.user ||
+        params.user === 'self') {
+      return 'hide';
+    }
+    return '';
+  }
 
-    _handleToggleReviewed(e) {
-      this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-          e.detail.reviewed);
-    },
+  _handleToggleStar(e) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number,
+        e.detail.starred);
+  }
 
-    /**
-     * Banner is shown if a user is on their own dashboard and they have draft
-     * comments on closed changes.
-     */
-    _maybeShowDraftsBanner() {
-      this._showDraftsBanner = false;
-      if (!(this.params.user === 'self')) { return; }
+  _handleToggleReviewed(e) {
+    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
+        e.detail.reviewed);
+  }
 
-      const draftSection = this._results
-          .find(section => section.query === 'has:draft');
-      if (!draftSection || !draftSection.results.length) { return; }
+  /**
+   * Banner is shown if a user is on their own dashboard and they have draft
+   * comments on closed changes.
+   */
+  _maybeShowDraftsBanner() {
+    this._showDraftsBanner = false;
+    if (!(this.params.user === 'self')) { return; }
 
-      const closedChanges = draftSection.results
-          .filter(change => !this.changeIsOpen(change));
-      if (!closedChanges.length) { return; }
+    const draftSection = this._results
+        .find(section => section.query === 'has:draft');
+    if (!draftSection || !draftSection.results.length) { return; }
 
-      this._showDraftsBanner = true;
-    },
+    const closedChanges = draftSection.results
+        .filter(change => !this.changeIsOpen(change));
+    if (!closedChanges.length) { return; }
 
-    _computeBannerClass(show) {
-      return show ? '' : 'hide';
-    },
+    this._showDraftsBanner = true;
+  }
 
-    _handleOpenDeleteDialog() {
-      this.$.confirmDeleteOverlay.open();
-    },
+  _computeBannerClass(show) {
+    return show ? '' : 'hide';
+  }
 
-    _handleConfirmDelete() {
-      this.$.confirmDeleteDialog.disabled = true;
-      return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
-        this._closeConfirmDeleteOverlay();
-        this._reload();
-      });
-    },
+  _handleOpenDeleteDialog() {
+    this.$.confirmDeleteOverlay.open();
+  }
 
-    _closeConfirmDeleteOverlay() {
-      this.$.confirmDeleteOverlay.close();
-    },
+  _handleConfirmDelete() {
+    this.$.confirmDeleteDialog.disabled = true;
+    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+      this._closeConfirmDeleteOverlay();
+      this._reload();
+    });
+  }
 
-    _computeDraftsLink() {
-      return Gerrit.Nav.getUrlForSearchQuery('has:draft -is:open');
-    },
+  _closeConfirmDeleteOverlay() {
+    this.$.confirmDeleteOverlay.close();
+  }
 
-    _createChangeTap(e) {
-      this.$.destinationDialog.open();
-    },
+  _computeDraftsLink() {
+    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+  }
 
-    _handleDestinationConfirm(e) {
-      this.$.commandsDialog.branch = e.detail.branch;
-      this.$.commandsDialog.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_html.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
new file mode 100644
index 0000000..3389bd0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    .loading {
+      color: var(--deemphasized-text-color);
+      padding: var(--spacing-l);
+    }
+    gr-change-list {
+      width: 100%;
+    }
+    gr-user-header {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .banner {
+      align-items: center;
+      background-color: var(--comment-background-color);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-xs) var(--spacing-l);
+    }
+    .banner gr-button {
+      --gr-button: {
+        color: var(--primary-text-color);
+      }
+    }
+    .hide {
+      display: none;
+    }
+    #emptyOutgoing {
+      display: block;
+    }
+    @media only screen and (max-width: 50em) {
+      .loading {
+        padding: 0 var(--spacing-l);
+      }
+    }
+  </style>
+  <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
+    <div>
+      You have draft comments on closed changes.
+      <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank"
+        >(view all)</a
+      >
+    </div>
+    <div>
+      <gr-button class="delete" link="" on-click="_handleOpenDeleteDialog"
+        >Delete All</gr-button
+      >
+    </div>
+  </div>
+  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+  <div hidden$="[[_loading]]" hidden="">
+    <gr-user-header
+      user-id="[[params.user]]"
+      class$="[[_computeUserHeaderClass(params)]]"
+    ></gr-user-header>
+    <gr-change-list
+      show-star=""
+      show-reviewed-state=""
+      account="[[account]]"
+      preferences="[[preferences]]"
+      selected-index="{{viewState.selectedChangeIndex}}"
+      sections="[[_results]]"
+      on-toggle-star="_handleToggleStar"
+      on-toggle-reviewed="_handleToggleReviewed"
+    >
+      <div id="emptyOutgoing" slot="empty-outgoing">
+        <template is="dom-if" if="[[_showNewUserHelp]]">
+          <gr-create-change-help
+            on-create-tap="createChangeTap"
+          ></gr-create-change-help>
+        </template>
+        <template is="dom-if" if="[[!_showNewUserHelp]]">
+          No changes
+        </template>
+      </div>
+    </gr-change-list>
+  </div>
+  <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
+    <gr-dialog
+      id="confirmDeleteDialog"
+      confirm-label="Delete"
+      on-confirm="_handleConfirmDelete"
+      on-cancel="_closeConfirmDeleteOverlay"
+    >
+      <div class="header" slot="header">
+        Delete comments
+      </div>
+      <div class="main" slot="main">
+        Are you sure you want to delete all your draft comments in closed
+        changes? This action cannot be undone.
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-create-destination-dialog
+    id="destinationDialog"
+    on-confirm="_handleDestinationConfirm"
+  ></gr-create-destination-dialog>
+  <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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..5965d06 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dashboard-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,344 +31,351 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dashboard-view tests', () => {
-    let element;
-    let sandbox;
-    let paramsChangedPromise;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-dashboard-view.js';
+import {isHidden} from '../../../test/test-utils.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getAccountDetails() { return Promise.resolve({}); },
-        getAccountStatus() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
-          (_, qs) => Promise.resolve(qs.map(() => [])));
+suite('gr-dashboard-view tests', () => {
+  let element;
+  let sandbox;
+  let paramsChangedPromise;
+  let getChangesStub;
 
-      let resolver;
-      paramsChangedPromise = new Promise(resolve => {
-        resolver = resolve;
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getAccountDetails() { return Promise.resolve({}); },
+      getAccountStatus() { return Promise.resolve(false); },
+    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
+        (_, qs) => Promise.resolve(qs.map(() => [])));
+
+    let resolver;
+    paramsChangedPromise = new Promise(resolve => {
+      resolver = resolve;
+    });
+    const paramsChanged = element._paramsChanged.bind(element);
+    sandbox.stub(element, '_paramsChanged', params => {
+      paramsChanged(params).then(() => resolver());
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('drafts banner functionality', () => {
+    suite('_maybeShowDraftsBanner', () => {
+      test('not dashboard/self', () => {
+        element.params = {user: 'notself'};
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
       });
-      const paramsChanged = element._paramsChanged.bind(element);
-      sandbox.stub(element, '_paramsChanged', params => {
-        paramsChanged(params).then(() => resolver());
+
+      test('no drafts at all', () => {
+        element.params = {user: 'self'};
+        element._results = [];
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        element.params = {user: 'self'};
+        element._results = [{query: 'has:draft', results: [{status: '_'}]}];
+        sandbox.stub(element, 'changeIsOpen').returns(true);
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        element.params = {user: 'self'};
+        element._results = [{query: 'has:draft', results: [{status: '_'}]}];
+        sandbox.stub(element, 'changeIsOpen').returns(false);
+        element._maybeShowDraftsBanner();
+        assert.isTrue(element._showDraftsBanner);
       });
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_showDraftsBanner', () => {
+      element._showDraftsBanner = false;
+      flushAsynchronousOperations();
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('.banner')));
+
+      element._showDraftsBanner = true;
+      flushAsynchronousOperations();
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('.banner')));
     });
 
-    suite('drafts banner functionality', () => {
-      suite('_maybeShowDraftsBanner', () => {
-        test('not dashboard/self', () => {
-          element.params = {user: 'notself'};
-          element._maybeShowDraftsBanner();
-          assert.isFalse(element._showDraftsBanner);
-        });
-
-        test('no drafts at all', () => {
-          element.params = {user: 'self'};
-          element._results = [];
-          element._maybeShowDraftsBanner();
-          assert.isFalse(element._showDraftsBanner);
-        });
-
-        test('no drafts on open changes', () => {
-          element.params = {user: 'self'};
-          element._results = [{query: 'has:draft', results: [{status: '_'}]}];
-          sandbox.stub(element, 'changeIsOpen').returns(true);
-          element._maybeShowDraftsBanner();
-          assert.isFalse(element._showDraftsBanner);
-        });
-
-        test('no drafts on open changes', () => {
-          element.params = {user: 'self'};
-          element._results = [{query: 'has:draft', results: [{status: '_'}]}];
-          sandbox.stub(element, 'changeIsOpen').returns(false);
-          element._maybeShowDraftsBanner();
-          assert.isTrue(element._showDraftsBanner);
-        });
-      });
-
-      test('_showDraftsBanner', () => {
-        element._showDraftsBanner = false;
-        flushAsynchronousOperations();
-        assert.isTrue(isHidden(element.$$('.banner')));
-
-        element._showDraftsBanner = true;
-        flushAsynchronousOperations();
-        assert.isFalse(isHidden(element.$$('.banner')));
-      });
-
-      test('delete tap opens dialog', () => {
-        sandbox.stub(element, '_handleOpenDeleteDialog');
-        element._showDraftsBanner = true;
-        flushAsynchronousOperations();
-
-        MockInteractions.tap(element.$$('.banner .delete'));
-        assert.isTrue(element._handleOpenDeleteDialog.called);
-      });
-
-      test('delete comments flow', async () => {
-        sandbox.spy(element, '_handleConfirmDelete');
-        sandbox.stub(element, '_reload');
-
-        // Set up control over timing of when RPC resolves.
-        let deleteDraftCommentsPromiseResolver;
-        const deleteDraftCommentsPromise = new Promise(resolve => {
-          deleteDraftCommentsPromiseResolver = resolve;
-        });
-        sandbox.stub(element.$.restAPI, 'deleteDraftComments')
-            .returns(deleteDraftCommentsPromise);
-
-        // Open confirmation dialog and tap confirm button.
-        await element.$.confirmDeleteOverlay.open();
-        MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.restAPI.deleteDraftComments
-            .calledWithExactly('-is:open'));
-        assert.isTrue(element.$.confirmDeleteDialog.disabled);
-        assert.equal(element._reload.callCount, 0);
-
-        // Verify state after RPC resolves.
-        deleteDraftCommentsPromiseResolver([]);
-        await deleteDraftCommentsPromise;
-        assert.equal(element._reload.callCount, 1);
-      });
-    });
-
-    test('_computeTitle', () => {
-      assert.equal(element._computeTitle('self'), 'My Reviews');
-      assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
-    });
-
-    suite('_computeSectionCountLabel', () => {
-      test('empty changes dont count label', () => {
-        assert.equal('', element._computeSectionCountLabel([]));
-      });
-
-      test('1 change', () => {
-        assert.equal('(1)',
-            element._computeSectionCountLabel(['1']));
-      });
-
-      test('2 changes', () => {
-        assert.equal('(2)',
-            element._computeSectionCountLabel(['1', '2']));
-      });
-
-      test('1 change and more', () => {
-        assert.equal('(1 and more)',
-            element._computeSectionCountLabel([{_more_changes: true}]));
-      });
-    });
-
-    suite('_isViewActive', () => {
-      test('nothing happens when user param is falsy', () => {
-        element.params = {};
-        flushAsynchronousOperations();
-        assert.equal(getChangesStub.callCount, 0);
-
-        element.params = {user: ''};
-        flushAsynchronousOperations();
-        assert.equal(getChangesStub.callCount, 0);
-      });
-
-      test('content is refreshed when user param is updated', () => {
-        element.params = {
-          view: Gerrit.Nav.View.DASHBOARD,
-          user: 'self',
-        };
-        return paramsChangedPromise.then(() => {
-          assert.equal(getChangesStub.callCount, 1);
-        });
-      });
-    });
-
-    suite('selfOnly sections', () => {
-      test('viewing self dashboard includes selfOnly sections', () => {
-        element.params = {
-          view: Gerrit.Nav.View.DASHBOARD,
-          sections: [
-            {query: '1'},
-            {query: '2', selfOnly: true},
-          ],
-          user: 'self',
-        };
-        return paramsChangedPromise.then(() => {
-          assert.isTrue(
-              getChangesStub.calledWith(
-                  null, ['1', '2', 'owner:self limit:1'], null, element.options));
-        });
-      });
-
-      test('viewing another user\'s dashboard omits selfOnly sections', () => {
-        element.params = {
-          view: Gerrit.Nav.View.DASHBOARD,
-          sections: [
-            {query: '1'},
-            {query: '2', selfOnly: true},
-          ],
-          user: 'user',
-        };
-        return paramsChangedPromise.then(() => {
-          assert.isTrue(
-              getChangesStub.calledWith(
-                  null, ['1'], null, element.options));
-        });
-      });
-    });
-
-    test('suffixForDashboard is included in getChanges query', () => {
-      element.params = {
-        view: Gerrit.Nav.View.DASHBOARD,
-        sections: [
-          {query: '1'},
-          {query: '2', suffixForDashboard: 'suffix'},
-        ],
-      };
-      return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledOnce);
-        assert.deepEqual(
-            getChangesStub.firstCall.args,
-            [null, ['1', '2 suffix'], null, element.options]);
-      });
-    });
-
-    suite('_getProjectDashboard', () => {
-      test('dashboard with foreach', () => {
-        sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
-          title: 'title',
-          foreach: 'foreach for ${project}',
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: '${project} query 2'},
-          ],
-        }));
-        return element._getProjectDashboard('project', '').then(dashboard => {
-          assert.deepEqual(
-              dashboard,
-              {
-                title: 'title',
-                sections: [
-                  {name: 'section 1', query: 'query 1 foreach for project'},
-                  {
-                    name: 'section 2',
-                    query: 'project query 2 foreach for project',
-                  },
-                ],
-              });
-        });
-      });
-
-      test('dashboard without foreach', () => {
-        sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
-          title: 'title',
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: '${project} query 2'},
-          ],
-        }));
-        return element._getProjectDashboard('project', '').then(dashboard => {
-          assert.deepEqual(
-              dashboard,
-              {
-                title: 'title',
-                sections: [
-                  {name: 'section 1', query: 'query 1'},
-                  {name: 'section 2', query: 'project query 2'},
-                ],
-              });
-        });
-      });
-    });
-
-    test('hideIfEmpty sections', () => {
-      const sections = [
-        {name: 'test1', query: 'test1', hideIfEmpty: true},
-        {name: 'test2', query: 'test2', hideIfEmpty: true},
-      ];
-      getChangesStub.restore();
-      sandbox.stub(element.$.restAPI, 'getChanges')
-          .returns(Promise.resolve([[], ['nonempty']]));
-
-      return element._fetchDashboardChanges({sections}, false).then(() => {
-        assert.equal(element._results.length, 1);
-        assert.equal(element._results[0].name, 'test2');
-      });
-    });
-
-    test('preserve isOutgoing sections', () => {
-      const sections = [
-        {name: 'test1', query: 'test1', isOutgoing: true},
-        {name: 'test2', query: 'test2'},
-      ];
-      getChangesStub.restore();
-      sandbox.stub(element.$.restAPI, 'getChanges')
-          .returns(Promise.resolve([[], []]));
-
-      return element._fetchDashboardChanges({sections}, false).then(() => {
-        assert.equal(element._results.length, 2);
-        assert.isTrue(element._results[0].isOutgoing);
-        assert.isNotOk(element._results[1].isOutgoing);
-      });
-    });
-
-    test('_showNewUserHelp', () => {
-      element._loading = false;
-      element._showNewUserHelp = false;
+    test('delete tap opens dialog', () => {
+      sandbox.stub(element, '_handleOpenDeleteDialog');
+      element._showDraftsBanner = true;
       flushAsynchronousOperations();
 
-      assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-      assert.isNotOk(element.$$('gr-create-change-help'));
-      element._showNewUserHelp = true;
-      flushAsynchronousOperations();
-
-      assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-      assert.isOk(element.$$('gr-create-change-help'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.banner .delete'));
+      assert.isTrue(element._handleOpenDeleteDialog.called);
     });
 
-    test('_computeUserHeaderClass', () => {
-      assert.equal(element._computeUserHeaderClass(undefined), 'hide');
-      assert.equal(element._computeUserHeaderClass({}), 'hide');
-      assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
-      assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
-      assert.equal(
-          element._computeUserHeaderClass({project: 'p', user: 'user'}),
-          'hide');
-    });
+    test('delete comments flow', async () => {
+      sandbox.spy(element, '_handleConfirmDelete');
+      sandbox.stub(element, '_reload');
 
-    test('404 page', done => {
-      const response = {status: 404};
-      sandbox.stub(element.$.restAPI, 'getDashboard',
-          async (project, dashboard, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
-        assert.strictEqual(e.detail.response, response);
-        done();
+      // Set up control over timing of when RPC resolves.
+      let deleteDraftCommentsPromiseResolver;
+      const deleteDraftCommentsPromise = new Promise(resolve => {
+        deleteDraftCommentsPromiseResolver = resolve;
       });
-      element.params = {
-        view: Gerrit.Nav.View.DASHBOARD,
-        project: 'project',
-        dashboard: 'dashboard',
-      };
+      sandbox.stub(element.$.restAPI, 'deleteDraftComments')
+          .returns(deleteDraftCommentsPromise);
+
+      // Open confirmation dialog and tap confirm button.
+      await element.$.confirmDeleteOverlay.open();
+      MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.restAPI.deleteDraftComments
+          .calledWithExactly('-is:open'));
+      assert.isTrue(element.$.confirmDeleteDialog.disabled);
+      assert.equal(element._reload.callCount, 0);
+
+      // Verify state after RPC resolves.
+      deleteDraftCommentsPromiseResolver([]);
+      await deleteDraftCommentsPromise;
+      assert.equal(element._reload.callCount, 1);
+    });
+  });
+
+  test('_computeTitle', () => {
+    assert.equal(element._computeTitle('self'), 'My Reviews');
+    assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
+  });
+
+  suite('_computeSectionCountLabel', () => {
+    test('empty changes dont count label', () => {
+      assert.equal('', element._computeSectionCountLabel([]));
     });
 
-    test('params change triggers dashboardDisplayed()', () => {
-      sandbox.stub(element.$.reporting, 'dashboardDisplayed');
+    test('1 change', () => {
+      assert.equal('(1)',
+          element._computeSectionCountLabel(['1']));
+    });
+
+    test('2 changes', () => {
+      assert.equal('(2)',
+          element._computeSectionCountLabel(['1', '2']));
+    });
+
+    test('1 change and more', () => {
+      assert.equal('(1 and more)',
+          element._computeSectionCountLabel([{_more_changes: true}]));
+    });
+  });
+
+  suite('_isViewActive', () => {
+    test('nothing happens when user param is falsy', () => {
+      element.params = {};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
+
+      element.params = {user: ''};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
+    });
+
+    test('content is refreshed when user param is updated', () => {
       element.params = {
-        view: Gerrit.Nav.View.DASHBOARD,
-        project: 'project',
-        dashboard: 'dashboard',
+        view: GerritNav.View.DASHBOARD,
+        user: 'self',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(element.$.reporting.dashboardDisplayed.calledOnce);
+        assert.equal(getChangesStub.callCount, 1);
       });
     });
   });
+
+  suite('selfOnly sections', () => {
+    test('viewing self dashboard includes selfOnly sections', () => {
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(
+            getChangesStub.calledWith(null, ['1', '2', 'owner:self limit:1']));
+      });
+    });
+
+    test('viewing another user\'s dashboard omits selfOnly sections', () => {
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'user',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(getChangesStub.calledWith(null, ['1']));
+      });
+    });
+  });
+
+  test('suffixForDashboard is included in getChanges query', () => {
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      sections: [
+        {query: '1'},
+        {query: '2', suffixForDashboard: 'suffix'},
+      ],
+    };
+    return paramsChangedPromise.then(() => {
+      assert.isTrue(getChangesStub.calledOnce);
+      assert.deepEqual(
+          getChangesStub.firstCall.args, [null, ['1', '2 suffix']]);
+    });
+  });
+
+  suite('_getProjectDashboard', () => {
+    test('dashboard with foreach', () => {
+      sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
+        title: 'title',
+        foreach: 'foreach for ${project}',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: '${project} query 2'},
+        ],
+      }));
+      return element._getProjectDashboard('project', '').then(dashboard => {
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1 foreach for project'},
+                {
+                  name: 'section 2',
+                  query: 'project query 2 foreach for project',
+                },
+              ],
+            });
+      });
+    });
+
+    test('dashboard without foreach', () => {
+      sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: '${project} query 2'},
+        ],
+      }));
+      return element._getProjectDashboard('project', '').then(dashboard => {
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1'},
+                {name: 'section 2', query: 'project query 2'},
+              ],
+            });
+      });
+    });
+  });
+
+  test('hideIfEmpty sections', () => {
+    const sections = [
+      {name: 'test1', query: 'test1', hideIfEmpty: true},
+      {name: 'test2', query: 'test2', hideIfEmpty: true},
+    ];
+    getChangesStub.restore();
+    sandbox.stub(element.$.restAPI, 'getChanges')
+        .returns(Promise.resolve([[], ['nonempty']]));
+
+    return element._fetchDashboardChanges({sections}, false).then(() => {
+      assert.equal(element._results.length, 1);
+      assert.equal(element._results[0].name, 'test2');
+    });
+  });
+
+  test('preserve isOutgoing sections', () => {
+    const sections = [
+      {name: 'test1', query: 'test1', isOutgoing: true},
+      {name: 'test2', query: 'test2'},
+    ];
+    getChangesStub.restore();
+    sandbox.stub(element.$.restAPI, 'getChanges')
+        .returns(Promise.resolve([[], []]));
+
+    return element._fetchDashboardChanges({sections}, false).then(() => {
+      assert.equal(element._results.length, 2);
+      assert.isTrue(element._results[0].isOutgoing);
+      assert.isNotOk(element._results[1].isOutgoing);
+    });
+  });
+
+  test('_showNewUserHelp', () => {
+    element._loading = false;
+    element._showNewUserHelp = false;
+    flushAsynchronousOperations();
+
+    assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+    assert.isNotOk(element.shadowRoot
+        .querySelector('gr-create-change-help'));
+    element._showNewUserHelp = true;
+    flushAsynchronousOperations();
+
+    assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+    assert.isOk(element.shadowRoot
+        .querySelector('gr-create-change-help'));
+  });
+
+  test('_computeUserHeaderClass', () => {
+    assert.equal(element._computeUserHeaderClass(undefined), 'hide');
+    assert.equal(element._computeUserHeaderClass({}), 'hide');
+    assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
+    assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+    assert.equal(
+        element._computeUserHeaderClass({project: 'p', user: 'user'}),
+        'hide');
+  });
+
+  test('404 page', done => {
+    const response = {status: 404};
+    sandbox.stub(element.$.restAPI, 'getDashboard',
+        async (project, dashboard, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.strictEqual(e.detail.response, response);
+      done();
+    });
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+  });
+
+  test('params change triggers dashboardDisplayed()', () => {
+    sandbox.stub(element.$.reporting, 'dashboardDisplayed');
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+    return paramsChangedPromise.then(() => {
+      assert.isTrue(element.$.reporting.dashboardDisplayed.calledOnce);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
deleted file mode 100644
index d445185..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html
+++ /dev/null
@@ -1,41 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
-<link rel="import" href="../gr-create-change-help/gr-create-change-help.html">
-
-<dom-module id="gr-embed-dashboard">
-  <template>
-    <gr-change-list
-        show-star
-        account="[[account]]"
-        preferences="[[preferences]]"
-        sections="[[sections]]">
-      <div id="emptyOutgoing" slot="empty-outgoing">
-        <template is="dom-if" if="[[showNewUserHelp]]">
-          <gr-create-change-help></gr-create-change-help>
-        </template>
-        <template is="dom-if" if="[[!showNewUserHelp]]">
-          No changes
-        </template>
-      </div>
-    </gr-change-list>
-  </template>
-  <script src="gr-embed-dashboard.js"></script>
-</dom-module>
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..2523700 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
@@ -14,17 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-embed-dashboard',
+import '../gr-change-list/gr-change-list.js';
+import '../gr-create-change-help/gr-create-change-help.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-embed-dashboard_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrEmbedDashboard extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-embed-dashboard'; }
+
+  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-embed-dashboard/gr-embed-dashboard_html.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
new file mode 100644
index 0000000..802e365
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-change-list
+    show-star=""
+    account="[[account]]"
+    preferences="[[preferences]]"
+    sections="[[sections]]"
+  >
+    <div id="emptyOutgoing" slot="empty-outgoing">
+      <template is="dom-if" if="[[showNewUserHelp]]">
+        <gr-create-change-help></gr-create-change-help>
+      </template>
+      <template is="dom-if" if="[[!showNewUserHelp]]">
+        No changes
+      </template>
+    </div>
+  </gr-change-list>
+`;
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
deleted file mode 100644
index 0b4459c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
+++ /dev/null
@@ -1,41 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/dashboard-header-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-repo-header">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="dashboard-header-styles"></style>
-    <div class="info">
-      <h1 class$="name">
-        [[repo]]
-        <hr/>
-      </h1>
-      <div>
-        <span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-header.js"></script>
-</dom-module>
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..5f0021e 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
@@ -14,28 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-repo-header',
+import '../../../styles/dashboard-header-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-header_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
-      /** @type {?String} */
+/** @extends Polymer.Element */
+class GrRepoHeader extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-header'; }
+
+  static get properties() {
+    return {
+    /** @type {?string} */
       repo: {
         type: String,
         observer: '_repoChanged',
       },
-      /** @type {String|null} */
+      /** @type {string|null} */
       _repoUrl: String,
-    },
+    };
+  }
 
-    _repoChanged(repoName) {
-      if (!repoName) {
-        this._repoUrl = null;
-        return;
-      }
-      this._repoUrl = Gerrit.Nav.getUrlForRepo(repoName);
-    },
-  });
-})();
+  _repoChanged(repoName) {
+    if (!repoName) {
+      this._repoUrl = null;
+      return;
+    }
+    this._repoUrl = GerritNav.getUrlForRepo(repoName);
+  }
+}
+
+customElements.define(GrRepoHeader.is, GrRepoHeader);
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
new file mode 100644
index 0000000..f6fb1d0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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]]
+      <hr />
+    </h1>
+    <div><span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a></div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..78c1f09 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-header.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,25 +31,29 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-header tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-header.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+suite('gr-repo-header tests', () => {
+  let element;
+  let sandbox;
 
-    teardown(() => { sandbox.restore(); });
-
-    test('repoUrl reset once repo changed', () => {
-      sandbox.stub(Gerrit.Nav, 'getUrlForRepo',
-          repoName => `http://test.com/${repoName}`
-      );
-      assert.equal(element._repoUrl, undefined);
-      element.repo = 'test';
-      assert.equal(element._repoUrl, 'http://test.com/test');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('repoUrl reset once repo changed', () => {
+    sandbox.stub(GerritNav, 'getUrlForRepo',
+        repoName => `http://test.com/${repoName}`
+    );
+    assert.equal(element._repoUrl, undefined);
+    element.repo = 'test';
+    assert.equal(element._repoUrl, 'http://test.com/test');
+  });
+});
 </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
deleted file mode 100644
index fed1c12..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
+++ /dev/null
@@ -1,82 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/dashboard-header-styles.html">
-
-<dom-module id="gr-user-header">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="dashboard-header-styles">
-      .name {
-        display: inline-block;
-      }
-      .name hr {
-        width: 100%;
-      }
-      .status.hide,
-      .name.hide,
-      .dashboardLink.hide {
-        display: none;
-      }
-    </style>
-    <gr-avatar
-        account="[[_accountDetails]]"
-        image-size="100"
-        aria-label="Account avatar"></gr-avatar>
-    <div class="info">
-      <h1 class="name">
-        [[_computeDetail(_accountDetails, 'name')]]
-      </h1>
-      <hr/>
-      <div class$="status [[_computeStatusClass(_accountDetails)]]">
-        <span>Status:</span> [[_status]]
-      </div>
-      <div>
-        <span>Email:</span>
-        <a href="mailto:[[_computeDetail(_accountDetails, 'email')]]"><!--
-          -->[[_computeDetail(_accountDetails, 'email')]]</a>
-      </div>
-      <div>
-        <span>Joined:</span>
-        <gr-date-formatter
-            date-str="[[_computeDetail(_accountDetails, 'registered_on')]]">
-        </gr-date-formatter>
-      </div>
-      <gr-endpoint-decorator name="user-header">
-        <gr-endpoint-param name="accountDetails" value="[[_accountDetails]]">
-        </gr-endpoint-param>
-        <gr-endpoint-param name="loggedIn" value="[[loggedIn]]">
-        </gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
-    <div class="info">
-      <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
-        <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-user-header.js"></script>
-</dom-module>
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..6bb1bf8 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
@@ -14,14 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-user-header',
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-avatar/gr-avatar.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/dashboard-header-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-user-header_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
-      /** @type {?String} */
+/**
+ * @extends Polymer.Element
+ */
+class GrUserHeader extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-user-header'; }
+
+  static get properties() {
+    return {
+    /** @type {?string} */
       userId: {
         type: String,
         observer: '_accountChanged',
@@ -45,51 +65,53 @@
         value: null,
       },
 
-      /** @type {?String} */
+      /** @type {?string} */
       _status: {
         type: String,
         value: null,
       },
-    },
+    };
+  }
 
-    _accountChanged(userId) {
-      if (!userId) {
-        this._accountDetails = null;
-        this._status = null;
-        return;
-      }
+  _accountChanged(userId) {
+    if (!userId) {
+      this._accountDetails = null;
+      this._status = null;
+      return;
+    }
 
-      this.$.restAPI.getAccountDetails(userId).then(details => {
-        this._accountDetails = details;
-      });
-      this.$.restAPI.getAccountStatus(userId).then(status => {
-        this._status = status;
-      });
-    },
+    this.$.restAPI.getAccountDetails(userId).then(details => {
+      this._accountDetails = details;
+    });
+    this.$.restAPI.getAccountStatus(userId).then(status => {
+      this._status = status;
+    });
+  }
 
-    _computeDisplayClass(status) {
-      return status ? ' ' : 'hide';
-    },
+  _computeDisplayClass(status) {
+    return status ? ' ' : 'hide';
+  }
 
-    _computeDetail(accountDetails, name) {
-      return accountDetails ? accountDetails[name] : '';
-    },
+  _computeDetail(accountDetails, name) {
+    return accountDetails ? accountDetails[name] : '';
+  }
 
-    _computeStatusClass(accountDetails) {
-      return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
-    },
+  _computeStatusClass(accountDetails) {
+    return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
+  }
 
-    _computeDashboardUrl(accountDetails) {
-      if (!accountDetails) { return null; }
-      const id = accountDetails._account_id;
-      const email = accountDetails.email;
-      if (!id && !email ) { return null; }
-      return Gerrit.Nav.getUrlForUserDashboard(id ? id : email);
-    },
+  _computeDashboardUrl(accountDetails) {
+    if (!accountDetails) { return null; }
+    const id = accountDetails._account_id;
+    const email = accountDetails.email;
+    if (!id && !email ) { return null; }
+    return GerritNav.getUrlForUserDashboard(id ? id : email);
+  }
 
-    _computeDashboardLinkClass(showDashboardLink, loggedIn) {
-      return showDashboardLink && loggedIn ?
-        'dashboardLink' : 'dashboardLink hide';
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
new file mode 100644
index 0000000..5a5d590
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    .name hr {
+      width: 100%;
+    }
+    .status.hide,
+    .name.hide,
+    .dashboardLink.hide {
+      display: none;
+    }
+  </style>
+  <gr-avatar
+    account="[[_accountDetails]]"
+    image-size="100"
+    aria-label="Account avatar"
+  ></gr-avatar>
+  <div class="info">
+    <h1 class="name">
+      [[_computeDetail(_accountDetails, 'name')]]
+    </h1>
+    <hr />
+    <div class$="status [[_computeStatusClass(_accountDetails)]]">
+      <span>Status:</span> [[_status]]
+    </div>
+    <div>
+      <span>Email:</span>
+      <a href="mailto:[[_computeDetail(_accountDetails, 'email')]]"
+        ><!--
+          -->[[_computeDetail(_accountDetails, 'email')]]</a
+      >
+    </div>
+    <div>
+      <span>Joined:</span>
+      <gr-date-formatter
+        date-str="[[_computeDetail(_accountDetails, 'registered_on')]]"
+      >
+      </gr-date-formatter>
+    </div>
+    <gr-endpoint-decorator name="user-header">
+      <gr-endpoint-param name="accountDetails" value="[[_accountDetails]]">
+      </gr-endpoint-param>
+      <gr-endpoint-param name="loggedIn" value="[[loggedIn]]">
+      </gr-endpoint-param>
+    </gr-endpoint-decorator>
+  </div>
+  <div class="info">
+    <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
+      <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..44eb96c 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-user-header.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,49 +31,51 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-user-header tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-user-header.js';
+suite('gr-user-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => { sandbox.restore(); });
+  teardown(() => { sandbox.restore(); });
 
-    test('loads and clears account info', done => {
-      sandbox.stub(element.$.restAPI, 'getAccountDetails')
-          .returns(Promise.resolve({
-            name: 'foo',
-            email: 'bar',
-            registered_on: '2015-03-12 18:32:08.000000000',
-          }));
-      sandbox.stub(element.$.restAPI, 'getAccountStatus')
-          .returns(Promise.resolve('baz'));
+  test('loads and clears account info', done => {
+    sandbox.stub(element.$.restAPI, 'getAccountDetails')
+        .returns(Promise.resolve({
+          name: 'foo',
+          email: 'bar',
+          registered_on: '2015-03-12 18:32:08.000000000',
+        }));
+    sandbox.stub(element.$.restAPI, 'getAccountStatus')
+        .returns(Promise.resolve('baz'));
 
-      element.userId = 'foo.bar@baz';
+    element.userId = 'foo.bar@baz';
+    flush(() => {
+      assert.isOk(element._accountDetails);
+      assert.isOk(element._status);
+
+      element.userId = null;
       flush(() => {
-        assert.isOk(element._accountDetails);
-        assert.isOk(element._status);
+        flushAsynchronousOperations();
+        assert.isNull(element._accountDetails);
+        assert.isNull(element._status);
 
-        element.userId = null;
-        flush(() => {
-          flushAsynchronousOperations();
-          assert.isNull(element._accountDetails);
-          assert.isNull(element._status);
-
-          done();
-        });
+        done();
       });
     });
-
-    test('_computeDashboardLinkClass', () => {
-      assert.include(element._computeDashboardLinkClass(false, false), 'hide');
-      assert.include(element._computeDashboardLinkClass(true, false), 'hide');
-      assert.include(element._computeDashboardLinkClass(false, true), 'hide');
-      assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
-    });
   });
+
+  test('_computeDashboardLinkClass', () => {
+    assert.include(element._computeDashboardLinkClass(false, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(true, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(false, true), 'hide');
+    assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
+  });
+});
 </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
deleted file mode 100644
index e12f10d..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ /dev/null
@@ -1,276 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../admin/gr-create-change-dialog/gr-create-change-dialog.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.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-confirm-abandon-dialog/gr-confirm-abandon-dialog.html">
-<link rel="import" href="../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html">
-<link rel="import" href="../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html">
-<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-submit-dialog/gr-confirm-submit-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-actions">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: flex;
-        font-family: var(--font-family);
-      }
-      #actionLoadingMessage,
-      #mainContent,
-      section {
-        display: flex;
-      }
-      #actionLoadingMessage,
-      gr-button,
-      gr-dropdown {
-        /* px because don't have the same font size */
-        margin-left: 8px;
-      }
-      #actionLoadingMessage {
-        align-items: center;
-        color: var(--deemphasized-text-color);
-      }
-      #confirmSubmitDialog .changeSubject {
-        margin: var(--spacing-l);
-        text-align: center;
-      }
-      iron-icon {
-        color: inherit;
-        height: 1.2rem;
-        margin-right: var(--spacing-xs);
-        width: 1.2rem;
-      }
-      gr-button {
-        min-height: 2.25em;
-      }
-      gr-dropdown {
-        --gr-button: {
-          min-height: 2.25em;
-        }
-      }
-      #moreActions iron-icon {
-        margin: 0;
-      }
-      #moreMessage,
-      .hidden {
-        display: none;
-      }
-      @media screen and (max-width: 50em) {
-        #mainContent {
-          flex-wrap: wrap;
-        }
-        gr-button {
-          --gr-button: {
-            padding: var(--spacing-m);
-            white-space: nowrap;
-          }
-        }
-        gr-button,
-        gr-dropdown {
-          margin: 0;
-        }
-        #actionLoadingMessage {
-          margin: var(--spacing-m);
-          text-align: center;
-        }
-        #moreMessage {
-          display: inline;
-        }
-      }
-    </style>
-    <div id="mainContent">
-      <span
-          id="actionLoadingMessage"
-          hidden$="[[!_actionLoadingMessage]]">
-        [[_actionLoadingMessage]]</span>
-        <section id="primaryActions"
-            hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
-          <template
-              is="dom-repeat"
-              items="[[_topLevelPrimaryActions]]"
-              as="action">
-            <gr-button
-                link
-                title$="[[action.title]]"
-                has-tooltip="[[_computeHasTooltip(action.title)]]"
-                position-below="true"
-                data-action-key$="[[action.__key]]"
-                data-action-type$="[[action.__type]]"
-                data-label$="[[action.label]]"
-                disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-                on-click="_handleActionTap">
-                <iron-icon class$="[[_computeHasIcon(action)]]" icon$="gr-icons:[[action.icon]]"></iron-icon>
-              [[action.label]]
-            </gr-button>
-          </template>
-        </section>
-        <section id="secondaryActions"
-            hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
-          <template
-              is="dom-repeat"
-              items="[[_topLevelSecondaryActions]]"
-              as="action">
-            <gr-button
-                link
-                title$="[[action.title]]"
-                has-tooltip="[[_computeHasTooltip(action.title)]]"
-                position-below="true"
-                data-action-key$="[[action.__key]]"
-                data-action-type$="[[action.__type]]"
-                data-label$="[[action.label]]"
-                disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-                on-click="_handleActionTap">
-              <iron-icon class$="[[_computeHasIcon(action)]]" icon$="gr-icons:[[action.icon]]"></iron-icon>
-              [[action.label]]
-            </gr-button>
-          </template>
-        </section>
-      <gr-button hidden$="[[!_loading]]" disabled>Loading actions...</gr-button>
-      <gr-dropdown
-          id="moreActions"
-          link
-          tabindex="0"
-          vertical-offset="32"
-          horizontal-align="right"
-          on-tap-item="_handleOveflowItemTap"
-          hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
-          disabled-ids="[[_disabledMenuActions]]"
-          items="[[_menuActions]]">
-          <iron-icon icon="gr-icons:more-vert"></iron-icon>
-          <span id="moreMessage">More</span>
-        </gr-dropdown>
-    </div>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-rebase-dialog id="confirmRebase"
-          class="confirmDialog"
-          change-number="[[change._number]]"
-          on-confirm="_handleRebaseConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          branch="[[change.branch]]"
-          has-parent="[[hasParent]]"
-          rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
-          hidden></gr-confirm-rebase-dialog>
-      <gr-confirm-cherrypick-dialog id="confirmCherrypick"
-          class="confirmDialog"
-          change-status="[[changeStatus]]"
-          commit-message="[[commitMessage]]"
-          commit-num="[[commitNum]]"
-          on-confirm="_handleCherrypickConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          project="[[change.project]]"
-          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"
-          on-confirm="_handleMoveConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          project="[[change.project]]"
-          hidden></gr-confirm-move-dialog>
-      <gr-confirm-revert-dialog id="confirmRevertDialog"
-          class="confirmDialog"
-          on-confirm="_handleRevertDialogConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          hidden></gr-confirm-revert-dialog>
-      <gr-confirm-abandon-dialog id="confirmAbandonDialog"
-          class="confirmDialog"
-          on-confirm="_handleAbandonDialogConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          hidden></gr-confirm-abandon-dialog>
-      <gr-confirm-submit-dialog
-          id="confirmSubmitDialog"
-          class="confirmDialog"
-          change="[[change]]"
-          action="[[_revisionSubmitAction]]"
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleSubmitConfirm" hidden></gr-confirm-submit-dialog>
-      <gr-dialog id="createFollowUpDialog"
-          class="confirmDialog"
-          confirm-label="Create"
-          on-confirm="_handleCreateFollowUpChange"
-          on-cancel="_handleCloseCreateFollowUpChange">
-        <div class="header" slot="header">
-          Create Follow-Up Change
-        </div>
-        <div class="main" slot="main">
-          <gr-create-change-dialog
-              id="createFollowUpChange"
-              branch="[[change.branch]]"
-              base-change="[[change.id]]"
-              repo-name="[[change.project]]"
-              private-by-default="[[privateByDefault]]"></gr-create-change-dialog>
-        </div>
-      </gr-dialog>
-      <gr-dialog
-          id="confirmDeleteDialog"
-          class="confirmDialog"
-          confirm-label="Delete"
-          confirm-on-enter
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleDeleteConfirm">
-        <div class="header" slot="header">
-          Delete Change
-        </div>
-        <div class="main" slot="main">
-          Do you really want to delete the change?
-        </div>
-      </gr-dialog>
-      <gr-dialog
-          id="confirmDeleteEditDialog"
-          class="confirmDialog"
-          confirm-label="Delete"
-          confirm-on-enter
-          on-cancel="_handleConfirmDialogCancel"
-          on-confirm="_handleDeleteEditConfirm">
-        <div class="header" slot="header">
-          Delete Change Edit
-        </div>
-        <div class="main" slot="main">
-          Do you really want to delete the edit?
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting" category="change-actions"></gr-reporting>
-  </template>
-  <script src="gr-change-actions.js"></script>
-</dom-module>
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 9c0c38f..ca9016f 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
@@ -14,218 +14,279 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
-  const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
-  const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+import '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
+import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
+import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
+import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
+import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
+import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
+import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
+import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-actions_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+
+const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
+const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
+const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+/**
+ * @enum {string}
+ */
+const LabelStatus = {
   /**
-   * @enum {string}
+   * This label provides what is necessary for submission.
    */
-  const LabelStatus = {
+  OK: 'OK',
+  /**
+   * This label prevents the change from being submitted.
+   */
+  REJECT: 'REJECT',
+  /**
+   * The label may be set, but it's neither necessary for submission
+   * nor does it block submission if set.
+   */
+  MAY: 'MAY',
+  /**
+   * The label is required for submission, but has not been satisfied.
+   */
+  NEED: 'NEED',
+  /**
+   * The label is required for submission, but is impossible to complete.
+   * The likely cause is access has not been granted correctly by the
+   * project owner or site administrator.
+   */
+  IMPOSSIBLE: 'IMPOSSIBLE',
+  OPTIONAL: 'OPTIONAL',
+};
+
+const ChangeActions = {
+  ABANDON: 'abandon',
+  DELETE: '/',
+  DELETE_EDIT: 'deleteEdit',
+  EDIT: 'edit',
+  FOLLOW_UP: 'followup',
+  IGNORE: 'ignore',
+  MOVE: 'move',
+  PRIVATE: 'private',
+  PRIVATE_DELETE: 'private.delete',
+  PUBLISH_EDIT: 'publishEdit',
+  READY: 'ready',
+  REBASE_EDIT: 'rebaseEdit',
+  RESTORE: 'restore',
+  REVERT: 'revert',
+  REVERT_SUBMISSION: 'revert_submission',
+  REVIEWED: 'reviewed',
+  STOP_EDIT: 'stopEdit',
+  UNIGNORE: 'unignore',
+  UNREVIEWED: 'unreviewed',
+  WIP: 'wip',
+};
+
+const RevisionActions = {
+  CHERRYPICK: 'cherrypick',
+  REBASE: 'rebase',
+  SUBMIT: 'submit',
+  DOWNLOAD: 'download',
+};
+
+const ActionLoadingLabels = {
+  abandon: 'Abandoning...',
+  cherrypick: 'Cherry-picking...',
+  delete: 'Deleting...',
+  move: 'Moving..',
+  rebase: 'Rebasing...',
+  restore: 'Restoring...',
+  revert: 'Reverting...',
+  revert_submission: 'Reverting Submission...',
+  submit: 'Submitting...',
+};
+
+const ActionType = {
+  CHANGE: 'change',
+  REVISION: 'revision',
+};
+
+const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+
+const QUICK_APPROVE_ACTION = {
+  __key: 'review',
+  __type: 'change',
+  enabled: true,
+  key: 'review',
+  label: 'Quick approve',
+  method: 'POST',
+};
+
+const ActionPriority = {
+  CHANGE: 2,
+  DEFAULT: 0,
+  PRIMARY: 3,
+  REVIEW: -3,
+  REVISION: 1,
+};
+
+const DOWNLOAD_ACTION = {
+  enabled: true,
+  label: 'Download patch',
+  title: 'Open download dialog',
+  __key: 'download',
+  __primary: false,
+  __type: 'revision',
+};
+
+const REBASE_EDIT = {
+  enabled: true,
+  label: 'Rebase edit',
+  title: 'Rebase change edit',
+  __key: 'rebaseEdit',
+  __primary: false,
+  __type: 'change',
+  method: 'POST',
+};
+
+const PUBLISH_EDIT = {
+  enabled: true,
+  label: 'Publish edit',
+  title: 'Publish change edit',
+  __key: 'publishEdit',
+  __primary: false,
+  __type: 'change',
+  method: 'POST',
+};
+
+const DELETE_EDIT = {
+  enabled: true,
+  label: 'Delete edit',
+  title: 'Delete change edit',
+  __key: 'deleteEdit',
+  __primary: false,
+  __type: 'change',
+  method: 'DELETE',
+};
+
+const EDIT = {
+  enabled: true,
+  label: 'Edit',
+  title: 'Edit this change',
+  __key: 'edit',
+  __primary: false,
+  __type: 'change',
+};
+
+const STOP_EDIT = {
+  enabled: true,
+  label: 'Stop editing',
+  title: 'Stop editing this change',
+  __key: 'stopEdit',
+  __primary: false,
+  __type: 'change',
+};
+
+// Set of keys that have icons. As more icons are added to gr-icons.html, this
+// set should be expanded.
+const ACTIONS_WITH_ICONS = new Set([
+  ChangeActions.ABANDON,
+  ChangeActions.DELETE_EDIT,
+  ChangeActions.EDIT,
+  ChangeActions.PUBLISH_EDIT,
+  ChangeActions.READY,
+  ChangeActions.REBASE_EDIT,
+  ChangeActions.RESTORE,
+  ChangeActions.REVERT,
+  ChangeActions.REVERT_SUBMISSION,
+  ChangeActions.STOP_EDIT,
+  QUICK_APPROVE_ACTION.key,
+  RevisionActions.REBASE,
+  RevisionActions.SUBMIT,
+]);
+
+const AWAIT_CHANGE_ATTEMPTS = 5;
+const AWAIT_CHANGE_TIMEOUT_MS = 1000;
+
+const REVERT_TYPES = {
+  REVERT_SINGLE_CHANGE: 1,
+  REVERT_SUBMISSION: 2,
+};
+
+/* Revert submission is skipped as the normal revert dialog will now show
+the user a choice between reverting single change or an entire submission.
+Hence, a second button is not needed.
+*/
+const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+
+/**
+ * @extends Polymer.Element
+ */
+class GrChangeActions extends mixinBehaviors( [
+  PatchSetBehavior,
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-actions'; }
+  /**
+   * Fired when the change should be reloaded.
+   *
+   * @event reload-change
+   */
+
+  /**
+   * Fired when an action is tapped.
+   *
+   * @event custom-tap - naming pattern: <action key>-tap
+   */
+
+  /**
+   * Fires to show an alert when a send is attempted on the non-latest patch.
+   *
+   * @event show-alert
+   */
+
+  /**
+   * Fires when a change action fails.
+   *
+   * @event show-error
+   */
+
+  constructor() {
+    super();
+    this.ActionType = ActionType;
+    this.ChangeActions = ChangeActions;
+    this.RevisionActions = RevisionActions;
+  }
+
+  static get properties() {
+    return {
     /**
-     * This label provides what is necessary for submission.
+     * @type {{
+     *    _number: number,
+     *    branch: string,
+     *    id: string,
+     *    project: string,
+     *    subject: string,
+     *  }}
      */
-    OK: 'OK',
-    /**
-     * This label prevents the change from being submitted.
-     */
-    REJECT: 'REJECT',
-    /**
-     * The label may be set, but it's neither necessary for submission
-     * nor does it block submission if set.
-     */
-    MAY: 'MAY',
-    /**
-     * The label is required for submission, but has not been satisfied.
-     */
-    NEED: 'NEED',
-    /**
-     * The label is required for submission, but is impossible to complete.
-     * The likely cause is access has not been granted correctly by the
-     * project owner or site administrator.
-     */
-    IMPOSSIBLE: 'IMPOSSIBLE',
-    OPTIONAL: 'OPTIONAL',
-  };
-
-  const ChangeActions = {
-    ABANDON: 'abandon',
-    DELETE: '/',
-    DELETE_EDIT: 'deleteEdit',
-    EDIT: 'edit',
-    FOLLOW_UP: 'followup',
-    IGNORE: 'ignore',
-    MOVE: 'move',
-    PRIVATE: 'private',
-    PRIVATE_DELETE: 'private.delete',
-    PUBLISH_EDIT: 'publishEdit',
-    REBASE_EDIT: 'rebaseEdit',
-    RESTORE: 'restore',
-    REVERT: 'revert',
-    REVIEWED: 'reviewed',
-    STOP_EDIT: 'stopEdit',
-    UNIGNORE: 'unignore',
-    UNREVIEWED: 'unreviewed',
-    WIP: 'wip',
-  };
-
-  const RevisionActions = {
-    CHERRYPICK: 'cherrypick',
-    REBASE: 'rebase',
-    SUBMIT: 'submit',
-    DOWNLOAD: 'download',
-  };
-
-  const ActionLoadingLabels = {
-    abandon: 'Abandoning...',
-    cherrypick: 'Cherry-picking...',
-    delete: 'Deleting...',
-    move: 'Moving..',
-    rebase: 'Rebasing...',
-    restore: 'Restoring...',
-    revert: 'Reverting...',
-    submit: 'Submitting...',
-  };
-
-  const ActionType = {
-    CHANGE: 'change',
-    REVISION: 'revision',
-  };
-
-  const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
-
-  const QUICK_APPROVE_ACTION = {
-    __key: 'review',
-    __type: 'change',
-    enabled: true,
-    key: 'review',
-    label: 'Quick approve',
-    method: 'POST',
-  };
-
-  const ActionPriority = {
-    CHANGE: 2,
-    DEFAULT: 0,
-    PRIMARY: 3,
-    REVIEW: -3,
-    REVISION: 1,
-  };
-
-  const DOWNLOAD_ACTION = {
-    enabled: true,
-    label: 'Download patch',
-    title: 'Open download dialog',
-    __key: 'download',
-    __primary: false,
-    __type: 'revision',
-  };
-
-  const REBASE_EDIT = {
-    enabled: true,
-    label: 'Rebase edit',
-    title: 'Rebase change edit',
-    __key: 'rebaseEdit',
-    __primary: false,
-    __type: 'change',
-    method: 'POST',
-  };
-
-  const PUBLISH_EDIT = {
-    enabled: true,
-    label: 'Publish edit',
-    title: 'Publish change edit',
-    __key: 'publishEdit',
-    __primary: false,
-    __type: 'change',
-    method: 'POST',
-  };
-
-  const DELETE_EDIT = {
-    enabled: true,
-    label: 'Delete edit',
-    title: 'Delete change edit',
-    __key: 'deleteEdit',
-    __primary: false,
-    __type: 'change',
-    method: 'DELETE',
-  };
-
-  const EDIT = {
-    enabled: true,
-    label: 'Edit',
-    title: 'Edit this change',
-    __key: 'edit',
-    __primary: false,
-    __type: 'change',
-  };
-
-  const STOP_EDIT = {
-    enabled: true,
-    label: 'Stop editing',
-    title: 'Stop editing this change',
-    __key: 'stopEdit',
-    __primary: false,
-    __type: 'change',
-  };
-
-  // Set of keys that have icons. As more icons are added to gr-icons.html, this
-  // set should be expanded.
-  const ACTIONS_WITH_ICONS = new Set([
-    ChangeActions.ABANDON,
-    ChangeActions.DELETE_EDIT,
-    ChangeActions.EDIT,
-    ChangeActions.PUBLISH_EDIT,
-    ChangeActions.REBASE_EDIT,
-    ChangeActions.RESTORE,
-    ChangeActions.REVERT,
-    ChangeActions.STOP_EDIT,
-    QUICK_APPROVE_ACTION.key,
-    RevisionActions.REBASE,
-    RevisionActions.SUBMIT,
-  ]);
-
-  const AWAIT_CHANGE_ATTEMPTS = 5;
-  const AWAIT_CHANGE_TIMEOUT_MS = 1000;
-
-  Polymer({
-    is: 'gr-change-actions',
-
-    /**
-     * Fired when the change should be reloaded.
-     *
-     * @event reload-change
-     */
-
-    /**
-     * Fired when an action is tapped.
-     *
-     * @event custom-tap - naming pattern: <action key>-tap
-     */
-
-    /**
-     * Fires to show an alert when a send is attempted on the non-latest patch.
-     *
-     * @event show-alert
-     */
-
-    /**
-     * Fires when a change action fails.
-     *
-     * @event show-error
-     */
-
-    properties: {
-      /**
-       * @type {{
-       *    _number: number,
-       *    branch: string,
-       *    id: string,
-       *    project: string,
-       *    subject: string,
-       *  }}
-       */
       change: Object,
       actions: {
         type: Object,
@@ -235,6 +296,7 @@
         type: Array,
         value() {
           return [
+            ChangeActions.READY,
             RevisionActions.SUBMIT,
           ];
         },
@@ -296,21 +358,21 @@
       _allActionValues: {
         type: Array,
         computed: '_computeAllActions(actions.*, revisionActions.*,' +
-            'primaryActionKeys.*, _additionalActions.*, change, ' +
-            '_actionPriorityOverrides.*)',
+          'primaryActionKeys.*, _additionalActions.*, change, ' +
+          '_actionPriorityOverrides.*)',
       },
       _topLevelActions: {
         type: Array,
         computed: '_computeTopLevelActions(_allActionValues.*, ' +
-            '_hiddenActions.*, _overflowActions.*)',
+          '_hiddenActions.*, _overflowActions.*)',
         observer: '_filterPrimaryActions',
       },
       _topLevelPrimaryActions: Array,
       _topLevelSecondaryActions: Array,
       _menuActions: {
         type: Array,
-        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*, ' +
-            '_overflowActions.*)',
+        computed: '_computeMenuActions(_allActionValues.*, ' +
+          '_hiddenActions.*, _overflowActions.*)',
       },
       _overflowActions: {
         type: Array,
@@ -399,1132 +461,1220 @@
         type: Boolean,
         value: true,
       },
-    },
+    };
+  }
 
-    ActionType,
-    ChangeActions,
-    RevisionActions,
-
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
       '_changeChanged(change)',
       '_editStatusChanged(editMode, editPatchsetLoaded, ' +
-          'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
-    ],
+        '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());
+  }
 
-    ready() {
-      this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
-      this._handleLoadingComplete();
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
+    this._handleLoadingComplete();
+  }
 
-    _getSubmitAction(revisionActions) {
-      return this._getRevisionAction(revisionActions, 'submit', null);
-    },
+  _getSubmitAction(revisionActions) {
+    return this._getRevisionAction(revisionActions, 'submit', null);
+  }
 
-    _getRebaseAction(revisionActions) {
-      return this._getRevisionAction(revisionActions, 'rebase', null);
-    },
+  _getRebaseAction(revisionActions) {
+    return this._getRevisionAction(revisionActions, 'rebase', null);
+  }
 
-    _getRevisionAction(revisionActions, actionName, emptyActionValue) {
-      if (!revisionActions) {
-        return undefined;
-      }
-      if (revisionActions[actionName] === undefined) {
-        // Return null to fire an event when reveisionActions was loaded
-        // but doesn't contain actionName. undefined doesn't fire an event
-        return emptyActionValue;
-      }
-      return revisionActions[actionName];
-    },
+  _getRevisionAction(revisionActions, actionName, emptyActionValue) {
+    if (!revisionActions) {
+      return undefined;
+    }
+    if (revisionActions[actionName] === undefined) {
+      // Return null to fire an event when reveisionActions was loaded
+      // but doesn't contain actionName. undefined doesn't fire an event
+      return emptyActionValue;
+    }
+    return revisionActions[actionName];
+  }
 
-    reload() {
-      if (!this.changeNum || !this.latestPatchNum) {
-        return Promise.resolve();
-      }
+  reload() {
+    if (!this.changeNum || !this.latestPatchNum) {
+      return Promise.resolve();
+    }
 
-      this._loading = true;
-      return this._getRevisionActions().then(revisionActions => {
-        if (!revisionActions) { return; }
+    this._loading = true;
+    return this._getRevisionActions()
+        .then(revisionActions => {
+          if (!revisionActions) { return; }
 
-        this.revisionActions = revisionActions;
-        this._sendShowRevisionActions({
-          change: this.change,
-          revisionActions,
+          this.revisionActions = revisionActions;
+          this._sendShowRevisionActions({
+            change: this.change,
+            revisionActions,
+          });
+          this._handleLoadingComplete();
+        })
+        .catch(err => {
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: ERR_REVISION_ACTIONS},
+            composed: true, bubbles: true,
+          }));
+          this._loading = false;
+          throw err;
         });
-        this._handleLoadingComplete();
-      }).catch(err => {
-        this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
-        this._loading = false;
-        throw err;
-      });
-    },
+  }
 
-    _handleLoadingComplete() {
-      Gerrit.awaitPluginsLoaded().then(() => this._loading = false);
-    },
+  _handleLoadingComplete() {
+    pluginLoader.awaitPluginsLoaded().then(() => this._loading = false);
+  }
 
-    _sendShowRevisionActions(detail) {
-      this.$.jsAPI.handleEvent(
-          this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
-          detail
-      );
-    },
+  _sendShowRevisionActions(detail) {
+    this.$.jsAPI.handleEvent(
+        this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
+        detail
+    );
+  }
 
-    _changeChanged() {
-      this.reload();
-    },
+  _changeChanged() {
+    this.reload();
+  }
 
-    addActionButton(type, label) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type: ${type}`);
+  addActionButton(type, label) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type: ${type}`);
+    }
+    const action = {
+      enabled: true,
+      label,
+      __type: type,
+      __key: ADDITIONAL_ACTION_KEY_PREFIX +
+          Math.random().toString(36)
+              .substr(2),
+    };
+    this.push('_additionalActions', action);
+    return action.__key;
+  }
+
+  removeActionButton(key) {
+    const idx = this._indexOfActionButtonWithKey(key);
+    if (idx === -1) {
+      return;
+    }
+    this.splice('_additionalActions', idx, 1);
+  }
+
+  setActionButtonProp(key, prop, value) {
+    this.set([
+      '_additionalActions',
+      this._indexOfActionButtonWithKey(key),
+      prop,
+    ], value);
+  }
+
+  setActionOverflow(type, key, overflow) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+    const index = this._getActionOverflowIndex(type, key);
+    const action = {
+      type,
+      key,
+      overflow,
+    };
+    if (!overflow && index !== -1) {
+      this.splice('_overflowActions', index, 1);
+    } 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 => action.type === type && action.key === key);
+    const action = {
+      type,
+      key,
+      priority,
+    };
+    if (index !== -1) {
+      this.set('_actionPriorityOverrides', index, action);
+    } else {
+      this.push('_actionPriorityOverrides', action);
+    }
+  }
+
+  setActionHidden(type, key, hidden) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+
+    const idx = this._hiddenActions.indexOf(key);
+    if (hidden && idx === -1) {
+      this.push('_hiddenActions', key);
+    } else if (!hidden && idx !== -1) {
+      this.splice('_hiddenActions', idx, 1);
+    }
+  }
+
+  getActionDetails(action) {
+    if (this.revisionActions[action]) {
+      return this.revisionActions[action];
+    } else if (this.actions[action]) {
+      return this.actions[action];
+    }
+  }
+
+  _indexOfActionButtonWithKey(key) {
+    for (let i = 0; i < this._additionalActions.length; i++) {
+      if (this._additionalActions[i].__key === key) {
+        return i;
       }
-      const action = {
-        enabled: true,
-        label,
-        __type: type,
-        __key: ADDITIONAL_ACTION_KEY_PREFIX +
-            Math.random().toString(36).substr(2),
-      };
-      this.push('_additionalActions', action);
-      return action.__key;
-    },
+    }
+    return -1;
+  }
 
-    removeActionButton(key) {
-      const idx = this._indexOfActionButtonWithKey(key);
-      if (idx === -1) {
-        return;
-      }
-      this.splice('_additionalActions', idx, 1);
-    },
+  _getRevisionActions() {
+    return this.$.restAPI.getChangeRevisionActions(this.changeNum,
+        this.latestPatchNum);
+  }
 
-    setActionButtonProp(key, prop, value) {
-      this.set([
-        '_additionalActions',
-        this._indexOfActionButtonWithKey(key),
-        prop,
-      ], value);
-    },
+  _shouldHideActions(actions, loading) {
+    return loading || !actions || !actions.base || !actions.base.length;
+  }
 
-    setActionOverflow(type, key, overflow) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type given: ${type}`);
-      }
-      const index = this._getActionOverflowIndex(type, key);
-      const action = {
-        type,
-        key,
-        overflow,
-      };
-      if (!overflow && index !== -1) {
-        this.splice('_overflowActions', index, 1);
-      } else if (overflow) {
-        this.push('_overflowActions', action);
-      }
-    },
+  _keyCount(changeRecord) {
+    return Object.keys((changeRecord && changeRecord.base) || {}).length;
+  }
 
-    setActionPriority(type, key, priority) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type given: ${type}`);
+  _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
+      additionalActionsChangeRecord) {
+    // Polymer 2: check for undefined
+    if ([
+      actionsChangeRecord,
+      revisionActionsChangeRecord,
+      additionalActionsChangeRecord,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    const additionalActions = (additionalActionsChangeRecord &&
+        additionalActionsChangeRecord.base) || [];
+    this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
+        this._keyCount(revisionActionsChangeRecord) === 0 &&
+            additionalActions.length === 0;
+    this._actionLoadingMessage = '';
+    this._disabledMenuActions = [];
+
+    const revisionActions = revisionActionsChangeRecord.base || {};
+    if (Object.keys(revisionActions).length !== 0) {
+      if (!revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
       }
-      const index = this._actionPriorityOverrides.findIndex(action => {
-        return action.type === type && action.key === key;
-      });
-      const action = {
-        type,
-        key,
-        priority,
-      };
-      if (index !== -1) {
-        this.set('_actionPriorityOverrides', index, action);
+    }
+  }
+
+  /**
+   * @param {string=} actionName
+   */
+  _deleteAndNotify(actionName) {
+    if (this.actions && this.actions[actionName]) {
+      delete this.actions[actionName];
+      // We assign a fake value of 'false' to support Polymer 2
+      // see https://github.com/Polymer/polymer/issues/2631
+      this.notifyPath('actions.' + actionName, false);
+    }
+  }
+
+  _editStatusChanged(editMode, editPatchsetLoaded,
+      editBasedOnCurrentPatchSet, disableEdit) {
+    // Polymer 2: check for undefined
+    if ([
+      editMode,
+      editBasedOnCurrentPatchSet,
+      disableEdit,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (disableEdit) {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+      this._deleteAndNotify('stopEdit');
+      this._deleteAndNotify('edit');
+      return;
+    }
+    if (this.actions && editPatchsetLoaded) {
+      // Only show actions that mutate an edit if an actual edit patch set
+      // is loaded.
+      if (this.changeIsOpen(this.change)) {
+        if (editBasedOnCurrentPatchSet) {
+          if (!this.actions.publishEdit) {
+            this.set('actions.publishEdit', PUBLISH_EDIT);
+          }
+          this._deleteAndNotify('rebaseEdit');
+        } else {
+          if (!this.actions.rebaseEdit) {
+            this.set('actions.rebaseEdit', REBASE_EDIT);
+          }
+          this._deleteAndNotify('publishEdit');
+        }
+      }
+      if (!this.actions.deleteEdit) {
+        this.set('actions.deleteEdit', DELETE_EDIT);
+      }
+    } else {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+    }
+
+    if (this.actions && this.changeIsOpen(this.change)) {
+      // Only show edit button if there is no edit patchset loaded and the
+      // file list is not in edit mode.
+      if (editPatchsetLoaded || editMode) {
+        this._deleteAndNotify('edit');
       } else {
-        this.push('_actionPriorityOverrides', action);
+        if (!this.actions.edit) { this.set('actions.edit', EDIT); }
       }
-    },
-
-    setActionHidden(type, key, hidden) {
-      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error(`Invalid action type given: ${type}`);
-      }
-
-      const idx = this._hiddenActions.indexOf(key);
-      if (hidden && idx === -1) {
-        this.push('_hiddenActions', key);
-      } else if (!hidden && idx !== -1) {
-        this.splice('_hiddenActions', idx, 1);
-      }
-    },
-
-    getActionDetails(action) {
-      if (this.revisionActions[action]) {
-        return this.revisionActions[action];
-      } else if (this.actions[action]) {
-        return this.actions[action];
-      }
-    },
-
-    _indexOfActionButtonWithKey(key) {
-      for (let i = 0; i < this._additionalActions.length; i++) {
-        if (this._additionalActions[i].__key === key) {
-          return i;
+      // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
+      // is loaded.
+      if (editMode && !editPatchsetLoaded) {
+        if (!this.actions.stopEdit) {
+          this.set('actions.stopEdit', STOP_EDIT);
         }
-      }
-      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) {
-      // Polymer 2: check for undefined
-      if ([
-        actionsChangeRecord,
-        revisionActionsChangeRecord,
-        additionalActionsChangeRecord,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      const additionalActions = (additionalActionsChangeRecord &&
-          additionalActionsChangeRecord.base) || [];
-      this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
-          this._keyCount(revisionActionsChangeRecord) === 0 &&
-              additionalActions.length === 0;
-      this._actionLoadingMessage = '';
-      this._disabledMenuActions = [];
-
-      const revisionActions = revisionActionsChangeRecord.base || {};
-      if (Object.keys(revisionActions).length !== 0) {
-        if (!revisionActions.download) {
-          this.set('revisionActions.download', DOWNLOAD_ACTION);
-        }
-      }
-    },
-
-    /**
-     * @param {string=} actionName
-     */
-    _deleteAndNotify(actionName) {
-      if (this.actions && this.actions[actionName]) {
-        delete this.actions[actionName];
-        // We assign a fake value of 'false' to support Polymer 2
-        // see https://github.com/Polymer/polymer/issues/2631
-        this.notifyPath('actions.' + actionName, false);
-      }
-    },
-
-    _editStatusChanged(editMode, editPatchsetLoaded,
-        editBasedOnCurrentPatchSet, disableEdit) {
-      // Polymer 2: check for undefined
-      if ([
-        editMode,
-        editBasedOnCurrentPatchSet,
-        disableEdit,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (disableEdit) {
-        this._deleteAndNotify('publishEdit');
-        this._deleteAndNotify('rebaseEdit');
-        this._deleteAndNotify('deleteEdit');
+      } else {
         this._deleteAndNotify('stopEdit');
-        this._deleteAndNotify('edit');
-        return;
       }
-      if (this.actions && editPatchsetLoaded) {
-        // Only show actions that mutate an edit if an actual edit patch set
-        // is loaded.
-        if (this.changeIsOpen(this.change)) {
-          if (editBasedOnCurrentPatchSet) {
-            if (!this.actions.publishEdit) {
-              this.set('actions.publishEdit', PUBLISH_EDIT);
-            }
-            this._deleteAndNotify('rebaseEdit');
-          } else {
-            if (!this.actions.rebaseEdit) {
-              this.set('actions.rebaseEdit', REBASE_EDIT);
-            }
-            this._deleteAndNotify('publishEdit');
-          }
-        }
-        if (!this.actions.deleteEdit) {
-          this.set('actions.deleteEdit', DELETE_EDIT);
-        }
-      } else {
-        this._deleteAndNotify('publishEdit');
-        this._deleteAndNotify('rebaseEdit');
-        this._deleteAndNotify('deleteEdit');
-      }
+    } else {
+      // Remove edit button.
+      this._deleteAndNotify('edit');
+    }
+  }
 
-      if (this.actions && this.changeIsOpen(this.change)) {
-        // Only show edit button if there is no edit patchset loaded and the
-        // file list is not in edit mode.
-        if (editPatchsetLoaded || editMode) {
-          this._deleteAndNotify('edit');
-        } else {
-          if (!this.actions.edit) { this.set('actions.edit', EDIT); }
-        }
-        // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
-        // is loaded.
-        if (editMode && !editPatchsetLoaded) {
-          if (!this.actions.stopEdit) {
-            this.set('actions.stopEdit', STOP_EDIT);
-          }
-        } else {
-          this._deleteAndNotify('stopEdit');
-        }
-      } else {
-        // Remove edit button.
-        this._deleteAndNotify('edit');
-      }
-    },
+  _getValuesFor(obj) {
+    return Object.keys(obj).map(key => obj[key]);
+  }
 
-    _getValuesFor(obj) {
-      return Object.keys(obj).map(key => {
-        return obj[key];
-      });
-    },
+  _getLabelStatus(label) {
+    if (label.approved) {
+      return LabelStatus.OK;
+    } else if (label.rejected) {
+      return LabelStatus.REJECT;
+    } else if (label.optional) {
+      return LabelStatus.OPTIONAL;
+    } else {
+      return LabelStatus.NEED;
+    }
+  }
 
-    _getLabelStatus(label) {
-      if (label.approved) {
-        return LabelStatus.OK;
-      } else if (label.rejected) {
-        return LabelStatus.REJECT;
-      } else if (label.optional) {
-        return LabelStatus.OPTIONAL;
-      } else {
-        return LabelStatus.NEED;
+  /**
+   * Get highest score for last missing permitted label for current change.
+   * Returns null if no labels permitted or more than one label missing.
+   *
+   * @return {{label: string, score: string}|null}
+   */
+  _getTopMissingApproval() {
+    if (!this.change ||
+        !this.change.labels ||
+        !this.change.permitted_labels) {
+      return null;
+    }
+    let result;
+    for (const label in this.change.labels) {
+      if (!(label in this.change.permitted_labels)) {
+        continue;
       }
-    },
-
-    /**
-     * Get highest score for last missing permitted label for current change.
-     * Returns null if no labels permitted or more than one label missing.
-     *
-     * @return {{label: string, score: string}|null}
-     */
-    _getTopMissingApproval() {
-      if (!this.change ||
-          !this.change.labels ||
-          !this.change.permitted_labels) {
-        return null;
+      if (this.change.permitted_labels[label].length === 0) {
+        continue;
       }
-      let result;
-      for (const label in this.change.labels) {
-        if (!(label in this.change.permitted_labels)) {
-          continue;
-        }
-        if (this.change.permitted_labels[label].length === 0) {
-          continue;
-        }
-        const status = this._getLabelStatus(this.change.labels[label]);
-        if (status === LabelStatus.NEED) {
-          if (result) {
-            // More than one label is missing, so it's unclear which to quick
-            // approve, return null;
-            return null;
-          }
-          result = label;
-        } else if (status === LabelStatus.REJECT ||
-            status === LabelStatus.IMPOSSIBLE) {
+      const status = this._getLabelStatus(this.change.labels[label]);
+      if (status === LabelStatus.NEED) {
+        if (result) {
+          // More than one label is missing, so it's unclear which to quick
+          // approve, return null;
           return null;
         }
+        result = label;
+      } else if (status === LabelStatus.REJECT ||
+          status === LabelStatus.IMPOSSIBLE) {
+        return null;
       }
-      if (result) {
-        const score = this.change.permitted_labels[result].slice(-1)[0];
-        const maxScore =
-            Object.keys(this.change.labels[result].values).slice(-1)[0];
-        if (score === maxScore) {
-          // Allow quick approve only for maximal score.
-          return {
-            label: result,
-            score,
-          };
-        }
+    }
+    if (result) {
+      const score = this.change.permitted_labels[result].slice(-1)[0];
+      const maxScore =
+          Object.keys(this.change.labels[result].values).slice(-1)[0];
+      if (score === maxScore) {
+        // Allow quick approve only for maximal score.
+        return {
+          label: result,
+          score,
+        };
       }
-      return null;
-    },
+    }
+    return null;
+  }
 
-    hideQuickApproveAction() {
-      this._topLevelSecondaryActions =
-        this._topLevelSecondaryActions.filter(sa => {
-          return sa.key !== QUICK_APPROVE_ACTION.key;
+  hideQuickApproveAction() {
+    this._topLevelSecondaryActions =
+      this._topLevelSecondaryActions
+          .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
+    this._hideQuickApproveAction = true;
+  }
+
+  _getQuickApproveAction() {
+    if (this._hideQuickApproveAction) {
+      return null;
+    }
+    const approval = this._getTopMissingApproval();
+    if (!approval) {
+      return null;
+    }
+    const action = Object.assign({}, QUICK_APPROVE_ACTION);
+    action.label = approval.label + approval.score;
+    const review = {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {},
+    };
+    review.labels[approval.label] = approval.score;
+    action.payload = review;
+    return action;
+  }
+
+  _getActionValues(actionsChangeRecord, primariesChangeRecord,
+      additionalActionsChangeRecord, type) {
+    if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
+
+    const actions = actionsChangeRecord.base || {};
+    const primaryActionKeys = primariesChangeRecord.base || [];
+    const result = [];
+    const values = this._getValuesFor(
+        type === ActionType.CHANGE ? ChangeActions : RevisionActions);
+    const pluginActions = [];
+    Object.keys(actions).forEach(a => {
+      actions[a].__key = a;
+      actions[a].__type = type;
+      actions[a].__primary = primaryActionKeys.includes(a);
+      // Plugin actions always contain ~ in the key.
+      if (a.indexOf('~') !== -1) {
+        this._populateActionUrl(actions[a]);
+        pluginActions.push(actions[a]);
+        // Add server-side provided plugin actions to overflow menu.
+        this._overflowActions.push({
+          type,
+          key: a,
         });
-      this._hideQuickApproveAction = true;
-    },
-
-    _getQuickApproveAction() {
-      if (this._hideQuickApproveAction) {
-        return null;
+        return;
+      } else if (!values.includes(a)) {
+        return;
       }
-      const approval = this._getTopMissingApproval();
-      if (!approval) {
-        return null;
+      actions[a].label = this._getActionLabel(actions[a]);
+
+      // Triggers a re-render by ensuring object inequality.
+      result.push(Object.assign({}, actions[a]));
+    });
+
+    let additionalActions = (additionalActionsChangeRecord &&
+    additionalActionsChangeRecord.base) || [];
+    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 =
+          action.__type === ActionType.REVISION ? this.latestPatchNum : null;
+    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
+   * casing or includes explanatory details.
+   */
+  _getActionLabel(action) {
+    if (action.label === 'Delete') {
+      // This label is common within change and revision actions. Make it more
+      // explicit to the user.
+      return 'Delete change';
+    } else if (action.label === 'WIP') {
+      return 'Mark as work in progress';
+    }
+    // Otherwise, just map the name to sentence case.
+    return this._toSentenceCase(action.label);
+  }
+
+  /**
+   * Capitalize the first letter and lowecase all others.
+   *
+   * @param {string} s
+   * @return {string}
+   */
+  _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)) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        return rev;
       }
-      const action = Object.assign({}, QUICK_APPROVE_ACTION);
-      action.label = approval.label + approval.score;
-      const review = {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {},
-      };
-      review.labels[approval.label] = approval.score;
-      action.payload = review;
-      return action;
-    },
+    }
+    return null;
+  }
 
-    _getActionValues(actionsChangeRecord, primariesChangeRecord,
-        additionalActionsChangeRecord, type) {
-      if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
+  showRevertDialog() {
+    // The search is still broken if there is a " in the topic.
+    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);
+        });
+  }
 
-      const actions = actionsChangeRecord.base || {};
-      const primaryActionKeys = primariesChangeRecord.base || [];
-      const result = [];
-      const values = this._getValuesFor(
-          type === ActionType.CHANGE ? ChangeActions : RevisionActions);
-      const pluginActions = [];
-      Object.keys(actions).forEach(a => {
-        actions[a].__key = a;
-        actions[a].__type = type;
-        actions[a].__primary = primaryActionKeys.includes(a);
-        // Plugin actions always contain ~ in the key.
-        if (a.indexOf('~') !== -1) {
-          this._populateActionUrl(actions[a]);
-          pluginActions.push(actions[a]);
-          // Add server-side provided plugin actions to overflow menu.
-          this._overflowActions.push({
-            type,
-            key: a,
-          });
-          return;
-        } else if (!values.includes(a)) {
-          return;
+  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();
+    let el = dom(e).localTarget;
+    while (el.tagName.toLowerCase() !== 'gr-button') {
+      if (!el.parentElement) { return; }
+      el = el.parentElement;
+    }
+
+    const key = el.getAttribute('data-action-key');
+    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+        key.indexOf('~') !== -1) {
+      this.dispatchEvent(new CustomEvent(`${key}-tap`, {
+        detail: {node: el},
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    const type = el.getAttribute('data-action-type');
+    this._handleAction(type, key);
+  }
+
+  _handleOveflowItemTap(e) {
+    e.preventDefault();
+    const el = dom(e).localTarget;
+    const key = e.detail.action.__key;
+    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+        key.indexOf('~') !== -1) {
+      this.dispatchEvent(new CustomEvent(`${key}-tap`, {
+        detail: {node: el},
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    this._handleAction(e.detail.action.__type, e.detail.action.__key);
+  }
+
+  _handleAction(type, key) {
+    this.$.reporting.reportInteraction(`${type}-${key}`);
+    switch (type) {
+      case ActionType.REVISION:
+        this._handleRevisionAction(key);
+        break;
+      case ActionType.CHANGE:
+        this._handleChangeAction(key);
+        break;
+      default:
+        this._fireAction(this._prependSlash(key), this.actions[key], false);
+    }
+  }
+
+  _handleChangeAction(key) {
+    let action;
+    switch (key) {
+      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 => o.key === key);
+        this._fireAction(
+            this._prependSlash(key), action, true, action.payload);
+        break;
+      case ChangeActions.EDIT:
+        this._handleEditTap();
+        break;
+      case ChangeActions.STOP_EDIT:
+        this._handleStopEditTap();
+        break;
+      case ChangeActions.DELETE:
+        this._handleDeleteTap();
+        break;
+      case ChangeActions.DELETE_EDIT:
+        this._handleDeleteEditTap();
+        break;
+      case ChangeActions.FOLLOW_UP:
+        this._handleFollowUpTap();
+        break;
+      case ChangeActions.WIP:
+        this._handleWipTap();
+        break;
+      case ChangeActions.MOVE:
+        this._handleMoveTap();
+        break;
+      case ChangeActions.PUBLISH_EDIT:
+        this._handlePublishEditTap();
+        break;
+      case ChangeActions.REBASE_EDIT:
+        this._handleRebaseEditTap();
+        break;
+      default:
+        this._fireAction(this._prependSlash(key), this.actions[key], false);
+    }
+  }
+
+  _handleRevisionAction(key) {
+    switch (key) {
+      case RevisionActions.REBASE:
+        this._showActionDialog(this.$.confirmRebase);
+        this.$.confirmRebase.fetchRecentChanges();
+        break;
+      case RevisionActions.CHERRYPICK:
+        this._handleCherrypickTap();
+        break;
+      case RevisionActions.DOWNLOAD:
+        this._handleDownloadTap();
+        break;
+      case RevisionActions.SUBMIT:
+        if (!this._canSubmitChange()) { return; }
+        this._showActionDialog(this.$.confirmSubmitDialog);
+        break;
+      default:
+        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
+   * either true or false). set to false otherwise.
+   */
+  _computeChainState(hasParent) {
+    this._hasKnownChainState = true;
+  }
+
+  _calculateDisabled(action, hasKnownChainState) {
+    if (action.__key === 'rebase') {
+      // Rebase button is only disabled when change has no parent(s).
+      return hasKnownChainState === false;
+    }
+    return !action.enabled;
+  }
+
+  _handleConfirmDialogCancel() {
+    this._hideAllDialogs();
+  }
+
+  _hideAllDialogs() {
+    const dialogEls =
+        dom(this.root).querySelectorAll('.confirmDialog');
+    for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
+    this.$.overlay.close();
+  }
+
+  _handleRebaseConfirm(e) {
+    const el = this.$.confirmRebase;
+    const payload = {base: e.detail.base};
+    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;
+    if (!el.branch) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_BRANCH_EMPTY},
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    if (!el.message) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_COMMIT_EMPTY},
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+        '/cherrypick',
+        this.revisionActions.cherrypick,
+        true,
+        {
+          destination: el.branch,
+          base: el.baseCommit ? el.baseCommit : null,
+          message: el.message,
+          allow_conflicts: conflicts,
         }
-        actions[a].label = this._getActionLabel(actions[a]);
+    );
+  }
 
-        // Triggers a re-render by ensuring object inequality.
-        result.push(Object.assign({}, actions[a]));
-      });
-
-      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);
-      });
-      return result.concat(additionalActions).concat(pluginActions);
-    },
-
-    _populateActionUrl(action) {
-      const patchNum =
-            action.__type === ActionType.REVISION ? this.latestPatchNum : null;
-      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
-     * casing or includes explanatory details.
-     */
-    _getActionLabel(action) {
-      if (action.label === 'Delete') {
-        // This label is common within change and revision actions. Make it more
-        // explicit to the user.
-        return 'Delete change';
-      } else if (action.label === 'WIP') {
-        return 'Mark as work in progress';
-      }
-      // Otherwise, just map the name to sentence case.
-      return this._toSentenceCase(action.label);
-    },
-
-    /**
-     * Capitalize the first letter and lowecase all others.
-     *
-     * @param {string} s
-     * @return {string}
-     */
-    _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)) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          return rev;
+  _handleMoveConfirm() {
+    const el = this.$.confirmMove;
+    if (!el.branch) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_BRANCH_EMPTY},
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+        '/move',
+        this.actions.move,
+        false,
+        {
+          destination_branch: el.branch,
+          message: el.message,
         }
-      }
-      return null;
-    },
+    );
+  }
 
-    _modifyRevertMsg() {
-      return this.$.jsAPI.modifyRevertMsg(this.change,
-          this.$.confirmRevertDialog.message, this.commitMessage);
-    },
+  _handleRevertDialogConfirm(e) {
+    const revertType = e.detail.revertType;
+    const message = e.detail.message;
+    const el = this.$.confirmRevertDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    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');
+    }
+  }
 
-    showRevertDialog() {
-      this.$.confirmRevertDialog.populateRevertMessage(
-          this.commitMessage, this.change.current_revision);
-      this.$.confirmRevertDialog.message = this._modifyRevertMsg();
-      this._showActionDialog(this.$.confirmRevertDialog);
-    },
+  _handleRevertSubmissionDialogConfirm() {
+    const el = this.$.confirmRevertSubmissionDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/revert_submission', this.actions.revert_submission,
+        false, {message: el.message});
+  }
 
-    _handleActionTap(e) {
-      e.preventDefault();
-      let el = Polymer.dom(e).localTarget;
-      while (el.tagName.toLowerCase() !== 'gr-button') {
-        if (!el.parentElement) { return; }
-        el = el.parentElement;
-      }
+  _handleAbandonDialogConfirm() {
+    const el = this.$.confirmAbandonDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/abandon', this.actions.abandon, false,
+        {message: el.message});
+  }
 
-      const key = el.getAttribute('data-action-key');
-      if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-          key.indexOf('~') !== -1) {
-        this.fire(`${key}-tap`, {node: el});
-        return;
-      }
-      const type = el.getAttribute('data-action-type');
-      this._handleAction(type, key);
-    },
+  _handleCreateFollowUpChange() {
+    this.$.createFollowUpChange.handleCreateChange();
+    this._handleCloseCreateFollowUpChange();
+  }
 
-    _handleOveflowItemTap(e) {
-      e.preventDefault();
-      const el = Polymer.dom(e).localTarget;
-      const key = e.detail.action.__key;
-      if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-          key.indexOf('~') !== -1) {
-        this.fire(`${key}-tap`, {node: el});
-        return;
-      }
-      this._handleAction(e.detail.action.__type, e.detail.action.__key);
-    },
+  _handleCloseCreateFollowUpChange() {
+    this.$.overlay.close();
+  }
 
-    _handleAction(type, key) {
-      this.$.reporting.reportInteraction(`${type}-${key}`);
-      switch (type) {
-        case ActionType.REVISION:
-          this._handleRevisionAction(key);
-          break;
-        case ActionType.CHANGE:
-          this._handleChangeAction(key);
-          break;
-        default:
-          this._fireAction(this._prependSlash(key), this.actions[key], false);
-      }
-    },
+  _handleDeleteConfirm() {
+    this._fireAction('/', this.actions[ChangeActions.DELETE], false);
+  }
 
-    _handleChangeAction(key) {
-      let action;
-      switch (key) {
-        case ChangeActions.REVERT:
-          this.showRevertDialog();
-          break;
-        case ChangeActions.ABANDON:
-          this._showActionDialog(this.$.confirmAbandonDialog);
-          break;
-        case QUICK_APPROVE_ACTION.key:
-          action = this._allActionValues.find(o => {
-            return o.key === key;
-          });
-          this._fireAction(
-              this._prependSlash(key), action, true, action.payload);
-          break;
-        case ChangeActions.EDIT:
-          this._handleEditTap();
-          break;
-        case ChangeActions.STOP_EDIT:
-          this._handleStopEditTap();
-          break;
-        case ChangeActions.DELETE:
-          this._handleDeleteTap();
-          break;
-        case ChangeActions.DELETE_EDIT:
-          this._handleDeleteEditTap();
-          break;
-        case ChangeActions.FOLLOW_UP:
-          this._handleFollowUpTap();
-          break;
-        case ChangeActions.WIP:
-          this._handleWipTap();
-          break;
-        case ChangeActions.MOVE:
-          this._handleMoveTap();
-          break;
-        case ChangeActions.PUBLISH_EDIT:
-          this._handlePublishEditTap();
-          break;
-        case ChangeActions.REBASE_EDIT:
-          this._handleRebaseEditTap();
-          break;
-        default:
-          this._fireAction(this._prependSlash(key), this.actions[key], false);
-      }
-    },
+  _handleDeleteEditConfirm() {
+    this._hideAllDialogs();
 
-    _handleRevisionAction(key) {
-      switch (key) {
-        case RevisionActions.REBASE:
-          this._showActionDialog(this.$.confirmRebase);
-          this.$.confirmRebase.fetchRecentChanges();
-          break;
-        case RevisionActions.CHERRYPICK:
-          this._handleCherrypickTap();
-          break;
-        case RevisionActions.DOWNLOAD:
-          this._handleDownloadTap();
-          break;
-        case RevisionActions.SUBMIT:
-          if (!this._canSubmitChange()) { return; }
-          this._showActionDialog(this.$.confirmSubmitDialog);
-          break;
-        default:
-          this._fireAction(this._prependSlash(key),
-              this.revisionActions[key], true);
-      }
-    },
+    this._fireAction('/edit', this.actions.deleteEdit, false);
+  }
 
-    _prependSlash(key) {
-      return key === '/' ? key : `/${key}`;
-    },
+  _handleSubmitConfirm() {
+    if (!this._canSubmitChange()) { return; }
+    this._hideAllDialogs();
+    this._fireAction('/submit', this.revisionActions.submit, true);
+  }
 
-    /**
-     * _hasKnownChainState set to true true if hasParent is defined (can be
-     * either true or false). set to false otherwise.
-     */
-    _computeChainState(hasParent) {
-      this._hasKnownChainState = true;
-    },
+  _getActionOverflowIndex(type, key) {
+    return this._overflowActions
+        .findIndex(action => action.type === type && action.key === key);
+  }
 
-    _calculateDisabled(action, hasKnownChainState) {
-      if (action.__key === 'rebase') {
-        // Rebase button is only disabled when change has no parent(s).
-        return hasKnownChainState === false;
-      }
-      return !action.enabled;
-    },
+  _setLoadingOnButtonWithKey(type, key) {
+    this._actionLoadingMessage = this._computeLoadingLabel(key);
+    let buttonKey = key;
+    // TODO(dhruvsri): clean this up later
+    // If key is revert-submission, then button key should be 'revert'
+    if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
+      // Revert submission button no longer exists
+      buttonKey = ChangeActions.REVERT;
+    }
 
-    _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;
-      const payload = {base: e.detail.base};
-      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;
-      if (!el.branch) {
-        this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
-        return;
-      }
-      if (!el.message) {
-        this.fire('show-alert', {message: ERR_COMMIT_EMPTY});
-        return;
-      }
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction(
-          '/cherrypick',
-          this.revisionActions.cherrypick,
-          true,
-          {
-            destination: el.branch,
-            base: el.baseCommit ? el.baseCommit : null,
-            message: el.message,
-            allow_conflicts: conflicts,
-          }
-      );
-    },
-
-    _handleMoveConfirm() {
-      const el = this.$.confirmMove;
-      if (!el.branch) {
-        this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
-        return;
-      }
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction(
-          '/move',
-          this.actions.move,
-          false,
-          {
-            destination_branch: el.branch,
-            message: el.message,
-          }
-      );
-    },
-
-    _handleRevertDialogConfirm() {
-      const el = this.$.confirmRevertDialog;
-      this.$.overlay.close();
-      el.hidden = true;
-      this._fireAction('/revert', this.actions.revert, false,
-          {message: el.message});
-    },
-
-    _handleAbandonDialogConfirm() {
-      const el = this.$.confirmAbandonDialog;
-      this.$.overlay.close();
-      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;
-      });
-    },
-
-    _setLoadingOnButtonWithKey(type, key) {
-      this._actionLoadingMessage = this._computeLoadingLabel(key);
-
-      // If the action appears in the overflow menu.
-      if (this._getActionOverflowIndex(type, key) !== -1) {
-        this.push('_disabledMenuActions', key === '/' ? 'delete' : key);
-        return function() {
-          this._actionLoadingMessage = '';
-          this._disabledMenuActions = [];
-        }.bind(this);
-      }
-
-      // Otherwise it's a top-level action.
-      const buttonEl = this.$$(`[data-action-key="${key}"]`);
-      buttonEl.setAttribute('loading', true);
-      buttonEl.disabled = true;
+    // If the action appears in the overflow menu.
+    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
+      this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
+        buttonKey);
       return function() {
         this._actionLoadingMessage = '';
-        buttonEl.removeAttribute('loading');
-        buttonEl.disabled = false;
+        this._disabledMenuActions = [];
       }.bind(this);
-    },
+    }
 
-    /**
-     * @param {string} endpoint
-     * @param {!Object|undefined} action
-     * @param {boolean} revAction
-     * @param {!Object|string=} opt_payload
-     */
-    _fireAction(endpoint, action, revAction, opt_payload) {
-      const cleanupFn =
-          this._setLoadingOnButtonWithKey(action.__type, action.__key);
+    // Otherwise it's a top-level action.
+    const buttonEl = this.shadowRoot
+        .querySelector(`[data-action-key="${buttonKey}"]`);
+    buttonEl.setAttribute('loading', true);
+    buttonEl.disabled = true;
+    return function() {
+      this._actionLoadingMessage = '';
+      buttonEl.removeAttribute('loading');
+      buttonEl.disabled = false;
+    }.bind(this);
+  }
 
-      this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
-          action).then(this._handleResponse.bind(this, action));
-    },
+  /**
+   * @param {string} endpoint
+   * @param {!Object|undefined} action
+   * @param {boolean} revAction
+   * @param {!Object|string=} opt_payload
+   */
+  _fireAction(endpoint, action, revAction, opt_payload) {
+    const cleanupFn =
+        this._setLoadingOnButtonWithKey(action.__type, action.__key);
 
-    _showActionDialog(dialog) {
-      this._hideAllDialogs();
+    this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
+        action).then(this._handleResponse.bind(this, action));
+  }
 
-      dialog.hidden = false;
-      this.$.overlay.open().then(() => {
-        if (dialog.resetFocus) {
-          dialog.resetFocus();
-        }
-      });
-    },
+  _showActionDialog(dialog) {
+    this._hideAllDialogs();
 
-    // TODO(rmistry): Redo this after
-    // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-    _setLabelValuesOnRevert(newChangeId) {
-      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; }
-      return this.$.restAPI.getResponseObject(response).then(obj => {
-        switch (action.__key) {
-          case ChangeActions.REVERT:
-            this._waitForChangeReachable(obj._number)
-                .then(() => this._setLabelValuesOnRevert(obj._number))
-                .then(() => {
-                  Gerrit.Nav.navigateToChange(obj);
-                });
-            break;
-          case RevisionActions.CHERRYPICK:
-            this._waitForChangeReachable(obj._number).then(() => {
-              Gerrit.Nav.navigateToChange(obj);
-            });
-            break;
-          case ChangeActions.DELETE:
-            if (action.__type === ActionType.CHANGE) {
-              Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getUrlForRoot());
-            }
-            break;
-          case ChangeActions.WIP:
-          case ChangeActions.DELETE_EDIT:
-          case ChangeActions.PUBLISH_EDIT:
-          case ChangeActions.REBASE_EDIT:
-            Gerrit.Nav.navigateToChange(this.change);
-            break;
-          default:
-            this.dispatchEvent(new CustomEvent('reload-change',
-                {detail: {action: action.__key}, bubbles: false}));
-            break;
-        }
-      });
-    },
-
-    _handleResponseError(action, response, body) {
-      if (action && action.__key === RevisionActions.CHERRYPICK) {
-        if (response && response.status === 409 &&
-            body && !body.allow_conflicts) {
-          return this._showActionDialog(
-              this.$.confirmCherrypickConflict);
-        }
+    dialog.hidden = false;
+    this.$.overlay.open().then(() => {
+      if (dialog.resetFocus) {
+        dialog.resetFocus();
       }
-      return response.text().then(errText => {
-        this.fire('show-error',
-            {message: `Could not perform action: ${errText}`});
-        if (!errText.startsWith('Change is already up to date')) {
-          throw Error(errText);
-        }
-      });
-    },
+    });
+  }
 
-    /**
-     * @param {string} method
-     * @param {string|!Object|undefined} payload
-     * @param {string} actionEndpoint
-     * @param {boolean} revisionAction
-     * @param {?Function} cleanupFn
-     * @param {!Object|undefined} action
-     */
-    _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
-      const handleError = response => {
-        cleanupFn.call(this);
-        this._handleResponseError(action, response, payload);
-      };
+  // TODO(rmistry): Redo this after
+  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+  _setLabelValuesOnRevert(newChangeId) {
+    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+    if (!labels) { return Promise.resolve(); }
+    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+  }
 
-      return this.fetchChangeUpdates(this.change, this.$.restAPI)
-          .then(result => {
-            if (!result.isLatest) {
-              this.fire('show-alert', {
+  _handleResponse(action, response) {
+    if (!response) { return; }
+    return this.$.restAPI.getResponseObject(response).then(obj => {
+      switch (action.__key) {
+        case ChangeActions.REVERT:
+          this._waitForChangeReachable(obj._number)
+              .then(() => this._setLabelValuesOnRevert(obj._number))
+              .then(() => {
+                GerritNav.navigateToChange(obj);
+              });
+          break;
+        case RevisionActions.CHERRYPICK:
+          this._waitForChangeReachable(obj._number).then(() => {
+            GerritNav.navigateToChange(obj);
+          });
+          break;
+        case ChangeActions.DELETE:
+          if (action.__type === ActionType.CHANGE) {
+            GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
+          }
+          break;
+        case ChangeActions.WIP:
+        case ChangeActions.DELETE_EDIT:
+        case ChangeActions.PUBLISH_EDIT:
+        case ChangeActions.REBASE_EDIT:
+          GerritNav.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 */
+          GerritNav.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) {
+      if (response && response.status === 409 &&
+          body && !body.allow_conflicts) {
+        return this._showActionDialog(
+            this.$.confirmCherrypickConflict);
+      }
+    }
+    return response.text().then(errText => {
+      this.dispatchEvent(new CustomEvent('show-error', {
+        detail: {message: `Could not perform action: ${errText}`},
+        composed: true, bubbles: true,
+      }));
+      if (!errText.startsWith('Change is already up to date')) {
+        throw Error(errText);
+      }
+    });
+  }
+
+  /**
+   * @param {string} method
+   * @param {string|!Object|undefined} payload
+   * @param {string} actionEndpoint
+   * @param {boolean} revisionAction
+   * @param {?Function} cleanupFn
+   * @param {!Object|undefined} action
+   */
+  _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
+    const handleError = response => {
+      cleanupFn.call(this);
+      this._handleResponseError(action, response, payload);
+    };
+    return this.fetchChangeUpdates(this.change, this.$.restAPI)
+        .then(result => {
+          if (!result.isLatest) {
+            this.dispatchEvent(new CustomEvent('show-alert', {
+              detail: {
                 message: 'Cannot set label: a newer patch has been ' +
-                    'uploaded to this change.',
+                  'uploaded to this change.',
                 action: 'Reload',
                 callback: () => {
-                  // Load the current change without any patch range.
-                  Gerrit.Nav.navigateToChange(this.change);
+                // Load the current change without any patch range.
+                  GerritNav.navigateToChange(this.change);
                 },
+              },
+              composed: true, bubbles: true,
+            }));
+
+            // Because this is not a network error, call the cleanup function
+            // but not the error handler.
+            cleanupFn();
+
+            return Promise.resolve();
+          }
+          const patchNum = revisionAction ? this.latestPatchNum : null;
+          return this.$.restAPI.executeChangeAction(this.changeNum, method,
+              actionEndpoint, patchNum, payload, handleError)
+              .then(response => {
+                cleanupFn.call(this);
+                return response;
               });
-
-              // Because this is not a network error, call the cleanup function
-              // but not the error handler.
-              cleanupFn();
-
-              return Promise.resolve();
-            }
-            const patchNum = revisionAction ? this.latestPatchNum : null;
-            return this.$.restAPI.executeChangeAction(this.changeNum, method,
-                actionEndpoint, patchNum, payload, handleError)
-                .then(response => {
-                  cleanupFn.call(this);
-                  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
-     * values.
-     *
-     * @param {!Array} changeActionsRecord
-     * @param {!Array} revisionActionsRecord
-     * @param {!Array} primariesRecord
-     * @param {!Array} additionalActionsRecord
-     * @param {!Object} change The change object.
-     * @return {!Array}
-     */
-    _computeAllActions(changeActionsRecord, revisionActionsRecord,
-        primariesRecord, additionalActionsRecord, change) {
-      // Polymer 2: check for undefined
-      if ([
-        changeActionsRecord,
-        revisionActionsRecord,
-        primariesRecord,
-        additionalActionsRecord,
-        change,
-      ].some(arg => arg === undefined)) {
-        return [];
-      }
-
-      const revisionActionValues = this._getActionValues(revisionActionsRecord,
-          primariesRecord, additionalActionsRecord, ActionType.REVISION);
-      const changeActionValues = this._getActionValues(changeActionsRecord,
-          primariesRecord, additionalActionsRecord, ActionType.CHANGE);
-      const quickApprove = this._getQuickApproveAction();
-      if (quickApprove) {
-        changeActionValues.unshift(quickApprove);
-      }
-
-      return revisionActionValues
-          .concat(changeActionValues)
-          .sort(this._actionComparator.bind(this))
-          .map(action => {
-            if (ACTIONS_WITH_ICONS.has(action.__key)) {
-              action.icon = action.__key;
-            }
-            return action;
-          });
-    },
-
-    _getActionPriority(action) {
-      if (action.__type && action.__key) {
-        const overrideAction = this._actionPriorityOverrides.find(i => {
-          return i.type === action.__type && i.key === action.__key;
         });
+  }
 
-        if (overrideAction !== undefined) {
-          return overrideAction.priority;
-        }
+  _handleAbandonTap() {
+    this._showActionDialog(this.$.confirmAbandonDialog);
+  }
+
+  _handleCherrypickTap() {
+    this.$.confirmCherrypick.branch = '';
+    const query = `topic: "${this.change.topic}"`;
+    const options =
+      this.listChangesOptionsToHex(this.ListChangesOption.MESSAGES,
+          this.ListChangesOption.ALL_REVISIONS);
+    this.$.restAPI.getChanges('', query, undefined, options)
+        .then(changes => {
+          this.$.confirmCherrypick.updateChanges(changes);
+          this._showActionDialog(this.$.confirmCherrypick);
+        });
+  }
+
+  _handleMoveTap() {
+    this.$.confirmMove.branch = '';
+    this.$.confirmMove.message = '';
+    this._showActionDialog(this.$.confirmMove);
+  }
+
+  _handleDownloadTap() {
+    this.dispatchEvent(new CustomEvent('download-tap', {
+      composed: true, 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
+   * values.
+   *
+   * @param {!Array} changeActionsRecord
+   * @param {!Array} revisionActionsRecord
+   * @param {!Array} primariesRecord
+   * @param {!Array} additionalActionsRecord
+   * @param {!Object} change The change object.
+   * @return {!Array}
+   */
+  _computeAllActions(changeActionsRecord, revisionActionsRecord,
+      primariesRecord, additionalActionsRecord, change) {
+    // Polymer 2: check for undefined
+    if ([
+      changeActionsRecord,
+      revisionActionsRecord,
+      primariesRecord,
+      additionalActionsRecord,
+      change,
+    ].some(arg => arg === undefined)) {
+      return [];
+    }
+
+    const revisionActionValues = this._getActionValues(revisionActionsRecord,
+        primariesRecord, additionalActionsRecord, ActionType.REVISION);
+    const changeActionValues = this._getActionValues(changeActionsRecord,
+        primariesRecord, additionalActionsRecord, ActionType.CHANGE);
+    const quickApprove = this._getQuickApproveAction();
+    if (quickApprove) {
+      changeActionValues.unshift(quickApprove);
+    }
+
+    return revisionActionValues
+        .concat(changeActionValues)
+        .sort(this._actionComparator.bind(this))
+        .map(action => {
+          if (ACTIONS_WITH_ICONS.has(action.__key)) {
+            action.icon = action.__key;
+          }
+          return action;
+        })
+        .filter(action => !this._shouldSkipAction(action));
+  }
+
+  _getActionPriority(action) {
+    if (action.__type && action.__key) {
+      const overrideAction = this._actionPriorityOverrides
+          .find(i => i.type === action.__type && i.key === action.__key);
+
+      if (overrideAction !== undefined) {
+        return overrideAction.priority;
       }
-      if (action.__key === 'review') {
-        return ActionPriority.REVIEW;
-      } else if (action.__primary) {
-        return ActionPriority.PRIMARY;
-      } else if (action.__type === ActionType.CHANGE) {
-        return ActionPriority.CHANGE;
-      } else if (action.__type === ActionType.REVISION) {
-        return ActionPriority.REVISION;
-      }
-      return ActionPriority.DEFAULT;
-    },
+    }
+    if (action.__key === 'review') {
+      return ActionPriority.REVIEW;
+    } else if (action.__primary) {
+      return ActionPriority.PRIMARY;
+    } else if (action.__type === ActionType.CHANGE) {
+      return ActionPriority.CHANGE;
+    } else if (action.__type === ActionType.REVISION) {
+      return ActionPriority.REVISION;
+    }
+    return ActionPriority.DEFAULT;
+  }
 
-    /**
-     * Sort comparator to define the order of change actions.
-     */
-    _actionComparator(actionA, actionB) {
-      const priorityDelta = this._getActionPriority(actionA) -
-          this._getActionPriority(actionB);
-      // Sort by the button label if same priority.
-      if (priorityDelta === 0) {
-        return actionA.label > actionB.label ? 1 : -1;
-      } else {
-        return priorityDelta;
-      }
-    },
+  /**
+   * Sort comparator to define the order of change actions.
+   */
+  _actionComparator(actionA, actionB) {
+    const priorityDelta = this._getActionPriority(actionA) -
+        this._getActionPriority(actionB);
+    // Sort by the button label if same priority.
+    if (priorityDelta === 0) {
+      return actionA.label > actionB.label ? 1 : -1;
+    } else {
+      return priorityDelta;
+    }
+  }
 
-    _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
-      const hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base.filter(a => {
-        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-        return !(overflow || hiddenActions.includes(a.__key));
-      });
-    },
+  _shouldSkipAction(action) {
+    return SKIP_ACTION_KEYS.includes(action.__key);
+  }
 
-    _filterPrimaryActions(_topLevelActions) {
-      this._topLevelPrimaryActions = _topLevelActions.filter(action =>
-        action.__primary);
-      this._topLevelSecondaryActions = _topLevelActions.filter(action =>
-        !action.__primary);
-    },
+  _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base.filter(a => {
+      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+      return !(overflow || hiddenActions.includes(a.__key));
+    });
+  }
 
-    _computeMenuActions(actionRecord, hiddenActionsRecord) {
-      const hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base.filter(a => {
-        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-        return overflow && !hiddenActions.includes(a.__key);
-      }).map(action => {
-        let key = action.__key;
-        if (key === '/') { key = 'delete'; }
-        return {
-          name: action.label,
-          id: `${key}-${action.__type}`,
-          action,
-          tooltip: action.title,
-        };
-      });
-    },
+  _filterPrimaryActions(_topLevelActions) {
+    this._topLevelPrimaryActions = _topLevelActions.filter(action =>
+      action.__primary);
+    this._topLevelSecondaryActions = _topLevelActions.filter(action =>
+      !action.__primary);
+  }
 
-    _computeRebaseOnCurrent(revisionRebaseAction) {
-      if (revisionRebaseAction) {
-        return !!revisionRebaseAction.enabled;
-      }
-      return null;
-    },
+  _computeMenuActions(actionRecord, hiddenActionsRecord) {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base.filter(a => {
+      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+      return overflow && !hiddenActions.includes(a.__key);
+    }).map(action => {
+      let key = action.__key;
+      if (key === '/') { key = 'delete'; }
+      return {
+        name: action.label,
+        id: `${key}-${action.__type}`,
+        action,
+        tooltip: action.title,
+      };
+    });
+  }
 
-    /**
-     * Occasionally, a change created by a change action is not yet knwon to the
-     * API for a brief time. Wait for the given change number to be recognized.
-     *
-     * Returns a promise that resolves with true if a request is recognized, or
-     * false if the change was never recognized after all attempts.
-     *
-     * @param  {number} changeNum
-     * @return {Promise<boolean>}
-     */
-    _waitForChangeReachable(changeNum) {
-      let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
-      return new Promise(resolve => {
-        const check = () => {
-          attempsRemaining--;
-          // Pass a no-op error handler to avoid the "not found" error toast.
-          this.$.restAPI.getChange(changeNum, () => {}).then(response => {
-            // If the response is 404, the response will be undefined.
-            if (response) {
-              resolve(true);
-              return;
-            }
+  _computeRebaseOnCurrent(revisionRebaseAction) {
+    if (revisionRebaseAction) {
+      return !!revisionRebaseAction.enabled;
+    }
+    return null;
+  }
 
-            if (attempsRemaining) {
-              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
-            } else {
-              resolve(false);
-            }
-          });
-        };
-        check();
-      });
-    },
+  /**
+   * Occasionally, a change created by a change action is not yet knwon to the
+   * API for a brief time. Wait for the given change number to be recognized.
+   *
+   * Returns a promise that resolves with true if a request is recognized, or
+   * false if the change was never recognized after all attempts.
+   *
+   * @param  {number} changeNum
+   * @return {Promise<boolean>}
+   */
+  _waitForChangeReachable(changeNum) {
+    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    return new Promise(resolve => {
+      const check = () => {
+        attempsRemaining--;
+        // Pass a no-op error handler to avoid the "not found" error toast.
+        this.$.restAPI.getChange(changeNum, () => {}).then(response => {
+          // If the response is 404, the response will be undefined.
+          if (response) {
+            resolve(true);
+            return;
+          }
 
-    _handleEditTap() {
-      this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
-    },
+          if (attempsRemaining) {
+            this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+          } else {
+            resolve(false);
+          }
+        });
+      };
+      check();
+    });
+  }
 
-    _handleStopEditTap() {
-      this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
-    },
+  _handleEditTap() {
+    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+  }
 
-    _computeHasTooltip(title) {
-      return !!title;
-    },
+  _handleStopEditTap() {
+    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+  }
 
-    _computeHasIcon(action) {
-      return action.icon ? '' : 'hidden';
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
new file mode 100644
index 0000000..f12e600
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
@@ -0,0 +1,275 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: flex;
+      font-family: var(--font-family);
+    }
+    #actionLoadingMessage,
+    #mainContent,
+    section {
+      display: flex;
+    }
+    #actionLoadingMessage,
+    gr-button,
+    gr-dropdown {
+      /* px because don't have the same font size */
+      margin-left: 8px;
+    }
+    #actionLoadingMessage {
+      align-items: center;
+      color: var(--deemphasized-text-color);
+    }
+    #confirmSubmitDialog .changeSubject {
+      margin: var(--spacing-l);
+      text-align: center;
+    }
+    iron-icon {
+      color: inherit;
+      margin-right: var(--spacing-xs);
+    }
+    #moreActions iron-icon {
+      margin: 0;
+    }
+    #moreMessage,
+    .hidden {
+      display: none;
+    }
+    @media screen and (max-width: 50em) {
+      #mainContent {
+        flex-wrap: wrap;
+      }
+      gr-button {
+        --gr-button: {
+          padding: var(--spacing-m);
+          white-space: nowrap;
+        }
+      }
+      gr-button,
+      gr-dropdown {
+        margin: 0;
+      }
+      #actionLoadingMessage {
+        margin: var(--spacing-m);
+        text-align: center;
+      }
+      #moreMessage {
+        display: inline;
+      }
+    }
+  </style>
+  <div id="mainContent">
+    <span id="actionLoadingMessage" hidden$="[[!_actionLoadingMessage]]">
+      [[_actionLoadingMessage]]</span
+    >
+    <section
+      id="primaryActions"
+      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
+    >
+      <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
+        <gr-button
+          link=""
+          title$="[[action.title]]"
+          has-tooltip="[[_computeHasTooltip(action.title)]]"
+          position-below="true"
+          data-action-key$="[[action.__key]]"
+          data-action-type$="[[action.__type]]"
+          data-label$="[[action.label]]"
+          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+          on-click="_handleActionTap"
+        >
+          <iron-icon
+            class$="[[_computeHasIcon(action)]]"
+            icon$="gr-icons:[[action.icon]]"
+          ></iron-icon>
+          [[action.label]]
+        </gr-button>
+      </template>
+    </section>
+    <section
+      id="secondaryActions"
+      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
+    >
+      <template
+        is="dom-repeat"
+        items="[[_topLevelSecondaryActions]]"
+        as="action"
+      >
+        <gr-button
+          link=""
+          title$="[[action.title]]"
+          has-tooltip="[[_computeHasTooltip(action.title)]]"
+          position-below="true"
+          data-action-key$="[[action.__key]]"
+          data-action-type$="[[action.__type]]"
+          data-label$="[[action.label]]"
+          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+          on-click="_handleActionTap"
+        >
+          <iron-icon
+            class$="[[_computeHasIcon(action)]]"
+            icon$="gr-icons:[[action.icon]]"
+          ></iron-icon>
+          [[action.label]]
+        </gr-button>
+      </template>
+    </section>
+    <gr-button hidden$="[[!_loading]]" disabled=""
+      >Loading actions...</gr-button
+    >
+    <gr-dropdown
+      id="moreActions"
+      link=""
+      tabindex="0"
+      vertical-offset="32"
+      horizontal-align="right"
+      on-tap-item="_handleOveflowItemTap"
+      hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
+      disabled-ids="[[_disabledMenuActions]]"
+      items="[[_menuActions]]"
+    >
+      <iron-icon icon="gr-icons:more-vert"></iron-icon>
+      <span id="moreMessage">More</span>
+    </gr-dropdown>
+  </div>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-confirm-rebase-dialog
+      id="confirmRebase"
+      class="confirmDialog"
+      change-number="[[change._number]]"
+      on-confirm="_handleRebaseConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      branch="[[change.branch]]"
+      has-parent="[[hasParent]]"
+      rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
+      hidden=""
+    ></gr-confirm-rebase-dialog>
+    <gr-confirm-cherrypick-dialog
+      id="confirmCherrypick"
+      class="confirmDialog"
+      change-status="[[changeStatus]]"
+      commit-message="[[commitMessage]]"
+      commit-num="[[commitNum]]"
+      on-confirm="_handleCherrypickConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      project="[[change.project]]"
+      hidden=""
+    ></gr-confirm-cherrypick-dialog>
+    <gr-confirm-cherrypick-conflict-dialog
+      id="confirmCherrypickConflict"
+      class="confirmDialog"
+      on-confirm="_handleCherrypickConflictConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      hidden=""
+    ></gr-confirm-cherrypick-conflict-dialog>
+    <gr-confirm-move-dialog
+      id="confirmMove"
+      class="confirmDialog"
+      on-confirm="_handleMoveConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      project="[[change.project]]"
+      hidden=""
+    ></gr-confirm-move-dialog>
+    <gr-confirm-revert-dialog
+      id="confirmRevertDialog"
+      class="confirmDialog"
+      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"
+      on-cancel="_handleConfirmDialogCancel"
+      hidden=""
+    ></gr-confirm-abandon-dialog>
+    <gr-confirm-submit-dialog
+      id="confirmSubmitDialog"
+      class="confirmDialog"
+      change="[[change]]"
+      action="[[_revisionSubmitAction]]"
+      on-cancel="_handleConfirmDialogCancel"
+      on-confirm="_handleSubmitConfirm"
+      hidden=""
+    ></gr-confirm-submit-dialog>
+    <gr-dialog
+      id="createFollowUpDialog"
+      class="confirmDialog"
+      confirm-label="Create"
+      on-confirm="_handleCreateFollowUpChange"
+      on-cancel="_handleCloseCreateFollowUpChange"
+    >
+      <div class="header" slot="header">
+        Create Follow-Up Change
+      </div>
+      <div class="main" slot="main">
+        <gr-create-change-dialog
+          id="createFollowUpChange"
+          branch="[[change.branch]]"
+          base-change="[[change.id]]"
+          repo-name="[[change.project]]"
+          private-by-default="[[privateByDefault]]"
+        ></gr-create-change-dialog>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="confirmDeleteDialog"
+      class="confirmDialog"
+      confirm-label="Delete"
+      confirm-on-enter=""
+      on-cancel="_handleConfirmDialogCancel"
+      on-confirm="_handleDeleteConfirm"
+    >
+      <div class="header" slot="header">
+        Delete Change
+      </div>
+      <div class="main" slot="main">
+        Do you really want to delete the change?
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="confirmDeleteEditDialog"
+      class="confirmDialog"
+      confirm-label="Delete"
+      confirm-on-enter=""
+      on-cancel="_handleConfirmDialogCancel"
+      on-confirm="_handleDeleteEditConfirm"
+    >
+      <div class="header" slot="header">
+        Delete Change Edit
+      </div>
+      <div class="main" slot="main">
+        Do you really want to delete the edit?
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-reporting id="reporting" category="change-actions"></gr-reporting>
+`;
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 74d262a..acf17ea 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-change-actions.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,11 +31,22 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-actions tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-actions.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
+suite('gr-change-actions tests', () => {
+  let element;
+  let sandbox;
 
+  suite('basic tests', () => {
     setup(() => {
       stub('gr-rest-api-interface', {
         getChangeRevisionActions() {
@@ -63,6 +69,12 @@
               title: 'Submit patch set 2 into master',
               enabled: true,
             },
+            revert_submission: {
+              method: 'POST',
+              label: 'Revert submission',
+              title: 'Revert this submission',
+              enabled: true,
+            },
           });
         },
         send(method, url, payload) {
@@ -88,7 +100,8 @@
       });
 
       sandbox = sinon.sandbox.create();
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+      sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
+          .returns(Promise.resolve());
 
       element = fixture('basic');
       element.change = {};
@@ -131,6 +144,11 @@
           element._topLevelActions.length - 1);
     });
 
+    test('revert submission action is skipped', () => {
+      assert.isFalse(element._allActionValues.includes(action =>
+        action.key === 'revert_submission'));
+    });
+
     test('_shouldHideActions', () => {
       assert.isTrue(element._shouldHideActions(undefined, true));
       assert.isTrue(element._shouldHideActions({base: {}}, false));
@@ -186,7 +204,8 @@
 
     test('hide revision action', done => {
       flush(() => {
-        const buttonEl = element.$$('[data-action-key="submit"]');
+        const buttonEl = element.shadowRoot
+            .querySelector('[data-action-key="submit"]');
         assert.isOk(buttonEl);
         assert.throws(element.setActionHidden.bind(element, 'invalid type'));
         element.setActionHidden(element.ActionType.REVISION,
@@ -196,13 +215,15 @@
             element.RevisionActions.SUBMIT, true);
         assert.lengthOf(element._hiddenActions, 1);
         flush(() => {
-          const buttonEl = element.$$('[data-action-key="submit"]');
+          const buttonEl = element.shadowRoot
+              .querySelector('[data-action-key="submit"]');
           assert.isNotOk(buttonEl);
 
           element.setActionHidden(element.ActionType.REVISION,
               element.RevisionActions.SUBMIT, false);
           flush(() => {
-            const buttonEl = element.$$('[data-action-key="submit"]');
+            const buttonEl = element.shadowRoot
+                .querySelector('[data-action-key="submit"]');
             assert.isOk(buttonEl);
             assert.isFalse(buttonEl.hasAttribute('hidden'));
             done();
@@ -214,7 +235,7 @@
     test('buttons exist', done => {
       element._loading = false;
       flush(() => {
-        const buttonEls = Polymer.dom(element.root)
+        const buttonEls = dom(element.root)
             .querySelectorAll('gr-button');
         const menuItems = element.$.moreActions.items;
 
@@ -229,9 +250,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');
@@ -270,7 +290,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: {
@@ -280,7 +300,8 @@
       };
       element.latestPatchNum = '2';
 
-      const submitButton = element.$$('gr-button[data-action-key="submit"]');
+      const submitButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="submit"]');
       assert.ok(submitButton);
       MockInteractions.tap(submitButton);
 
@@ -293,7 +314,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: {
@@ -304,7 +325,8 @@
       element.latestPatchNum = '2';
 
       const submitIcon =
-          element.$$('gr-button[data-action-key="submit"] iron-icon');
+          element.shadowRoot
+              .querySelector('gr-button[data-action-key="submit"] iron-icon');
       assert.ok(submitIcon);
       MockInteractions.tap(submitIcon);
     });
@@ -327,10 +349,11 @@
 
     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"]');
+        const submitButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
         MockInteractions.tap(submitButton);
         assert.equal(fireActionStub.callCount, 0);
@@ -372,7 +395,8 @@
           'fetchRecentChanges').returns(Promise.resolve([]));
       element._hasKnownChainState = true;
       flush(() => {
-        const rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+        const rebaseButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebase"]');
         MockInteractions.tap(rebaseButton);
         const rebaseAction = {
           __key: 'rebase',
@@ -395,12 +419,16 @@
       const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
           'fetchRecentChanges').returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      const rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+      const rebaseButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="rebase"]');
       MockInteractions.tap(rebaseButton);
       assert.isTrue(fetchChangesStub.calledOnce);
 
       flush(() => {
-        element.$.confirmRebase.fire('cancel');
+        element.$.confirmRebase.dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
         MockInteractions.tap(rebaseButton);
         assert.isTrue(fetchChangesStub.calledTwice);
         done();
@@ -410,30 +438,39 @@
     test('two dialogs are not shown at the same time', done => {
       element._hasKnownChainState = true;
       flush(() => {
-        const rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+        const rebaseButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebase"]');
         assert.ok(rebaseButton);
         MockInteractions.tap(rebaseButton);
         flushAsynchronousOperations();
         assert.isFalse(element.$.confirmRebase.hidden);
-
+        sandbox.stub(element.$.restAPI, 'getChanges')
+            .returns(Promise.resolve([]));
         element._handleCherrypickTap();
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.confirmRebase.hidden);
-        assert.isFalse(element.$.confirmCherrypick.hidden);
-        done();
+        flush(() => {
+          assert.isTrue(element.$.confirmRebase.hidden);
+          assert.isFalse(element.$.confirmCherrypick.hidden);
+          done();
+        });
       });
     });
 
     test('fullscreen-overlay-opened hides content', () => {
       sandbox.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.fire('fullscreen-overlay-opened');
+      element.$.overlay.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true, bubbles: true,
+          }));
       assert.isTrue(element._handleHideBackgroundContent.called);
       assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
     test('fullscreen-overlay-closed shows content', () => {
       sandbox.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.fire('fullscreen-overlay-closed');
+      element.$.overlay.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-closed', {
+            composed: true, bubbles: true,
+          }));
       assert.isTrue(element._handleShowBackgroundContent.called);
       assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
@@ -459,11 +496,16 @@
         element.set('disableEdit', true);
         flushAsynchronousOperations();
 
-        assert.isNotOk(element.$$('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('shows confirm dialog for delete edit', () => {
@@ -474,7 +516,10 @@
         element._handleDeleteEditTap();
         assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
         MockInteractions.tap(
-            element.$$('#confirmDeleteEditDialog').$$('gr-button[primary]'));
+            element.shadowRoot
+                .querySelector('#confirmDeleteEditDialog')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
@@ -486,10 +531,14 @@
         element.change = {status: 'MERGED'};
         flushAsynchronousOperations();
 
-        assert.isNotOk(element.$$('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
       });
 
       test('edit patchset is loaded, needs rebase', () => {
@@ -499,11 +548,16 @@
         element.editBasedOnCurrentPatchSet = false;
         flushAsynchronousOperations();
 
-        assert.isNotOk(element.$$('gr-button[data-action-key="publishEdit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('edit patchset is loaded, does not need rebase', () => {
@@ -513,11 +567,16 @@
         element.editBasedOnCurrentPatchSet = true;
         flushAsynchronousOperations();
 
-        assert.isOk(element.$$('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('edit mode is loaded, no edit patchset', () => {
@@ -526,11 +585,16 @@
         element.change = {status: 'NEW'};
         flushAsynchronousOperations();
 
-        assert.isNotOk(element.$$('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('normal patch set', () => {
@@ -539,11 +603,16 @@
         element.change = {status: 'NEW'};
         flushAsynchronousOperations();
 
-        assert.isNotOk(element.$$('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('edit action', done => {
@@ -552,17 +621,21 @@
         element.change = {status: 'NEW'};
         flushAsynchronousOperations();
 
-        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="stopEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
         element.change = {status: 'MERGED'};
         flushAsynchronousOperations();
 
-        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
         element.change = {status: 'NEW'};
         element.set('editMode', false);
         flushAsynchronousOperations();
 
-        const editButton = element.$$('gr-button[data-action-key="edit"]');
+        const editButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]');
         assert.isOk(editButton);
         MockInteractions.tap(editButton);
       });
@@ -588,11 +661,21 @@
           title: 'Cherry pick change to a different branch',
         };
 
-        element._handleCherrypickConfirm();
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: '',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
         assert.equal(fireActionStub.callCount, 0);
 
         element.$.confirmCherrypick.branch = 'master';
-        element._handleCherrypickConfirm();
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: 'master',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
         assert.equal(fireActionStub.callCount, 0); // Still needs a message.
 
         // Add attributes that are used to determine the message.
@@ -600,10 +683,15 @@
         element.$.confirmCherrypick.changeStatus = 'OPEN';
         element.$.confirmCherrypick.commitNum = '123';
 
-        element._handleCherrypickConfirm();
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: 'master',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
 
-        assert.equal(element.$.confirmCherrypick.$.messageInput.value,
-            'foo message');
+        assert.equal(element.$.confirmCherrypick.shadowRoot.
+            querySelector('#messageInput').value, 'foo message');
 
         assert.deepEqual(fireActionStub.lastCall.args, [
           '/cherrypick', action, true, {
@@ -653,6 +741,78 @@
         element._handleCherrypickTap();
         assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
       });
+
+      suite('cherry pick topics', () => {
+        const changes = [
+          {
+            change_id: '12345678901234', topic: 'T', subject: 'random',
+            project: 'A',
+          },
+          {
+            change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
+            project: 'B',
+          },
+        ];
+        setup(done => {
+          sandbox.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve(changes));
+          element._handleCherrypickTap();
+          flush(() => {
+            const radioButtons = element.$.confirmCherrypick.shadowRoot.
+                querySelectorAll(`input[name='cherryPickOptions']`);
+            assert.equal(radioButtons.length, 2);
+            MockInteractions.tap(radioButtons[1]);
+            flush(() => {
+              done();
+            });
+          });
+        });
+
+        test('cherry pick topic dialog is rendered', done => {
+          const dialog = element.$.confirmCherrypick;
+          flush(() => {
+            const changesTable = dialog.shadowRoot.querySelector('table');
+            const headers = Array.from(changesTable.querySelectorAll('th'));
+            const expectedHeadings = ['Change', 'Subject', 'Project',
+              'Status', ''];
+            const headings = headers.map(header => header.innerText);
+            assert.equal(headings.length, expectedHeadings.length);
+            for (let i = 0; i < headings.length; i++) {
+              assert.equal(headings[i].trim(), expectedHeadings[i]);
+            }
+            const changeRows = changesTable.querySelectorAll('tbody > tr');
+            const change = Array.from(changeRows[0].querySelectorAll('td'))
+                .map(e => e.innerText);
+            const expectedChange = ['1234567890', 'random', 'A',
+              'NOT STARTED', ''];
+            for (let i = 0; i < change.length; i++) {
+              assert.equal(change[i].trim(), expectedChange[i]);
+            }
+            done();
+          });
+        });
+
+        test('changes with duplicate project show an error', done => {
+          const dialog = element.$.confirmCherrypick;
+          const error = dialog.shadowRoot.querySelector('.error-message');
+          assert.equal(error.innerText, '');
+          dialog.updateChanges([
+            {
+              change_id: '12345678901234', topic: 'T', subject: 'random',
+              project: 'A',
+            },
+            {
+              change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
+              project: 'A',
+            },
+          ]);
+          flush(() => {
+            assert.equal(error.innerText, 'Two changes cannot be of the same'
+             + ' project');
+            done();
+          });
+        });
+      });
     });
 
     suite('move change', () => {
@@ -691,12 +851,14 @@
         assert.equal(e.detail.node.getAttribute('data-action-key'), key);
         element.removeActionButton(key);
         flush(() => {
-          assert.notOk(element.$$('[data-action-key="' + key + '"]'));
+          assert.notOk(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
           done();
         });
       });
       flush(() => {
-        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
       });
     });
 
@@ -706,7 +868,8 @@
       const cleanup = element._setLoadingOnButtonWithKey(type, key);
       assert.equal(element._actionLoadingMessage, 'Rebasing...');
 
-      const button = element.$$('[data-action-key="' + key + '"]');
+      const button = element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]');
       assert.isTrue(button.hasAttribute('loading'));
       assert.isTrue(button.disabled);
 
@@ -756,7 +919,8 @@
         element.$.confirmAbandonDialog.message = newAbandonMsg;
         flush(() => {
           const abandonButton =
-              element.$$('gr-button[data-action-key="abandon"]');
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key="abandon"]');
           MockInteractions.tap(abandonButton);
 
           assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
@@ -767,7 +931,8 @@
       test('abandon change with no message', done => {
         flush(() => {
           const abandonButton =
-              element.$$('gr-button[data-action-key="abandon"]');
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key="abandon"]');
           MockInteractions.tap(abandonButton);
 
           assert.isUndefined(element.$.confirmAbandonDialog.message);
@@ -778,7 +943,8 @@
       test('works', () => {
         element.$.confirmAbandonDialog.message = 'original message';
         const restoreButton =
-            element.$$('gr-button[data-action-key="abandon"]');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key="abandon"]');
         MockInteractions.tap(restoreButton);
 
         element.$.confirmAbandonDialog.message = 'foo message';
@@ -802,12 +968,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',
@@ -820,50 +986,200 @@
       });
 
       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', () => {
+        let getChangesStub;
+        setup(() => {
+          element.change = {
+            submission_id: '199 0',
+            current_revision: '2000',
+          };
+          getChangesStub = 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(() => {
+            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const revertSingleChangeLabel = confirmRevertDialog
+                .shadowRoot.querySelector('.revertSingleChange');
+            const revertSubmissionLabel = confirmRevertDialog.
+                shadowRoot.querySelector('.revertSubmission');
+            assert(revertSingleChangeLabel.innerText.trim() ===
+                'Revert single change');
+            assert(revertSubmissionLabel.innerText.trim() ===
+                'Revert entire submission (2 Changes)');
+            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+              'Reverted Changes:' + '\n' +
+              '1234567890:random' + '\n' +
+              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            assert.equal(confirmRevertDialog._message, expectedMsg);
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
+               + 'commit 2000.\n\nReason'
+               + ' for revert: <INSERT REASONING HERE>\n';
+              assert.equal(confirmRevertDialog._message, expectedMsg);
+              done();
+            });
+          });
+        });
 
-        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, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('message modification is retained on switching', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            const revertSubmissionMsg = 'Revert submission 199 0' + '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+            'Reverted Changes:' + '\n' +
+            '1234567890:random' + '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+            const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts '
+              + 'commit 2000.\n\nReason'
+              + ' for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+            const newRevertMsg = revertSubmissionMsg + 'random';
+            const newSingleChangeMsg = singleChangeMsg + 'random';
+            confirmRevertDialog._message = newRevertMsg;
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              assert.equal(confirmRevertDialog._message, singleChangeMsg);
+              confirmRevertDialog._message = newSingleChangeMsg;
+              MockInteractions.tap(radioInputs[1]);
+              flush(() => {
+                assert.equal(confirmRevertDialog._message, newRevertMsg);
+                MockInteractions.tap(radioInputs[0]);
+                flush(() => {
+                  assert.equal(
+                      confirmRevertDialog._message,
+                      newSingleChangeMsg
+                  );
+                  done();
+                });
+              });
+            });
+          });
+        });
+      });
+
+      suite('revert single change', () => {
+        setup(() => {
+          element.change = {
+            submission_id: '199',
+            current_revision: '2000',
+          };
+          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, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('confirm revert dialog shows no radio button', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            assert.equal(radioInputs.length, 0);
+            const msg = 'Revert "random commit message"\n\n'
+              + 'This reverts commit 2000.\n\nReason '
+              + 'for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, msg);
+            const editedMsg = msg + 'hello';
+            confirmRevertDialog._message += 'hello';
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+              assert.equal(fireActionStub.getCall(0).args[3].message,
+                  editedMsg);
+              done();
+            });
+          });
+        });
       });
     });
 
@@ -894,7 +1210,8 @@
       test('make sure the mark private change button is not outside of the ' +
            'overflow menu', done => {
         flush(() => {
-          assert.isNotOk(element.$$('[data-action-key="private"]'));
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
           done();
         });
       });
@@ -902,12 +1219,15 @@
       test('private change', done => {
         flush(() => {
           assert.isOk(
-              element.$.moreActions.$$('span[data-id="private-change"]'));
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
           element.setActionOverflow('change', 'private', false);
           flushAsynchronousOperations();
-          assert.isOk(element.$$('[data-action-key="private"]'));
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
           assert.isNotOk(
-              element.$.moreActions.$$('span[data-id="private-change"]'));
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
           done();
         });
       });
@@ -940,7 +1260,8 @@
       test('make sure the unmark private change button is not outside of the ' +
            'overflow menu', done => {
         flush(() => {
-          assert.isNotOk(element.$$('[data-action-key="private.delete"]'));
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
           done();
         });
       });
@@ -948,13 +1269,16 @@
       test('unmark the private change', done => {
         flush(() => {
           assert.isOk(
-              element.$.moreActions.$$('span[data-id="private.delete-change"]')
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
           );
           element.setActionOverflow('change', 'private.delete', false);
           flushAsynchronousOperations();
-          assert.isOk(element.$$('[data-action-key="private.delete"]'));
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
           assert.isNotOk(
-              element.$.moreActions.$$('span[data-id="private.delete-change"]')
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
           );
           done();
         });
@@ -988,9 +1312,13 @@
 
       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')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
@@ -998,9 +1326,13 @@
       test('hides delete confirm on cancel', () => {
         element._handleDeleteTap();
         MockInteractions.tap(
-            element.$$('#confirmDeleteDialog').$$('gr-button:not([primary])'));
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .shadowRoot
+                .querySelector('gr-button:not([primary])'));
         flushAsynchronousOperations();
-        assert.isTrue(element.$$('#confirmDeleteDialog').hidden);
+        assert.isTrue(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
         assert.isFalse(fireActionStub.called);
       });
     });
@@ -1031,16 +1363,20 @@
 
       test('make sure the ignore button is not outside of the overflow menu',
           () => {
-            assert.isNotOk(element.$$('[data-action-key="ignore"]'));
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="ignore"]'));
           });
 
       test('ignoring change', () => {
-        assert.isOk(element.$.moreActions.$$('span[data-id="ignore-change"]'));
+        assert.isOk(element.$.moreActions.shadowRoot
+            .querySelector('span[data-id="ignore-change"]'));
         element.setActionOverflow('change', 'ignore', false);
         flushAsynchronousOperations();
-        assert.isOk(element.$$('[data-action-key="ignore"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="ignore"]'));
         assert.isNotOk(
-            element.$.moreActions.$$('span[data-id="ignore-change"]'));
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="ignore-change"]'));
       });
     });
 
@@ -1068,19 +1404,22 @@
         element.reload().then(() => { flush(done); });
       });
 
-
       test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(element.$$('[data-action-key="unignore"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
       });
 
       test('unignoring change', () => {
         assert.isOk(
-            element.$.moreActions.$$('span[data-id="unignore-change"]'));
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
         element.setActionOverflow('change', 'unignore', false);
         flushAsynchronousOperations();
-        assert.isOk(element.$$('[data-action-key="unignore"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
         assert.isNotOk(
-            element.$.moreActions.$$('span[data-id="unignore-change"]'));
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
       });
     });
 
@@ -1110,17 +1449,21 @@
 
       test('make sure the reviewed button is not outside of the overflow menu',
           () => {
-            assert.isNotOk(element.$$('[data-action-key="reviewed"]'));
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="reviewed"]'));
           });
 
       test('reviewing change', () => {
         assert.isOk(
-            element.$.moreActions.$$('span[data-id="reviewed-change"]'));
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
         element.setActionOverflow('change', 'reviewed', false);
         flushAsynchronousOperations();
-        assert.isOk(element.$$('[data-action-key="reviewed"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="reviewed"]'));
         assert.isNotOk(
-            element.$.moreActions.$$('span[data-id="reviewed-change"]'));
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
       });
     });
 
@@ -1148,19 +1491,22 @@
         element.reload().then(() => { flush(done); });
       });
 
-
       test('unreviewed button not outside of the overflow menu', () => {
-        assert.isNotOk(element.$$('[data-action-key="unreviewed"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
       });
 
       test('unreviewed change', () => {
         assert.isOk(
-            element.$.moreActions.$$('span[data-id="unreviewed-change"]'));
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
         element.setActionOverflow('change', 'unreviewed', false);
         flushAsynchronousOperations();
-        assert.isOk(element.$$('[data-action-key="unreviewed"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
         assert.isNotOk(
-            element.$.moreActions.$$('span[data-id="unreviewed-change"]'));
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
       });
     });
 
@@ -1189,13 +1535,15 @@
 
       test('added when can approve', () => {
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.isNotNull(approveButton);
       });
 
       test('hide quick approve', () => {
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.isNotNull(approveButton);
         assert.isFalse(element._hideQuickApproveAction);
 
@@ -1203,7 +1551,8 @@
         element.hideQuickApproveAction();
         flushAsynchronousOperations();
         const approveButtonUpdated =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButtonUpdated);
         assert.isTrue(element._hideQuickApproveAction);
       });
@@ -1229,7 +1578,8 @@
         };
         flushAsynchronousOperations();
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
@@ -1245,14 +1595,16 @@
         };
         flushAsynchronousOperations();
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
       test('approves when tapped', () => {
         const fireActionStub = sandbox.stub(element, '_fireAction');
         MockInteractions.tap(
-            element.$$('gr-button[data-action-key=\'review\']'));
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']'));
         flushAsynchronousOperations();
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
@@ -1274,7 +1626,8 @@
         };
         flushAsynchronousOperations();
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
@@ -1297,7 +1650,8 @@
         };
         flushAsynchronousOperations();
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
@@ -1320,7 +1674,8 @@
         };
         flushAsynchronousOperations();
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
@@ -1343,7 +1698,8 @@
         };
         flushAsynchronousOperations();
         const approveButton =
-            element.$$('gr-button[data-action-key=\'review\']');
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
     });
@@ -1376,21 +1732,25 @@
 
     suite('setActionOverflow', () => {
       test('move action from overflow', () => {
-        assert.isNotOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
         assert.strictEqual(
             element.$.moreActions.items[0].id, 'cherrypick-revision');
         element.setActionOverflow('revision', 'cherrypick', false);
         flushAsynchronousOperations();
-        assert.isOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
         assert.notEqual(
             element.$.moreActions.items[0].id, 'cherrypick-revision');
       });
 
       test('move action to overflow', () => {
-        assert.isOk(element.$$('[data-action-key="submit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
         element.setActionOverflow('revision', 'submit', true);
         flushAsynchronousOperations();
-        assert.isNotOk(element.$$('[data-action-key="submit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
         assert.strictEqual(
             element.$.moreActions.items[3].id, 'submit-revision');
       });
@@ -1400,15 +1760,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', () => {
@@ -1432,10 +1790,12 @@
       let payload;
       let onShowError;
       let onShowAlert;
+      let getResponseObjectStub;
 
       setup(() => {
         cleanup = sinon.stub();
         element.changeNum = 42;
+        element.change._number = 42;
         element.latestPatchNum = 12;
         payload = {foo: 'bar'};
 
@@ -1447,31 +1807,121 @@
 
       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(GerritNav,
+              'navigateToChange').returns(Promise.resolve(true));
+          sandbox.stub(element, 'computeLatestPatchNum')
+              .returns(element.latestPatchNum);
         });
 
-        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},
+                ]}));
+            navigateToSearchQueryStub = sandbox.stub(GerritNav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission single change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).
+                  then(() => {
+                    assert.isTrue(navigateToSearchQueryStub.called);
+                    done();
+                  });
+            });
+          });
+        });
+
+        suite('multiple changes revert', () => {
+          let showActionDialogStub;
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345, topic: 'T'},
+                  {change_id: 23456, topic: 'T'},
+                ]}));
+            showActionDialogStub = sandbox.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sandbox.stub(GerritNav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission multiple change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).then(
+                  () => {
+                    assert.isFalse(showActionDialogStub.called);
+                    assert.isTrue(navigateToSearchQueryStub.calledWith(
+                        'topic: T'));
+                    done();
+                  });
+            });
+          });
+        });
+
+        test('revision action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
                 assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
                 assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
                     12, payload));
+                done();
               });
         });
       });
@@ -1540,7 +1990,8 @@
       });
 
       sandbox = sinon.sandbox.create();
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+      sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
+          .returns(Promise.resolve());
 
       element = fixture('basic');
       // getChangeRevisionActions is not called without
@@ -1549,7 +2000,6 @@
       element.changeNum = '42';
       element.latestPatchNum = '2';
 
-
       sandbox.stub(element.$.confirmCherrypick.$.restAPI,
           'getRepoBranches').returns(Promise.resolve([]));
       sandbox.stub(element.$.confirmMove.$.restAPI,
@@ -1587,4 +2037,5 @@
       assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
     });
   });
+});
 </script>
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..d08f529 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="gr-change-metadata.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="element">
   <template>
@@ -41,138 +37,147 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-metadata integration tests', () => {
-    let sandbox;
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../plugins/gr-plugin-host/gr-plugin-host.js';
+import './gr-change-metadata.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    const sectionSelectors = [
-      'section.assignee',
-      'section.strategy',
-      'section.topic',
-    ];
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    const labels = {
-      CI: {
-        all: [
-          {value: 1, name: 'user 2', _account_id: 1},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': 'Don\'t submit as-is',
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
+suite('gr-change-metadata integration tests', () => {
+  let sandbox;
+  let element;
+
+  const sectionSelectors = [
+    'section.strategy',
+    'section.topic',
+  ];
+
+  const labels = {
+    CI: {
+      all: [
+        {value: 1, name: 'user 2', _account_id: 1},
+        {value: 2, name: 'user '},
+      ],
+      values: {
+        ' 0': 'Don\'t submit as-is',
+        '+1': 'No score',
+        '+2': 'Looks good to me',
       },
-    };
+    },
+  };
 
-    const getStyle = function(selector, name) {
-      return window.getComputedStyle(
-          Polymer.dom(element.root).querySelector(selector))[name];
-    };
+  const getStyle = function(selector, name) {
+    return window.getComputedStyle(
+        dom(element.root).querySelector(selector))[name];
+  };
 
-    function createElement() {
-      const element = fixture('element');
-      element.change = {labels, status: 'NEW'};
-      element.revision = {};
-      return element;
-    }
+  function createElement() {
+    const element = fixture('element');
+    element.change = {labels, status: 'NEW'};
+    element.revision = {};
+    return element;
+  }
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        deleteVote() { return Promise.resolve({ok: true}); },
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-      Gerrit._testOnly_resetPlugins();
-    });
-
-    suite('by default', () => {
-      setup(done => {
-        element = createElement();
-        flush(done);
-      });
-
-      for (const sectionSelector of sectionSelectors) {
-        test(sectionSelector + ' does not have display: none', () => {
-          assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
-        });
-      }
-    });
-
-    suite('with plugin style', () => {
-      setup(done => {
-        Gerrit._testOnly_resetPlugins();
-        const pluginHost = fixture('plugin-host');
-        pluginHost.config = {
-          plugin: {
-            js_resource_paths: [],
-            html_resource_paths: [
-              new URL('test/plugin.html?' + Math.random(),
-                  window.location.href).toString(),
-            ],
-          },
-        };
-        element = createElement();
-        const importSpy = sandbox.spy(element.$.externalStyle, '_import');
-        Gerrit.awaitPluginsLoaded().then(() => {
-          Promise.all(importSpy.returnValues).then(() => {
-            flush(done);
-          });
-        });
-      });
-
-      for (const sectionSelector of sectionSelectors) {
-        test(sectionSelector + ' may have display: none', () => {
-          assert.equal(getStyle(sectionSelector, 'display'), 'none');
-        });
-      }
-    });
-
-    suite('label updates', () => {
-      let plugin;
-
-      setup(() => {
-        Gerrit.install(p => plugin = p, '0.1',
-            new URL('test/plugin.html?' + Math.random(),
-                window.location.href).toString());
-        sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-        Gerrit._loadPlugins([]);
-        element = createElement();
-      });
-
-      test('labels changed callback', done => {
-        let callCount = 0;
-        const labelChangeSpy = sandbox.spy(arg => {
-          callCount++;
-          if (callCount === 1) {
-            assert.deepEqual(arg, labels);
-            assert.equal(arg.CI.all.length, 2);
-            element.set(['change', 'labels'], {
-              CI: {
-                all: [
-                  {value: 1, name: 'user 2', _account_id: 1},
-                ],
-                values: {
-                  ' 0': 'Don\'t submit as-is',
-                  '+1': 'No score',
-                  '+2': 'Looks good to me',
-                },
-              },
-            });
-          } else if (callCount === 2) {
-            assert.equal(arg.CI.all.length, 1);
-            done();
-          }
-        });
-
-        plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
-      });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+      deleteVote() { return Promise.resolve({ok: true}); },
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+    resetPlugins();
+  });
+
+  suite('by default', () => {
+    setup(done => {
+      element = createElement();
+      flush(done);
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test(sectionSelector + ' does not have display: none', () => {
+        assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('with plugin style', () => {
+    setup(done => {
+      resetPlugins();
+      const pluginHost = fixture('plugin-host');
+      pluginHost.config = {
+        plugin: {
+          js_resource_paths: [],
+          html_resource_paths: [
+            new URL('test/plugin.html?' + Math.random(),
+                window.location.href).toString(),
+          ],
+        },
+      };
+      element = createElement();
+      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+      pluginLoader.awaitPluginsLoaded().then(() => {
+        Promise.all(importSpy.returnValues).then(() => {
+          flush(done);
+        });
+      });
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test(sectionSelector + ' may have display: none', () => {
+        assert.equal(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('label updates', () => {
+    let plugin;
+
+    setup(() => {
+      pluginApi.install(p => plugin = p, '0.1',
+          new URL('test/plugin.html?' + Math.random(),
+              window.location.href).toString());
+      sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
+      pluginLoader.loadPlugins([]);
+      element = createElement();
+    });
+
+    test('labels changed callback', done => {
+      let callCount = 0;
+      const labelChangeSpy = sandbox.spy(arg => {
+        callCount++;
+        if (callCount === 1) {
+          assert.deepEqual(arg, labels);
+          assert.equal(arg.CI.all.length, 2);
+          element.set(['change', 'labels'], {
+            CI: {
+              all: [
+                {value: 1, name: 'user 2', _account_id: 1},
+              ],
+              values: {
+                ' 0': 'Don\'t submit as-is',
+                '+1': 'No score',
+                '+2': 'Looks good to me',
+              },
+            },
+          });
+        } else if (callCount === 2) {
+          assert.equal(arg.CI.all.length, 1);
+          done();
+        }
+      });
+
+      plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
+    });
+  });
+});
 </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
deleted file mode 100644
index 6a92d96..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ /dev/null
@@ -1,323 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-change-metadata-shared-styles.html">
-<link rel="import" href="../../../styles/gr-change-view-integration-shared-styles.html">
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-change-requirements/gr-change-requirements.html">
-<link rel="import" href="../gr-commit-info/gr-commit-info.html">
-<link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
-<link rel="import" href="../../shared/gr-account-list/gr-account-list.html">
-<script src="../../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js"></script>
-
-<dom-module id="gr-change-metadata">
-  <template>
-    <style include="gr-change-metadata-shared-styles"></style>
-    <style include="shared-styles">
-      :host {
-        display: table;
-      }
-      gr-change-requirements {
-        --requirements-horizontal-padding: var(--metadata-horizontal-padding);
-      }
-      gr-account-link {
-        max-width: 20ch;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        vertical-align: top;
-        white-space: nowrap;
-      }
-      gr-editable-label {
-        max-width: 9em;
-      }
-      .webLink {
-        display: block;
-      }
-      /* CSS Mixins should be applied last. */
-      section.assignee {
-        @apply --change-metadata-assignee;
-      }
-      section.strategy {
-        @apply --change-metadata-strategy;
-      }
-      section.topic {
-        @apply --change-metadata-topic;
-      }
-      gr-account-chip[disabled],
-      gr-linked-chip[disabled] {
-        opacity: 0;
-        pointer-events: none;
-      }
-      .hashtagChip {
-        margin-bottom: var(--spacing-m);
-      }
-      #externalStyle {
-        display: block;
-      }
-      .parentList.merge {
-        list-style-type: decimal;
-        padding-left: var(--spacing-l);
-      }
-      .parentList gr-commit-info {
-        display: inline-block;
-      }
-      .hideDisplay,
-      #parentNotCurrentMessage {
-        display: none;
-      }
-      .icon {
-        margin: -3px 0;
-      }
-      .icon.help,
-      .icon.notTrusted {
-        color: #FFA62F;
-      }
-      .icon.invalid {
-        color: var(--vote-text-color-disliked);
-      }
-      .icon.trusted {
-        color: var(--vote-text-color-recommended);
-      }
-      .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-        --arrow-color: #ffa62f;
-        display: inline-block;
-      }
-      .separatedSection {
-        margin-top: var(--spacing-l);
-        padding: var(--spacing-m) 0;
-      }
-      .hashtag gr-linked-chip,
-      .topic gr-linked-chip {
-        --linked-chip-text-color: var(--link-color);
-      }
-    </style>
-    <gr-external-style id="externalStyle" name="change-metadata">
-      <section>
-        <span class="title">Updated</span>
-        <span class="value">
-          <gr-date-formatter
-              has-tooltip
-              date-str="[[change.updated]]"></gr-date-formatter>
-        </span>
-      </section>
-      <section>
-        <span class="title">Owner</span>
-        <span class="value">
-          <gr-account-link account="[[change.owner]]"></gr-account-link>
-          <template is="dom-if" if="[[_pushCertificateValidation]]">
-            <gr-tooltip-content
-                has-tooltip
-                title$="[[_pushCertificateValidation.message]]">
-              <iron-icon
-                  class$="icon [[_pushCertificateValidation.class]]"
-                  icon="[[_pushCertificateValidation.icon]]">
-              </iron-icon>
-            </gr-tooltip-content>
-          </template>
-        </span>
-      </section>
-      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
-        <span class="title">Uploader</span>
-        <span class="value">
-          <gr-account-link
-              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
-              ></gr-account-link>
-        </span>
-      </section>
-      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
-        <span class="title">Author</span>
-        <span class="value">
-          <gr-account-link
-              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
-              ></gr-account-link>
-        </span>
-      </section>
-      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
-        <span class="title">Committer</span>
-        <span class="value">
-          <gr-account-link
-              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
-              ></gr-account-link>
-        </span>
-      </section>
-      <section class="assignee">
-        <span class="title">Assignee</span>
-        <span class="value">
-          <gr-account-list
-              max-count="1"
-              id="assigneeValue"
-              placeholder="Set assignee..."
-              accounts="{{_assignee}}"
-              readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
-              suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
-          </gr-account-list>
-        </span>
-      </section>
-      <section>
-        <span class="title">Reviewers</span>
-        <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[_mutable]]"
-              reviewers-only
-              max-reviewers-displayed="3"></gr-reviewer-list>
-        </span>
-      </section>
-      <section>
-        <span class="title">CC</span>
-        <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[_mutable]]"
-              ccs-only
-              max-reviewers-displayed="3"></gr-reviewer-list>
-        </span>
-      </section>
-      <section>
-        <span class="title">Repo</span>
-        <span class="value">
-          <a href$="[[_computeProjectURL(change.project)]]">
-            <gr-limited-text limit="40" text="[[change.project]]"></gr-limited-text>
-          </a>
-        </span>
-      </section>
-      <section>
-        <span class="title">Branch</span>
-        <span class="value">
-          <a href$="[[_computeBranchURL(change.project, change.branch)]]">
-            <gr-limited-text limit="40" text="[[change.branch]]"></gr-limited-text>
-          </a>
-        </span>
-      </section>
-      <section>
-        <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
-        <span class="value">
-          <ol class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]">
-            <template is="dom-repeat" items="[[_currentParents]]" as="parent">
-              <li>
-                <gr-commit-info
-                    change="[[change]]"
-                    commit-info="[[parent]]"
-                    server-config="[[serverConfig]]"></gr-commit-info>
-                <gr-tooltip-content
-                    id="parentNotCurrentMessage"
-                    has-tooltip
-                    show-icon
-                    title$="[[_notCurrentMessage]]"></gr-tooltip-content>
-              </li>
-            </template>
-          </ol>
-        </span>
-      </section>
-      <section class="topic">
-        <span class="title">Topic</span>
-        <span class="value">
-          <template
-              is="dom-if"
-              if="[[_showTopicChip(change.*, _settingTopic)]]">
-            <gr-linked-chip
-                text="[[change.topic]]"
-                limit="40"
-                href="[[_computeTopicURL(change.topic)]]"
-                removable="[[!_topicReadOnly]]"
-                on-remove="_handleTopicRemoved"></gr-linked-chip>
-          </template>
-          <template
-              is="dom-if"
-              if="[[_showAddTopic(change.*, _settingTopic)]]">
-            <gr-editable-label
-                class="topicEditableLabel"
-                label-text="Add a topic"
-                value="[[change.topic]]"
-                max-length="1024"
-                placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-                read-only="[[_topicReadOnly]]"
-                on-changed="_handleTopicChanged"></gr-editable-label>
-          </template>
-        </span>
-      </section>
-      <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
-        <span class="title">Strategy</span>
-        <span class="value">[[_computeStrategy(change)]]</span>
-      </section>
-      <section class="hashtag">
-        <span class="title">Hashtags</span>
-        <span class="value">
-          <template is="dom-repeat" items="[[change.hashtags]]">
-            <gr-linked-chip
-                class="hashtagChip"
-                text="[[item]]"
-                href="[[_computeHashtagURL(item)]]"
-                removable="[[!_hashtagReadOnly]]"
-                on-remove="_handleHashtagRemoved">
-            </gr-linked-chip>
-          </template>
-          <template is="dom-if" if="[[!_hashtagReadOnly]]">
-            <gr-editable-label
-                uppercase
-                label-text="Add a hashtag"
-                value="{{_newHashtag}}"
-                placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
-                read-only="[[_hashtagReadOnly]]"
-                on-changed="_handleHashtagChanged"></gr-editable-label>
-          </template>
-        </span>
-      </section>
-      <div class="separatedSection">
-        <gr-change-requirements
-            change="{{change}}"
-            account="[[account]]"
-            mutable="[[_mutable]]"></gr-change-requirements>
-      </div>
-      <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]">
-        <span class="title">Links</span>
-        <span class="value">
-          <template is="dom-repeat"
-              items="[[_computeWebLinks(commitInfo, serverConfig)]]" as="link">
-            <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
-              [[link.name]]
-            </a>
-          </template>
-        </span>
-      </section>
-      <gr-endpoint-decorator name="change-metadata-item">
-        <gr-endpoint-param name="labels" value="[[labels]]"></gr-endpoint-param>
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <gr-endpoint-param name="revision" value="[[revision]]"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </gr-external-style>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-change-metadata.js"></script>
-</dom-module>
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 4fe5ddc..7d4e878 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
@@ -14,51 +14,89 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-change-metadata-shared-styles.js';
+import '../../../styles/gr-change-view-integration-shared-styles.js';
+import '../../../styles/gr-voting-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../plugins/gr-external-style/gr-external-style.js';
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-editable-label/gr-editable-label.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-limited-text/gr-limited-text.js';
+import '../../shared/gr-linked-chip/gr-linked-chip.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-change-requirements/gr-change-requirements.js';
+import '../gr-commit-info/gr-commit-info.js';
+import '../gr-reviewer-list/gr-reviewer-list.js';
+import '../../shared/gr-account-list/gr-account-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-metadata_html.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  const SubmitTypeLabel = {
-    FAST_FORWARD_ONLY: 'Fast Forward Only',
-    MERGE_IF_NECESSARY: 'Merge if Necessary',
-    REBASE_IF_NECESSARY: 'Rebase if Necessary',
-    MERGE_ALWAYS: 'Always Merge',
-    REBASE_ALWAYS: 'Rebase Always',
-    CHERRY_PICK: 'Cherry Pick',
-  };
+const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
-  const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+const SubmitTypeLabel = {
+  FAST_FORWARD_ONLY: 'Fast Forward Only',
+  MERGE_IF_NECESSARY: 'Merge if Necessary',
+  REBASE_IF_NECESSARY: 'Rebase if Necessary',
+  MERGE_ALWAYS: 'Always Merge',
+  REBASE_ALWAYS: 'Rebase Always',
+  CHERRY_PICK: 'Cherry Pick',
+};
 
+const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
+/**
+ * @enum {string}
+ */
+const CertificateStatus = {
   /**
-   * @enum {string}
+   * This certificate status is bad.
    */
-  const CertificateStatus = {
-    /**
-     * This certificate status is bad.
-     */
-    BAD: 'BAD',
-    /**
-     * This certificate status is OK.
-     */
-    OK: 'OK',
-    /**
-     * This certificate status is TRUSTED.
-     */
-    TRUSTED: 'TRUSTED',
-  };
+  BAD: 'BAD',
+  /**
+   * This certificate status is OK.
+   */
+  OK: 'OK',
+  /**
+   * This certificate status is TRUSTED.
+   */
+  TRUSTED: 'TRUSTED',
+};
 
-  Polymer({
-    is: 'gr-change-metadata',
+/**
+ * @extends Polymer.Element
+ */
+class GrChangeMetadata extends mixinBehaviors( [
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the change topic is changed.
-     *
-     * @event topic-changed
-     */
+  static get is() { return 'gr-change-metadata'; }
+  /**
+   * Fired when the change topic is changed.
+   *
+   * @event topic-changed
+   */
 
-    properties: {
-      /** @type {?} */
+  static get properties() {
+    return {
+    /** @type {?} */
       change: Object,
       labels: {
         type: Object,
@@ -128,351 +166,377 @@
           COMMITTER: 'committer',
         },
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.RESTClientBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_changeChanged(change)',
       '_labelsChanged(change.labels)',
       '_assigneeChanged(_assignee.*)',
-    ],
+    ];
+  }
 
-    _labelsChanged(labels) {
-      this.labels = Object.assign({}, labels) || null;
-    },
+  _labelsChanged(labels) {
+    this.labels = Object.assign({}, labels) || null;
+  }
 
-    _changeChanged(change) {
-      this._assignee = change.assignee ? [change.assignee] : [];
-    },
+  _changeChanged(change) {
+    this._assignee = change.assignee ? [change.assignee] : [];
+  }
 
-    _assigneeChanged(assigneeRecord) {
-      if (!this.change) { return; }
-      const assignee = assigneeRecord.base;
-      if (assignee.length) {
-        const acct = assignee[0];
-        if (this.change.assignee &&
-            acct._account_id === this.change.assignee._account_id) { return; }
-        this.set(['change', 'assignee'], acct);
-        this.$.restAPI.setAssignee(this.change._number, acct._account_id);
-      } else {
-        if (!this.change.assignee) { return; }
-        this.set(['change', 'assignee'], undefined);
-        this.$.restAPI.deleteAssignee(this.change._number);
-      }
-    },
+  _assigneeChanged(assigneeRecord) {
+    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
+      return;
+    }
+    const assignee = assigneeRecord.base;
+    if (assignee.length) {
+      const acct = assignee[0];
+      if (this.change.assignee &&
+          acct._account_id === this.change.assignee._account_id) { return; }
+      this.set(['change', 'assignee'], acct);
+      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+    } else {
+      if (!this.change.assignee) { return; }
+      this.set(['change', 'assignee'], undefined);
+      this.$.restAPI.deleteAssignee(this.change._number);
+    }
+  }
 
-    _computeHideStrategy(change) {
-      return !this.changeIsOpen(change);
-    },
+  _computeHideStrategy(change) {
+    return !this.changeIsOpen(change);
+  }
 
-    /**
-     * @param {Object} commitInfo
-     * @return {?Array} If array is empty, returns null instead so
-     * an existential check can be used to hide or show the webLinks
-     * section.
-     */
-    _computeWebLinks(commitInfo, serverConfig) {
-      if (!commitInfo) { return null; }
-      const weblinks = Gerrit.Nav.getChangeWeblinks(
-          this.change ? this.change.repo : '',
-          commitInfo.commit,
-          {
-            weblinks: commitInfo.web_links,
-            config: serverConfig,
-          });
-      return weblinks.length ? weblinks : null;
-    },
+  /**
+   * @param {Object} commitInfo
+   * @return {?Array} If array is empty, returns null instead so
+   * an existential check can be used to hide or show the webLinks
+   * section.
+   */
+  _computeWebLinks(commitInfo, serverConfig) {
+    if (!commitInfo) { return null; }
+    const weblinks = GerritNav.getChangeWeblinks(
+        this.change ? this.change.repo : '',
+        commitInfo.commit,
+        {
+          weblinks: commitInfo.web_links,
+          config: serverConfig,
+        });
+    return weblinks.length ? weblinks : null;
+  }
 
-    _computeStrategy(change) {
-      return SubmitTypeLabel[change.submit_type];
-    },
+  _isAssigneeEnabled(serverConfig) {
+    return serverConfig && serverConfig.change
+        && !!serverConfig.change.enable_assignee;
+  }
 
-    _computeLabelNames(labels) {
-      return Object.keys(labels).sort();
-    },
+  _computeStrategy(change) {
+    return SubmitTypeLabel[change.submit_type];
+  }
 
-    _handleTopicChanged(e, topic) {
-      const lastTopic = this.change.topic;
-      if (!topic.length) { topic = null; }
-      this._settingTopic = true;
-      this.$.restAPI.setChangeTopic(this.change._number, topic)
-          .then(newTopic => {
-            this._settingTopic = false;
-            this.set(['change', 'topic'], newTopic);
-            if (newTopic !== lastTopic) {
-              this.dispatchEvent(new CustomEvent(
-                  'topic-changed', {bubbles: true, composed: true}));
-            }
-          });
-    },
+  _computeLabelNames(labels) {
+    return Object.keys(labels).sort();
+  }
 
-    _showAddTopic(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord &&
-          !!changeRecord.base && !!changeRecord.base.topic;
-      return !hasTopic && !settingTopic;
-    },
+  _handleTopicChanged(e, topic) {
+    const lastTopic = this.change.topic;
+    if (!topic.length) { topic = null; }
+    this._settingTopic = true;
+    this.$.restAPI.setChangeTopic(this.change._number, topic)
+        .then(newTopic => {
+          this._settingTopic = false;
+          this.set(['change', 'topic'], newTopic);
+          if (newTopic !== lastTopic) {
+            this.dispatchEvent(new CustomEvent(
+                'topic-changed', {bubbles: true, composed: true}));
+          }
+        });
+  }
 
-    _showTopicChip(changeRecord, settingTopic) {
-      const hasTopic = !!changeRecord &&
-          !!changeRecord.base && !!changeRecord.base.topic;
-      return hasTopic && !settingTopic;
-    },
+  _showAddTopic(changeRecord, settingTopic) {
+    const hasTopic = !!changeRecord &&
+        !!changeRecord.base && !!changeRecord.base.topic;
+    return !hasTopic && !settingTopic;
+  }
 
-    _handleHashtagChanged(e) {
-      const lastHashtag = this.change.hashtag;
-      if (!this._newHashtag.length) { return; }
-      const newHashtag = this._newHashtag;
-      this._newHashtag = '';
-      this.$.restAPI.setChangeHashtag(
-          this.change._number, {add: [newHashtag]}).then(newHashtag => {
-        this.set(['change', 'hashtags'], newHashtag);
-        if (newHashtag !== lastHashtag) {
-          this.dispatchEvent(
-              new CustomEvent('hashtag-changed', {
-                bubbles: true, composed: true}));
-        }
-      });
-    },
+  _showTopicChip(changeRecord, settingTopic) {
+    const hasTopic = !!changeRecord &&
+        !!changeRecord.base && !!changeRecord.base.topic;
+    return hasTopic && !settingTopic;
+  }
 
-    _computeTopicReadOnly(mutable, change) {
-      return !mutable ||
-          !change ||
-          !change.actions ||
-          !change.actions.topic ||
-          !change.actions.topic.enabled;
-    },
+  _showCherryPickOf(changeRecord) {
+    const hasCherryPickOf = !!changeRecord &&
+        !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
+        !!changeRecord.base.cherry_pick_of_patch_set;
+    return hasCherryPickOf;
+  }
 
-    _computeHashtagReadOnly(mutable, change) {
-      return !mutable ||
-          !change ||
-          !change.actions ||
-          !change.actions.hashtags ||
-          !change.actions.hashtags.enabled;
-    },
-
-    _computeAssigneeReadOnly(mutable, change) {
-      return !mutable ||
-          !change ||
-          !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) {
-        // TODO(maximeg) change this to display the stored
-        // requirements, once it is implemented server-side.
-        return false;
-      }
-      const hasRequirements = !!change.requirements &&
-          Object.keys(change.requirements).length > 0;
-      const hasLabels = !!change.labels &&
-          Object.keys(change.labels).length > 0;
-      return hasRequirements || hasLabels || !!change.work_in_progress;
-    },
-
-    /**
-     * @return {?Gerrit.PushCertificateValidation} object representing data for
-     *     the push validation.
-     */
-    _computePushCertificateValidation(serverConfig, change) {
-      if (!change || !serverConfig || !serverConfig.receive ||
-          !serverConfig.receive.enable_signed_push) {
-        return null;
-      }
-      const rev = change.revisions[change.current_revision];
-      if (!rev.push_certificate || !rev.push_certificate.key) {
-        return {
-          class: 'help',
-          icon: 'gr-icons:help',
-          message: 'This patch set was created without a push certificate',
-        };
-      }
-
-      const key = rev.push_certificate.key;
-      switch (key.status) {
-        case CertificateStatus.BAD:
-          return {
-            class: 'invalid',
-            icon: 'gr-icons:close',
-            message: this._problems('Push certificate is invalid', key),
-          };
-        case CertificateStatus.OK:
-          return {
-            class: 'notTrusted',
-            icon: 'gr-icons:info',
-            message: this._problems(
-                'Push certificate is valid, but key is not trusted', key),
-          };
-        case CertificateStatus.TRUSTED:
-          return {
-            class: 'trusted',
-            icon: 'gr-icons:check',
-            message: this._problems(
-                'Push certificate is valid and key is trusted', key),
-          };
-        default:
-          throw new Error(`unknown certificate status: ${key.status}`);
-      }
-    },
-
-    _problems(msg, key) {
-      if (!key || !key.problems || key.problems.length === 0) {
-        return msg;
-      }
-
-      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());
-    },
-
-    _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'], '');
+  _handleHashtagChanged(e) {
+    const lastHashtag = this.change.hashtag;
+    if (!this._newHashtag.length) { return; }
+    const newHashtag = this._newHashtag;
+    this._newHashtag = '';
+    this.$.restAPI.setChangeHashtag(
+        this.change._number, {add: [newHashtag]}).then(newHashtag => {
+      this.set(['change', 'hashtags'], newHashtag);
+      if (newHashtag !== lastHashtag) {
         this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true, composed: true}));
-      }).catch(err => {
-        target.disabled = false;
-        return;
-      });
-    },
-
-    _handleHashtagRemoved(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      target.disabled = true;
-      this.$.restAPI.setChangeHashtag(this.change._number,
-          {remove: [target.text]})
-          .then(newHashtag => {
-            target.disabled = false;
-            this.set(['change', 'hashtags'], newHashtag);
-          }).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
-     * user with that role is the same as the owner.
-     *
-     * @param {!Object} change
-     * @param {string} role One of the values from _CHANGE_ROLE
-     * @return {Object|null} either an accound or null.
-     */
-    _getNonOwnerRole(change, role) {
-      if (!change || !change.current_revision ||
-          !change.revisions[change.current_revision]) {
-        return null;
+            new CustomEvent('hashtag-changed', {
+              bubbles: true, composed: true}));
       }
+    });
+  }
 
-      const rev = change.revisions[change.current_revision];
-      if (!rev) { return null; }
+  _computeTopicReadOnly(mutable, change) {
+    return !mutable ||
+        !change ||
+        !change.actions ||
+        !change.actions.topic ||
+        !change.actions.topic.enabled;
+  }
 
-      if (role === this._CHANGE_ROLE.UPLOADER &&
-          rev.uploader &&
-          change.owner._account_id !== rev.uploader._account_id) {
-        return rev.uploader;
-      }
+  _computeHashtagReadOnly(mutable, change) {
+    return !mutable ||
+        !change ||
+        !change.actions ||
+        !change.actions.hashtags ||
+        !change.actions.hashtags.enabled;
+  }
 
-      if (role === this._CHANGE_ROLE.AUTHOR &&
-          rev.commit && rev.commit.author &&
-          change.owner.email !== rev.commit.author.email) {
-        return rev.commit.author;
-      }
+  _computeAssigneeReadOnly(mutable, change) {
+    return !mutable ||
+        !change ||
+        !change.actions ||
+        !change.actions.assignee ||
+        !change.actions.assignee.enabled;
+  }
 
-      if (role === this._CHANGE_ROLE.COMMITTER &&
-          rev.commit && rev.commit.committer &&
-          change.owner.email !== rev.commit.committer.email) {
-        return rev.commit.committer;
-      }
+  _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) {
+      // TODO(maximeg) change this to display the stored
+      // requirements, once it is implemented server-side.
+      return false;
+    }
+    const hasRequirements = !!change.requirements &&
+        Object.keys(change.requirements).length > 0;
+    const hasLabels = !!change.labels &&
+        Object.keys(change.labels).length > 0;
+    return hasRequirements || hasLabels || !!change.work_in_progress;
+  }
+
+  /**
+   * @return {?Gerrit.PushCertificateValidation} object representing data for
+   *     the push validation.
+   */
+  _computePushCertificateValidation(serverConfig, change) {
+    if (!change || !serverConfig || !serverConfig.receive ||
+        !serverConfig.receive.enable_signed_push) {
       return null;
-    },
+    }
+    const rev = change.revisions[change.current_revision];
+    if (!rev.push_certificate || !rev.push_certificate.key) {
+      return {
+        class: 'help',
+        icon: 'gr-icons:help',
+        message: 'This patch set was created without a push certificate',
+      };
+    }
 
-    _computeParents(change, revision) {
-      if (!revision || !revision.commit) {
-        if (!change || !change.current_revision) { return []; }
-        revision = change.revisions[change.current_revision];
-        if (!revision || !revision.commit) { return []; }
-      }
-      return revision.commit.parents;
-    },
+    const key = rev.push_certificate.key;
+    switch (key.status) {
+      case CertificateStatus.BAD:
+        return {
+          class: 'invalid',
+          icon: 'gr-icons:close',
+          message: this._problems('Push certificate is invalid', key),
+        };
+      case CertificateStatus.OK:
+        return {
+          class: 'notTrusted',
+          icon: 'gr-icons:info',
+          message: this._problems(
+              'Push certificate is valid, but key is not trusted', key),
+        };
+      case CertificateStatus.TRUSTED:
+        return {
+          class: 'trusted',
+          icon: 'gr-icons:check',
+          message: this._problems(
+              'Push certificate is valid and key is trusted', key),
+        };
+      default:
+        throw new Error(`unknown certificate status: ${key.status}`);
+    }
+  }
 
-    _computeParentsLabel(parents) {
-      return parents && parents.length > 1 ? 'Parents' : 'Parent';
-    },
+  _problems(msg, key) {
+    if (!key || !key.problems || key.problems.length === 0) {
+      return msg;
+    }
 
-    _computeParentListClass(parents, parentIsCurrent) {
-      // Undefined check for polymer 2
-      if (parents === undefined || parentIsCurrent === undefined) {
-        return '';
-      }
+    return [msg + ':'].concat(key.problems).join('\n');
+  }
 
-      return [
-        'parentList',
-        parents && parents.length > 1 ? 'merge' : 'nonMerge',
-        parentIsCurrent ? 'current' : 'notCurrent',
-      ].join(' ');
-    },
+  _computeShowRepoBranchTogether(repo, branch) {
+    return !!repo && !!branch && repo.length + branch.length < 40;
+  }
 
-    _computeIsMutable(account) {
-      return !!Object.keys(account).length;
-    },
+  _computeProjectUrl(project) {
+    return GerritNav.getUrlForProjectChanges(project);
+  }
 
-    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();
-    },
+  _computeBranchUrl(project, branch) {
+    if (!this.change || !this.change.status) return '';
+    return GerritNav.getUrlForBranch(branch, project,
+        this.change.status == this.ChangeStatus.NEW ? 'open' :
+          this.change.status.toLowerCase());
+  }
 
-    _getReviewerSuggestionsProvider(change) {
-      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      provider.init();
-      return provider;
-    },
-  });
-})();
+  _computeCherryPickOfUrl(change, patchset, project) {
+    return GerritNav.getUrlForChangeById(change, project, patchset);
+  }
+
+  _computeTopicUrl(topic) {
+    return GerritNav.getUrlForTopic(topic);
+  }
+
+  _computeHashtagUrl(hashtag) {
+    return GerritNav.getUrlForHashtag(hashtag);
+  }
+
+  _handleTopicRemoved(e) {
+    const target = 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;
+        });
+  }
+
+  _handleHashtagRemoved(e) {
+    e.preventDefault();
+    const target = dom(e).rootTarget;
+    target.disabled = true;
+    this.$.restAPI.setChangeHashtag(this.change._number,
+        {remove: [target.text]})
+        .then(newHashtag => {
+          target.disabled = false;
+          this.set(['change', 'hashtags'], newHashtag);
+        })
+        .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
+   * user with that role is the same as the owner.
+   *
+   * @param {!Object} change
+   * @param {string} role One of the values from _CHANGE_ROLE
+   * @return {Object|null} either an accound or null.
+   */
+  _getNonOwnerRole(change, role) {
+    if (!change || !change.current_revision ||
+        !change.revisions[change.current_revision]) {
+      return null;
+    }
+
+    const rev = change.revisions[change.current_revision];
+    if (!rev) { return null; }
+
+    if (role === this._CHANGE_ROLE.UPLOADER &&
+        rev.uploader &&
+        change.owner._account_id !== rev.uploader._account_id) {
+      return rev.uploader;
+    }
+
+    if (role === this._CHANGE_ROLE.AUTHOR &&
+        rev.commit && rev.commit.author &&
+        change.owner.email !== rev.commit.author.email) {
+      return rev.commit.author;
+    }
+
+    if (role === this._CHANGE_ROLE.COMMITTER &&
+        rev.commit && rev.commit.committer &&
+        change.owner.email !== rev.commit.committer.email) {
+      return rev.commit.committer;
+    }
+
+    return null;
+  }
+
+  _computeParents(change, revision) {
+    if (!revision || !revision.commit) {
+      if (!change || !change.current_revision) { return []; }
+      revision = change.revisions[change.current_revision];
+      if (!revision || !revision.commit) { return []; }
+    }
+    return revision.commit.parents;
+  }
+
+  _computeParentsLabel(parents) {
+    return parents && parents.length > 1 ? 'Parents' : 'Parent';
+  }
+
+  _computeParentListClass(parents, parentIsCurrent) {
+    // Undefined check for polymer 2
+    if (parents === undefined || parentIsCurrent === undefined) {
+      return '';
+    }
+
+    return [
+      'parentList',
+      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.shadowRoot.querySelector('.topicEditableLabel').open();
+  }
+
+  _getReviewerSuggestionsProvider(change) {
+    const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+        change._number, 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_html.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
new file mode 100644
index 0000000..1b18412
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
@@ -0,0 +1,365 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+      --account-max-length: 20ch;
+    }
+    gr-change-requirements {
+      --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+    }
+    gr-editable-label {
+      max-width: 9em;
+    }
+    .webLink {
+      display: block;
+    }
+    /* CSS Mixins should be applied last. */
+    section.assignee {
+      @apply --change-metadata-assignee;
+    }
+    section.strategy {
+      @apply --change-metadata-strategy;
+    }
+    section.topic {
+      @apply --change-metadata-topic;
+    }
+    gr-account-chip[disabled],
+    gr-linked-chip[disabled] {
+      opacity: 0;
+      pointer-events: none;
+    }
+    .hashtagChip {
+      margin-bottom: var(--spacing-m);
+    }
+    #externalStyle {
+      display: block;
+    }
+    .parentList.merge {
+      list-style-type: decimal;
+      padding-left: var(--spacing-l);
+    }
+    .parentList gr-commit-info {
+      display: inline-block;
+    }
+    .hideDisplay,
+    #parentNotCurrentMessage {
+      display: none;
+    }
+    .icon {
+      margin: -3px 0;
+    }
+    .icon.help,
+    .icon.notTrusted {
+      color: #ffa62f;
+    }
+    .icon.invalid {
+      color: var(--vote-text-color-disliked);
+    }
+    .icon.trusted {
+      color: var(--vote-text-color-recommended);
+    }
+    .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
+      --arrow-color: #ffa62f;
+      display: inline-block;
+    }
+    .separatedSection {
+      margin-top: var(--spacing-l);
+      padding: var(--spacing-m) 0;
+    }
+    .hashtag gr-linked-chip,
+    .topic gr-linked-chip {
+      --linked-chip-text-color: var(--link-color);
+    }
+    gr-reviewer-list {
+      max-width: 200px;
+    }
+  </style>
+  <gr-external-style id="externalStyle" name="change-metadata">
+    <section>
+      <span class="title">Updated</span>
+      <span class="value">
+        <gr-date-formatter
+          has-tooltip=""
+          date-str="[[change.updated]]"
+        ></gr-date-formatter>
+      </span>
+    </section>
+    <section>
+      <span class="title">Owner</span>
+      <span class="value">
+        <gr-account-link account="[[change.owner]]"></gr-account-link>
+        <template is="dom-if" if="[[_pushCertificateValidation]]">
+          <gr-tooltip-content
+            has-tooltip=""
+            title$="[[_pushCertificateValidation.message]]"
+          >
+            <iron-icon
+              class$="icon [[_pushCertificateValidation.class]]"
+              icon="[[_pushCertificateValidation.icon]]"
+            >
+            </iron-icon>
+          </gr-tooltip-content>
+        </template>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
+      <span class="title">Uploader</span>
+      <span class="value">
+        <gr-account-link
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
+        ></gr-account-link>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
+      <span class="title">Author</span>
+      <span class="value">
+        <gr-account-link
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
+        ></gr-account-link>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
+      <span class="title">Committer</span>
+      <span class="value">
+        <gr-account-link
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
+        ></gr-account-link>
+      </span>
+    </section>
+    <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
+      <section class="assignee">
+        <span class="title">Assignee</span>
+        <span class="value">
+          <gr-account-list
+            id="assigneeValue"
+            placeholder="Set assignee..."
+            max-count="1"
+            skip-suggest-on-empty=""
+            accounts="{{_assignee}}"
+            readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
+            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
+          >
+          </gr-account-list>
+        </span>
+      </section>
+    </template>
+    <section>
+      <span class="title">Reviewers</span>
+      <span class="value">
+        <gr-reviewer-list
+          change="{{change}}"
+          mutable="[[_mutable]]"
+          reviewers-only=""
+          server-config="[[serverConfig]]"
+        ></gr-reviewer-list>
+      </span>
+    </section>
+    <section>
+      <span class="title">CC</span>
+      <span class="value">
+        <gr-reviewer-list
+          change="{{change}}"
+          mutable="[[_mutable]]"
+          ccs-only=""
+          server-config="[[serverConfig]]"
+        ></gr-reviewer-list>
+      </span>
+    </section>
+    <template
+      is="dom-if"
+      if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
+    >
+      <section>
+        <span class="title">Repo | Branch</span>
+        <span class="value">
+          <a href$="[[_computeProjectUrl(change.project)]]"
+            >[[change.project]]</a
+          >
+          |
+          <a href$="[[_computeBranchUrl(change.project, change.branch)]]"
+            >[[change.branch]]</a
+          >
+        </span>
+      </section>
+    </template>
+    <template
+      is="dom-if"
+      if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
+    >
+      <section>
+        <span class="title">Repo</span>
+        <span class="value">
+          <a href$="[[_computeProjectUrl(change.project)]]">
+            <gr-limited-text
+              limit="40"
+              text="[[change.project]]"
+            ></gr-limited-text>
+          </a>
+        </span>
+      </section>
+      <section>
+        <span class="title">Branch</span>
+        <span class="value">
+          <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
+            <gr-limited-text
+              limit="40"
+              text="[[change.branch]]"
+            ></gr-limited-text>
+          </a>
+        </span>
+      </section>
+    </template>
+    <section>
+      <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
+      <span class="value">
+        <ol
+          class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]"
+        >
+          <template is="dom-repeat" items="[[_currentParents]]" as="parent">
+            <li>
+              <gr-commit-info
+                change="[[change]]"
+                commit-info="[[parent]]"
+                server-config="[[serverConfig]]"
+              ></gr-commit-info>
+              <gr-tooltip-content
+                id="parentNotCurrentMessage"
+                has-tooltip=""
+                show-icon=""
+                title$="[[_notCurrentMessage]]"
+              ></gr-tooltip-content>
+            </li>
+          </template>
+        </ol>
+      </span>
+    </section>
+    <section class="topic">
+      <span class="title">Topic</span>
+      <span class="value">
+        <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
+          <gr-linked-chip
+            text="[[change.topic]]"
+            limit="40"
+            href="[[_computeTopicUrl(change.topic)]]"
+            removable="[[!_topicReadOnly]]"
+            on-remove="_handleTopicRemoved"
+          ></gr-linked-chip>
+        </template>
+        <template is="dom-if" if="[[_showAddTopic(change.*, _settingTopic)]]">
+          <gr-editable-label
+            class="topicEditableLabel"
+            label-text="Add a topic"
+            value="[[change.topic]]"
+            max-length="1024"
+            placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+            read-only="[[_topicReadOnly]]"
+            on-changed="_handleTopicChanged"
+          ></gr-editable-label>
+        </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>
+    </section>
+    <section class="hashtag">
+      <span class="title">Hashtags</span>
+      <span class="value">
+        <template is="dom-repeat" items="[[change.hashtags]]">
+          <gr-linked-chip
+            class="hashtagChip"
+            text="[[item]]"
+            href="[[_computeHashtagUrl(item)]]"
+            removable="[[!_hashtagReadOnly]]"
+            on-remove="_handleHashtagRemoved"
+          >
+          </gr-linked-chip>
+        </template>
+        <template is="dom-if" if="[[!_hashtagReadOnly]]">
+          <gr-editable-label
+            uppercase=""
+            label-text="Add a hashtag"
+            value="{{_newHashtag}}"
+            placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
+            read-only="[[_hashtagReadOnly]]"
+            on-changed="_handleHashtagChanged"
+          ></gr-editable-label>
+        </template>
+      </span>
+    </section>
+    <div class="separatedSection">
+      <gr-change-requirements
+        change="{{change}}"
+        account="[[account]]"
+        mutable="[[_mutable]]"
+      ></gr-change-requirements>
+    </div>
+    <section
+      id="webLinks"
+      hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]"
+    >
+      <span class="title">Links</span>
+      <span class="value">
+        <template
+          is="dom-repeat"
+          items="[[_computeWebLinks(commitInfo, serverConfig)]]"
+          as="link"
+        >
+          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
+            [[link.name]]
+          </a>
+        </template>
+      </span>
+    </section>
+    <gr-endpoint-decorator name="change-metadata-item">
+      <gr-endpoint-param name="labels" value="[[labels]]"></gr-endpoint-param>
+      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+      <gr-endpoint-param
+        name="revision"
+        value="[[revision]]"
+      ></gr-endpoint-param>
+    </gr-endpoint-decorator>
+  </gr-external-style>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 d60847b..8e780d7 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../core/gr-router/gr-router.html">
-<link rel="import" href="gr-change-metadata.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,728 +31,770 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-metadata tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-change-metadata.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
+    });
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('computed fields', () => {
+    assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
+    assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
+    assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
+    assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
+        'Cherry Pick');
+    assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
+        'Rebase Always');
+  });
+
+  test('computed fields requirements', () => {
+    assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
+    assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
+
+    // No labels and no requirements: submit status is useless
+    assert.isFalse(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+    }));
+
+    // Work in Progress: submit status should be present
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+      work_in_progress: true,
+    }));
+
+    // We have at least one reason to display Submit Status
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: false,
+        },
+      },
+      requirements: [],
+    }));
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    }));
+  });
+
+  test('show strategy for open change', () => {
+    element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
+    flushAsynchronousOperations();
+    const strategy = element.shadowRoot
+        .querySelector('.strategy');
+    assert.ok(strategy);
+    assert.isFalse(strategy.hasAttribute('hidden'));
+    assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
+  });
+
+  test('hide strategy for closed change', () => {
+    element.change = {status: 'MERGED', labels: {}};
+    flushAsynchronousOperations();
+    assert.isTrue(element.shadowRoot
+        .querySelector('.strategy').hasAttribute('hidden'));
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sandbox.stub(GerritNav, '_generateWeblinks')
+        .returns([{name: 'stubb', url: '#s'}]);
+    element.commitInfo = {};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(weblinksStub.called);
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+  });
+
+  test('weblinks hidden when no weblinks', () => {
+    element.commitInfo = {};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks hidden when only gitiles weblink', () => {
+    element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo), null);
+  });
+
+  test('weblinks hidden when sole weblink is set as primary', () => {
+    const browser = 'browser';
+    element.commitInfo = {web_links: [{name: browser, url: '#'}]};
+    element.serverConfig = {
+      gerrit: {
+        primary_weblink_name: browser,
+      },
+    };
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks are visible when other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(GerritNav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+    // With two non-gitiles weblinks, there are two returned.
+    element.commitInfo = {
+      web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
+  });
+
+  test('weblinks are visible when gitiles and other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(GerritNav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    // Only the non-gitiles weblink is returned.
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+  });
+
+  suite('_getNonOwnerRole', () => {
+    let change;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-endpoint-decorator', {
-        _import: sandbox.stub().returns(Promise.resolve()),
-      });
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('computed fields', () => {
-      assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
-      assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
-      assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
-      assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
-          'Cherry Pick');
-      assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
-          'Rebase Always');
-    });
-
-    test('computed fields requirements', () => {
-      assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
-      assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
-
-      // No labels and no requirements: submit status is useless
-      assert.isFalse(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {},
-      }));
-
-      // Work in Progress: submit status should be present
-      assert.isTrue(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {},
-        work_in_progress: true,
-      }));
-
-      // We have at least one reason to display Submit Status
-      assert.isTrue(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {
-          Verified: {
-            approved: false,
-          },
-        },
-        requirements: [],
-      }));
-      assert.isTrue(element._computeShowRequirements({
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'OK',
-        }],
-      }));
-    });
-
-    test('show strategy for open change', () => {
-      element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
-      flushAsynchronousOperations();
-      const strategy = element.$$('.strategy');
-      assert.ok(strategy);
-      assert.isFalse(strategy.hasAttribute('hidden'));
-      assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
-    });
-
-    test('hide strategy for closed change', () => {
-      element.change = {status: 'MERGED', labels: {}};
-      flushAsynchronousOperations();
-      assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
-    });
-
-    test('weblinks use Gerrit.Nav interface', () => {
-      const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-          .returns([{name: 'stubb', url: '#s'}]);
-      element.commitInfo = {};
-      element.serverConfig = {};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(weblinksStub.called);
-      assert.isFalse(webLinks.hasAttribute('hidden'));
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-    });
-
-    test('weblinks hidden when no weblinks', () => {
-      element.commitInfo = {};
-      element.serverConfig = {};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(webLinks.hasAttribute('hidden'));
-    });
-
-    test('weblinks hidden when only gitiles weblink', () => {
-      element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
-      element.serverConfig = {};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(webLinks.hasAttribute('hidden'));
-      assert.equal(element._computeWebLinks(element.commitInfo), null);
-    });
-
-    test('weblinks hidden when sole weblink is set as primary', () => {
-      const browser = 'browser';
-      element.commitInfo = {web_links: [{name: browser, url: '#'}]};
-      element.serverConfig = {
-        gerrit: {
-          primary_weblink_name: browser,
-        },
-      };
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isTrue(webLinks.hasAttribute('hidden'));
-    });
-
-    test('weblinks are visible when other weblinks', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isFalse(webLinks.hasAttribute('hidden'));
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-      // With two non-gitiles weblinks, there are two returned.
-      element.commitInfo = {
-        web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
-    });
-
-    test('weblinks are visible when gitiles and other weblinks', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.commitInfo = {
-        web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
-      flushAsynchronousOperations();
-      const webLinks = element.$.webLinks;
-      assert.isFalse(webLinks.hasAttribute('hidden'));
-      // Only the non-gitiles weblink is returned.
-      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-    });
-
-    suite('_getNonOwnerRole', () => {
-      let change;
-
-      setup(() => {
-        change = {
-          owner: {
-            email: 'abc@def',
-            _account_id: 1019328,
-          },
-          revisions: {
-            rev1: {
-              _number: 1,
-              uploader: {
-                email: 'ghi@def',
-                _account_id: 1011123,
-              },
-              commit: {
-                author: {email: 'jkl@def'},
-                committer: {email: 'ghi@def'},
-              },
-            },
-          },
-          current_revision: 'rev1',
-        };
-      });
-
-      suite('role=uploader', () => {
-        test('_getNonOwnerRole for uploader', () => {
-          assert.deepEqual(
-              element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
-              {email: 'ghi@def', _account_id: 1011123});
-        });
-
-        test('_getNonOwnerRole that it does not return uploader', () => {
-          // Set the uploader email to be the same as the owner.
-          change.revisions.rev1.uploader._account_id = 1019328;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.UPLOADER));
-        });
-
-        test('_getNonOwnerRole null for uploader with no current rev', () => {
-          delete change.current_revision;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.UPLOADER));
-        });
-
-        test('_computeShowRoleClass show uploader', () => {
-          assert.equal(element._computeShowRoleClass(
-              change, element._CHANGE_ROLE.UPLOADER), '');
-        });
-
-        test('_computeShowRoleClass hide uploader', () => {
-          // Set the uploader email to be the same as the owner.
-          change.revisions.rev1.uploader._account_id = 1019328;
-          assert.equal(element._computeShowRoleClass(change,
-              element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
-        });
-      });
-
-      suite('role=committer', () => {
-        test('_getNonOwnerRole for committer', () => {
-          assert.deepEqual(
-              element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
-              {email: 'ghi@def'});
-        });
-
-        test('_getNonOwnerRole that it does not return committer', () => {
-          // Set the committer email to be the same as the owner.
-          change.revisions.rev1.commit.committer.email = 'abc@def';
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-
-        test('_getNonOwnerRole null for committer with no current rev', () => {
-          delete change.current_revision;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-
-        test('_getNonOwnerRole null for committer with no commit', () => {
-          delete change.revisions.rev1.commit;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-
-        test('_getNonOwnerRole null for committer with no committer', () => {
-          delete change.revisions.rev1.commit.committer;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.COMMITTER));
-        });
-      });
-
-      suite('role=author', () => {
-        test('_getNonOwnerRole for author', () => {
-          assert.deepEqual(
-              element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-              {email: 'jkl@def'});
-        });
-
-        test('_getNonOwnerRole that it does not return author', () => {
-          // Set the author email to be the same as the owner.
-          change.revisions.rev1.commit.author.email = 'abc@def';
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-
-        test('_getNonOwnerRole null for author with no current rev', () => {
-          delete change.current_revision;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-
-        test('_getNonOwnerRole null for author with no commit', () => {
-          delete change.revisions.rev1.commit;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-
-        test('_getNonOwnerRole null for author with no author', () => {
-          delete change.revisions.rev1.commit.author;
-          assert.isNull(element._getNonOwnerRole(change,
-              element._CHANGE_ROLE.AUTHOR));
-        });
-      });
-    });
-
-    test('Push Certificate Validation test BAD', () => {
-      const serverConfig = {
-        receive: {
-          enable_signed_push: true,
-        },
-      };
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      change = {
         owner: {
+          email: 'abc@def',
           _account_id: 1019328,
         },
         revisions: {
           rev1: {
             _number: 1,
-            push_certificate: {
-              key: {
-                status: 'BAD',
-                problems: [
-                  'No public keys found for key ID E5E20E52',
-                ],
-              },
+            uploader: {
+              email: 'ghi@def',
+              _account_id: 1011123,
+            },
+            commit: {
+              author: {email: 'jkl@def'},
+              committer: {email: 'ghi@def'},
             },
           },
         },
         current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
       };
-      const result =
-          element._computePushCertificateValidation(serverConfig, change);
-      assert.equal(result.message,
-          'Push certificate is invalid:\n' +
-          'No public keys found for key ID E5E20E52');
-      assert.equal(result.icon, 'gr-icons:close');
-      assert.equal(result.class, 'invalid');
     });
 
-    test('Push Certificate Validation test TRUSTED', () => {
-      const serverConfig = {
-        receive: {
-          enable_signed_push: true,
-        },
-      };
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            push_certificate: {
-              key: {
-                status: 'TRUSTED',
-              },
-            },
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      const result =
-          element._computePushCertificateValidation(serverConfig, change);
-      assert.equal(result.message,
-          'Push certificate is valid and key is trusted');
-      assert.equal(result.icon, 'gr-icons:check');
-      assert.equal(result.class, 'trusted');
-    });
-
-    test('Push Certificate Validation is missing test', () => {
-      const serverConfig = {
-        receive: {
-          enable_signed_push: true,
-        },
-      };
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      const result =
-          element._computePushCertificateValidation(serverConfig, change);
-      assert.equal(result.message,
-          'This patch set was created without a push certificate');
-      assert.equal(result.icon, 'gr-icons:help');
-      assert.equal(result.class, 'help');
-    });
-
-    test('_computeParents', () => {
-      const parents = [{commit: '123', subject: 'abc'}];
-      const revision = {commit: {parents}};
-      assert.deepEqual(element._computeParents({}, {}), []);
-      assert.equal(element._computeParents(null, revision), parents);
-      const change = current_revision => {
-        return {current_revision, revisions: {456: revision}};
-      };
-      assert.deepEqual(element._computeParents(change(null), null), []);
-      const change_bad_revision = change('789');
-      assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
-      const change_no_commit = {current_revision: '456', revisions: {456: {}}};
-      assert.deepEqual(element._computeParents(change_no_commit, null), []);
-      const change_good = change('456');
-      assert.equal(element._computeParents(change_good, null), parents);
-    });
-
-    test('_currentParents', () => {
-      const revision = parent => {
-        return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
-      };
-      element.change = {
-        current_revision: '456',
-        revisions: {456: revision('111')},
-      };
-      element.revision = revision('222');
-      assert.equal(element._currentParents[0].commit, '222');
-      element.revision = revision('333');
-      assert.equal(element._currentParents[0].commit, '333');
-      element.revision = null;
-      assert.equal(element._currentParents[0].commit, '111');
-      element.change = {current_revision: null};
-      assert.deepEqual(element._currentParents, []);
-    });
-
-    test('_computeParentsLabel', () => {
-      const parent = {commit: 'abc123', subject: 'My parent commit'};
-      assert.equal(element._computeParentsLabel([parent]), 'Parent');
-      assert.equal(element._computeParentsLabel([parent, parent]),
-          'Parents');
-    });
-
-    test('_computeParentListClass', () => {
-      const parent = {commit: 'abc123', subject: 'My parent commit'};
-      assert.equal(element._computeParentListClass([parent], true),
-          'parentList nonMerge current');
-      assert.equal(element._computeParentListClass([parent], false),
-          'parentList nonMerge notCurrent');
-      assert.equal(element._computeParentListClass([parent, parent], false),
-          'parentList merge notCurrent');
-      assert.equal(element._computeParentListClass([parent, parent], true),
-          'parentList merge current');
-    });
-
-    test('_showAddTopic', () => {
-      assert.isTrue(element._showAddTopic(null, false));
-      assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
-      assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
-      assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
-      assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
-    });
-
-    test('_showTopicChip', () => {
-      assert.isFalse(element._showTopicChip(null, false));
-      assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
-      assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
-      assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
-      assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
-    });
-
-    suite('Topic removal', () => {
-      let change;
-      setup(() => {
-        change = {
-          _number: 'the number',
-          actions: {
-            topic: {enabled: false},
-          },
-          change_id: 'the id',
-          topic: 'the topic',
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {
-            test: {
-              all: [{_account_id: 1, name: 'bojack', value: 1}],
-              default_value: 0,
-              values: [],
-            },
-          },
-          removable_reviewers: [],
-        };
+    suite('role=uploader', () => {
+      test('_getNonOwnerRole for uploader', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+            {email: 'ghi@def', _account_id: 1011123});
       });
 
-      test('_computeTopicReadOnly', () => {
-        let mutable = false;
-        assert.isTrue(element._computeTopicReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeTopicReadOnly(mutable, change));
-        change.actions.topic.enabled = true;
-        assert.isFalse(element._computeTopicReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      test('_getNonOwnerRole that it does not return uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change.revisions.rev1.uploader._account_id = 1019328;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.UPLOADER));
       });
 
-      test('topic read only hides delete button', () => {
-        element.account = {};
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.$$('gr-linked-chip').$$('gr-button');
-        assert.isTrue(button.hasAttribute('hidden'));
+      test('_getNonOwnerRole null for uploader with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.UPLOADER));
       });
 
-      test('topic not read only does not hide delete button', () => {
-        element.account = {test: true};
-        change.actions.topic.enabled = true;
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.$$('gr-linked-chip').$$('gr-button');
-        assert.isFalse(button.hasAttribute('hidden'));
+      test('_computeShowRoleClass show uploader', () => {
+        assert.equal(element._computeShowRoleClass(
+            change, element._CHANGE_ROLE.UPLOADER), '');
+      });
+
+      test('_computeShowRoleClass hide uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change.revisions.rev1.uploader._account_id = 1019328;
+        assert.equal(element._computeShowRoleClass(change,
+            element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
       });
     });
 
-    suite('Hashtag removal', () => {
-      let change;
-      setup(() => {
-        change = {
-          _number: 'the number',
-          actions: {
-            hashtags: {enabled: false},
-          },
-          change_id: 'the id',
-          hashtags: ['test-hashtag'],
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {
-            test: {
-              all: [{_account_id: 1, name: 'bojack', value: 1}],
-              default_value: 0,
-              values: [],
-            },
-          },
-          removable_reviewers: [],
-        };
+    suite('role=committer', () => {
+      test('_getNonOwnerRole for committer', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+            {email: 'ghi@def'});
       });
 
-      test('_computeHashtagReadOnly', () => {
-        flushAsynchronousOperations();
-        let mutable = false;
-        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-        change.actions.hashtags.enabled = true;
-        assert.isFalse(element._computeHashtagReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      test('_getNonOwnerRole that it does not return committer', () => {
+        // Set the committer email to be the same as the owner.
+        change.revisions.rev1.commit.committer.email = 'abc@def';
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
       });
 
-      test('hashtag read only hides delete button', () => {
-        flushAsynchronousOperations();
-        element.account = {};
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.$$('gr-linked-chip').$$('gr-button');
-        assert.isTrue(button.hasAttribute('hidden'));
+      test('_getNonOwnerRole null for committer with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
       });
 
-      test('hashtag not read only does not hide delete button', () => {
-        flushAsynchronousOperations();
-        element.account = {test: true};
-        change.actions.hashtags.enabled = true;
-        element.change = change;
-        flushAsynchronousOperations();
-        const button = element.$$('gr-linked-chip').$$('gr-button');
-        assert.isFalse(button.hasAttribute('hidden'));
+      test('_getNonOwnerRole null for committer with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no committer', () => {
+        delete change.revisions.rev1.commit.committer;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
       });
     });
 
-    suite('remove reviewer votes', () => {
-      setup(() => {
-        sandbox.stub(element, '_computeTopicReadOnly').returns(true);
-        element.change = {
-          _number: 42,
-          change_id: 'the id',
-          actions: [],
-          topic: 'the topic',
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {
-            test: {
-              all: [{_account_id: 1, name: 'bojack', value: 1}],
-              default_value: 0,
-              values: [],
-            },
-          },
-          removable_reviewers: [],
-        };
-        flushAsynchronousOperations();
+    suite('role=author', () => {
+      test('_getNonOwnerRole for author', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+            {email: 'jkl@def'});
       });
 
-      suite('assignee field', () => {
-        const dummyAccount = {
-          _account_id: 1,
-          name: 'bojack',
-        };
-        const change = {
-          actions: {
-            assignee: {enabled: false},
-          },
-          assignee: dummyAccount,
-        };
-        let deleteStub;
-        let setStub;
-
-        setup(() => {
-          deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
-          setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
-        });
-
-        test('changing change recomputes _assignee', () => {
-          assert.isFalse(!!element._assignee.length);
-          const change = element.change;
-          change.assignee = dummyAccount;
-          element._changeChanged(change);
-          assert.deepEqual(element._assignee[0], dummyAccount);
-        });
-
-        test('modifying _assignee calls API', () => {
-          assert.isFalse(!!element._assignee.length);
-          element.set('_assignee', [dummyAccount]);
-          assert.isTrue(setStub.calledOnce);
-          assert.deepEqual(element.change.assignee, dummyAccount);
-          element.set('_assignee', [dummyAccount]);
-          assert.isTrue(setStub.calledOnce);
-          element.set('_assignee', []);
-          assert.isTrue(deleteStub.calledOnce);
-          assert.equal(element.change.assignee, undefined);
-          element.set('_assignee', []);
-          assert.isTrue(deleteStub.calledOnce);
-        });
-
-        test('_computeAssigneeReadOnly', () => {
-          let mutable = false;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-          mutable = true;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-          change.actions.assignee.enabled = true;
-          assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
-          mutable = false;
-          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        });
+      test('_getNonOwnerRole that it does not return author', () => {
+        // Set the author email to be the same as the owner.
+        change.revisions.rev1.commit.author.email = 'abc@def';
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
 
-      test('changing topic', () => {
-        const newTopic = 'the new topic';
-        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-            Promise.resolve(newTopic));
-        element._handleTopicChanged({}, newTopic);
-        const topicChangedSpy = sandbox.spy();
-        element.addEventListener('topic-changed', topicChangedSpy);
-        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-            42, newTopic));
-        return element.$.restAPI.setChangeTopic.lastCall.returnValue
-            .then(() => {
-              assert.equal(element.change.topic, newTopic);
-              assert.isTrue(topicChangedSpy.called);
-            });
+      test('_getNonOwnerRole null for author with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
 
-      test('topic removal', () => {
-        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-            Promise.resolve());
-        const chip = element.$$('gr-linked-chip');
-        const remove = chip.$.remove;
-        const topicChangedSpy = sandbox.spy();
-        element.addEventListener('topic-changed', topicChangedSpy);
-        MockInteractions.tap(remove);
-        assert.isTrue(chip.disabled);
-        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-            42, null));
-        return element.$.restAPI.setChangeTopic.lastCall.returnValue
-            .then(() => {
-              assert.isFalse(chip.disabled);
-              assert.equal(element.change.topic, '');
-              assert.isTrue(topicChangedSpy.called);
-            });
+      test('_getNonOwnerRole null for author with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
 
-      test('changing hashtag', () => {
-        flushAsynchronousOperations();
-        element._newHashtag = 'new hashtag';
-        const newHashtag = ['new hashtag'];
-        sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
-            Promise.resolve(newHashtag));
-        element._handleHashtagChanged({}, 'new hashtag');
-        assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
-            42, {add: ['new hashtag']}));
-        return element.$.restAPI.setChangeHashtag.lastCall.returnValue
-            .then(() => {
-              assert.equal(element.change.hashtags, newHashtag);
-            });
-      });
-    });
-
-    test('editTopic', () => {
-      element.account = {test: true};
-      element.change = {actions: {topic: {enabled: true}}};
-      flushAsynchronousOperations();
-
-      const label = element.$$('.topicEditableLabel');
-      assert.ok(label);
-      sandbox.stub(label, 'open');
-      element.editTopic();
-      flushAsynchronousOperations();
-
-      assert.isTrue(label.open.called);
-    });
-
-    suite('plugin endpoints', () => {
-      test('endpoint params', done => {
-        element.change = {labels: {}};
-        element.revision = {};
-        let hookEl;
-        let plugin;
-        Gerrit.install(
-            p => {
-              plugin = p;
-              plugin.hook('change-metadata-item').getLastAttached().then(
-                  el => hookEl = el);
-            },
-            '0.1',
-            'http://some/plugins/url.html');
-        Gerrit._loadPlugins([]);
-        flush(() => {
-          assert.strictEqual(hookEl.plugin, plugin);
-          assert.strictEqual(hookEl.change, element.change);
-          assert.strictEqual(hookEl.revision, element.revision);
-          done();
-        });
+      test('_getNonOwnerRole null for author with no author', () => {
+        delete change.revisions.rev1.commit.author;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
       });
     });
   });
+
+  test('Push Certificate Validation test BAD', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+          push_certificate: {
+            key: {
+              status: 'BAD',
+              problems: [
+                'No public keys found for key ID E5E20E52',
+              ],
+            },
+          },
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'Push certificate is invalid:\n' +
+        'No public keys found for key ID E5E20E52');
+    assert.equal(result.icon, 'gr-icons:close');
+    assert.equal(result.class, 'invalid');
+  });
+
+  test('Push Certificate Validation test TRUSTED', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+          push_certificate: {
+            key: {
+              status: 'TRUSTED',
+            },
+          },
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'Push certificate is valid and key is trusted');
+    assert.equal(result.icon, 'gr-icons:check');
+    assert.equal(result.class, 'trusted');
+  });
+
+  test('Push Certificate Validation is missing test', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'This patch set was created without a push certificate');
+    assert.equal(result.icon, 'gr-icons:help');
+    assert.equal(result.class, 'help');
+  });
+
+  test('_computeParents', () => {
+    const parents = [{commit: '123', subject: 'abc'}];
+    const revision = {commit: {parents}};
+    assert.deepEqual(element._computeParents({}, {}), []);
+    assert.equal(element._computeParents(null, revision), parents);
+    const change = current_revision => {
+      return {current_revision, revisions: {456: revision}};
+    };
+    assert.deepEqual(element._computeParents(change(null), null), []);
+    const change_bad_revision = change('789');
+    assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
+    const change_no_commit = {current_revision: '456', revisions: {456: {}}};
+    assert.deepEqual(element._computeParents(change_no_commit, null), []);
+    const change_good = change('456');
+    assert.equal(element._computeParents(change_good, null), parents);
+  });
+
+  test('_currentParents', () => {
+    const revision = parent => {
+      return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
+    };
+    element.change = {
+      current_revision: '456',
+      revisions: {456: revision('111')},
+    };
+    element.revision = revision('222');
+    assert.equal(element._currentParents[0].commit, '222');
+    element.revision = revision('333');
+    assert.equal(element._currentParents[0].commit, '333');
+    element.revision = null;
+    assert.equal(element._currentParents[0].commit, '111');
+    element.change = {current_revision: null};
+    assert.deepEqual(element._currentParents, []);
+  });
+
+  test('_computeParentsLabel', () => {
+    const parent = {commit: 'abc123', subject: 'My parent commit'};
+    assert.equal(element._computeParentsLabel([parent]), 'Parent');
+    assert.equal(element._computeParentsLabel([parent, parent]),
+        'Parents');
+  });
+
+  test('_computeParentListClass', () => {
+    const parent = {commit: 'abc123', subject: 'My parent commit'};
+    assert.equal(element._computeParentListClass([parent], true),
+        'parentList nonMerge current');
+    assert.equal(element._computeParentListClass([parent], false),
+        'parentList nonMerge notCurrent');
+    assert.equal(element._computeParentListClass([parent, parent], false),
+        'parentList merge notCurrent');
+    assert.equal(element._computeParentListClass([parent, parent], true),
+        'parentList merge current');
+  });
+
+  test('_showAddTopic', () => {
+    assert.isTrue(element._showAddTopic(null, false));
+    assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
+    assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
+    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
+    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
+  });
+
+  test('_showTopicChip', () => {
+    assert.isFalse(element._showTopicChip(null, false));
+    assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
+    assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
+    assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
+    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(() => {
+      change = {
+        _number: 'the number',
+        actions: {
+          topic: {enabled: false},
+        },
+        change_id: 'the id',
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeTopicReadOnly', () => {
+      let mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      change.actions.topic.enabled = true;
+      assert.isFalse(element._computeTopicReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+    });
+
+    test('topic read only hides delete button', () => {
+      element.account = {};
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
+    });
+
+    test('topic not read only does not hide delete button', () => {
+      element.account = {test: true};
+      change.actions.topic.enabled = true;
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+  });
+
+  suite('Hashtag removal', () => {
+    let change;
+    setup(() => {
+      change = {
+        _number: 'the number',
+        actions: {
+          hashtags: {enabled: false},
+        },
+        change_id: 'the id',
+        hashtags: ['test-hashtag'],
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeHashtagReadOnly', () => {
+      flushAsynchronousOperations();
+      let mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      change.actions.hashtags.enabled = true;
+      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+    });
+
+    test('hashtag read only hides delete button', () => {
+      flushAsynchronousOperations();
+      element.account = {};
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
+    });
+
+    test('hashtag not read only does not hide delete button', () => {
+      flushAsynchronousOperations();
+      element.account = {test: true};
+      change.actions.hashtags.enabled = true;
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sandbox.stub(element, '_computeTopicReadOnly').returns(true);
+      element.change = {
+        _number: 42,
+        change_id: 'the id',
+        actions: [],
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+      flushAsynchronousOperations();
+    });
+
+    suite('assignee field', () => {
+      const dummyAccount = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      const change = {
+        actions: {
+          assignee: {enabled: false},
+        },
+        assignee: dummyAccount,
+      };
+      let deleteStub;
+      let setStub;
+
+      setup(() => {
+        deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
+        setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
+        element.serverConfig = {
+          change: {
+            enable_assignee: true,
+          },
+        };
+      });
+
+      test('changing change recomputes _assignee', () => {
+        assert.isFalse(!!element._assignee.length);
+        const change = element.change;
+        change.assignee = dummyAccount;
+        element._changeChanged(change);
+        assert.deepEqual(element._assignee[0], dummyAccount);
+      });
+
+      test('modifying _assignee calls API', () => {
+        assert.isFalse(!!element._assignee.length);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        assert.deepEqual(element.change.assignee, dummyAccount);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+        assert.equal(element.change.assignee, undefined);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+      });
+
+      test('_computeAssigneeReadOnly', () => {
+        let mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        mutable = true;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        change.actions.assignee.enabled = true;
+        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+        mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+      });
+    });
+
+    test('changing topic', () => {
+      const newTopic = 'the new topic';
+      sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve(newTopic));
+      element._handleTopicChanged({}, newTopic);
+      const topicChangedSpy = sandbox.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+          42, newTopic));
+      return element.$.restAPI.setChangeTopic.lastCall.returnValue
+          .then(() => {
+            assert.equal(element.change.topic, newTopic);
+            assert.isTrue(topicChangedSpy.called);
+          });
+    });
+
+    test('topic removal', () => {
+      sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve());
+      const chip = element.shadowRoot
+          .querySelector('gr-linked-chip');
+      const remove = chip.$.remove;
+      const topicChangedSpy = sandbox.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      MockInteractions.tap(remove);
+      assert.isTrue(chip.disabled);
+      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+          42, null));
+      return element.$.restAPI.setChangeTopic.lastCall.returnValue
+          .then(() => {
+            assert.isFalse(chip.disabled);
+            assert.equal(element.change.topic, '');
+            assert.isTrue(topicChangedSpy.called);
+          });
+    });
+
+    test('changing hashtag', () => {
+      flushAsynchronousOperations();
+      element._newHashtag = 'new hashtag';
+      const newHashtag = ['new hashtag'];
+      sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
+          Promise.resolve(newHashtag));
+      element._handleHashtagChanged({}, 'new hashtag');
+      assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
+          42, {add: ['new hashtag']}));
+      return element.$.restAPI.setChangeHashtag.lastCall.returnValue
+          .then(() => {
+            assert.equal(element.change.hashtags, newHashtag);
+          });
+    });
+  });
+
+  test('editTopic', () => {
+    element.account = {test: true};
+    element.change = {actions: {topic: {enabled: true}}};
+    flushAsynchronousOperations();
+
+    const label = element.shadowRoot
+        .querySelector('.topicEditableLabel');
+    assert.ok(label);
+    sandbox.stub(label, 'open');
+    element.editTopic();
+    flushAsynchronousOperations();
+
+    assert.isTrue(label.open.called);
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element.change = {labels: {}};
+      element.revision = {};
+      let hookEl;
+      let plugin;
+      pluginApi.install(
+          p => {
+            plugin = p;
+            plugin.hook('change-metadata-item').getLastAttached()
+                .then(el => hookEl = el);
+          },
+          '0.1',
+          'http://some/plugins/url.html');
+      pluginLoader.loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        assert.strictEqual(hookEl.revision, element.revision);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
deleted file mode 100644
index 47ff7f7..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
+++ /dev/null
@@ -1,169 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-label/gr-label.html">
-<link rel="import" href="../../shared/gr-label-info/gr-label-info.html">
-<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
-
-<dom-module id="gr-change-requirements">
-  <template strip-whitespace>
-    <style include="shared-styles">
-      :host {
-        display: table;
-        width: 100%;
-      }
-      .status {
-        color: #FFA62F;
-        display: inline-block;
-        text-align: center;
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-      }
-      .approved.status {
-        color: var(--vote-text-color-recommended);
-      }
-      .rejected.status {
-        color: var(--vote-text-color-disliked);
-      }
-      iron-icon {
-        color: inherit;
-      }
-      .status iron-icon {
-        vertical-align: top;
-      }
-      section {
-        display: table-row;
-      }
-      .show-hide {
-        float: right;
-      }
-      .title {
-        min-width: 10em;
-        padding: var(--spacing-s) var(--spacing-m) 0 var(--requirements-horizontal-padding);
-      }
-      .value {
-        padding: var(--spacing-s) 0 0 0;
-      }
-      .title,
-      .value {
-        display: table-cell;
-        vertical-align: top;
-      }
-      .hidden {
-        display: none;
-      }
-      .showHide {
-        cursor: pointer;
-      }
-      .showHide .title {
-        padding-bottom: var(--spacing-m);
-        padding-top: var(--spacing-l);
-      }
-      .showHide .value {
-        padding-top: 0;
-        vertical-align: middle;
-      }
-      .showHide iron-icon {
-        color: var(--deemphasized-text-color);
-        float: right;
-      }
-      .spacer {
-        height: var(--spacing-m);
-      }
-    </style>
-    <template
-        is="dom-repeat"
-        items="[[_requirements]]">
-      <section>
-        <div class="title requirement">
-          <span class$="status [[item.style]]">
-            <iron-icon class="icon" icon="[[_computeRequirementIcon(item.satisfied)]]"></iron-icon>
-          </span>
-          <gr-limited-text class="name" limit="40" text="[[item.fallback_text]]"></gr-limited-text>
-        </div>
-      </section>
-    </template>
-    <template
-        is="dom-repeat"
-        items="[[_requiredLabels]]">
-      <section>
-        <div class="title">
-          <span class$="status [[item.style]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </span>
-          <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
-        </div>
-        <div class="value">
-          <gr-label-info
-              change="{{change}}"
-              account="[[account]]"
-              mutable="[[mutable]]"
-              label="[[item.label]]"
-              label-info="[[item.labelInfo]]"></gr-label-info>
-        </div>
-      </section>
-    </template>
-    <section class="spacer"></section>
-    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"></section>
-    <section
-        show-bottom-border$="[[_showOptionalLabels]]"
-        on-click="_handleShowHide"
-        class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
-      <div class="title">Other labels</div>
-      <div class="value">
-        <iron-icon
-            id="showHide"
-            icon="[[_computeShowHideIcon(_showOptionalLabels)]]">
-        </iron-icon>
-      </label>
-      </div>
-    </section>
-    <template
-        is="dom-repeat"
-        items="[[_optionalLabels]]">
-      <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-        <div class="title">
-          <span class$="status [[item.style]]">
-            <template is="dom-if" if="[[item.icon]]">
-              <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-            </template>
-            <template is="dom-if" if="[[!item.icon]]">
-              <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-            </template>
-          </span>
-          <gr-limited-text class="name" limit="40" text="[[item.label]]"></gr-limited-text>
-        </div>
-        <div class="value">
-          <gr-label-info
-              change="{{change}}"
-              account="[[account]]"
-              mutable="[[mutable]]"
-              label="[[item.label]]"
-              label-info="[[item.labelInfo]]"></gr-label-info>
-        </div>
-      </section>
-    </template>
-    <section class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"></section>
-  </template>
-  <script src="gr-change-requirements.js"></script>
-</dom-module>
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..d301813 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
@@ -14,14 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-change-requirements',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-label/gr-label.js';
+import '../../shared/gr-label-info/gr-label-info.js';
+import '../../shared/gr-limited-text/gr-limited-text.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-requirements_html.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 
-    properties: {
-      /** @type {?} */
+/**
+ * @extends Polymer.Element
+ */
+class GrChangeRequirements extends mixinBehaviors( [
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-requirements'; }
+
+  static get properties() {
+    return {
+    /** @type {?} */
       change: Object,
       account: Object,
       mutable: Boolean,
@@ -45,106 +67,106 @@
         type: Boolean,
         value: true,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.RESTClientBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_computeLabels(change.labels.*)',
-    ],
+    ];
+  }
 
-    _computeShowWip(change) {
-      return change.work_in_progress;
-    },
+  _computeShowWip(change) {
+    return change.work_in_progress;
+  }
 
-    _computeRequirements(change) {
-      const _requirements = [];
+  _computeRequirements(change) {
+    const _requirements = [];
 
-      if (change.requirements) {
-        for (const requirement of change.requirements) {
-          requirement.satisfied = requirement.status === 'OK';
-          requirement.style =
-              this._computeRequirementClass(requirement.satisfied);
-          _requirements.push(requirement);
-        }
+    if (change.requirements) {
+      for (const requirement of change.requirements) {
+        requirement.satisfied = requirement.status === 'OK';
+        requirement.style =
+            this._computeRequirementClass(requirement.satisfied);
+        _requirements.push(requirement);
       }
-      if (change.work_in_progress) {
-        _requirements.push({
-          fallback_text: 'Work-in-progress',
-          tooltip: 'Change must not be in \'Work in Progress\' state.',
-        });
-      }
+    }
+    if (change.work_in_progress) {
+      _requirements.push({
+        fallback_text: 'Work-in-progress',
+        tooltip: 'Change must not be in \'Work in Progress\' state.',
+      });
+    }
 
-      return _requirements;
-    },
+    return _requirements;
+  }
 
-    _computeRequirementClass(requirementStatus) {
-      return requirementStatus ? 'approved' : '';
-    },
+  _computeRequirementClass(requirementStatus) {
+    return requirementStatus ? 'approved' : '';
+  }
 
-    _computeRequirementIcon(requirementStatus) {
-      return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
-    },
+  _computeRequirementIcon(requirementStatus) {
+    return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
+  }
 
-    _computeLabels(labelsRecord) {
-      const labels = labelsRecord.base;
-      this._optionalLabels = [];
-      this._requiredLabels = [];
+  _computeLabels(labelsRecord) {
+    const labels = labelsRecord.base;
+    this._optionalLabels = [];
+    this._requiredLabels = [];
 
-      for (const label in labels) {
-        if (!labels.hasOwnProperty(label)) { continue; }
+    for (const label in labels) {
+      if (!labels.hasOwnProperty(label)) { continue; }
 
-        const labelInfo = labels[label];
-        const icon = this._computeLabelIcon(labelInfo);
-        const style = this._computeLabelClass(labelInfo);
-        const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
+      const labelInfo = labels[label];
+      const icon = this._computeLabelIcon(labelInfo);
+      const style = this._computeLabelClass(labelInfo);
+      const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
 
-        this.push(path, {label, icon, style, labelInfo});
-      }
-    },
+      this.push(path, {label, icon, style, labelInfo});
+    }
+  }
 
-    /**
-     * @param {Object} labelInfo
-     * @return {string} The icon name, or undefined if no icon should
-     *     be used.
-     */
-    _computeLabelIcon(labelInfo) {
-      if (labelInfo.approved) { return 'gr-icons:check'; }
-      if (labelInfo.rejected) { return 'gr-icons:close'; }
-      return 'gr-icons:hourglass';
-    },
+  /**
+   * @param {Object} labelInfo
+   * @return {string} The icon name, or undefined if no icon should
+   *     be used.
+   */
+  _computeLabelIcon(labelInfo) {
+    if (labelInfo.approved) { return 'gr-icons:check'; }
+    if (labelInfo.rejected) { return 'gr-icons:close'; }
+    return 'gr-icons:hourglass';
+  }
 
-    /**
-     * @param {Object} labelInfo
-     */
-    _computeLabelClass(labelInfo) {
-      if (labelInfo.approved) { return 'approved'; }
-      if (labelInfo.rejected) { return 'rejected'; }
-      return '';
-    },
+  /**
+   * @param {Object} labelInfo
+   */
+  _computeLabelClass(labelInfo) {
+    if (labelInfo.approved) { return 'approved'; }
+    if (labelInfo.rejected) { return 'rejected'; }
+    return '';
+  }
 
-    _computeShowOptional(optionalFieldsRecord) {
-      return optionalFieldsRecord.base.length ? '' : 'hidden';
-    },
+  _computeShowOptional(optionalFieldsRecord) {
+    return optionalFieldsRecord.base.length ? '' : 'hidden';
+  }
 
-    _computeLabelValue(value) {
-      return (value > 0 ? '+' : '') + value;
-    },
+  _computeLabelValue(value) {
+    return (value > 0 ? '+' : '') + value;
+  }
 
-    _computeShowHideIcon(showOptionalLabels) {
-      return showOptionalLabels ?
-        'gr-icons:expand-less' :
-        'gr-icons:expand-more';
-    },
+  _computeShowHideIcon(showOptionalLabels) {
+    return showOptionalLabels ?
+      'gr-icons:expand-less' :
+      'gr-icons:expand-more';
+  }
 
-    _computeSectionClass(show) {
-      return show ? '' : 'hidden';
-    },
+  _computeSectionClass(show) {
+    return show ? '' : 'hidden';
+  }
 
-    _handleShowHide(e) {
-      this._showOptionalLabels = !this._showOptionalLabels;
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
new file mode 100644
index 0000000..0da31de
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
@@ -0,0 +1,175 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: table;
+      width: 100%;
+    }
+    .status {
+      color: #ffa62f;
+      display: inline-block;
+      text-align: center;
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    .approved.status {
+      color: var(--vote-text-color-recommended);
+    }
+    .rejected.status {
+      color: var(--vote-text-color-disliked);
+    }
+    iron-icon {
+      color: inherit;
+    }
+    .status iron-icon {
+      vertical-align: top;
+    }
+    section {
+      display: table-row;
+    }
+    .show-hide {
+      float: right;
+    }
+    .title {
+      min-width: 10em;
+      padding: var(--spacing-s) var(--spacing-m) 0
+        var(--requirements-horizontal-padding);
+    }
+    .value {
+      padding: var(--spacing-s) 0 0 0;
+    }
+    .title,
+    .value {
+      display: table-cell;
+      vertical-align: top;
+    }
+    .hidden {
+      display: none;
+    }
+    .showHide {
+      cursor: pointer;
+    }
+    .showHide .title {
+      padding-bottom: var(--spacing-m);
+      padding-top: var(--spacing-l);
+    }
+    .showHide .value {
+      padding-top: 0;
+      vertical-align: middle;
+    }
+    .showHide iron-icon {
+      color: var(--deemphasized-text-color);
+      float: right;
+    }
+    .spacer {
+      height: var(--spacing-m);
+    }
+  </style>
+  <template is="dom-repeat" items="[[_requirements]]">
+    <section>
+      <div class="title requirement">
+        <span class$="status [[item.style]]">
+          <iron-icon
+            class="icon"
+            icon="[[_computeRequirementIcon(item.satisfied)]]"
+          ></iron-icon>
+        </span>
+        <gr-limited-text
+          class="name"
+          limit="40"
+          text="[[item.fallback_text]]"
+        ></gr-limited-text>
+      </div>
+    </section>
+  </template>
+  <template is="dom-repeat" items="[[_requiredLabels]]">
+    <section>
+      <div class="title">
+        <span class$="status [[item.style]]">
+          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+        </span>
+        <gr-limited-text
+          class="name"
+          limit="40"
+          text="[[item.label]]"
+        ></gr-limited-text>
+      </div>
+      <div class="value">
+        <gr-label-info
+          change="{{change}}"
+          account="[[account]]"
+          mutable="[[mutable]]"
+          label="[[item.label]]"
+          label-info="[[item.labelInfo]]"
+        ></gr-label-info>
+      </div>
+    </section>
+  </template>
+  <section class="spacer"></section>
+  <section
+    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
+  ></section>
+  <section
+    show-bottom-border$="[[_showOptionalLabels]]"
+    on-click="_handleShowHide"
+    class$="showHide [[_computeShowOptional(_optionalLabels.*)]]"
+  >
+    <div class="title">Other labels</div>
+    <div class="value">
+      <iron-icon
+        id="showHide"
+        icon="[[_computeShowHideIcon(_showOptionalLabels)]]"
+      >
+      </iron-icon>
+    </div>
+  </section>
+  <template is="dom-repeat" items="[[_optionalLabels]]">
+    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
+      <div class="title">
+        <span class$="status [[item.style]]">
+          <template is="dom-if" if="[[item.icon]]">
+            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+          </template>
+          <template is="dom-if" if="[[!item.icon]]">
+            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
+          </template>
+        </span>
+        <gr-limited-text
+          class="name"
+          limit="40"
+          text="[[item.label]]"
+        ></gr-limited-text>
+      </div>
+      <div class="value">
+        <gr-label-info
+          change="{{change}}"
+          account="[[account]]"
+          mutable="[[mutable]]"
+          label="[[item.label]]"
+          label-info="[[item.labelInfo]]"
+        ></gr-label-info>
+      </div>
+    </section>
+  </template>
+  <section
+    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
+  ></section>
+`;
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..e100f91 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-requirements.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,191 +31,206 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-metadata tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-requirements.js';
+import {isHidden} from '../../../test/test-utils.js';
+suite('gr-change-metadata tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('requirements computed fields', () => {
-      assert.isTrue(element._computeShowWip({work_in_progress: true}));
-      assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-      assert.equal(element._computeRequirementClass(true), 'approved');
-      assert.equal(element._computeRequirementClass(false), '');
-
-      assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-      assert.equal(element._computeRequirementIcon(false),
-          'gr-icons:hourglass');
-    });
-
-    test('label computed fields', () => {
-      assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-      assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-      assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
-
-      assert.equal(element._computeLabelClass({approved: []}), 'approved');
-      assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-      assert.equal(element._computeLabelClass({}), '');
-      assert.equal(element._computeLabelClass({value: 0}), '');
-
-      assert.equal(element._computeLabelValue(1), '+1');
-      assert.equal(element._computeLabelValue(-1), '-1');
-      assert.equal(element._computeLabelValue(0), '0');
-    });
-
-    test('_computeLabels', () => {
-      assert.equal(element._optionalLabels.length, 0);
-      assert.equal(element._requiredLabels.length, 0);
-      element._computeLabels({base: {
-        test: {
-          all: [{_account_id: 1, name: 'bojack', value: 1}],
-          default_value: 0,
-          values: [],
-          value: 1,
-        },
-        opt_test: {
-          all: [{_account_id: 1, name: 'bojack', value: 1}],
-          default_value: 0,
-          values: [],
-          optional: true,
-        },
-      }});
-      assert.equal(element._optionalLabels.length, 1);
-      assert.equal(element._requiredLabels.length, 1);
-
-      assert.equal(element._optionalLabels[0].label, 'opt_test');
-      assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
-      assert.equal(element._optionalLabels[0].style, '');
-      assert.ok(element._optionalLabels[0].labelInfo);
-    });
-
-    test('optional show/hide', () => {
-      element._optionalLabels = [{label: 'test'}];
-      flushAsynchronousOperations();
-
-      assert.ok(element.$$('section.optional'));
-      MockInteractions.tap(element.$$('.showHide'));
-      flushAsynchronousOperations();
-
-      assert.isFalse(element._showOptionalLabels);
-      assert.isTrue(isHidden(element.$$('section.optional')));
-    });
-
-    test('properly converts satisfied labels', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {
-          Verified: {
-            approved: [],
-          },
-        },
-        requirements: [],
-      };
-      flushAsynchronousOperations();
-
-      assert.ok(element.$$('.approved'));
-      assert.ok(element.$$('.name'));
-      assert.equal(element.$$('.name').text, 'Verified');
-    });
-
-    test('properly converts unsatisfied labels', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {
-          Verified: {
-            approved: false,
-          },
-        },
-      };
-      flushAsynchronousOperations();
-
-      const name = element.$$('.name');
-      assert.ok(name);
-      assert.isFalse(name.hasAttribute('hidden'));
-      assert.equal(name.text, 'Verified');
-    });
-
-    test('properly displays Work In Progress', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [],
-        work_in_progress: true,
-      };
-      flushAsynchronousOperations();
-
-      const changeIsWip = element.$$('.title');
-      assert.ok(changeIsWip);
-    });
-
-    test('properly displays a satisfied requirement', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'OK',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.$$('.requirement');
-      assert.ok(requirement);
-      assert.isFalse(requirement.hasAttribute('hidden'));
-      assert.ok(requirement.querySelector('.approved'));
-      assert.equal(requirement.querySelector('.name').text,
-          'Resolve all comments');
-    });
-
-    test('satisfied class is applied with OK', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'OK',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.$$('.requirement');
-      assert.ok(requirement);
-      assert.ok(requirement.querySelector('.approved'));
-    });
-
-    test('satisfied class is not applied with NOT_READY', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'NOT_READY',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.$$('.requirement');
-      assert.ok(requirement);
-      assert.strictEqual(requirement.querySelector('.approved'), null);
-    });
-
-    test('satisfied class is not applied with RULE_ERROR', () => {
-      element.change = {
-        status: 'NEW',
-        labels: {},
-        requirements: [{
-          fallback_text: 'Resolve all comments',
-          status: 'RULE_ERROR',
-        }],
-      };
-      flushAsynchronousOperations();
-
-      const requirement = element.$$('.requirement');
-      assert.ok(requirement);
-      assert.strictEqual(requirement.querySelector('.approved'), null);
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('requirements computed fields', () => {
+    assert.isTrue(element._computeShowWip({work_in_progress: true}));
+    assert.isFalse(element._computeShowWip({work_in_progress: false}));
+
+    assert.equal(element._computeRequirementClass(true), 'approved');
+    assert.equal(element._computeRequirementClass(false), '');
+
+    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
+    assert.equal(element._computeRequirementIcon(false),
+        'gr-icons:hourglass');
+  });
+
+  test('label computed fields', () => {
+    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
+    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
+    assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
+
+    assert.equal(element._computeLabelClass({approved: []}), 'approved');
+    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
+    assert.equal(element._computeLabelClass({}), '');
+    assert.equal(element._computeLabelClass({value: 0}), '');
+
+    assert.equal(element._computeLabelValue(1), '+1');
+    assert.equal(element._computeLabelValue(-1), '-1');
+    assert.equal(element._computeLabelValue(0), '0');
+  });
+
+  test('_computeLabels', () => {
+    assert.equal(element._optionalLabels.length, 0);
+    assert.equal(element._requiredLabels.length, 0);
+    element._computeLabels({base: {
+      test: {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+        value: 1,
+      },
+      opt_test: {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+        optional: true,
+      },
+    }});
+    assert.equal(element._optionalLabels.length, 1);
+    assert.equal(element._requiredLabels.length, 1);
+
+    assert.equal(element._optionalLabels[0].label, 'opt_test');
+    assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
+    assert.equal(element._optionalLabels[0].style, '');
+    assert.ok(element._optionalLabels[0].labelInfo);
+  });
+
+  test('optional show/hide', () => {
+    element._optionalLabels = [{label: 'test'}];
+    flushAsynchronousOperations();
+
+    assert.ok(element.shadowRoot
+        .querySelector('section.optional'));
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.showHide'));
+    flushAsynchronousOperations();
+
+    assert.isFalse(element._showOptionalLabels);
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('section.optional')));
+  });
+
+  test('properly converts satisfied labels', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: [],
+        },
+      },
+      requirements: [],
+    };
+    flushAsynchronousOperations();
+
+    assert.ok(element.shadowRoot
+        .querySelector('.approved'));
+    assert.ok(element.shadowRoot
+        .querySelector('.name'));
+    assert.equal(element.shadowRoot
+        .querySelector('.name').text, 'Verified');
+  });
+
+  test('properly converts unsatisfied labels', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: false,
+        },
+      },
+    };
+    flushAsynchronousOperations();
+
+    const name = element.shadowRoot
+        .querySelector('.name');
+    assert.ok(name);
+    assert.isFalse(name.hasAttribute('hidden'));
+    assert.equal(name.text, 'Verified');
+  });
+
+  test('properly displays Work In Progress', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [],
+      work_in_progress: true,
+    };
+    flushAsynchronousOperations();
+
+    const changeIsWip = element.shadowRoot
+        .querySelector('.title');
+    assert.ok(changeIsWip);
+  });
+
+  test('properly displays a satisfied requirement', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.isFalse(requirement.hasAttribute('hidden'));
+    assert.ok(requirement.querySelector('.approved'));
+    assert.equal(requirement.querySelector('.name').text,
+        'Resolve all comments');
+  });
+
+  test('satisfied class is applied with OK', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.ok(requirement.querySelector('.approved'));
+  });
+
+  test('satisfied class is not applied with NOT_READY', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'NOT_READY',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.strictEqual(requirement.querySelector('.approved'), null);
+  });
+
+  test('satisfied class is not applied with RULE_ERROR', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'RULE_ERROR',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.strictEqual(requirement.querySelector('.approved'), null);
+  });
+});
 </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
deleted file mode 100644
index 29f2d83..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ /dev/null
@@ -1,681 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
-<link rel="import" href="../../edit/gr-edit-constants.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
-<link rel="import" href="../../shared/gr-change-status/gr-change-status.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.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="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../shared/revision-info/revision-info.html">
-<link rel="import" href="../gr-change-actions/gr-change-actions.html">
-<link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../gr-commit-info/gr-commit-info.html">
-<link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
-<link rel="import" href="../gr-file-list-header/gr-file-list-header.html">
-<link rel="import" href="../gr-file-list/gr-file-list.html">
-<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="../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">
-
-<dom-module id="gr-change-view">
-  <template>
-    <style include="shared-styles">
-      .container:not(.loading) {
-        background-color: var(--view-background-color);
-      }
-      .container.loading {
-        color: var(--deemphasized-text-color);
-        padding: var(--spacing-l);
-      }
-      .header {
-        align-items: center;
-        background-color: var(--table-header-background-color);
-        border-bottom: 1px solid var(--border-color);
-        display: flex;
-        padding: var(--spacing-s) var(--spacing-l);
-        z-index: 99;  /* Less than gr-overlay's backdrop */
-      }
-      .header.editMode {
-        background-color: var(--edit-mode-background-color);
-      }
-      .header .download {
-        margin-right: var(--spacing-l);
-      }
-      gr-change-status {
-        display: initial;
-        margin: var(--spacing-xxs) var(--spacing-xxs) var(--spacing-xxs) var(--spacing-s);
-      }
-      gr-change-status:first-child {
-        margin-left: 0;
-      }
-      .headerTitle {
-        align-items: center;
-        display: flex;
-        flex: 1;
-        font-size: var(--font-size-h3);
-      }
-      .headerTitle .headerSubject {
-        font-weight: var(--font-weight-bold);
-      }
-      #replyBtn {
-        margin-bottom: var(--spacing-l);
-      }
-      gr-change-star {
-        margin-right: var(--spacing-xs);
-      }
-      gr-reply-dialog {
-        width: 60em;
-      }
-      .changeStatus {
-        text-transform: capitalize;
-      }
-      .changeInfo {
-        background-color: var(--table-header-background-color);
-      }
-      /* Strong specificity here is needed due to
-         https://github.com/Polymer/polymer/issues/2531 */
-      .container section.changeInfo {
-        display: flex;
-      }
-      .changeId {
-        color: var(--deemphasized-text-color);
-        font-family: var(--font-family);
-        margin-top: var(--spacing-l);
-      }
-      .changeMetadata {
-        /* Limit meta section to half of the screen at max */
-        max-width: 50%;
-      }
-      .commitMessage {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        margin-right: var(--spacing-l);
-        margin-bottom: var(--spacing-l);
-        /* 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 and rounding errors. */
-        min-width: calc(72ch + 2px + 2*var(--spacing-m) + 0.4px);
-      }
-      .editCommitMessage {
-        margin-top: var(--spacing-l);
-
-        --gr-button: {
-          padding: 5px 0px;
-        }
-      }
-      .changeStatuses,
-      .commitActions,
-      .statusText {
-        align-items: center;
-        display: flex;
-      }
-      .changeStatuses {
-        flex-wrap: wrap;
-      }
-      .mainChangeInfo {
-        display: flex;
-        flex: 1;
-        flex-direction: column;
-        min-width: 0;
-      }
-      #commitAndRelated {
-        align-content: flex-start;
-        display: flex;
-        flex: 1;
-        overflow-x: hidden;
-      }
-      .relatedChanges {
-        flex: 1 1 auto;
-        overflow: hidden;
-        padding: var(--spacing-l) 0;
-      }
-      .mobile {
-        display: none;
-      }
-      .warning {
-        color: var(--error-text-color);
-      }
-      hr {
-        border: 0;
-        border-top: 1px solid var(--border-color);
-        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);
-        overflow: hidden;
-      }
-      .commitContainer {
-        display: flex;
-        flex-direction: column;
-        flex-shrink: 0;
-        margin: var(--spacing-l) 0;
-        padding: 0 var(--spacing-l);
-      }
-      .collapseToggleContainer {
-        display: flex;
-        margin-bottom: 8px;
-      }
-      #relatedChangesToggle {
-        display: none;
-      }
-      #relatedChangesToggle.showToggle {
-        display: flex;
-      }
-      .collapseToggleContainer gr-button {
-        display: block;
-      }
-      #relatedChangesToggle {
-        margin-left: var(--spacing-l);
-        padding-top: var(--related-change-btn-top-padding, 0);
-      }
-      .showOnEdit {
-        display: none;
-      }
-      .scrollable {
-        overflow: auto;
-      }
-      .text {
-        white-space: pre;
-      }
-      gr-commit-info {
-        display: inline-block;
-        margin-right: -5px;
-      }
-      paper-tabs {
-        background-color: var(--table-header-background-color);
-        border-top: 1px solid var(--border-color);
-        height: calc(var(--line-height-normal) + 2*var(--spacing-m));
-        --paper-tabs-selection-bar-color: var(--link-color);
-      }
-      paper-tab {
-        box-sizing: border-box;
-        max-width: 12em;
-        --paper-tab-ink: var(--link-color);
-      }
-      gr-thread-list,
-      gr-messages-list {
-        display: block;
-      }
-      #includedInOverlay {
-        width: 65em;
-      }
-      #uploadHelpOverlay {
-        width: 50em;
-      }
-      #metadata {
-        --metadata-horizontal-padding: var(--spacing-l);
-        padding-top: var(--spacing-l);
-        width: 100%;
-      }
-      /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_MED in the JS */
-      @media screen and (max-width: 75em) {
-        .relatedChanges {
-          padding: 0;
-        }
-        #relatedChanges {
-          padding-top: var(--spacing-l);
-        }
-        #commitAndRelated {
-          flex-direction: column;
-          flex-wrap: nowrap;
-        }
-        #commitMessageEditor {
-          min-width: 0;
-        }
-        .commitMessage {
-          margin-right: 0;
-        }
-        .mainChangeInfo {
-          padding-right: 0;
-        }
-      }
-      /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_SMALL in the JS */
-      @media screen and (max-width: 50em) {
-        .mobile {
-          display: block;
-        }
-        .header {
-          align-items: flex-start;
-          flex-direction: column;
-          flex: 1;
-          padding: var(--spacing-s) var(--spacing-l);
-        }
-        gr-change-star {
-          vertical-align: middle;
-        }
-        .headerTitle {
-          flex-wrap: wrap;
-          font-size: var(--font-size-h3);
-        }
-        .desktop {
-          display: none;
-        }
-        .reply {
-          display: block;
-          margin-right: 0;
-          /* px because don't have the same font size */
-          margin-bottom: 6px;
-        }
-        .changeInfo-column:not(:last-of-type) {
-          margin-right: 0;
-          padding-right: 0;
-        }
-        .changeInfo,
-        #commitAndRelated {
-          flex-direction: column;
-          flex-wrap: nowrap;
-        }
-        .commitContainer {
-          margin: 0;
-          padding: var(--spacing-l);
-        }
-        .changeMetadata {
-          margin-top: var(--spacing-xs);
-          max-width: none;
-        }
-        #metadata,
-        .mainChangeInfo {
-          padding: 0;
-        }
-        .commitActions {
-          display: block;
-          margin-top: var(--spacing-l);
-          width: 100%;
-        }
-        .commitMessage {
-          flex: initial;
-          margin: 0;
-        }
-        /* Change actions are the only thing thant need to remain visible due
-        to the fact that they may have the currently visible overlay open. */
-        #mainContent.overlayOpen .hideOnMobileOverlay {
-          display: none;
-        }
-        gr-reply-dialog {
-          height: 100vh;
-          min-width: initial;
-          width: 100vw;
-        }
-        #replyOverlay {
-          z-index: var(--reply-overlay-z-index);
-        }
-      }
-    </style>
-    <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-    <div
-        id="mainContent"
-        class="container"
-        on-show-checks-table="_handleShowTab"
-        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
-                  max-width="100"
-                  status="[[status]]"></gr-change-status>
-            </template>
-          </div>
-          <div class="statusText">
-            <template
-                is="dom-if"
-                if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]">
-              <span class="text"> as </span>
-              <gr-commit-info
-                  change="[[_change]]"
-                  commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
-                  server-config="[[_serverConfig]]"></gr-commit-info>
-            </template>
-          </div>
-          <span class="separator"></span>
-          <a aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-              href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a>
-          <pre>: </pre>
-          <span class="headerSubject">[[_change.subject]]</span>
-        </div><!-- end headerTitle -->
-        <div class="commitActions" hidden$="[[!_loggedIn]]">
-          <gr-change-actions
-              id="actions"
-              change="[[_change]]"
-              disable-edit="[[disableEdit]]"
-              has-parent="[[hasParent]]"
-              actions="[[_change.actions]]"
-              revision-actions="{{_currentRevisionActions}}"
-              change-num="[[_changeNum]]"
-              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]]"
-              edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
-              private-by-default="[[_projectConfig.private_by_default]]"
-              on-reload-change="_handleReloadChange"
-              on-edit-tap="_handleEditTap"
-              on-stop-edit-tap="_handleStopEditTap"
-              on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
-        </div><!-- end commit actions -->
-      </div><!-- end header -->
-      <section class="changeInfo">
-        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
-          <gr-change-metadata
-              id="metadata"
-              change="{{_change}}"
-              account="[[_account]]"
-              revision="[[_selectedRevision]]"
-              commit-info="[[_commitInfo]]"
-              server-config="[[_serverConfig]]"
-              parent-is-current="[[_parentIsCurrent]]"
-              on-show-reply-dialog="_handleShowReplyDialog">
-          </gr-change-metadata>
-        </div>
-        <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
-          <div id="commitAndRelated" class="hideOnMobileOverlay">
-            <div class="commitContainer">
-              <div>
-                <gr-button
-                    id="replyBtn"
-                    class="reply"
-                    hidden$="[[!_loggedIn]]"
-                    primary
-                    disabled="[[_replyDisabled]]"
-                    on-click="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
-              </div>
-              <div
-                  id="commitMessage"
-                  class$="commitMessage [[_computeCommitClass(_commitCollapsed, _latestCommitMessage)]]">
-                <gr-editable-content id="commitMessageEditor"
-                    editing="[[_editingCommitMessage]]"
-                    content="{{_latestCommitMessage}}"
-                    storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
-                    remove-zero-width-space>
-                  <gr-linked-text pre
-                      content="[[_latestCommitMessage]]"
-                      config="[[_projectConfig.commentlinks]]"
-                      remove-zero-width-space></gr-linked-text>
-                </gr-editable-content>
-                <gr-button link
-                    class="editCommitMessage"
-                    on-click="_handleEditCommitMessage"
-                    hidden$="[[_hideEditCommitMessage]]">Edit</gr-button>
-                <div class="changeId" hidden$="[[!_changeIdCommitMessageError]]">
-                  <hr>
-                  Change-Id:
-                  <span
-                      class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
-                      title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]">
-                    [[_change.change_id]]
-                  </span>
-                </div>
-              </div>
-              <div
-                  id="commitCollapseToggle"
-                  class="collapseToggleContainer"
-                  hidden$="[[_computeCommitToggleHidden(_latestCommitMessage)]]">
-                <gr-button
-                    link
-                    id="commitCollapseToggleButton"
-                    class="collapseToggleButton"
-                    on-click="_toggleCommitCollapsed">
-                  [[_computeCollapseText(_commitCollapsed)]]
-                </gr-button>
-              </div>
-              <gr-endpoint-decorator name="commit-container">
-                <gr-endpoint-param name="change" value="[[_change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-                </gr-endpoint-param>
-              </gr-endpoint-decorator>
-            </div>
-            <div class="relatedChanges">
-              <gr-related-changes-list id="relatedChanges"
-                  class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
-                  change="[[_change]]"
-                  mergeable="[[_mergeable]]"
-                  has-parent="{{hasParent}}"
-                  on-update="_updateRelatedChangeMaxHeight"
-                  patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-                  on-new-section-loaded="_computeShowRelatedToggle">
-              </gr-related-changes-list>
-              <div
-                  id="relatedChangesToggle"
-                  class="collapseToggleContainer">
-                <gr-button
-                    link
-                    id="relatedChangesToggleButton"
-                    class="collapseToggleButton"
-                    on-click="_toggleRelatedChangesCollapsed">
-                  [[_computeCollapseText(_relatedChangesCollapsed)]]
-                </gr-button>
-              </div>
-            </div>
-          </div>
-        </div>
-      </section>
-
-      <section class="patchInfo">
-        <template is="dom-if" if="[[_showPrimaryTabs]]">
-          <paper-tabs id="primaryTabs" on-selected-changed="_handleFileTabChange">
-            <paper-tab>Files</paper-tab>
-            <template is="dom-repeat" items="[[_dynamicTabHeaderEndpoints]]"
-              as="tabHeader">
-              <paper-tab>
-                  <gr-endpoint-decorator name$="[[tabHeader]]">
-                      <gr-endpoint-param name="change" value="[[_change]]">
-                      </gr-endpoint-param>
-                      <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-                      </gr-endpoint-param>
-                  </gr-endpoint-decorator>
-              </paper-tab>
-            </template>
-          </paper-tabs>
-        </template>
-
-        <div hidden$="[[!_showFileTabContent]]">
-          <gr-file-list-header
-            id="fileListHeader"
-            account="[[_account]]"
-            all-patch-sets="[[_allPatchSets]]"
-            change="[[_change]]"
-            change-num="[[_changeNum]]"
-            revision-info="[[_revisionInfo]]"
-            change-comments="[[_changeComments]]"
-            commit-info="[[_commitInfo]]"
-            change-url="[[_computeChangeUrl(_change)]]"
-            edit-mode="[[_editMode]]"
-            logged-in="[[_loggedIn]]"
-            server-config="[[_serverConfig]]"
-            shown-file-count="[[_shownFileCount]]"
-            diff-prefs="[[_diffPrefs]]"
-            diff-view-mode="{{viewState.diffMode}}"
-            patch-num="{{_patchRange.patchNum}}"
-            base-patch-num="{{_patchRange.basePatchNum}}"
-            files-expanded="[[_filesExpanded]]"
-            diff-prefs-disabled="[[_diffPrefsDisabled]]"
-            show-title="[[!_showPrimaryTabs]]"
-            on-open-diff-prefs="_handleOpenDiffPrefs"
-            on-open-download-dialog="_handleOpenDownloadDialog"
-            on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
-            on-open-included-in-dialog="_handleOpenIncludedInDialog"
-            on-expand-diffs="_expandAllDiffs"
-            on-collapse-diffs="_collapseAllDiffs">
-        </gr-file-list-header>
-        <gr-file-list
-            id="fileList"
-            class="hideOnMobileOverlay"
-            diff-prefs="{{_diffPrefs}}"
-            change="[[_change]]"
-            change-num="[[_changeNum]]"
-            patch-range="{{_patchRange}}"
-            change-comments="[[_changeComments]]"
-            drafts="[[_diffDrafts]]"
-            revisions="[[_change.revisions]]"
-            project-config="[[_projectConfig]]"
-            selected-index="{{viewState.selectedFileIndex}}"
-            diff-view-mode="[[viewState.diffMode]]"
-            edit-mode="[[_editMode]]"
-            num-files-shown="{{_numFilesShown}}"
-            files-expanded="{{_filesExpanded}}"
-            file-list-increment="{{_numFilesShown}}"
-            on-files-shown-changed="_setShownFiles"
-            on-file-action-tap="_handleFileActionTap"
-            on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
-        </div>
-
-        <template is="dom-if" if="[[!_showFileTabContent]]">
-            <gr-endpoint-decorator name$="[[_selectedFilesTabPluginEndpoint]]">
-                <gr-endpoint-param name="change" value="[[_change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-                </gr-endpoint-param>
-            </gr-endpoint-decorator>
-        </template>
-      </section>
-
-      <gr-endpoint-decorator name="change-view-integration">
-        <gr-endpoint-param name="change" value="[[_change]]">
-        </gr-endpoint-param>
-        <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-        </gr-endpoint-param>
-      </gr-endpoint-decorator>
-      <paper-tabs
-          id="commentTabs"
-          on-selected-changed="_handleCommentTabChange">
-        <paper-tab class="changeLog">Change Log</paper-tab>
-        <paper-tab
-            class="commentThreads">
-          <gr-tooltip-content
-              has-tooltip
-              title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]">
-            <span>Comment Threads</span></gr-tooltip-content>
-        </paper-tab>
-      </paper-tabs>
-      <template is="dom-if" if="[[_showMessagesView]]">
-        <gr-messages-list
-            class="hideOnMobileOverlay"
-            change-num="[[_changeNum]]"
-            labels="[[_change.labels]]"
-            messages="[[_change.messages]]"
-            reviewer-updates="[[_change.reviewer_updates]]"
-            change-comments="[[_changeComments]]"
-            project-name="[[_change.project]]"
-            show-reply-buttons="[[_loggedIn]]"
-            on-message-anchor-tap="_handleMessageAnchorTap"
-            on-reply="_handleMessageReply"></gr-messages-list>
-      </template>
-      <template is="dom-if" if="[[!_showMessagesView]]">
-        <gr-thread-list
-            threads="[[_commentThreads]]"
-            change="[[_change]]"
-            change-num="[[_changeNum]]"
-            logged-in="[[_loggedIn]]"
-            on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
-      </template>
-    </div>
-    <gr-overlay id="downloadOverlay" with-backdrop>
-      <gr-download-dialog
-          id="downloadDialog"
-          change="[[_change]]"
-          patch-num="[[_patchRange.patchNum]]"
-          config="[[_serverConfig.download]]"
-          on-close="_handleDownloadDialogClose"></gr-download-dialog>
-    </gr-overlay>
-    <gr-overlay id="uploadHelpOverlay" with-backdrop>
-      <gr-upload-help-dialog
-          revision="[[_currentRevision]]"
-          target-branch="[[_change.branch]]"
-          on-close="_handleCloseUploadHelpDialog"></gr-upload-help-dialog>
-    </gr-overlay>
-    <gr-overlay id="includedInOverlay" with-backdrop>
-      <gr-included-in-dialog
-          id="includedInDialog"
-          change-num="[[_changeNum]]"
-          on-close="_handleIncludedInDialogClose"></gr-included-in-dialog>
-    </gr-overlay>
-    <gr-overlay id="replyOverlay"
-        class="scrollable"
-        no-cancel-on-outside-click
-        no-cancel-on-esc-key
-        with-backdrop>
-      <gr-reply-dialog id="replyDialog"
-          change="{{_change}}"
-          patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-          permitted-labels="[[_change.permitted_labels]]"
-          diff-drafts="[[_diffDrafts]]"
-          project-config="[[_projectConfig]]"
-          can-be-started="[[_canStartReview]]"
-          on-send="_handleReplySent"
-          on-cancel="_handleReplyCancel"
-          on-autogrow="_handleReplyAutogrow"
-          on-send-disabled-changed="_resetReplyOverlayFocusStops"
-          hidden$="[[!_loggedIn]]">
-      </gr-reply-dialog>
-    </gr-overlay>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-change-view.js"></script>
-</dom-module>
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 02cf861..ddf28d4 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,80 +14,158 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const CHANGE_ID_ERROR = {
-    MISMATCH: 'mismatch',
-    MISSING: 'missing',
-  };
-  const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
+import '@polymer/paper-tabs/paper-tabs.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-account-link/gr-account-link.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-change-star/gr-change-star.js';
+import '../../shared/gr-change-status/gr-change-status.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-editable-content/gr-editable-content.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-linked-text/gr-linked-text.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../shared/revision-info/revision-info.js';
+import '../gr-change-actions/gr-change-actions.js';
+import '../gr-change-metadata/gr-change-metadata.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../gr-commit-info/gr-commit-info.js';
+import '../gr-download-dialog/gr-download-dialog.js';
+import '../gr-file-list-header/gr-file-list-header.js';
+import '../gr-file-list/gr-file-list.js';
+import '../gr-included-in-dialog/gr-included-in-dialog.js';
+import '../gr-messages-list/gr-messages-list.js';
+import '../gr-messages-list/gr-messages-list-experimental.js';
+import '../gr-related-changes-list/gr-related-changes-list.js';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
+import '../gr-reply-dialog/gr-reply-dialog.js';
+import '../gr-thread-list/gr-thread-list.js';
+import '../gr-upload-help-dialog/gr-upload-help-dialog.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-view_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GrEditConstants} from '../../edit/gr-edit-constants.js';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {util} from '../../../scripts/util.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
 
-  const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-  const DEFAULT_NUM_FILES_SHOWN = 200;
+import {PrimaryTabs, SecondaryTabs} from '../../../constants/constants.js';
+import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
+import {appContext} from '../../../services/app-context.js';
 
-  const REVIEWERS_REGEX = /^(R|CC)=/gm;
-  const MIN_CHECK_INTERVAL_SECS = 0;
+const CHANGE_ID_ERROR = {
+  MISMATCH: 'mismatch',
+  MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
 
-  // These are the same as the breakpoint set in CSS. Make sure both are changed
-  // together.
-  const BREAKPOINT_RELATED_SMALL = '50em';
-  const BREAKPOINT_RELATED_MED = '75em';
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+const DEFAULT_NUM_FILES_SHOWN = 200;
 
-  // In the event that the related changes medium width calculation is too close
-  // to zero, provide some height.
-  const MINIMUM_RELATED_MAX_HEIGHT = 100;
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
+const MIN_CHECK_INTERVAL_SECS = 0;
 
-  const SMALL_RELATED_HEIGHT = 400;
+// These are the same as the breakpoint set in CSS. Make sure both are changed
+// together.
+const BREAKPOINT_RELATED_SMALL = '50em';
+const BREAKPOINT_RELATED_MED = '75em';
 
-  const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
+// In the event that the related changes medium width calculation is too close
+// to zero, provide some height.
+const MINIMUM_RELATED_MAX_HEIGHT = 100;
 
-  const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+const SMALL_RELATED_HEIGHT = 400;
 
-  const MSG_PREFIX = '#message-';
+const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
-  const ReloadToastMessage = {
-    NEWER_REVISION: 'A newer patch set has been uploaded',
-    RESTORED: 'This change has been restored',
-    ABANDONED: 'This change has been abandoned',
-    MERGED: 'This change has been merged',
-    NEW_MESSAGE: 'There are new messages on this change',
-  };
+const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const MSG_PREFIX = '#message-';
 
-  const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
-  const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-  const SEND_REPLY_TIMING_LABEL = 'SendReply';
+const ReloadToastMessage = {
+  NEWER_REVISION: 'A newer patch set has been uploaded',
+  RESTORED: 'This change has been restored',
+  ABANDONED: 'This change has been abandoned',
+  MERGED: 'This change has been merged',
+  NEW_MESSAGE: 'There are new messages on this change',
+};
 
-  Polymer({
-    is: 'gr-change-view',
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
+const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+// Making the tab names more unique in case a plugin adds one with same name
+const ROBOT_COMMENTS_LIMIT = 10;
+
+// types used in this file
+/**
+ * Type for the custom event to switch tab.
+ *
+ * @typedef {Object} SwitchTabEventDetail
+ * @property {?string} tab - name of the tab to set as active, from custom event
+ * @property {?boolean} scrollIntoView - scroll into the tab afterwards, from custom event
+ * @property {?number} value - index of tab to set as active, from paper-tabs event
+ */
+
+/**
+ * @appliesMixin RESTClientMixin
+ * @appliesMixin PatchSetMixin
+ * @extends Polymer.Element
+ */
+class GrChangeView extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+  PatchSetBehavior,
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired if an error occurs when fetching the change data.
+   *
+   * @event page-error
+   */
+
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
+     * URL params passed from the router.
      */
-
-    /**
-     * Fired if an error occurs when fetching the change data.
-     *
-     * @event page-error
-     */
-
-    /**
-     * Fired if being logged in is required.
-     *
-     * @event show-auth-required
-     */
-
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -118,6 +196,17 @@
         computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
       },
       _commentThreads: Array,
+      // TODO(taoalpha): Consider replacing diffDrafts
+      // with _draftCommentThreads everywhere, currently only
+      // replaced in reply-dialoig
+      _draftCommentThreads: {
+        type: Array,
+      },
+      _robotCommentThreads: {
+        type: Array,
+        computed: '_computeRobotCommentThreads(_commentThreads,'
+          + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+      },
       /** @type {?} */
       _serverConfig: {
         type: Object,
@@ -155,7 +244,8 @@
       _currentRevision: {
         type: Object,
         computed: '_computeCurrentRevision(_change.current_revision, ' +
-            '_change.revisions)',
+          '_change.revisions)',
+        observer: '_handleCurrentRevisionUpdate',
       },
       _files: Object,
       _changeNum: String,
@@ -170,7 +260,8 @@
       _hideEditCommitMessage: {
         type: Boolean,
         computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change, _editMode)',
+            '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+            '_commitCollapsible)',
       },
       _diffAgainst: String,
       /** @type {?string} */
@@ -178,11 +269,24 @@
         type: String,
         value: '',
       },
+      _constants: {
+        type: Object,
+        value: {
+          SecondaryTabs,
+          PrimaryTabs,
+        },
+      },
+      _messages: {
+        type: Object,
+        value: {
+          NO_ROBOT_COMMENTS_THREADS_MSG,
+        },
+      },
       _lineHeight: Number,
       _changeIdCommitMessageError: {
         type: String,
         computed:
-          '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+        '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
       },
       /** @type {?} */
       _patchRange: {
@@ -226,12 +330,18 @@
       _changeStatuses: {
         type: String,
         computed:
-          '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+        '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
       },
+      /** 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,
@@ -261,10 +371,6 @@
         type: Boolean,
         value: undefined,
       },
-      _showMessagesView: {
-        type: Boolean,
-        value: true,
-      },
       _showFileTabContent: {
         type: Boolean,
         value: true,
@@ -273,1586 +379,1832 @@
       _dynamicTabHeaderEndpoints: {
         type: Array,
       },
-      _showPrimaryTabs: {
-        type: Boolean,
-        computed: '_computeShowPrimaryTabs(_dynamicTabHeaderEndpoints)',
-      },
       /** @type {Array<string>} */
       _dynamicTabContentEndpoints: {
         type: Array,
       },
-      _selectedFilesTabPluginEndpoint: {
+      // The dynamic content of the plugin added tab
+      _selectedTabPluginEndpoint: {
         type: String,
       },
-    },
+      // The dynamic heading of the plugin added tab
+      _selectedTabPluginHeader: {
+        type: String,
+      },
+      _robotCommentsPatchSetDropdownItems: {
+        type: Array,
+        value() { return []; },
+        computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
+          '_commentThreads)',
+      },
+      _currentRobotCommentsPatchSet: {
+        type: Number,
+      },
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+      /**
+       * @type {Array<string>} this is a two-element tuple to always
+       * hold the current active tab for both primary and secondary tabs
+       */
+      _activeTabs: {
+        type: Array,
+        value: [PrimaryTabs.FILES, SecondaryTabs.CHANGE_LOG],
+      },
+      _showAllRobotComments: {
+        type: Boolean,
+        value: false,
+      },
+      _showRobotCommentsButton: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    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: [
+  static get observers() {
+    return [
       '_labelsChanged(_change.labels.*)',
       '_paramsAndChangeChanged(params, _change)',
       '_patchNumChanged(_patchRange.patchNum)',
-    ],
+    ];
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
-        [this.Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
-        [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
-        [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-        [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
-            '_handleOpenDownloadDialogShortcut',
-        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-        [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
-        [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
-        [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
-        [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-        [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
-        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
-        [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
-      };
-    },
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+      [this.Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+      [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+      [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
+          '_handleOpenDownloadDialogShortcut',
+      [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
+      [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+      [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+      [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+      [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+      [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+      [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+    };
+  }
 
-    attached() {
-      this._getServerConfig().then(config => {
-        this._serverConfig = config;
-      });
+  constructor() {
+    super();
+    this.flagsService = appContext.flagsService;
+  }
 
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this.$.restAPI.getAccount().then(acct => {
-            this._account = acct;
-          });
-        }
-        this._setDiffViewMode();
-      });
+  /** @override */
+  created() {
+    super.created();
 
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicTabHeaderEndpoints =
-            Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header');
-        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());
+    this.addEventListener('topic-changed',
+        () => this._handleTopicChanged());
 
-      this.addEventListener('comment-save', this._handleCommentSave.bind(this));
-      this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
-      this.addEventListener('comment-discard',
-          this._handleCommentDiscard.bind(this));
-      this.addEventListener('editable-content-save',
-          this._handleCommitMessageSave.bind(this));
-      this.addEventListener('editable-content-cancel',
-          this._handleCommitMessageCancel.bind(this));
-      this.listen(window, 'scroll', '_handleScroll');
-      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-    },
+    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());
 
-    detached() {
-      this.unlisten(window, 'scroll', '_handleScroll');
-      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.addEventListener('fullscreen-overlay-closed',
+        () => this._handleShowBackgroundContent());
 
-      if (this._updateCheckTimerHandle) {
-        this._cancelUpdateCheckTimer();
+    this.addEventListener('diff-comments-modified',
+        () => this._handleReloadCommentThreads());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+    });
+
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.$.restAPI.getAccount().then(acct => {
+          this._account = acct;
+        });
       }
-    },
+      this._setDiffViewMode();
+    });
 
-    get messagesList() {
-      return this.$$('gr-messages-list');
-    },
+    pluginLoader.awaitPluginsLoaded()
+        .then(() => {
+          this._dynamicTabHeaderEndpoints =
+            pluginEndpoints.getDynamicEndpoints('change-view-tab-header');
+          this._dynamicTabContentEndpoints =
+            pluginEndpoints.getDynamicEndpoints('change-view-tab-content');
+          if (this._dynamicTabContentEndpoints.length !==
+          this._dynamicTabHeaderEndpoints.length) {
+            console.warn('Different number of tab headers and tab content.');
+          }
+        })
+        .then(() => this._initActiveTabs(this.params));
 
-    get threadList() {
-      return this.$$('gr-thread-list');
-    },
+    this.addEventListener('comment-save', this._handleCommentSave.bind(this));
+    this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
+    this.addEventListener('comment-discard',
+        this._handleCommentDiscard.bind(this));
+    this.addEventListener('change-message-deleted',
+        () => this._reload());
+    this.addEventListener('editable-content-save',
+        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');
 
-    /**
-     * @param {boolean=} opt_reset
-     */
-    _setDiffViewMode(opt_reset) {
-      if (!opt_reset && this.viewState.diffViewMode) { return; }
+    this.addEventListener('show-primary-tab',
+        e => this._setActivePrimaryTab(e));
+    this.addEventListener('show-secondary-tab',
+        e => this._setActiveSecondaryTab(e));
+  }
 
-      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');
-        }
-      });
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleScroll');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
 
-    _handleToggleDiffMode(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+    if (this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    }
+  }
 
-      e.preventDefault();
-      if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
-      } else {
-        this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
-      }
-    },
+  _isChangeLogExperimentEnabled() {
+    return this.flagsService.isEnabled('UiFeature__cleaner_changelog');
+  }
 
-    _handleCommentTabChange() {
-      this._showMessagesView = this.$.commentTabs.selected === 0;
-    },
+  get messagesList() {
+    const tagName = this._isChangeLogExperimentEnabled()
+      ? 'gr-messages-list-experimental' : 'gr-messages-list';
+    return this.shadowRoot.querySelector(tagName);
+  }
 
-    _handleFileTabChange(e) {
-      const selectedIndex = this.$$('#primaryTabs').selected;
-      this._showFileTabContent = selectedIndex === 0;
-      // Initial tab is the static files list.
-      const newSelectedTab =
-          this._dynamicTabContentEndpoints[selectedIndex - 1];
-      if (newSelectedTab !== this._selectedFilesTabPluginEndpoint) {
-        this._selectedFilesTabPluginEndpoint = newSelectedTab;
+  get threadList() {
+    return this.shadowRoot.querySelector('gr-thread-list');
+  }
 
-        const tabName = this._selectedFilesTabPluginEndpoint || 'files';
-        const source = e && e.type ? e.type : '';
-        this.$.reporting.reportInteraction('tab-changed',
-            `tabname: ${tabName}, source: ${source}`);
-      }
-    },
+  /**
+   * @param {boolean=} opt_reset
+   */
+  _setDiffViewMode(opt_reset) {
+    if (!opt_reset && this.viewState.diffViewMode) { return; }
 
-    _handleShowTab(e) {
-      const idx = this._dynamicTabContentEndpoints.indexOf(e.detail.tab);
-      if (idx === -1) {
-        console.warn(e.detail.tab + ' tab not found');
-        return;
-      }
-      this.$$('#primaryTabs').selected = idx + 1;
-      this.$$('#primaryTabs').scrollIntoView();
-      this.$.reporting.reportInteraction('show-tab', e.detail.tab);
-    },
+    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');
+          }
+        });
+  }
 
-    _handleEditCommitMessage(e) {
-      this._editingCommitMessage = true;
-      this.$.commitMessageEditor.focusTextarea();
-    },
+  _onOpenFixPreview(e) {
+    this.$.applyFixDialog.open(e);
+  }
 
-    _handleCommitMessageSave(e) {
-      // Trim trailing whitespace from each line.
-      const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+  _onCloseFixPreview(e) {
+    this._reload();
+  }
 
-      this.$.jsAPI.handleCommitMessage(this._change, message);
+  _handleToggleDiffMode(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-      this.$.commitMessageEditor.disabled = true;
-      this.$.restAPI.putChangeCommitMessage(
-          this._changeNum, message).then(resp => {
-        this.$.commitMessageEditor.disabled = false;
-        if (!resp.ok) { return; }
+    e.preventDefault();
+    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
 
-        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-            message);
-        this._editingCommitMessage = false;
-        this._reloadWindow();
-      }).catch(err => {
-        this.$.commitMessageEditor.disabled = false;
-      });
-    },
+  _isTabActive(tab, activeTabs) {
+    return activeTabs.includes(tab);
+  }
 
-    _reloadWindow() {
-      window.location.reload();
-    },
-
-    _handleCommitMessageCancel(e) {
-      this._editingCommitMessage = false;
-    },
-
-    _computeChangeStatusChips(change, mergeable, submitEnabled) {
-      // Polymer 2: check for undefined
-      if ([
-        change,
-        mergeable,
-      ].some(arg => arg === undefined)) {
-        // To keep consistent with Polymer 1, we are returning undefined
-        // if not all dependencies are defined
-        return undefined;
-      }
-
-      // Show no chips until mergeability is loaded.
-      if (mergeable === null) {
-        return [];
-      }
-
-      const options = {
-        includeDerived: true,
-        mergeable: !!mergeable,
-        submitEnabled: !!submitEnabled,
-      };
-      return this.changeStatuses(change, options);
-    },
-
-    _computeHideEditCommitMessage(loggedIn, editing, change, editMode) {
-      if (!loggedIn || editing ||
-          (change && change.status === this.ChangeStatus.MERGED) ||
-          editMode) {
-        return true;
-      }
-
-      return false;
-    },
-
-    _handleReloadCommentThreads() {
-      // Get any new drafts that have been saved in the diff view and show
-      // in the comment thread view.
-      this._reloadDrafts().then(() => {
-        this._commentThreads = this._changeComments.getAllThreadsForChange()
-            .map(c => Object.assign({}, c));
-        Polymer.dom.flush();
-      });
-    },
-
-    _handleReloadDiffComments(e) {
-      // Keeps the file list counts updated.
-      this._reloadDrafts().then(() => {
-        // Get any new drafts that have been saved in the thread view and show
-        // in the diff view.
-        this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
-            e.detail.path);
-        Polymer.dom.flush();
-      });
-    },
-
-    _computeTotalCommentCounts(unresolvedCount, changeComments) {
-      if (!changeComments) return undefined;
-      const draftCount = changeComments.computeDraftCount();
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
-      const draftString = GrCountStringFormatter.computePluralString(
-          draftCount, 'draft');
-
-      return unresolvedString +
-          // Add a comma and space if both unresolved and draft comments exist.
-          (unresolvedString && draftString ? ', ' : '') +
-          draftString;
-    },
-
-    _handleCommentSave(e) {
-      const draft = e.detail.comment;
-      if (!draft.__draft) { return; }
-
-      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-      // The use of path-based notification helpers (set, push) can’t be used
-      // because the paths could contain dots in them. A new object must be
-      // created to satisfy Polymer’s dirty checking.
-      // https://github.com/Polymer/polymer/issues/3127
-      const diffDrafts = Object.assign({}, this._diffDrafts);
-      if (!diffDrafts[draft.path]) {
-        diffDrafts[draft.path] = [draft];
-        this._diffDrafts = diffDrafts;
-        return;
-      }
-      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-        if (this._diffDrafts[draft.path][i].id === draft.id) {
-          diffDrafts[draft.path][i] = draft;
-          this._diffDrafts = diffDrafts;
-          return;
-        }
-      }
-      diffDrafts[draft.path].push(draft);
-      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);
-      });
-      this._diffDrafts = diffDrafts;
-    },
-
-    _handleCommentDiscard(e) {
-      const draft = e.detail.comment;
-      if (!draft.__draft) { return; }
-
-      if (!this._diffDrafts[draft.path]) {
-        return;
-      }
-      let index = -1;
-      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-        if (this._diffDrafts[draft.path][i].id === draft.id) {
-          index = i;
+  /**
+   * Actual implementation of switching a tab
+   *
+   * @param {!HTMLElement} paperTabs - the parent tabs container
+   * @param {!SwitchTabEventDetail} activeDetails
+   */
+  _setActiveTab(paperTabs, activeDetails) {
+    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
+    const tabs = paperTabs.querySelectorAll('paper-tab');
+    let activeIndex = -1;
+    if (activeTabIndex !== undefined) {
+      activeIndex = activeTabIndex;
+    } else {
+      for (let i = 0; i <= tabs.length; i++) {
+        const tab = tabs[i];
+        if (tab.dataset.name === activeTabName) {
+          activeIndex = i;
           break;
         }
       }
-      if (index === -1) {
-        // It may be a draft that hasn’t been added to _diffDrafts since it was
-        // never saved.
+    }
+    if (activeIndex === -1) {
+      console.warn('tab not found with given info', activeDetails);
+      return;
+    }
+    const tabName = tabs[activeIndex].dataset.name;
+    if (scrollIntoView) {
+      paperTabs.scrollIntoView();
+    }
+    if (paperTabs.selected !== activeIndex) {
+      paperTabs.selected = activeIndex;
+      this.$.reporting.reportInteraction('show-tab', {tabName});
+    }
+    return tabName;
+  }
+
+  /**
+   * Changes active primary tab.
+   *
+   * @param {CustomEvent<SwitchTabEventDetail>} e
+   */
+  _setActivePrimaryTab(e) {
+    const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
+    const activeTabName = this._setActiveTab(primaryTabs, {
+      activeTabName: e.detail.tab,
+      activeTabIndex: e.detail.value,
+      scrollIntoView: e.detail.scrollIntoView,
+    });
+    if (activeTabName) {
+      this._activeTabs = [activeTabName, this._activeTabs[1]];
+
+      // update plugin endpoint if its a plugin tab
+      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
+          activeTabName);
+      if (pluginIndex !== -1) {
+        this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
+            pluginIndex];
+        this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
+            pluginIndex];
+      } else {
+        this._selectedTabPluginEndpoint = '';
+        this._selectedTabPluginHeader = '';
+      }
+    }
+  }
+
+  /**
+   * Changes active secondary tab.
+   *
+   * @param {CustomEvent<SwitchTabEventDetail>} e
+   */
+  _setActiveSecondaryTab(e) {
+    const secondaryTabs = this.shadowRoot.querySelector('#secondaryTabs');
+    const activeTabName = this._setActiveTab(secondaryTabs, {
+      activeTabName: e.detail.tab,
+      activeTabIndex: e.detail.value,
+      scrollIntoView: e.detail.scrollIntoView,
+    });
+    if (activeTabName) {
+      this._activeTabs = [this._activeTabs[0], activeTabName];
+    }
+  }
+
+  _handleEditCommitMessage() {
+    this._editingCommitMessage = true;
+    this.$.commitMessageEditor.focusTextarea();
+  }
+
+  _handleCommitMessageSave(e) {
+    // Trim trailing whitespace from each line.
+    const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+
+    this.$.jsAPI.handleCommitMessage(this._change, message);
+
+    this.$.commitMessageEditor.disabled = true;
+    this.$.restAPI.putChangeCommitMessage(
+        this._changeNum, message)
+        .then(resp => {
+          this.$.commitMessageEditor.disabled = false;
+          if (!resp.ok) { return; }
+
+          this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+              message);
+          this._editingCommitMessage = false;
+          this._reloadWindow();
+        })
+        .catch(err => {
+          this.$.commitMessageEditor.disabled = false;
+        });
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _handleCommitMessageCancel(e) {
+    this._editingCommitMessage = false;
+  }
+
+  _computeChangeStatusChips(change, mergeable, submitEnabled) {
+    // Polymer 2: check for undefined
+    if ([
+      change,
+      mergeable,
+    ].some(arg => arg === undefined)) {
+      // To keep consistent with Polymer 1, we are returning undefined
+      // if not all dependencies are defined
+      return undefined;
+    }
+
+    // Show no chips until mergeability is loaded.
+    if (mergeable === null) {
+      return [];
+    }
+
+    const options = {
+      includeDerived: true,
+      mergeable: !!mergeable,
+      submitEnabled: !!submitEnabled,
+    };
+    return this.changeStatuses(change, options);
+  }
+
+  _computeHideEditCommitMessage(
+      loggedIn, editing, change, editMode, collapsed, collapsible) {
+    if (!loggedIn || editing ||
+        (change && change.status === this.ChangeStatus.MERGED) ||
+        editMode ||
+        (collapsed && collapsible)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _robotCommentCountPerPatchSet(threads) {
+    return threads.reduce((robotCommentCountMap, thread) => {
+      const comments = thread.comments;
+      const robotCommentsCount = comments.reduce((acc, comment) =>
+        (comment.robot_id ? acc + 1 : acc), 0);
+      robotCommentCountMap[comments[0].patch_set] =
+          (robotCommentCountMap[comments[0].patch_set] || 0) +
+        robotCommentsCount;
+      return robotCommentCountMap;
+    }, {});
+  }
+
+  _computeText(patch, commentThreads) {
+    const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+    const commentCnt = commentCount[patch._number] || 0;
+    if (commentCnt === 0) return `Patchset ${patch._number}`;
+    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
+    return `Patchset ${patch._number}`
+            + ` (${commentCnt} ${findingsText})`;
+  }
+
+  _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
+    if (!change || !commentThreads || !change.revisions) return [];
+
+    return Object.values(change.revisions)
+        .filter(patch => patch._number !== 'edit')
+        .map(patch => {
+          return {
+            text: this._computeText(patch, commentThreads),
+            value: patch._number,
+          };
+        })
+        .sort((a, b) => b.value - a.value);
+  }
+
+  _handleCurrentRevisionUpdate(currentRevision) {
+    this._currentRobotCommentsPatchSet = currentRevision._number;
+  }
+
+  _handleRobotCommentPatchSetChanged(e) {
+    const patchSet = parseInt(e.detail.value);
+    if (patchSet === this._currentRobotCommentsPatchSet) return;
+    this._currentRobotCommentsPatchSet = patchSet;
+  }
+
+  _computeShowText(showAllRobotComments) {
+    return showAllRobotComments ? 'Show Less' : 'Show more';
+  }
+
+  _toggleShowRobotComments() {
+    this._showAllRobotComments = !this._showAllRobotComments;
+  }
+
+  _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
+      showAllRobotComments) {
+    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+    const threads = commentThreads.filter(thread => {
+      const comments = thread.comments || [];
+      return comments.length && comments[0].robot_id && (comments[0].patch_set
+        === currentRobotCommentsPatchSet);
+    });
+    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+    return threads.slice(0, showAllRobotComments ? undefined :
+      ROBOT_COMMENTS_LIMIT);
+  }
+
+  _handleReloadCommentThreads() {
+    // Get any new drafts that have been saved in the diff view and show
+    // in the comment thread view.
+    this._reloadDrafts().then(() => {
+      this._commentThreads = this._changeComments.getAllThreadsForChange()
+          .map(c => Object.assign({}, c));
+      flush();
+    });
+  }
+
+  _handleReloadDiffComments(e) {
+    // Keeps the file list counts updated.
+    this._reloadDrafts().then(() => {
+      // Get any new drafts that have been saved in the thread view and show
+      // in the diff view.
+      this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
+          e.detail.path);
+      flush();
+    });
+  }
+
+  _computeTotalCommentCounts(unresolvedCount, changeComments) {
+    if (!changeComments) return undefined;
+    const draftCount = changeComments.computeDraftCount();
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
+    const draftString = GrCountStringFormatter.computePluralString(
+        draftCount, 'draft');
+
+    return unresolvedString +
+        // Add a comma and space if both unresolved and draft comments exist.
+        (unresolvedString && draftString ? ', ' : '') +
+        draftString;
+  }
+
+  _handleCommentSave(e) {
+    const draft = e.detail.comment;
+    if (!draft.__draft) { return; }
+
+    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+    // The use of path-based notification helpers (set, push) can’t be used
+    // because the paths could contain dots in them. A new object must be
+    // created to satisfy Polymer’s dirty checking.
+    // https://github.com/Polymer/polymer/issues/3127
+    const diffDrafts = Object.assign({}, this._diffDrafts);
+    if (!diffDrafts[draft.path]) {
+      diffDrafts[draft.path] = [draft];
+      this._diffDrafts = diffDrafts;
+      return;
+    }
+    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      if (this._diffDrafts[draft.path][i].id === draft.id) {
+        diffDrafts[draft.path][i] = draft;
+        this._diffDrafts = diffDrafts;
+        return;
+      }
+    }
+    diffDrafts[draft.path].push(draft);
+    diffDrafts[draft.path].sort((c1, c2) =>
+      // No line number means that it’s a file comment. Sort it above the
+      // others.
+      (c1.line || -1) - (c2.line || -1)
+    );
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleCommentDiscard(e) {
+    const draft = e.detail.comment;
+    if (!draft.__draft) { return; }
+
+    if (!this._diffDrafts[draft.path]) {
+      return;
+    }
+    let index = -1;
+    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      if (this._diffDrafts[draft.path][i].id === draft.id) {
+        index = i;
+        break;
+      }
+    }
+    if (index === -1) {
+      // It may be a draft that hasn’t been added to _diffDrafts since it was
+      // never saved.
+      return;
+    }
+
+    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+    // The use of path-based notification helpers (set, push) can’t be used
+    // because the paths could contain dots in them. A new object must be
+    // created to satisfy Polymer’s dirty checking.
+    // https://github.com/Polymer/polymer/issues/3127
+    const diffDrafts = Object.assign({}, this._diffDrafts);
+    diffDrafts[draft.path].splice(index, 1);
+    if (diffDrafts[draft.path].length === 0) {
+      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(() => {
+      flush();
+      this.$.includedInOverlay.refit();
+    });
+    this.$.includedInOverlay.open();
+  }
+
+  _handleIncludedInDialogClose(e) {
+    this.$.includedInOverlay.close();
+  }
+
+  _handleOpenDownloadDialog() {
+    this.$.downloadOverlay.open().then(() => {
+      this.$.downloadOverlay
+          .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 => '> ' + 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();
+  }
+
+  _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;
+    if (e.detail.value && e.detail.value.ccsOnly) {
+      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) {
+    if (value.view !== GerritNav.View.CHANGE) {
+      this._initialLoadComplete = false;
+      return;
+    }
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
+
+    const patchChanged = this._patchRange &&
+        (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
+        (this._patchRange.patchNum !== value.patchNum ||
+        this._patchRange.basePatchNum !== value.basePatchNum);
+
+    if (this._changeNum !== value.changeNum) {
+      this._initialLoadComplete = false;
+    }
+
+    const patchRange = {
+      patchNum: value.patchNum,
+      basePatchNum: value.basePatchNum || 'PARENT',
+    };
+
+    this.$.fileList.collapseAllDiffs();
+    this._patchRange = patchRange;
+
+    // If the change has already been loaded and the parameter change is only
+    // in the patch range, then don't do a full reload.
+    if (this._initialLoadComplete && patchChanged) {
+      if (patchRange.patchNum == null) {
+        patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
+      }
+      this._reloadPatchNumDependentResources().then(() => {
+        this._sendShowChangeEvent();
+      });
+      return;
+    }
+
+    this._changeNum = value.changeNum;
+    this.$.relatedChanges.clear();
+
+    this._reload(true).then(() => {
+      this._performPostLoadTasks();
+    });
+
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      this._initActiveTabs(value);
+    });
+  }
+
+  _initActiveTabs(params = {}) {
+    let primaryTab = PrimaryTabs.FILES;
+    if (params.queryMap && params.queryMap.has('tab')) {
+      primaryTab = params.queryMap.get('tab');
+    }
+    this._setActivePrimaryTab({
+      detail: {
+        tab: primaryTab,
+      },
+    });
+
+    // TODO: should drop this once we move CommentThreads tab
+    // to primary as well
+    let secondaryTab = SecondaryTabs.CHANGE_LOG;
+    if (params.queryMap && params.queryMap.has('secondaryTab')) {
+      secondaryTab = params.queryMap.get('secondaryTab');
+    }
+    this._setActiveSecondaryTab({
+      detail: {
+        tab: secondaryTab,
+      },
+    });
+  }
+
+  _sendShowChangeEvent() {
+    this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
+      change: this._change,
+      patchNum: this._patchRange.patchNum,
+      info: {mergeable: this._mergeable},
+    });
+  }
+
+  _performPostLoadTasks() {
+    this._maybeShowReplyDialog();
+    this._maybeShowRevertDialog();
+
+    this._sendShowChangeEvent();
+
+    this.async(() => {
+      if (this.viewState.scrollTop) {
+        document.documentElement.scrollTop =
+            document.body.scrollTop = this.viewState.scrollTop;
+      } else {
+        this._maybeScrollToMessage(window.location.hash);
+      }
+      this._initialLoadComplete = true;
+    });
+  }
+
+  _paramsAndChangeChanged(value, change) {
+    // Polymer 2: check for undefined
+    if ([value, change].some(arg => arg === undefined)) {
+      return;
+    }
+
+    // If the change number or patch range is different, then reset the
+    // selected file index.
+    const patchRangeState = this.viewState.patchRange;
+    if (this.viewState.changeNum !== this._changeNum ||
+        patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+        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;
+    const url = GerritNav.getUrlForChange(this._change,
+        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);
+    const vars = pageURL.split('&');
+    for (let i = 0; i < vars.length; i++) {
+      const name = vars[i].split('=');
+      if (name[0] == param) {
+        return name[0];
+      }
+    }
+    return null;
+  }
+
+  _maybeShowRevertDialog() {
+    pluginLoader.awaitPluginsLoaded()
+        .then(this._getLoggedIn.bind(this))
+        .then(loggedIn => {
+          if (!loggedIn || !this._change ||
+              this._change.status !== this.ChangeStatus.MERGED) {
+          // Do not display dialog if not logged-in or the change is not
+          // merged.
+            return;
+          }
+          if (this._getUrlParameter('revert')) {
+            this.$.actions.showRevertDialog();
+          }
+        });
+  }
+
+  _maybeShowReplyDialog() {
+    this._getLoggedIn().then(loggedIn => {
+      if (!loggedIn) { return; }
+
+      if (this.viewState.showReplyDialog) {
+        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+        // TODO(kaspern@): Find a better signal for when to call center.
+        this.async(() => { this.$.replyOverlay.center(); }, 100);
+        this.async(() => { this.$.replyOverlay.center(); }, 1000);
+        this.set('viewState.showReplyDialog', false);
+      }
+    });
+  }
+
+  _resetFileListViewState() {
+    this.set('viewState.selectedFileIndex', 0);
+    this.set('viewState.scrollTop', 0);
+    if (!!this.viewState.changeNum &&
+        this.viewState.changeNum !== this._changeNum) {
+      // Reset the diff mode to null when navigating from one change to
+      // another, so that the user's preference is restored.
+      this._setDiffViewMode(true);
+      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+    }
+    this.set('viewState.changeNum', this._changeNum);
+    this.set('viewState.patchRange', this._patchRange);
+  }
+
+  _changeChanged(change) {
+    if (!change || !this._patchRange || !this._allPatchSets) { return; }
+
+    // We get the parent first so we keep the original value for basePatchNum
+    // and not the updated value.
+    const parent = this._getBasePatchNum(change, this._patchRange);
+
+    this.set('_patchRange.patchNum', this._patchRange.patchNum ||
+            this.computeLatestPatchNum(this._allPatchSets));
+
+    this.set('_patchRange.basePatchNum', parent);
+
+    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title},
+      composed: true, bubbles: true,
+    }));
+  }
+
+  /**
+   * Gets base patch number, if it is a parent try and decide from
+   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+   *
+   * @param {Object} change
+   * @param {Object} patchRange
+   * @return {number|string}
+   */
+  _getBasePatchNum(change, patchRange) {
+    if (patchRange.basePatchNum &&
+        patchRange.basePatchNum !== 'PARENT') {
+      return patchRange.basePatchNum;
+    }
+
+    const revisionInfo = this._getRevisionInfo(change);
+    if (!revisionInfo) return 'PARENT';
+
+    const parentCounts = revisionInfo.getParentCountMap();
+    // check that there is at least 2 parents otherwise fall back to 1,
+    // which means there is only one parent.
+    const parentCount = parentCounts.hasOwnProperty(1) ?
+      parentCounts[1] : 1;
+
+    const preferFirst = this._prefs &&
+        this._prefs.default_base_for_merges === 'FIRST_PARENT';
+
+    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
+      return -1;
+    }
+
+    return 'PARENT';
+  }
+
+  _computeChangeUrl(change) {
+    return GerritNav.getUrlForChange(change);
+  }
+
+  _computeShowCommitInfo(changeStatus, current_revision) {
+    return changeStatus === 'Merged' && current_revision;
+  }
+
+  _computeMergedCommitInfo(current_revision, revisions) {
+    const rev = revisions[current_revision];
+    if (!rev || !rev.commit) { return {}; }
+    // CommitInfo.commit is optional. Set commit in all cases to avoid error
+    // 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) {
+      return 'Change-Id mismatch';
+    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+      return 'No Change-Id in commit message';
+    }
+  }
+
+  _computeChangeIdCommitMessageError(commitMessage, change) {
+    // Polymer 2: check for undefined
+    if ([commitMessage, change].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
+
+    // Find the last match in the commit message:
+    let changeId;
+    let changeIdArr;
+
+    while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
+      changeId = changeIdArr[1];
+    }
+
+    if (changeId) {
+      // A change-id is detected in the commit message.
+
+      if (changeId === change.change_id) {
+        // The change-id found matches the real change-id.
+        return null;
+      }
+      // The change-id found does not match the change-id.
+      return CHANGE_ID_ERROR.MISMATCH;
+    }
+    // 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 = [];
+    const t = labels[labelName];
+    if (!t) { return result; }
+    const approvals = t.all || [];
+    for (const label of approvals) {
+      if (label.value && label.value != labels[labelName].default_value) {
+        let labelClassName;
+        let labelValPrefix = '';
+        if (label.value > 0) {
+          labelValPrefix = '+';
+          labelClassName = 'approved';
+        } else if (label.value < 0) {
+          labelClassName = 'notApproved';
+        }
+        result.push({
+          value: labelValPrefix + label.value,
+          className: labelClassName,
+          account: label,
+        });
+      }
+    }
+    return result;
+  }
+
+  _computeReplyButtonLabel(changeRecord, canStartReview) {
+    // Polymer 2: check for undefined
+    if ([changeRecord, canStartReview].some(arg => arg === undefined)) {
+      return 'Reply';
+    }
+    if (canStartReview) {
+      return 'Start Review';
+    }
+
+    const drafts = (changeRecord && changeRecord.base) || {};
+    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) ||
+        this.modifierPressed(e)) {
+      return;
+    }
+    this._getLoggedIn().then(isLoggedIn => {
+      if (!isLoggedIn) {
+        this.dispatchEvent(new CustomEvent('show-auth-required', {
+          composed: true, bubbles: true,
+        }));
         return;
       }
 
-      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-      // The use of path-based notification helpers (set, push) can’t be used
-      // because the paths could contain dots in them. A new object must be
-      // created to satisfy Polymer’s dirty checking.
-      // https://github.com/Polymer/polymer/issues/3127
-      const diffDrafts = Object.assign({}, this._diffDrafts);
-      diffDrafts[draft.path].splice(index, 1);
-      if (diffDrafts[draft.path].length === 0) {
-        delete diffDrafts[draft.path];
-      }
-      this._diffDrafts = diffDrafts;
-    },
-
-    _handleReplyTap(e) {
       e.preventDefault();
       this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-    },
+    });
+  }
 
-    _handleOpenDiffPrefs() {
-      this.$.fileList.openDiffPrefs();
-    },
+  _handleOpenDownloadDialogShortcut(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _handleOpenIncludedInDialog() {
-      this.$.includedInDialog.loadData().then(() => {
-        Polymer.dom.flush();
-        this.$.includedInOverlay.refit();
-      });
-      this.$.includedInOverlay.open();
-    },
+    e.preventDefault();
+    this.$.downloadOverlay.open();
+  }
 
-    _handleIncludedInDialogClose(e) {
-      this.$.includedInOverlay.close();
-    },
+  _handleEditTopic(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _handleOpenDownloadDialog() {
-      this.$.downloadOverlay.open().then(() => {
-        this.$.downloadOverlay
-            .setFocusStops(this.$.downloadDialog.getFocusStops());
-        this.$.downloadDialog.focus();
-      });
-    },
+    e.preventDefault();
+    this.$.metadata.editTopic();
+  }
 
-    _handleDownloadDialogClose(e) {
-      this.$.downloadOverlay.close();
-    },
+  _handleRefreshChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    e.preventDefault();
+    GerritNav.navigateToChange(this._change);
+  }
 
-    _handleOpenUploadHelpDialog(e) {
-      this.$.uploadHelpOverlay.open();
-    },
+  _handleToggleChangeStar(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _handleCloseUploadHelpDialog(e) {
-      this.$.uploadHelpOverlay.close();
-    },
+    e.preventDefault();
+    this.$.changeStar.toggleStar();
+  }
 
-    _handleMessageReply(e) {
-      const msg = e.detail.message.message;
-      const quoteStr = msg.split('\n').map(
-          line => { return '> ' + line; }).join('\n') + '\n\n';
-      this.$.replyDialog.quote = quoteStr;
-      this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
-    },
+  _handleUpToDashboard(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _handleHideBackgroundContent() {
-      this.$.mainContent.classList.add('overlayOpen');
-    },
+    e.preventDefault();
+    this._determinePageBack();
+  }
 
-    _handleShowBackgroundContent() {
-      this.$.mainContent.classList.remove('overlayOpen');
-    },
+  _handleExpandAllMessages(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _handleReplySent(e) {
-      this.$.replyOverlay.close();
-      this._reload().then(() => {
-        this.$.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
-      });
-    },
+    e.preventDefault();
+    this.messagesList.handleExpandCollapse(true);
+  }
 
-    _handleReplyCancel(e) {
-      this.$.replyOverlay.close();
-    },
+  _handleCollapseAllMessages(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _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);
-    },
+    e.preventDefault();
+    this.messagesList.handleExpandCollapse(false);
+  }
 
-    _handleShowReplyDialog(e) {
-      let target = this.$.replyDialog.FocusTarget.REVIEWERS;
-      if (e.detail.value && e.detail.value.ccsOnly) {
-        target = this.$.replyDialog.FocusTarget.CCS;
-      }
-      this._openReplyDialog(target);
-    },
+  _handleOpenDiffPrefsShortcut(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-    _handleScroll() {
-      this.debounce('scroll', () => {
-        this.viewState.scrollTop = document.body.scrollTop;
-      }, 150);
-    },
+    if (this._diffPrefsDisabled) { return; }
 
-    _setShownFiles(e) {
-      this._shownFileCount = e.detail.length;
-    },
+    e.preventDefault();
+    this.$.fileList.openDiffPrefs();
+  }
 
-    _expandAllDiffs() {
-      this.$.fileList.expandAllDiffs();
-    },
+  _determinePageBack() {
+    // Default backPage to root if user came to change view page
+    // via an email link, etc.
+    GerritNav.navigateToRelativeUrl(this.backPage ||
+         GerritNav.getUrlForRoot());
+  }
 
-    _collapseAllDiffs() {
-      this.$.fileList.collapseAllDiffs();
-    },
-
-    _paramsChanged(value) {
-      // Change the content of the comment tabs back to messages list, but
-      // do not yet change the tab itself. The animation of tab switching will
-      // 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;
-
-      if (value.view !== Gerrit.Nav.View.CHANGE) {
-        this._initialLoadComplete = false;
-        return;
-      }
-
-      if (value.changeNum && value.project) {
-        this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-      }
-
-      const patchChanged = this._patchRange &&
-          (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
-          (this._patchRange.patchNum !== value.patchNum ||
-          this._patchRange.basePatchNum !== value.basePatchNum);
-
-      if (this._changeNum !== value.changeNum) {
-        this._initialLoadComplete = false;
-      }
-
-      const patchRange = {
-        patchNum: value.patchNum,
-        basePatchNum: value.basePatchNum || 'PARENT',
-      };
-
-      this.$.fileList.collapseAllDiffs();
-      this._patchRange = patchRange;
-
-      // If the change has already been loaded and the parameter change is only
-      // in the patch range, then don't do a full reload.
-      if (this._initialLoadComplete && patchChanged) {
-        if (patchRange.patchNum == null) {
-          patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
+  _handleLabelRemoved(splices, path) {
+    for (const splice of splices) {
+      for (const removed of splice.removed) {
+        const changePath = path.split('.');
+        const labelPath = changePath.splice(0, changePath.length - 2);
+        const labelDict = this.get(labelPath);
+        if (labelDict.approved &&
+            labelDict.approved._account_id === removed._account_id) {
+          this._reload();
+          return;
         }
-        this._reloadPatchNumDependentResources().then(() => {
-          this._sendShowChangeEvent();
+      }
+    }
+  }
+
+  _labelsChanged(changeRecord) {
+    if (!changeRecord) { return; }
+    if (changeRecord.value && changeRecord.value.indexSplices) {
+      this._handleLabelRemoved(changeRecord.value.indexSplices,
+          changeRecord.path);
+    }
+    this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
+      change: this._change,
+    });
+  }
+
+  /**
+   * @param {string=} opt_section
+   */
+  _openReplyDialog(opt_section) {
+    this.$.replyOverlay.open().finally(() => {
+      // the following code should be executed no matter open succeed or not
+      this._resetReplyOverlayFocusStops();
+      this.$.replyDialog.open(opt_section);
+      flush();
+      this.$.replyOverlay.center();
+    });
+  }
+
+  _handleReloadChange(e) {
+    return this._reload().then(() => {
+      // If the change was rebased or submitted, we need to reload the page
+      // with the latest patch.
+      const action = e.detail.action;
+      if (action === 'rebase' || action === 'submit') {
+        GerritNav.navigateToChange(this._change);
+      }
+    });
+  }
+
+  _handleGetChangeDetailError(response) {
+    this.dispatchEvent(new CustomEvent('page-error', {
+      detail: {response},
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getServerConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _getProjectConfig() {
+    if (!this._change) return;
+    return this.$.restAPI.getProjectConfig(this._change.project).then(
+        config => {
+          this._projectConfig = config;
         });
-        return;
-      }
+  }
 
-      this._changeNum = value.changeNum;
-      this.$.relatedChanges.clear();
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
 
-      this._reload(true).then(() => {
-        this._performPostLoadTasks();
-      });
-    },
+  _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');
+  }
 
-    _sendShowChangeEvent() {
-      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
-        change: this._change,
-        patchNum: this._patchRange.patchNum,
-        info: {mergeable: this._mergeable},
-      });
-    },
+  /**
+   * Utility function to make the necessary modifications to a change in the
+   * case an edit exists.
+   *
+   * @param {!Object} change
+   * @param {?Object} edit
+   */
+  _processEdit(change, edit) {
+    if (!edit) { return; }
+    change.revisions[edit.commit.commit] = {
+      _number: this.EDIT_NAME,
+      basePatchNum: edit.base_patch_set_number,
+      commit: edit.commit,
+      fetch: edit.fetch,
+    };
+    // If the edit is based on the most recent patchset, load it by
+    // default, unless another patch set to load was specified in the URL.
+    if (!this._patchRange.patchNum &&
+        change.current_revision === edit.base_revision) {
+      change.current_revision = edit.commit.commit;
+      this.set('_patchRange.patchNum', this.EDIT_NAME);
+      // Because edits are fibbed as revisions and added to the revisions
+      // array, and revision actions are always derived from the 'latest'
+      // patch set, we must copy over actions from the patch set base.
+      // Context: Issue 7243
+      change.revisions[edit.commit.commit].actions =
+          change.revisions[edit.base_revision].actions;
+    }
+  }
 
-    _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');
-      if (primaryTabs) primaryTabs.selected = 0;
-    },
+  _getChangeDetail() {
+    const detailCompletes = this.$.restAPI.getChangeDetail(
+        this._changeNum, this._handleGetChangeDetailError.bind(this));
+    const editCompletes = this._getEdit();
+    const prefCompletes = this._getPreferences();
 
-    _performPostLoadTasks() {
-      this._maybeShowReplyDialog();
-      this._maybeShowRevertDialog();
+    return Promise.all([detailCompletes, editCompletes, prefCompletes])
+        .then(([change, edit, prefs]) => {
+          this._prefs = prefs;
 
-      this._sendShowChangeEvent();
-
-      this._setPrimaryTab();
-
-      this.async(() => {
-        if (this.viewState.scrollTop) {
-          document.documentElement.scrollTop =
-              document.body.scrollTop = this.viewState.scrollTop;
-        } else {
-          this._maybeScrollToMessage(window.location.hash);
-        }
-        this._initialLoadComplete = true;
-      });
-    },
-
-    _paramsAndChangeChanged(value, change) {
-      // Polymer 2: check for undefined
-      if ([value, change].some(arg => arg === undefined)) {
-        return;
-      }
-
-      // If the change number or patch range is different, then reset the
-      // selected file index.
-      const patchRangeState = this.viewState.patchRange;
-      if (this.viewState.changeNum !== this._changeNum ||
-          patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
-          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;
-      const url = Gerrit.Nav.getUrlForChange(this._change,
-          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);
-      const vars = pageURL.split('&');
-      for (let i = 0; i < vars.length; i++) {
-        const name = vars[i].split('=');
-        if (name[0] == param) {
-          return name[0];
-        }
-      }
-      return null;
-    },
-
-    _maybeShowRevertDialog() {
-      Gerrit.awaitPluginsLoaded()
-          .then(this._getLoggedIn.bind(this))
-          .then(loggedIn => {
-            if (!loggedIn || !this._change ||
-                this._change.status !== this.ChangeStatus.MERGED) {
-            // Do not display dialog if not logged-in or the change is not
-            // merged.
-              return;
-            }
-            if (this._getUrlParameter('revert')) {
-              this.$.actions.showRevertDialog();
-            }
-          });
-    },
-
-    _maybeShowReplyDialog() {
-      this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) { return; }
-
-        if (this.viewState.showReplyDialog) {
-          this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-          // TODO(kaspern@): Find a better signal for when to call center.
-          this.async(() => { this.$.replyOverlay.center(); }, 100);
-          this.async(() => { this.$.replyOverlay.center(); }, 1000);
-          this.set('viewState.showReplyDialog', false);
-        }
-      });
-    },
-
-    _resetFileListViewState() {
-      this.set('viewState.selectedFileIndex', 0);
-      this.set('viewState.scrollTop', 0);
-      if (!!this.viewState.changeNum &&
-          this.viewState.changeNum !== this._changeNum) {
-        // Reset the diff mode to null when navigating from one change to
-        // another, so that the user's preference is restored.
-        this._setDiffViewMode(true);
-        this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
-      }
-      this.set('viewState.changeNum', this._changeNum);
-      this.set('viewState.patchRange', this._patchRange);
-    },
-
-    _changeChanged(change) {
-      if (!change || !this._patchRange || !this._allPatchSets) { return; }
-
-      // We get the parent first so we keep the original value for basePatchNum
-      // and not the updated value.
-      const parent = this._getBasePatchNum(change, this._patchRange);
-
-      this.set('_patchRange.patchNum', this._patchRange.patchNum ||
-              this.computeLatestPatchNum(this._allPatchSets));
-
-      this.set('_patchRange.basePatchNum', parent);
-
-      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
-     * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
-     *
-     * @param {Object} change
-     * @param {Object} patchRange
-     * @return {number|string}
-     */
-    _getBasePatchNum(change, patchRange) {
-      if (patchRange.basePatchNum &&
-          patchRange.basePatchNum !== 'PARENT') {
-        return patchRange.basePatchNum;
-      }
-
-      const revisionInfo = this._getRevisionInfo(change);
-      if (!revisionInfo) return 'PARENT';
-
-      const parentCounts = revisionInfo.getParentCountMap();
-      // check that there is at least 2 parents otherwise fall back to 1,
-      // which means there is only one parent.
-      const parentCount = parentCounts.hasOwnProperty(1) ?
-        parentCounts[1] : 1;
-
-      const preferFirst = this._prefs &&
-          this._prefs.default_base_for_merges === 'FIRST_PARENT';
-
-      if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
-        return -1;
-      }
-
-      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];
-      if (!rev || !rev.commit) { return {}; }
-      // CommitInfo.commit is optional. Set commit in all cases to avoid error
-      // 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) {
-        return 'Change-Id mismatch';
-      } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
-        return 'No Change-Id in commit message';
-      }
-    },
-
-    _computeChangeIdCommitMessageError(commitMessage, change) {
-      // Polymer 2: check for undefined
-      if ([commitMessage, change].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
-
-      // Find the last match in the commit message:
-      let changeId;
-      let changeIdArr;
-
-      while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
-        changeId = changeIdArr[1];
-      }
-
-      if (changeId) {
-        // A change-id is detected in the commit message.
-
-        if (changeId === change.change_id) {
-          // The change-id found matches the real change-id.
-          return null;
-        }
-        // The change-id found does not match the change-id.
-        return CHANGE_ID_ERROR.MISMATCH;
-      }
-      // 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 = [];
-      const t = labels[labelName];
-      if (!t) { return result; }
-      const approvals = t.all || [];
-      for (const label of approvals) {
-        if (label.value && label.value != labels[labelName].default_value) {
-          let labelClassName;
-          let labelValPrefix = '';
-          if (label.value > 0) {
-            labelValPrefix = '+';
-            labelClassName = 'approved';
-          } else if (label.value < 0) {
-            labelClassName = 'notApproved';
+          if (!change) {
+            return '';
           }
-          result.push({
-            value: labelValPrefix + label.value,
-            className: labelClassName,
-            account: label,
-          });
+          this._processEdit(change, edit);
+          // Issue 4190: Coalesce missing topics to null.
+          if (!change.topic) { change.topic = null; }
+          if (!change.reviewer_updates) {
+            change.reviewer_updates = null;
+          }
+          const latestRevisionSha = this._getLatestRevisionSHA(change);
+          const currentRevision = change.revisions[latestRevisionSha];
+          if (currentRevision.commit && currentRevision.commit.message) {
+            this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+                currentRevision.commit.message);
+          } else {
+            this._latestCommitMessage = null;
+          }
+
+          const lineHeight = getComputedStyle(this).lineHeight;
+
+          // Slice returns a number as a string, convert to an int.
+          this._lineHeight =
+              parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
+
+          this._change = change;
+          if (!this._patchRange || !this._patchRange.patchNum ||
+              this.patchNumEquals(this._patchRange.patchNum,
+                  currentRevision._number)) {
+            // CommitInfo.commit is optional, and may need patching.
+            if (!currentRevision.commit.commit) {
+              currentRevision.commit.commit = latestRevisionSha;
+            }
+            this._commitInfo = currentRevision.commit;
+            this._selectedRevision = currentRevision;
+            // TODO: Fetch and process files.
+          } else {
+            this._selectedRevision =
+              Object.values(this._change.revisions).find(
+                  revision => {
+                    // edit patchset is a special one
+                    const thePatchNum = this._patchRange.patchNum;
+                    if (thePatchNum === 'edit') {
+                      return revision._number === thePatchNum;
+                    }
+                    return revision._number === parseInt(thePatchNum, 10);
+                  });
+          }
+        });
+  }
+
+  _isSubmitEnabled(revisionActions) {
+    return !!(revisionActions && revisionActions.submit &&
+      revisionActions.submit.enabled);
+  }
+
+  _isParentCurrent(revisionActions) {
+    if (revisionActions && revisionActions.rebase) {
+      return !revisionActions.rebase.enabled;
+    } else {
+      return true;
+    }
+  }
+
+  _getEdit() {
+    return this.$.restAPI.getChangeEdit(this._changeNum, true);
+  }
+
+  _getLatestCommitMessage() {
+    return this.$.restAPI.getChangeCommitInfo(this._changeNum,
+        this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
+      if (!commitInfo) return Promise.resolve();
+      this._latestCommitMessage =
+                  this._prepareCommitMsgForLinkify(commitInfo.message);
+    });
+  }
+
+  _getLatestRevisionSHA(change) {
+    if (change.current_revision) {
+      return change.current_revision;
+    }
+    // current_revision may not be present in the case where the latest rev is
+    // a draft and the user doesn’t have permission to view that rev.
+    let latestRev = null;
+    let latestPatchNum = -1;
+    for (const rev in change.revisions) {
+      if (!change.revisions.hasOwnProperty(rev)) { continue; }
+
+      if (change.revisions[rev]._number > latestPatchNum) {
+        latestRev = rev;
+        latestPatchNum = change.revisions[rev]._number;
+      }
+    }
+    return latestRev;
+  }
+
+  _getCommitInfo() {
+    return this.$.restAPI.getChangeCommitInfo(
+        this._changeNum, this._patchRange.patchNum).then(
+        commitInfo => {
+          this._commitInfo = commitInfo;
+        });
+  }
+
+  _reloadDraftsWithCallback(e) {
+    return this._reloadDrafts().then(() => e.detail.resolve());
+  }
+
+  /**
+   * Fetches a new changeComment object, and data for all types of comments
+   * (comments, robot comments, draft comments) is requested.
+   */
+  _reloadComments() {
+    return this.$.commentAPI.loadAll(this._changeNum)
+        .then(comments => this._recomputeComments(comments));
+  }
+
+  /**
+   * Fetches a new changeComment object, but only updated data for drafts is
+   * requested.
+   *
+   * TODO(taoalpha): clean up this and _reloadComments, as single comment
+   * can be a thread so it does not make sense to only update drafts
+   * without updating threads
+   */
+  _reloadDrafts() {
+    return this.$.commentAPI.reloadDrafts(this._changeNum)
+        .then(comments => this._recomputeComments(comments));
+  }
+
+  _recomputeComments(comments) {
+    this._changeComments = comments;
+    this._diffDrafts = Object.assign({}, this._changeComments.drafts);
+    this._commentThreads = this._changeComments.getAllThreadsForChange()
+        .map(c => Object.assign({}, c));
+    this._draftCommentThreads = this._commentThreads
+        .filter(c => c.comments[c.comments.length - 1].__draft);
+  }
+
+  /**
+   * Reload the change.
+   *
+   * @param {boolean=} opt_isLocationChange Reloads the related changes
+   *     when true and ends reporting events that started on location change.
+   * @return {Promise} A promise that resolves when the core data has loaded.
+   *     Some non-core data loading may still be in-flight when the core data
+   *     promise resolves.
+   */
+  _reload(opt_isLocationChange) {
+    this._loading = true;
+    this._relatedChangesCollapsed = true;
+    this.$.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+    this.$.reporting.time(CHANGE_DATA_TIMING_LABEL);
+
+    // Array to house all promises related to data requests.
+    const allDataPromises = [];
+
+    // Resolves when the change detail and the edit patch set (if available)
+    // are loaded.
+    const detailCompletes = this._getChangeDetail();
+    allDataPromises.push(detailCompletes);
+
+    // Resolves when the loading flag is set to false, meaning that some
+    // change content may start appearing.
+    const loadingFlagSet = detailCompletes
+        .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) {
+            this.$.reporting.changeDisplayed();
+          }
+        });
+
+    // Resolves when the project config has loaded.
+    const projectConfigLoaded = detailCompletes
+        .then(() => this._getProjectConfig());
+    allDataPromises.push(projectConfigLoaded);
+
+    // Resolves when change comments have loaded (comments, drafts and robot
+    // comments).
+    const commentsLoaded = this._reloadComments();
+    allDataPromises.push(commentsLoaded);
+
+    let coreDataPromise;
+
+    // If the patch number is specified
+    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();
+      allDataPromises.push(patchResourcesLoaded);
+
+      // Promise resolves when the change detail and patch dependent resources
+      // have loaded.
+      const detailAndPatchResourcesLoaded =
+          Promise.all([patchResourcesLoaded, loadingFlagSet]);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = detailAndPatchResourcesLoaded
+          .then(() => this._getMergeability());
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Promise resovles when the change actions have loaded.
+      const actionsLoaded = detailAndPatchResourcesLoaded
+          .then(() => this.$.actions.reload());
+      allDataPromises.push(actionsLoaded);
+
+      // The core data is loaded when both mergeability and actions are known.
+      coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
+    } else {
+      // Resolves when the file list has loaded.
+      const fileListReload = loadingFlagSet
+          .then(() => this.$.fileList.reload());
+      allDataPromises.push(fileListReload);
+
+      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
+        // If the latest commit message is known, there is nothing to do.
+        if (this._latestCommitMessage) { return Promise.resolve(); }
+        return this._getLatestCommitMessage();
+      });
+      allDataPromises.push(latestCommitMessageLoaded);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = loadingFlagSet
+          .then(() => this._getMergeability());
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Core data is loaded when mergeability has been loaded.
+      coreDataPromise = mergeabilityLoaded;
+    }
+
+    if (opt_isLocationChange) {
+      this._editingCommitMessage = false;
+      const relatedChangesLoaded = coreDataPromise
+          .then(() => this.$.relatedChanges.reload());
+      allDataPromises.push(relatedChangesLoaded);
+    }
+
+    Promise.all(allDataPromises).then(() => {
+      this.$.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+      if (opt_isLocationChange) {
+        this.$.reporting.changeFullyLoaded();
+      }
+    });
+
+    return coreDataPromise;
+  }
+
+  /**
+   * Kicks off requests for resources that rely on the patch range
+   * (`this._patchRange`) being defined.
+   */
+  _reloadPatchNumDependentResources() {
+    return Promise.all([
+      this._getCommitInfo(),
+      this.$.fileList.reload(),
+    ]);
+  }
+
+  _getMergeability() {
+    if (!this._change) {
+      this._mergeable = null;
+      return Promise.resolve();
+    }
+    // If the change is closed, it is not mergeable. Note: already merged
+    // changes are obviously not mergeable, but the mergeability API will not
+    // answer for abandoned changes.
+    if (this._change.status === this.ChangeStatus.MERGED ||
+        this._change.status === this.ChangeStatus.ABANDONED) {
+      this._mergeable = false;
+      return Promise.resolve();
+    }
+
+    this._mergeable = null;
+    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; }
+
+  _computeChangePermalinkAriaLabel(changeNum) {
+    return 'Change ' + changeNum;
+  }
+
+  _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} | ` +
+     `${location.protocol}//${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);
+    }
+  }
+
+  _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.
+   */
+  _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
+   * change info height.
+   */
+  _updateRelatedChangeMaxHeight() {
+    // Takes into account approximate height for the expand button and
+    // bottom margin.
+    const EXTRA_HEIGHT = 30;
+    let newHeight;
+
+    if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
+        .matches) {
+      // In a small (mobile) view, give the relation chain some space.
+      newHeight = SMALL_RELATED_HEIGHT;
+    } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
+        .matches) {
+      // Since related changes are below the commit message, but still next to
+      // metadata, the height should be the height of the metadata minus the
+      // height of the commit message to reduce jank. However, if that doesn't
+      // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
+      // Note: extraHeight is to take into account margin/padding.
+      const medRelatedHeight = Math.max(
+          this._getOffsetHeight(this.$.mainChangeInfo) -
+          this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
+          MINIMUM_RELATED_MAX_HEIGHT);
+      newHeight = medRelatedHeight;
+    } else {
+      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.
+        newHeight = this._getOffsetHeight(this.$.commitMessage);
+      } else {
+        newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
+            EXTRA_HEIGHT;
+      }
+    }
+    const stylesToUpdate = {};
+
+    // Get the line height of related changes, and convert it to the nearest
+    // integer.
+    const lineHeight = this._getLineHeight(this.$.relatedChanges);
+
+    // Figure out a new height that is divisible by the rounded line height.
+    const remainder = newHeight % lineHeight;
+    newHeight = newHeight - remainder;
+
+    stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
+
+    // Update the max-height of the relation chain to this new height.
+    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
+    // to populate.
+    if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
+      this._updateRelatedChangeMaxHeight();
+    }
+    // Prevents showMore from showing when click on related change, since the
+    // line height would be positive, but related changes height is 0.
+    if (!this._getScrollHeight(this.$.relatedChanges)) {
+      return this._showRelatedToggle = false;
+    }
+
+    if (this._getScrollHeight(this.$.relatedChanges) >
+        (this._getOffsetHeight(this.$.relatedChanges) +
+        this._getLineHeight(this.$.relatedChanges))) {
+      return this._showRelatedToggle = true;
+    }
+    this._showRelatedToggle = false;
+  }
+
+  _updateToggleContainerClass(showRelatedToggle) {
+    if (showRelatedToggle) {
+      this.$.relatedChangesToggle.classList.add('showToggle');
+    } else {
+      this.$.relatedChangesToggle.classList.remove('showToggle');
+    }
+  }
+
+  _startUpdateCheckTimer() {
+    if (!this._serverConfig ||
+        !this._serverConfig.change ||
+        this._serverConfig.change.update_delay === undefined ||
+        this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
+      return;
+    }
+
+    this._updateCheckTimerHandle = this.async(() => {
+      this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
+        let toastMessage = null;
+        if (!result.isLatest) {
+          toastMessage = ReloadToastMessage.NEWER_REVISION;
+        } else if (result.newStatus === this.ChangeStatus.MERGED) {
+          toastMessage = ReloadToastMessage.MERGED;
+        } else if (result.newStatus === this.ChangeStatus.ABANDONED) {
+          toastMessage = ReloadToastMessage.ABANDONED;
+        } else if (result.newStatus === this.ChangeStatus.NEW) {
+          toastMessage = ReloadToastMessage.RESTORED;
+        } else if (result.newMessages) {
+          toastMessage = ReloadToastMessage.NEW_MESSAGE;
         }
-      }
-      return result;
-    },
 
-    _computeReplyButtonLabel(changeRecord, canStartReview) {
-      // Polymer 2: check for undefined
-      if ([changeRecord, canStartReview].some(arg => arg === undefined)) {
-        return 'Reply';
-      }
-
-      if (canStartReview) {
-        return 'Start review';
-      }
-
-      const drafts = (changeRecord && changeRecord.base) || {};
-      const draftCount = Object.keys(drafts).reduce((count, file) => {
-        return count + drafts[file].length;
-      }, 0);
-
-      let label = 'Reply';
-      if (draftCount > 0) {
-        label += ' (' + draftCount + ')';
-      }
-      return label;
-    },
-
-    _handleOpenReplyDialog(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) {
-        return;
-      }
-      this._getLoggedIn().then(isLoggedIn => {
-        if (!isLoggedIn) {
-          this.fire('show-auth-required');
+        if (!toastMessage) {
+          this._startUpdateCheckTimer();
           return;
         }
 
-        e.preventDefault();
-        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-      });
-    },
-
-    _handleOpenDownloadDialogShortcut(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.downloadOverlay.open();
-    },
-
-    _handleEditTopic(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      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) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.$.changeStar.toggleStar();
-    },
-
-    _handleUpToDashboard(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this._determinePageBack();
-    },
-
-    _handleExpandAllMessages(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.messagesList.handleExpandCollapse(true);
-    },
-
-    _handleCollapseAllMessages(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this.messagesList.handleExpandCollapse(false);
-    },
-
-    _handleOpenDiffPrefsShortcut(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      if (this._diffPrefsDisabled) { return; }
-
-      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) {
-        for (const removed of splice.removed) {
-          const changePath = path.split('.');
-          const labelPath = changePath.splice(0, changePath.length - 2);
-          const labelDict = this.get(labelPath);
-          if (labelDict.approved &&
-              labelDict.approved._account_id === removed._account_id) {
-            this._reload();
-            return;
-          }
-        }
-      }
-    },
-
-    _labelsChanged(changeRecord) {
-      if (!changeRecord) { return; }
-      if (changeRecord.value && changeRecord.value.indexSplices) {
-        this._handleLabelRemoved(changeRecord.value.indexSplices,
-            changeRecord.path);
-      }
-      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
-        change: this._change,
-      });
-    },
-
-    /**
-     * @param {string=} opt_section
-     */
-    _openReplyDialog(opt_section) {
-      this.$.replyOverlay.open().then(() => {
-        this._resetReplyOverlayFocusStops();
-        this.$.replyDialog.open(opt_section);
-        Polymer.dom.flush();
-        this.$.replyOverlay.center();
-      });
-    },
-
-    _handleReloadChange(e) {
-      return this._reload().then(() => {
-        // If the change was rebased or submitted, we need to reload the page
-        // with the latest patch.
-        const action = e.detail.action;
-        if (action === 'rebase' || action === 'submit') {
-          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;
-      return this.$.restAPI.getProjectConfig(this._change.project).then(
-          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
-     * case an edit exists.
-     *
-     * @param {!Object} change
-     * @param {?Object} edit
-     */
-    _processEdit(change, edit) {
-      if (!edit) { return; }
-      change.revisions[edit.commit.commit] = {
-        _number: this.EDIT_NAME,
-        basePatchNum: edit.base_patch_set_number,
-        commit: edit.commit,
-        fetch: edit.fetch,
-      };
-      // If the edit is based on the most recent patchset, load it by
-      // default, unless another patch set to load was specified in the URL.
-      if (!this._patchRange.patchNum &&
-          change.current_revision === edit.base_revision) {
-        change.current_revision = edit.commit.commit;
-        this.set('_patchRange.patchNum', this.EDIT_NAME);
-        // Because edits are fibbed as revisions and added to the revisions
-        // array, and revision actions are always derived from the 'latest'
-        // patch set, we must copy over actions from the patch set base.
-        // Context: Issue 7243
-        change.revisions[edit.commit.commit].actions =
-            change.revisions[edit.base_revision].actions;
-      }
-    },
-
-    _getChangeDetail() {
-      const detailCompletes = this.$.restAPI.getChangeDetail(
-          this._changeNum, this._handleGetChangeDetailError.bind(this));
-      const editCompletes = this._getEdit();
-      const prefCompletes = this._getPreferences();
-
-      return Promise.all([detailCompletes, editCompletes, prefCompletes])
-          .then(([change, edit, prefs]) => {
-            this._prefs = prefs;
-
-            if (!change) {
-              return '';
-            }
-            this._processEdit(change, edit);
-            // Issue 4190: Coalesce missing topics to null.
-            if (!change.topic) { change.topic = null; }
-            if (!change.reviewer_updates) {
-              change.reviewer_updates = null;
-            }
-            const latestRevisionSha = this._getLatestRevisionSHA(change);
-            const currentRevision = change.revisions[latestRevisionSha];
-            if (currentRevision.commit && currentRevision.commit.message) {
-              this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-                  currentRevision.commit.message);
-            } else {
-              this._latestCommitMessage = null;
-            }
-
-
-            const lineHeight = getComputedStyle(this).lineHeight;
-
-            // Slice returns a number as a string, convert to an int.
-            this._lineHeight =
-                parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
-
-            this._change = change;
-            if (!this._patchRange || !this._patchRange.patchNum ||
-                this.patchNumEquals(this._patchRange.patchNum,
-                    currentRevision._number)) {
-              // CommitInfo.commit is optional, and may need patching.
-              if (!currentRevision.commit.commit) {
-                currentRevision.commit.commit = latestRevisionSha;
-              }
-              this._commitInfo = currentRevision.commit;
-              this._selectedRevision = currentRevision;
-              // TODO: Fetch and process files.
-            } else {
-              this._selectedRevision =
-                Object.values(this._change.revisions).find(
-                    revision => revision._number ===
-                      parseInt(this._patchRange.patchNum, 10));
-            }
-          });
-    },
-
-    _isSubmitEnabled(revisionActions) {
-      return !!(revisionActions && revisionActions.submit &&
-        revisionActions.submit.enabled);
-    },
-
-    _isParentCurrent(revisionActions) {
-      if (revisionActions && revisionActions.rebase) {
-        return !revisionActions.rebase.enabled;
-      } else {
-        return true;
-      }
-    },
-
-    _getEdit() {
-      return this.$.restAPI.getChangeEdit(this._changeNum, true);
-    },
-
-    _getLatestCommitMessage() {
-      return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-          this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
-        if (!commitInfo) return Promise.resolve();
-        this._latestCommitMessage =
-                    this._prepareCommitMsgForLinkify(commitInfo.message);
-      });
-    },
-
-    _getLatestRevisionSHA(change) {
-      if (change.current_revision) {
-        return change.current_revision;
-      }
-      // current_revision may not be present in the case where the latest rev is
-      // a draft and the user doesn’t have permission to view that rev.
-      let latestRev = null;
-      let latestPatchNum = -1;
-      for (const rev in change.revisions) {
-        if (!change.revisions.hasOwnProperty(rev)) { continue; }
-
-        if (change.revisions[rev]._number > latestPatchNum) {
-          latestRev = rev;
-          latestPatchNum = change.revisions[rev]._number;
-        }
-      }
-      return latestRev;
-    },
-
-    _getCommitInfo() {
-      return this.$.restAPI.getChangeCommitInfo(
-          this._changeNum, this._patchRange.patchNum).then(
-          commitInfo => {
-            this._commitInfo = commitInfo;
-          });
-    },
-
-    _reloadDraftsWithCallback(e) {
-      return this._reloadDrafts().then(() => {
-        return e.detail.resolve();
-      });
-    },
-
-    /**
-     * Fetches a new changeComment object, and data for all types of comments
-     * (comments, robot comments, draft comments) is requested.
-     */
-    _reloadComments() {
-      return this.$.commentAPI.loadAll(this._changeNum)
-          .then(comments => {
-            this._changeComments = comments;
-            this._diffDrafts = Object.assign({}, this._changeComments.drafts);
-            this._commentThreads = this._changeComments.getAllThreadsForChange()
-                .map(c => Object.assign({}, c));
-          });
-    },
-
-    /**
-     * Fetches a new changeComment object, but only updated data for drafts is
-     * requested.
-     */
-    _reloadDrafts() {
-      return this.$.commentAPI.reloadDrafts(this._changeNum)
-          .then(comments => {
-            this._changeComments = comments;
-            this._diffDrafts = Object.assign({}, this._changeComments.drafts);
-          });
-    },
-
-    /**
-     * Reload the change.
-     *
-     * @param {boolean=} opt_isLocationChange Reloads the related changes
-     *     when true and ends reporting events that started on location change.
-     * @return {Promise} A promise that resolves when the core data has loaded.
-     *     Some non-core data loading may still be in-flight when the core data
-     *     promise resolves.
-     */
-    _reload(opt_isLocationChange) {
-      this._loading = true;
-      this._relatedChangesCollapsed = true;
-      this.$.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
-      this.$.reporting.time(CHANGE_DATA_TIMING_LABEL);
-
-      // Array to house all promises related to data requests.
-      const allDataPromises = [];
-
-      // Resolves when the change detail and the edit patch set (if available)
-      // are loaded.
-      const detailCompletes = this._getChangeDetail();
-      allDataPromises.push(detailCompletes);
-
-      // 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.$.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
-            if (opt_isLocationChange) {
-              this.$.reporting.changeDisplayed();
-            }
-          });
-
-      // Resolves when the project config has loaded.
-      const projectConfigLoaded = detailCompletes
-          .then(() => this._getProjectConfig());
-      allDataPromises.push(projectConfigLoaded);
-
-      // Resolves when change comments have loaded (comments, drafts and robot
-      // comments).
-      const commentsLoaded = this._reloadComments();
-      allDataPromises.push(commentsLoaded);
-
-      let coreDataPromise;
-
-      // If the patch number is specified
-      if (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();
-        allDataPromises.push(patchResourcesLoaded);
-
-        // Promise resolves when the change detail and patch dependent resources
-        // have loaded.
-        const detailAndPatchResourcesLoaded =
-            Promise.all([patchResourcesLoaded, loadingFlagSet]);
-
-        // Promise resolves when mergeability information has loaded.
-        const mergeabilityLoaded = detailAndPatchResourcesLoaded
-            .then(() => this._getMergeability());
-        allDataPromises.push(mergeabilityLoaded);
-
-        // Promise resovles when the change actions have loaded.
-        const actionsLoaded = detailAndPatchResourcesLoaded
-            .then(() => this.$.actions.reload());
-        allDataPromises.push(actionsLoaded);
-
-        // The core data is loaded when both mergeability and actions are known.
-        coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
-      } else {
-        // Resolves when the file list has loaded.
-        const fileListReload = loadingFlagSet
-            .then(() => this.$.fileList.reload());
-        allDataPromises.push(fileListReload);
-
-        const latestCommitMessageLoaded = loadingFlagSet.then(() => {
-          // If the latest commit message is known, there is nothing to do.
-          if (this._latestCommitMessage) { return Promise.resolve(); }
-          return this._getLatestCommitMessage();
-        });
-        allDataPromises.push(latestCommitMessageLoaded);
-
-        // Promise resolves when mergeability information has loaded.
-        const mergeabilityLoaded = loadingFlagSet
-            .then(() => this._getMergeability());
-        allDataPromises.push(mergeabilityLoaded);
-
-        // Core data is loaded when mergeability has been loaded.
-        coreDataPromise = mergeabilityLoaded;
-      }
-
-      if (opt_isLocationChange) {
-        const relatedChangesLoaded = coreDataPromise
-            .then(() => this.$.relatedChanges.reload());
-        allDataPromises.push(relatedChangesLoaded);
-      }
-
-      Promise.all(allDataPromises).then(() => {
-        this.$.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
-        if (opt_isLocationChange) {
-          this.$.reporting.changeFullyLoaded();
-        }
-      });
-
-      return coreDataPromise;
-    },
-
-    /**
-     * Kicks off requests for resources that rely on the patch range
-     * (`this._patchRange`) being defined.
-     */
-    _reloadPatchNumDependentResources() {
-      return Promise.all([
-        this._getCommitInfo(),
-        this.$.fileList.reload(),
-      ]);
-    },
-
-    _getMergeability() {
-      if (!this._change) {
-        this._mergeable = null;
-        return Promise.resolve();
-      }
-      // If the change is closed, it is not mergeable. Note: already merged
-      // changes are obviously not mergeable, but the mergeability API will not
-      // answer for abandoned changes.
-      if (this._change.status === this.ChangeStatus.MERGED ||
-          this._change.status === this.ChangeStatus.ABANDONED) {
-        this._mergeable = false;
-        return Promise.resolve();
-      }
-
-      this._mergeable = null;
-      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; },
-
-    _computeChangePermalinkAriaLabel(changeNum) {
-      return 'Change ' + changeNum;
-    },
-
-    _computeCommitClass(collapsed, commitMessage) {
-      if (this._computeCommitToggleHidden(commitMessage)) { return ''; }
-      return collapsed ? 'collapsed' : '';
-    },
-
-    _computeRelatedChangesClass(collapsed) {
-      return collapsed ? 'collapsed' : '';
-    },
-
-    _computeCollapseText(collapsed) {
-      // Symbols are up and down triangles.
-      return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
-    },
-
-    _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;
-    },
-
-    _getOffsetHeight(element) {
-      return element.offsetHeight;
-    },
-
-    _getScrollHeight(element) {
-      return element.scrollHeight;
-    },
-
-    /**
-     * Get the line height of an element to the nearest integer.
-     */
-    _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
-     * change info height.
-     */
-    _updateRelatedChangeMaxHeight() {
-      // Takes into account approximate height for the expand button and
-      // bottom margin.
-      const EXTRA_HEIGHT = 30;
-      let newHeight;
-      const hasCommitToggle =
-          !this._computeCommitToggleHidden(this._latestCommitMessage);
-
-      if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
-          .matches) {
-        // In a small (mobile) view, give the relation chain some space.
-        newHeight = SMALL_RELATED_HEIGHT;
-      } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
-          .matches) {
-        // Since related changes are below the commit message, but still next to
-        // metadata, the height should be the height of the metadata minus the
-        // height of the commit message to reduce jank. However, if that doesn't
-        // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
-        // Note: extraHeight is to take into account margin/padding.
-        const medRelatedHeight = Math.max(
-            this._getOffsetHeight(this.$.mainChangeInfo) -
-            this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
-            MINIMUM_RELATED_MAX_HEIGHT);
-        newHeight = medRelatedHeight;
-      } else {
-        if (hasCommitToggle) {
-          // 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.
-          newHeight = this._getOffsetHeight(this.$.commitMessage);
-        } else {
-          newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
-              EXTRA_HEIGHT;
-        }
-      }
-      const stylesToUpdate = {};
-
-      // Get the line height of related changes, and convert it to the nearest
-      // integer.
-      const lineHeight = this._getLineHeight(this.$.relatedChanges);
-
-      // Figure out a new height that is divisible by the rounded line height.
-      const remainder = newHeight % lineHeight;
-      newHeight = newHeight - remainder;
-
-      stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
-
-      // Update the max-height of the relation chain to this new height.
-      if (hasCommitToggle) {
-        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
-      // to populate.
-      if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
-        this._updateRelatedChangeMaxHeight();
-      }
-      // Prevents showMore from showing when click on related change, since the
-      // line height would be positive, but related changes height is 0.
-      if (!this._getScrollHeight(this.$.relatedChanges)) {
-        return this._showRelatedToggle = false;
-      }
-
-      if (this._getScrollHeight(this.$.relatedChanges) >
-          (this._getOffsetHeight(this.$.relatedChanges) +
-          this._getLineHeight(this.$.relatedChanges))) {
-        return this._showRelatedToggle = true;
-      }
-      this._showRelatedToggle = false;
-    },
-
-    _updateToggleContainerClass(showRelatedToggle) {
-      if (showRelatedToggle) {
-        this.$.relatedChangesToggle.classList.add('showToggle');
-      } else {
-        this.$.relatedChangesToggle.classList.remove('showToggle');
-      }
-    },
-
-    _startUpdateCheckTimer() {
-      if (!this._serverConfig ||
-          !this._serverConfig.change ||
-          this._serverConfig.change.update_delay === undefined ||
-          this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
-        return;
-      }
-
-      this._updateCheckTimerHandle = this.async(() => {
-        this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
-          let toastMessage = null;
-          if (!result.isLatest) {
-            toastMessage = ReloadToastMessage.NEWER_REVISION;
-          } else if (result.newStatus === this.ChangeStatus.MERGED) {
-            toastMessage = ReloadToastMessage.MERGED;
-          } else if (result.newStatus === this.ChangeStatus.ABANDONED) {
-            toastMessage = ReloadToastMessage.ABANDONED;
-          } else if (result.newStatus === this.ChangeStatus.NEW) {
-            toastMessage = ReloadToastMessage.RESTORED;
-          } else if (result.newMessages) {
-            toastMessage = ReloadToastMessage.NEW_MESSAGE;
-          }
-
-          if (!toastMessage) {
-            this._startUpdateCheckTimer();
-            return;
-          }
-
-          this._cancelUpdateCheckTimer();
-          this.fire('show-alert', {
+        this._cancelUpdateCheckTimer();
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {
             message: toastMessage,
             // Persist this alert.
             dismissOnNavigation: true,
             action: 'Reload',
             callback: function() {
-              // Load the current change without any patch range.
-              Gerrit.Nav.navigateToChange(this._change);
+            // Load the current change without any patch range.
+              GerritNav.navigateToChange(this._change);
             }.bind(this),
-          });
-        });
-      }, this._serverConfig.change.update_delay * 1000);
-    },
+          },
+          composed: true, bubbles: true,
+        }));
+      });
+    }, this._serverConfig.change.update_delay * 1000);
+  }
 
-    _cancelUpdateCheckTimer() {
-      if (this._updateCheckTimerHandle) {
-        this.cancelAsync(this._updateCheckTimerHandle);
-      }
-      this._updateCheckTimerHandle = null;
-    },
+  _cancelUpdateCheckTimer() {
+    if (this._updateCheckTimerHandle) {
+      this.cancelAsync(this._updateCheckTimerHandle);
+    }
+    this._updateCheckTimerHandle = null;
+  }
 
-    _handleVisibilityChange() {
-      if (document.hidden && this._updateCheckTimerHandle) {
-        this._cancelUpdateCheckTimer();
-      } else if (!this._updateCheckTimerHandle) {
-        this._startUpdateCheckTimer();
-      }
-    },
+  _handleVisibilityChange() {
+    if (document.hidden && this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    } else if (!this._updateCheckTimerHandle) {
+      this._startUpdateCheckTimer();
+    }
+  }
 
-    _handleTopicChanged() {
-      this.$.relatedChanges.reload();
-    },
+  _handleTopicChanged() {
+    this.$.relatedChanges.reload();
+  }
 
-    _computeHeaderClass(editMode) {
-      const classes = ['header'];
-      if (editMode) { classes.push('editMode'); }
-      return classes.join(' ');
-    },
+  _computeHeaderClass(editMode) {
+    const classes = ['header'];
+    if (editMode) { classes.push('editMode'); }
+    return classes.join(' ');
+  }
 
-    _computeEditMode(patchRangeRecord, paramsRecord) {
-      if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
-        return undefined;
-      }
+  _computeEditMode(patchRangeRecord, paramsRecord) {
+    if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-      if (paramsRecord.base && paramsRecord.base.edit) { return true; }
+    if (paramsRecord.base && paramsRecord.base.edit) { return true; }
 
-      const patchRange = patchRangeRecord.base || {};
-      return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
-    },
+    const patchRange = patchRangeRecord.base || {};
+    return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
+  }
 
-    _handleFileActionTap(e) {
-      e.preventDefault();
-      const controls = this.$.fileListHeader.$.editControls;
-      const path = e.detail.path;
-      switch (e.detail.action) {
-        case GrEditConstants.Actions.DELETE.id:
-          controls.openDeleteDialog(path);
-          break;
-        case GrEditConstants.Actions.OPEN.id:
-          Gerrit.Nav.navigateToRelativeUrl(
-              Gerrit.Nav.getEditUrlForDiff(this._change, path,
-                  this._patchRange.patchNum));
-          break;
-        case GrEditConstants.Actions.RENAME.id:
-          controls.openRenameDialog(path);
-          break;
-        case GrEditConstants.Actions.RESTORE.id:
-          controls.openRestoreDialog(path);
-          break;
-      }
-    },
+  _handleFileActionTap(e) {
+    e.preventDefault();
+    const controls = this.$.fileListHeader.$.editControls;
+    const path = e.detail.path;
+    switch (e.detail.action) {
+      case GrEditConstants.Actions.DELETE.id:
+        controls.openDeleteDialog(path);
+        break;
+      case GrEditConstants.Actions.OPEN.id:
+        GerritNav.navigateToRelativeUrl(
+            GerritNav.getEditUrlForDiff(this._change, path,
+                this._patchRange.patchNum));
+        break;
+      case GrEditConstants.Actions.RENAME.id:
+        controls.openRenameDialog(path);
+        break;
+      case GrEditConstants.Actions.RESTORE.id:
+        controls.openRestoreDialog(path);
+        break;
+    }
+  }
 
-    _computeCommitMessageKey(number, revision) {
-      return `c${number}_rev${revision}`;
-    },
+  _computeCommitMessageKey(number, revision) {
+    return `c${number}_rev${revision}`;
+  }
 
-    _patchNumChanged(patchNumStr) {
-      if (!this._selectedRevision) {
-        return;
-      }
-      const patchNum = parseInt(patchNumStr, 10);
-      if (patchNum === this._selectedRevision._number) {
-        return;
-      }
-      this._selectedRevision = Object.values(this._change.revisions).find(
-          revision => revision._number === patchNum);
-    },
+  _patchNumChanged(patchNumStr) {
+    if (!this._selectedRevision) {
+      return;
+    }
 
-    /**
-     * If an edit exists already, load it. Otherwise, toggle edit mode via the
-     * navigation API.
-     */
-    _handleEditTap() {
-      const editInfo = Object.values(this._change.revisions).find(info =>
-        info._number === this.EDIT_NAME);
+    let patchNum = parseInt(patchNumStr, 10);
+    if (patchNumStr === 'edit') {
+      patchNum = patchNumStr;
+    }
 
-      if (editInfo) {
-        Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME);
-        return;
-      }
+    if (patchNum === this._selectedRevision._number) {
+      return;
+    }
+    this._selectedRevision = Object.values(this._change.revisions).find(
+        revision => revision._number === patchNum);
+  }
 
-      // Avoid putting patch set in the URL unless a non-latest patch set is
-      // selected.
-      let patchNum;
-      if (!this.patchNumEquals(this._patchRange.patchNum,
-          this.computeLatestPatchNum(this._allPatchSets))) {
-        patchNum = this._patchRange.patchNum;
-      }
-      Gerrit.Nav.navigateToChange(this._change, patchNum, null, true);
-    },
+  /**
+   * If an edit exists already, load it. Otherwise, toggle edit mode via the
+   * navigation API.
+   */
+  _handleEditTap() {
+    const editInfo = Object.values(this._change.revisions).find(info =>
+      info._number === this.EDIT_NAME);
 
-    _handleStopEditTap() {
-      Gerrit.Nav.navigateToChange(this._change, this._patchRange.patchNum);
-    },
+    if (editInfo) {
+      GerritNav.navigateToChange(this._change, this.EDIT_NAME);
+      return;
+    }
 
-    _resetReplyOverlayFocusStops() {
-      this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
-    },
+    // Avoid putting patch set in the URL unless a non-latest patch set is
+    // selected.
+    let patchNum;
+    if (!this.patchNumEquals(this._patchRange.patchNum,
+        this.computeLatestPatchNum(this._allPatchSets))) {
+      patchNum = this._patchRange.patchNum;
+    }
+    GerritNav.navigateToChange(this._change, patchNum, null, true);
+  }
 
-    _handleToggleStar(e) {
-      this.$.restAPI.saveChangeStarred(e.detail.change._number,
-          e.detail.starred);
-    },
+  _handleStopEditTap() {
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
 
-    _getRevisionInfo(change) {
-      return new Gerrit.RevisionInfo(change);
-    },
+  _resetReplyOverlayFocusStops() {
+    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+  }
 
-    _computeCurrentRevision(currentRevision, revisions) {
-      return currentRevision && revisions && revisions[currentRevision];
-    },
+  _handleToggleStar(e) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number,
+        e.detail.starred);
+  }
 
-    _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
-      return disableDiffPrefs || !loggedIn;
-    },
-  });
-})();
+  _getRevisionInfo(change) {
+    return new 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_html.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
new file mode 100644
index 0000000..a1d4c52
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
@@ -0,0 +1,793 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .container:not(.loading) {
+      background-color: var(--background-color-tertiary);
+    }
+    .container.loading {
+      color: var(--deemphasized-text-color);
+      padding: var(--spacing-l);
+    }
+    .header {
+      align-items: center;
+      background-color: var(--background-color-primary);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      padding: var(--spacing-s) var(--spacing-l);
+      z-index: 99; /* Less than gr-overlay's backdrop */
+    }
+    .header.editMode {
+      background-color: var(--edit-mode-background-color);
+    }
+    .header .download {
+      margin-right: var(--spacing-l);
+    }
+    gr-change-status {
+      display: initial;
+      margin-left: var(--spacing-s);
+    }
+    gr-change-status:first-child {
+      margin-left: 0;
+    }
+    .headerTitle {
+      align-items: center;
+      display: flex;
+      flex: 1;
+    }
+    .headerSubject {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+      margin-left: var(--spacing-l);
+    }
+    .changeNumberColon {
+      color: transparent;
+    }
+    .changeCopyClipboard {
+      margin-left: var(--spacing-s);
+    }
+    #replyBtn {
+      margin-bottom: var(--spacing-l);
+    }
+    gr-change-star {
+      margin-left: var(--spacing-s);
+      --gr-change-star-size: var(--line-height-normal);
+    }
+    a.changeNumber {
+      margin-left: var(--spacing-xs);
+    }
+    gr-reply-dialog {
+      width: 60em;
+    }
+    .changeStatus {
+      text-transform: capitalize;
+    }
+    /* Strong specificity here is needed due to
+         https://github.com/Polymer/polymer/issues/2531 */
+    .container .changeInfo {
+      display: flex;
+      background-color: var(--background-color-secondary);
+    }
+    section {
+      background-color: var(--view-background-color);
+      box-shadow: var(--elevation-level-1);
+    }
+    .changeId {
+      color: var(--deemphasized-text-color);
+      font-family: var(--font-family);
+      margin-top: var(--spacing-l);
+    }
+    .changeMetadata {
+      /* Limit meta section to half of the screen at max */
+      max-width: 50%;
+    }
+    .commitMessage {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      margin-right: var(--spacing-l);
+      margin-bottom: var(--spacing-l);
+      /* 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 and rounding errors. */
+      min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+    }
+    .editCommitMessage {
+      margin-top: var(--spacing-l);
+
+      --gr-button: {
+        padding: 5px 0px;
+      }
+    }
+    .changeStatuses,
+    .commitActions,
+    .statusText {
+      align-items: center;
+      display: flex;
+    }
+    .changeStatuses {
+      flex-wrap: wrap;
+    }
+    .mainChangeInfo {
+      display: flex;
+      flex: 1;
+      flex-direction: column;
+      min-width: 0;
+    }
+    #commitAndRelated {
+      align-content: flex-start;
+      display: flex;
+      flex: 1;
+      overflow-x: hidden;
+    }
+    .relatedChanges {
+      flex: 1 1 auto;
+      overflow: hidden;
+      padding: var(--spacing-l) 0;
+    }
+    .mobile {
+      display: none;
+    }
+    .warning {
+      color: var(--error-text-color);
+    }
+    hr {
+      border: 0;
+      border-top: 1px solid var(--border-color);
+      height: 0;
+      margin-bottom: var(--spacing-l);
+    }
+    #relatedChanges.collapsed {
+      margin-bottom: var(--spacing-l);
+      max-height: var(--relation-chain-max-height, 2em);
+      overflow: hidden;
+    }
+    .commitContainer {
+      display: flex;
+      flex-direction: column;
+      flex-shrink: 0;
+      margin: var(--spacing-l) 0;
+      padding: 0 var(--spacing-l);
+    }
+    .collapseToggleContainer {
+      display: flex;
+      margin-bottom: 8px;
+    }
+    #relatedChangesToggle {
+      display: none;
+    }
+    #relatedChangesToggle.showToggle {
+      display: flex;
+    }
+    .collapseToggleContainer gr-button {
+      display: block;
+    }
+    #relatedChangesToggle {
+      margin-left: var(--spacing-l);
+      padding-top: var(--related-change-btn-top-padding, 0);
+    }
+    .showOnEdit {
+      display: none;
+    }
+    .scrollable {
+      overflow: auto;
+    }
+    .text {
+      white-space: pre;
+    }
+    gr-commit-info {
+      display: inline-block;
+    }
+    paper-tabs {
+      background-color: var(--background-color-tertiary);
+      margin-top: var(--spacing-m);
+      height: calc(var(--line-height-h3) + var(--spacing-m));
+      --paper-tabs-selection-bar-color: var(--link-color);
+    }
+    paper-tab {
+      box-sizing: border-box;
+      max-width: 12em;
+      --paper-tab-ink: var(--link-color);
+    }
+    gr-thread-list,
+    gr-messages-list,
+    gr-messages-list-experimental {
+      display: block;
+    }
+    gr-thread-list {
+      min-height: 250px;
+    }
+    #includedInOverlay {
+      width: 65em;
+    }
+    #uploadHelpOverlay {
+      width: 50em;
+    }
+    #metadata {
+      --metadata-horizontal-padding: var(--spacing-l);
+      padding-top: var(--spacing-l);
+      width: 100%;
+    }
+    /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_MED in the JS */
+    @media screen and (max-width: 75em) {
+      .relatedChanges {
+        padding: 0;
+      }
+      #relatedChanges {
+        padding-top: var(--spacing-l);
+      }
+      #commitAndRelated {
+        flex-direction: column;
+        flex-wrap: nowrap;
+      }
+      #commitMessageEditor {
+        min-width: 0;
+      }
+      .commitMessage {
+        margin-right: 0;
+      }
+      .mainChangeInfo {
+        padding-right: 0;
+      }
+    }
+    /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_SMALL in the JS */
+    @media screen and (max-width: 50em) {
+      .mobile {
+        display: block;
+      }
+      .header {
+        align-items: flex-start;
+        flex-direction: column;
+        flex: 1;
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      gr-change-star {
+        vertical-align: middle;
+      }
+      .headerTitle {
+        flex-wrap: wrap;
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      .desktop {
+        display: none;
+      }
+      .reply {
+        display: block;
+        margin-right: 0;
+        /* px because don't have the same font size */
+        margin-bottom: 6px;
+      }
+      .changeInfo-column:not(:last-of-type) {
+        margin-right: 0;
+        padding-right: 0;
+      }
+      .changeInfo,
+      #commitAndRelated {
+        flex-direction: column;
+        flex-wrap: nowrap;
+      }
+      .commitContainer {
+        margin: 0;
+        padding: var(--spacing-l);
+      }
+      .changeMetadata {
+        margin-top: var(--spacing-xs);
+        max-width: none;
+      }
+      #metadata,
+      .mainChangeInfo {
+        padding: 0;
+      }
+      .commitActions {
+        display: block;
+        margin-top: var(--spacing-l);
+        width: 100%;
+      }
+      .commitMessage {
+        flex: initial;
+        margin: 0;
+      }
+      /* Change actions are the only thing thant need to remain visible due
+        to the fact that they may have the currently visible overlay open. */
+      #mainContent.overlayOpen .hideOnMobileOverlay {
+        display: none;
+      }
+      gr-reply-dialog {
+        height: 100vh;
+        min-width: initial;
+        width: 100vw;
+      }
+      #replyOverlay {
+        z-index: var(--reply-overlay-z-index);
+      }
+    }
+    .patch-set-dropdown {
+      margin: var(--spacing-m) 0 0 var(--spacing-m);
+    }
+    .show-robot-comments {
+      margin: var(--spacing-m);
+    }
+  </style>
+  <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
+  <!-- TODO(taoalpha): remove on-show-checks-table,
+    Gerrit should not have any thing too special for a plugin,
+    replace with a generic event: show-primary-tab. -->
+  <div
+    id="mainContent"
+    class="container"
+    on-show-checks-table="_setActivePrimaryTab"
+    hidden$="{{_loading}}"
+  >
+    <section class="changeInfoSection">
+      <div class$="[[_computeHeaderClass(_editMode)]]">
+        <div class="headerTitle">
+          <div class="changeStatuses">
+            <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
+              <gr-change-status
+                max-width="100"
+                status="[[status]]"
+              ></gr-change-status>
+            </template>
+          </div>
+          <div class="statusText">
+            <template
+              is="dom-if"
+              if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]"
+            >
+              <span class="text"> as </span>
+              <gr-commit-info
+                change="[[_change]]"
+                commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
+                server-config="[[_serverConfig]]"
+              ></gr-commit-info>
+            </template>
+          </div>
+          <gr-change-star
+            id="changeStar"
+            change="{{_change}}"
+            on-toggle-star="_handleToggleStar"
+            hidden$="[[!_loggedIn]]"
+          ></gr-change-star>
+
+          <a
+            class="changeNumber"
+            aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
+            href$="[[_computeChangeUrl(_change)]]"
+            >[[_change._number]]</a
+          >
+          <span class="changeNumberColon">:&nbsp;</span>
+          <span class="headerSubject">[[_change.subject]]</span>
+          <gr-copy-clipboard
+            class="changeCopyClipboard"
+            hide-input=""
+            text="[[_computeCopyTextForTitle(_change)]]"
+          >
+          </gr-copy-clipboard>
+        </div>
+        <!-- end headerTitle -->
+        <div class="commitActions" hidden$="[[!_loggedIn]]">
+          <gr-change-actions
+            id="actions"
+            change="[[_change]]"
+            disable-edit="[[disableEdit]]"
+            has-parent="[[hasParent]]"
+            actions="[[_change.actions]]"
+            revision-actions="{{_currentRevisionActions}}"
+            change-num="[[_changeNum]]"
+            change-status="[[_change.status]]"
+            commit-num="[[_commitInfo.commit]]"
+            latest-patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
+            commit-message="[[_latestCommitMessage]]"
+            edit-patchset-loaded="[[hasEditPatchsetLoaded(_patchRange.*)]]"
+            edit-mode="[[_editMode]]"
+            edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
+            private-by-default="[[_projectConfig.private_by_default]]"
+            on-reload-change="_handleReloadChange"
+            on-edit-tap="_handleEditTap"
+            on-stop-edit-tap="_handleStopEditTap"
+            on-download-tap="_handleOpenDownloadDialog"
+          ></gr-change-actions>
+        </div>
+        <!-- end commit actions -->
+      </div>
+      <!-- end header -->
+      <div class="changeInfo">
+        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+          <gr-change-metadata
+            id="metadata"
+            change="{{_change}}"
+            account="[[_account]]"
+            revision="[[_selectedRevision]]"
+            commit-info="[[_commitInfo]]"
+            server-config="[[_serverConfig]]"
+            parent-is-current="[[_parentIsCurrent]]"
+            on-show-reply-dialog="_handleShowReplyDialog"
+          >
+          </gr-change-metadata>
+        </div>
+        <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
+          <div id="commitAndRelated" class="hideOnMobileOverlay">
+            <div class="commitContainer">
+              <div>
+                <gr-button
+                  id="replyBtn"
+                  class="reply"
+                  title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
+                        ShortcutSection.ACTIONS)]]"
+                  hidden$="[[!_loggedIn]]"
+                  primary=""
+                  disabled="[[_replyDisabled]]"
+                  on-click="_handleReplyTap"
+                  >[[_replyButtonLabel]]</gr-button
+                >
+              </div>
+              <div id="commitMessage" class="commitMessage">
+                <gr-editable-content
+                  id="commitMessageEditor"
+                  editing="[[_editingCommitMessage]]"
+                  content="{{_latestCommitMessage}}"
+                  storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
+                  remove-zero-width-space=""
+                  collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]"
+                >
+                  <gr-linked-text
+                    pre=""
+                    content="[[_latestCommitMessage]]"
+                    config="[[_projectConfig.commentlinks]]"
+                    remove-zero-width-space=""
+                  ></gr-linked-text>
+                </gr-editable-content>
+                <gr-button
+                  link=""
+                  class="editCommitMessage"
+                  on-click="_handleEditCommitMessage"
+                  hidden$="[[_hideEditCommitMessage]]"
+                  >Edit</gr-button
+                >
+                <div
+                  class="changeId"
+                  hidden$="[[!_changeIdCommitMessageError]]"
+                >
+                  <hr />
+                  Change-Id:
+                  <span
+                    class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
+                    title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]"
+                  >
+                    [[_change.change_id]]
+                  </span>
+                </div>
+              </div>
+              <div
+                id="commitCollapseToggle"
+                class="collapseToggleContainer"
+                hidden$="[[!_commitCollapsible]]"
+              >
+                <gr-button
+                  link=""
+                  id="commitCollapseToggleButton"
+                  class="collapseToggleButton"
+                  on-click="_toggleCommitCollapsed"
+                >
+                  [[_computeCollapseText(_commitCollapsed)]]
+                </gr-button>
+              </div>
+              <gr-endpoint-decorator name="commit-container">
+                <gr-endpoint-param name="change" value="[[_change]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param
+                  name="revision"
+                  value="[[_selectedRevision]]"
+                >
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+            <div class="relatedChanges">
+              <gr-related-changes-list
+                id="relatedChanges"
+                class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
+                change="[[_change]]"
+                mergeable="[[_mergeable]]"
+                has-parent="{{hasParent}}"
+                on-update="_updateRelatedChangeMaxHeight"
+                patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
+                on-new-section-loaded="_computeShowRelatedToggle"
+              >
+              </gr-related-changes-list>
+              <div id="relatedChangesToggle" class="collapseToggleContainer">
+                <gr-button
+                  link=""
+                  id="relatedChangesToggleButton"
+                  class="collapseToggleButton"
+                  on-click="_toggleRelatedChangesCollapsed"
+                >
+                  [[_computeCollapseText(_relatedChangesCollapsed)]]
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
+      <paper-tab data-name$="[[_constants.PrimaryTabs.FILES]]">Files</paper-tab>
+      <template
+        is="dom-repeat"
+        items="[[_dynamicTabHeaderEndpoints]]"
+        as="tabHeader"
+      >
+        <paper-tab data-name$="[[tabHeader]]">
+          <gr-endpoint-decorator name$="[[tabHeader]]">
+            <gr-endpoint-param name="change" value="[[_change]]">
+            </gr-endpoint-param>
+            <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </paper-tab>
+      </template>
+      <paper-tab data-name$="[[_constants.PrimaryTabs.FINDINGS]]">
+        Findings
+      </paper-tab>
+    </paper-tabs>
+
+    <section class="patchInfo">
+      <div
+        hidden$="[[!_isTabActive(_constants.PrimaryTabs.FILES, _activeTabs)]]"
+      >
+        <gr-file-list-header
+          id="fileListHeader"
+          account="[[_account]]"
+          all-patch-sets="[[_allPatchSets]]"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          revision-info="[[_revisionInfo]]"
+          change-comments="[[_changeComments]]"
+          commit-info="[[_commitInfo]]"
+          change-url="[[_computeChangeUrl(_change)]]"
+          edit-mode="[[_editMode]]"
+          logged-in="[[_loggedIn]]"
+          server-config="[[_serverConfig]]"
+          shown-file-count="[[_shownFileCount]]"
+          diff-prefs="[[_diffPrefs]]"
+          diff-view-mode="{{viewState.diffMode}}"
+          patch-num="{{_patchRange.patchNum}}"
+          base-patch-num="{{_patchRange.basePatchNum}}"
+          files-expanded="[[_filesExpanded]]"
+          diff-prefs-disabled="[[_diffPrefsDisabled]]"
+          on-open-diff-prefs="_handleOpenDiffPrefs"
+          on-open-download-dialog="_handleOpenDownloadDialog"
+          on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
+          on-open-included-in-dialog="_handleOpenIncludedInDialog"
+          on-expand-diffs="_expandAllDiffs"
+          on-collapse-diffs="_collapseAllDiffs"
+        >
+        </gr-file-list-header>
+        <gr-file-list
+          id="fileList"
+          class="hideOnMobileOverlay"
+          diff-prefs="{{_diffPrefs}}"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          patch-range="{{_patchRange}}"
+          change-comments="[[_changeComments]]"
+          drafts="[[_diffDrafts]]"
+          revisions="[[_change.revisions]]"
+          project-config="[[_projectConfig]]"
+          selected-index="{{viewState.selectedFileIndex}}"
+          diff-view-mode="[[viewState.diffMode]]"
+          edit-mode="[[_editMode]]"
+          num-files-shown="{{_numFilesShown}}"
+          files-expanded="{{_filesExpanded}}"
+          file-list-increment="{{_numFilesShown}}"
+          on-files-shown-changed="_setShownFiles"
+          on-file-action-tap="_handleFileActionTap"
+          on-reload-drafts="_reloadDraftsWithCallback"
+        >
+        </gr-file-list>
+      </div>
+
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTabs.FINDINGS, _activeTabs)]]"
+      >
+        <gr-dropdown-list
+          class="patch-set-dropdown"
+          items="[[_robotCommentsPatchSetDropdownItems]]"
+          on-value-change="_handleRobotCommentPatchSetChanged"
+          value="[[_currentRobotCommentsPatchSet]]"
+        >
+        </gr-dropdown-list>
+        <gr-thread-list
+          threads="[[_robotCommentThreads]]"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          logged-in="[[_loggedIn]]"
+          hide-toggle-buttons
+          empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
+          on-thread-list-modified="_handleReloadDiffComments"
+        >
+        </gr-thread-list>
+        <template is="dom-if" if="[[_showRobotCommentsButton]]">
+          <gr-button
+            class="show-robot-comments"
+            on-click="_toggleShowRobotComments"
+          >
+            [[_computeShowText(_showAllRobotComments)]]
+          </gr-button>
+        </template>
+      </template>
+
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_selectedTabPluginHeader, _activeTabs)]]"
+      >
+        <gr-endpoint-decorator name$="[[_selectedTabPluginEndpoint]]">
+          <gr-endpoint-param name="change" value="[[_change]]">
+          </gr-endpoint-param>
+          <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </template>
+    </section>
+
+    <gr-endpoint-decorator name="change-view-integration">
+      <gr-endpoint-param name="change" value="[[_change]]"> </gr-endpoint-param>
+      <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+      </gr-endpoint-param>
+    </gr-endpoint-decorator>
+
+    <paper-tabs id="secondaryTabs" on-selected-changed="_setActiveSecondaryTab">
+      <paper-tab
+        data-name$="[[_constants.SecondaryTabs.CHANGE_LOG]]"
+        class="changeLog"
+      >
+        Change Log
+      </paper-tab>
+      <paper-tab
+        data-name$="[[_constants.SecondaryTabs.COMMENT_THREADS]]"
+        class="commentThreads"
+      >
+        <gr-tooltip-content
+          has-tooltip=""
+          title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
+        >
+          <span>Comment Threads</span></gr-tooltip-content
+        >
+      </paper-tab>
+    </paper-tabs>
+    <section class="changeLog">
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.SecondaryTabs.CHANGE_LOG, _activeTabs)]]"
+      >
+        <template is="dom-if" if="[[!_isChangeLogExperimentEnabled()]]">
+          <gr-messages-list
+            class="hideOnMobileOverlay"
+            change-num="[[_changeNum]]"
+            labels="[[_change.labels]]"
+            messages="[[_change.messages]]"
+            reviewer-updates="[[_change.reviewer_updates]]"
+            change-comments="[[_changeComments]]"
+            project-name="[[_change.project]]"
+            show-reply-buttons="[[_loggedIn]]"
+            on-message-anchor-tap="_handleMessageAnchorTap"
+            on-reply="_handleMessageReply"
+          ></gr-messages-list>
+        </template>
+        <template is="dom-if" if="[[_isChangeLogExperimentEnabled()]]">
+          <gr-messages-list-experimental
+            class="hideOnMobileOverlay"
+            change-num="[[_changeNum]]"
+            labels="[[_change.labels]]"
+            messages="[[_change.messages]]"
+            reviewer-updates="[[_change.reviewer_updates]]"
+            change-comments="[[_changeComments]]"
+            project-name="[[_change.project]]"
+            show-reply-buttons="[[_loggedIn]]"
+            on-message-anchor-tap="_handleMessageAnchorTap"
+            on-reply="_handleMessageReply"
+          ></gr-messages-list-experimental>
+        </template>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.SecondaryTabs.COMMENT_THREADS, _activeTabs)]]"
+      >
+        <gr-thread-list
+          threads="[[_commentThreads]]"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          logged-in="[[_loggedIn]]"
+          only-show-robot-comments-with-human-reply=""
+          on-thread-list-modified="_handleReloadDiffComments"
+        ></gr-thread-list>
+      </template>
+    </section>
+  </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"
+      change="[[_change]]"
+      patch-num="[[_patchRange.patchNum]]"
+      config="[[_serverConfig.download]]"
+      on-close="_handleDownloadDialogClose"
+    ></gr-download-dialog>
+  </gr-overlay>
+  <gr-overlay id="uploadHelpOverlay" with-backdrop="">
+    <gr-upload-help-dialog
+      revision="[[_currentRevision]]"
+      target-branch="[[_change.branch]]"
+      on-close="_handleCloseUploadHelpDialog"
+    ></gr-upload-help-dialog>
+  </gr-overlay>
+  <gr-overlay id="includedInOverlay" with-backdrop="">
+    <gr-included-in-dialog
+      id="includedInDialog"
+      change-num="[[_changeNum]]"
+      on-close="_handleIncludedInDialogClose"
+    ></gr-included-in-dialog>
+  </gr-overlay>
+  <gr-overlay
+    id="replyOverlay"
+    class="scrollable"
+    no-cancel-on-outside-click=""
+    no-cancel-on-esc-key=""
+    with-backdrop=""
+  >
+    <gr-reply-dialog
+      id="replyDialog"
+      change="{{_change}}"
+      patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
+      permitted-labels="[[_change.permitted_labels]]"
+      draft-comment-threads="[[_draftCommentThreads]]"
+      project-config="[[_projectConfig]]"
+      can-be-started="[[_canStartReview]]"
+      on-send="_handleReplySent"
+      on-cancel="_handleReplyCancel"
+      on-autogrow="_handleReplyAutogrow"
+      on-send-disabled-changed="_resetReplyOverlayFocusStops"
+      hidden$="[[!_loggedIn]]"
+    >
+    </gr-reply-dialog>
+  </gr-overlay>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-comment-api id="commentAPI"></gr-comment-api>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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..913a914 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
@@ -17,19 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../edit/gr-edit-constants.html">
-<link rel="import" href="gr-change-view.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -43,370 +38,736 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-view tests', () => {
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
-    kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
-    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
-    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../edit/gr-edit-constants.js';
+import './gr-change-view.js';
+import {PrimaryTabs, SecondaryTabs} from '../../../constants/constants.js';
 
-    let element;
-    let sandbox;
-    let navigateToChangeStub;
-    const TEST_SCROLL_TOP_PX = 100;
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GrEditConstants} from '../../edit/gr-edit-constants.js';
+import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {util} from '../../../scripts/util.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-endpoint-decorator', {
-        _import: sandbox.stub().returns(Promise.resolve()),
-      });
-      // Since _endpoints are global, must reset state.
-      Gerrit._endpoints = new GrPluginEndpoints();
-      navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({test: 'config'}); },
-        getAccount() { return Promise.resolve(null); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        _fetchSharedCacheURL() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
-      Gerrit._loadPlugins([]);
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-view tests', () => {
+  const kb = KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
+  kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
+  kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
+  kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
+  kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
+  kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+  kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+  kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+  kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
+
+  let element;
+  let sandbox;
+  let navigateToChangeStub;
+  const TEST_SCROLL_TOP_PX = 100;
+
+  const ROBOT_COMMENTS_LIMIT = 10;
+
+  const THREADS = [
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          robot_id: 'rb1',
+          id: 'ecf9fa_fe1a5f62',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'ecf0b9fa_fe1a5f62',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          id: '503008e2_0ab203ee',
+          path: '/COMMIT_MSG',
+          line: 5,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          updated: '2018-02-13 22:48:48.018000000',
+          message: 'draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'ecf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: 'ecf0b9fa_fe5f62',
+          robot_id: 'rb2',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: 'test.txt',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: '09a9fb0a_1484e6cf',
+          side: 'PARENT',
+          updated: '2018-02-13 22:47:19.000000000',
+          message: 'Some comment on another patchset.',
+          unresolved: false,
+        },
+      ],
+      patchNum: 3,
+      path: 'test.txt',
+      rootId: '09a9fb0a_1484e6cf',
+      start_datetime: '2018-02-13 22:47:19.000000000',
+      commentSide: 'PARENT',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: '8caddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-13 22:48:40.000000000',
+          message: 'Another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: '8caddf38_44770ec1',
+      start_datetime: '2018-02-13 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: 'scaddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-14 22:48:40.000000000',
+          message: 'Yet another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: 'scaddf38_44770ec1',
+      start_datetime: '2018-02-14 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          id: 'zcf0b9fa_fe1a5f62',
+          path: '/COMMIT_MSG',
+          line: 6,
+          updated: '2018-02-15 22:48:48.018000000',
+          message: 'resolved draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 6,
+      rootId: 'zcf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-09 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc1',
+          line: 5,
+          updated: '2019-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc1',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc1',
+      start_datetime: '2019-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc2',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc2',
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'c2_1',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc2',
+      start_datetime: '2019-03-08 18:49:18.000000000',
+    },
+  ];
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
+    });
+    // Since pluginEndpoints are global, must reset state.
+    _testOnly_resetEndpoints();
+    navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange');
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
+    pluginLoader.loadPlugins([]);
+    pluginApi.install(
+        plugin => {
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-header',
+              'gr-checks-change-view-tab-header-view'
+          );
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-content',
+              'gr-checks-view'
+          );
+        },
+        '0.1',
+        'http://some/plugins/url.html'
+    );
+  });
+
+  teardown(done => {
+    flush(() => {
+      sandbox.restore();
+      done();
+    });
+  });
+
+  const getCustomCssValue =
+      cssParam => util.getComputedStyleValue(cssParam, element);
+
+  test('_handleMessageAnchorTap', () => {
+    element._changeNum = '1';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForChange');
+    const replaceStateStub = sandbox.stub(history, 'replaceState');
+    element._handleMessageAnchorTap({detail: {id: 'a12345'}});
+
+    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.isTrue(replaceStateStub.called);
+  });
+
+  suite('plugins adding to file tab', () => {
+    setup(done => {
+      // Resolving it here instead of during setup() as other tests depend
+      // on flush() not being called during setup.
+      flush(() => done());
     });
 
-    teardown(done => {
+    test('plugin added tab shows up as a dynamic endpoint', () => {
+      assert(element._dynamicTabHeaderEndpoints.includes(
+          'change-view-tab-header-url'));
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      // 3 Tabs are : Files, Plugin, Findings
+      assert.equal(paperTabs.querySelectorAll('paper-tab').length, 3);
+      assert.equal(paperTabs.querySelectorAll('paper-tab')[1].dataset.name,
+          'change-view-tab-header-url');
+    });
+
+    test('_setActivePrimaryTab switched tab correctly', done => {
+      element._setActivePrimaryTab({detail:
+          {tab: 'change-view-tab-header-url'}});
       flush(() => {
-        sandbox.restore();
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
         done();
       });
     });
 
-    getCustomCssValue = cssParam => {
-      return util.getComputedStyleValue(cssParam, element);
-    };
-
-    test('_handleMessageAnchorTap', () => {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChange');
-      const replaceStateStub = sandbox.stub(history, 'replaceState');
-      element._handleMessageAnchorTap({detail: {id: 'a12345'}});
-
-      assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
-      assert.isTrue(replaceStateStub.called);
-    });
-
-    suite('keyboard shortcuts', () => {
-      test('t to add topic', () => {
-        const editStub = sandbox.stub(element.$.metadata, 'editTopic');
-        MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
-        assert(editStub.called);
-      });
-
-      test('S should toggle the CL star', () => {
-        const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
-        MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
-        assert(starStub.called);
-      });
-
-      test('U should navigate to root if no backPage set', () => {
-        const relativeNavStub = sandbox.stub(Gerrit.Nav,
-            'navigateToRelativeUrl');
-        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert.isTrue(relativeNavStub.called);
-        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-            Gerrit.Nav.getUrlForRoot()));
-      });
-
-      test('U should navigate to backPage if set', () => {
-        const relativeNavStub = sandbox.stub(Gerrit.Nav,
-            'navigateToRelativeUrl');
-        element.backPage = '/dashboard/self';
-        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert.isTrue(relativeNavStub.called);
-        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-            '/dashboard/self'));
-      });
-
-      test('A fires an error event when not logged in', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
-        const loggedInErrorSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        flush(() => {
-          assert.isFalse(element.$.replyOverlay.opened);
-          assert.isTrue(loggedInErrorSpy.called);
-          done();
-        });
-      });
-
-      test('shift A does not open reply overlay', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        flush(() => {
-          assert.isFalse(element.$.replyOverlay.opened);
-          done();
-        });
-      });
-
-      test('A toggles overlay when logged in', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
-            .returns(Promise.resolve({isLatest: true}));
-        element._change = {labels: {}};
-        const openSpy = sandbox.spy(element, '_openReplyDialog');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        flush(() => {
-          assert.isTrue(element.$.replyOverlay.opened);
-          element.$.replyOverlay.close();
-          assert.isFalse(element.$.replyOverlay.opened);
-          assert(openSpy.lastCall.calledWithExactly(
-              element.$.replyDialog.FocusTarget.ANY),
-          '_openReplyDialog should have been passed ANY');
-          assert.equal(openSpy.callCount, 1);
-          done();
-        });
-      });
-
-      test('fullscreen-overlay-opened hides content', () => {
-        element._loggedIn = true;
-        element._loading = false;
-        element._change = {
-          owner: {_account_id: 1},
-          labels: {},
-          actions: {
-            abandon: {
-              enabled: true,
-              label: 'Abandon',
-              method: 'POST',
-              title: 'Abandon',
+    test('show-primary-tab switched primary tab correctly', done => {
+      element.dispatchEvent(
+          new CustomEvent('show-primary-tab', {
+            composed: true,
+            bubbles: true,
+            detail: {
+              tab: 'change-view-tab-header-url',
             },
-          },
-        };
-        sandbox.spy(element, '_handleHideBackgroundContent');
-        element.$.replyDialog.fire('fullscreen-overlay-opened');
-        assert.isTrue(element._handleHideBackgroundContent.called);
-        assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-        assert.equal(getComputedStyle(element.$.actions).display, 'flex');
-      });
-
-      test('fullscreen-overlay-closed shows content', () => {
-        element._loggedIn = true;
-        element._loading = false;
-        element._change = {
-          owner: {_account_id: 1},
-          labels: {},
-          actions: {
-            abandon: {
-              enabled: true,
-              label: 'Abandon',
-              method: 'POST',
-              title: 'Abandon',
-            },
-          },
-        };
-        sandbox.spy(element, '_handleShowBackgroundContent');
-        element.$.replyDialog.fire('fullscreen-overlay-closed');
-        assert.isTrue(element._handleShowBackgroundContent.called);
-        assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-      });
-
-      test('expand all messages when expand-diffs fired', () => {
-        const handleExpand =
-            sandbox.stub(element.$.fileList, 'expandAllDiffs');
-        element.$.fileListHeader.fire('expand-diffs');
-        assert.isTrue(handleExpand.called);
-      });
-
-      test('collapse all messages when collapse-diffs fired', () => {
-        const handleCollapse =
-        sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-        element.$.fileListHeader.fire('collapse-diffs');
-        assert.isTrue(handleCollapse.called);
-      });
-
-      test('X should expand all messages', done => {
-        flush(() => {
-          const handleExpand = sandbox.stub(element.messagesList,
-              'handleExpandCollapse');
-          MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
-          assert(handleExpand.calledWith(true));
-          done();
-        });
-      });
-
-      test('Z should collapse all messages', done => {
-        flush(() => {
-          const handleExpand = sandbox.stub(element.messagesList,
-              'handleExpandCollapse');
-          MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
-          assert(handleExpand.calledWith(false));
-          done();
-        });
-      });
-
-      test('shift + R should fetch and navigate to the latest patch set',
-          done => {
-            element._changeNum = '42';
-            element._patchRange = {
-              basePatchNum: 'PARENT',
-              patchNum: 1,
-            };
-            element._change = {
-              change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-              _number: 42,
-              revisions: {
-                rev1: {_number: 1, commit: {parents: []}},
-              },
-              current_revision: 'rev1',
-              status: 'NEW',
-              labels: {},
-              actions: {},
-            };
-
-            navigateToChangeStub.restore();
-            navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange',
-                (change, patchNum, basePatchNum) => {
-                  assert.equal(change, element._change);
-                  assert.isUndefined(patchNum);
-                  assert.isUndefined(basePatchNum);
-                  done();
-                });
-
-            MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-          });
-
-      test('d should open download overlay', () => {
-        const stub = sandbox.stub(element.$.downloadOverlay, 'open');
-        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-        assert.isTrue(stub.called);
-      });
-
-      test(', should open diff preferences', () => {
-        const stub = sandbox.stub(
-            element.$.fileList.$.diffPreferencesDialog, 'open');
-        element._loggedIn = false;
-        element.disableDiffPrefs = true;
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert.isFalse(stub.called);
-
-        element._loggedIn = true;
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert.isFalse(stub.called);
-
-        element.disableDiffPrefs = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-        assert.isTrue(stub.called);
-      });
-
-      test('m should toggle diff mode', () => {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        const setModeStub = sandbox.stub(element.$.fileListHeader,
-            'setDiffViewMode');
-        const e = {preventDefault: () => {}};
-        flushAsynchronousOperations();
-
-        element.viewState.diffMode = 'SIDE_BY_SIDE';
-        element._handleToggleDiffMode(e);
-        assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
-
-        element.viewState.diffMode = 'UNIFIED_DIFF';
-        element._handleToggleDiffMode(e);
-        assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
+          }));
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
       });
     });
 
-    suite('reloading drafts', () => {
-      let reloadStub;
-      const drafts = {
-        'testfile.txt': [
+    test('param change should switch primary tab correctly', done => {
+      assert.equal(element._activeTabs[0], PrimaryTabs.FILES);
+      const queryMap = new Map();
+      queryMap.set('tab', PrimaryTabs.FINDINGS);
+      // view is required
+      element.params = Object.assign(
           {
-            patch_set: 5,
-            id: 'dd2982f5_c01c9e6a',
-            line: 1,
-            updated: '2017-11-08 18:47:45.000000000',
-            message: 'test',
-            unresolved: true,
+            view: GerritNav.View.CHANGE,
           },
-        ],
+          element.params, {queryMap});
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTabs.FINDINGS);
+        done();
+      });
+    });
+
+    test('invalid param change should not switch primary tab', done => {
+      assert.equal(element._activeTabs[0], PrimaryTabs.FILES);
+      const queryMap = new Map();
+      queryMap.set('tab', 'random');
+      // view is required
+      element.params = Object.assign(
+          {
+            view: GerritNav.View.CHANGE,
+          },
+          element.params, {queryMap});
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTabs.FILES);
+        done();
+      });
+    });
+
+    test('switching tab sets _selectedTabPluginEndpoint', done => {
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[1]);
+      flush(() => {
+        assert.equal(element._selectedTabPluginEndpoint,
+            'change-view-tab-content-url');
+        done();
+      });
+    });
+  });
+
+  suite('keyboard shortcuts', () => {
+    test('t to add topic', () => {
+      const editStub = sandbox.stub(element.$.metadata, 'editTopic');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
+      assert(editStub.called);
+    });
+
+    test('S should toggle the CL star', () => {
+      const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+    });
+
+    test('U should navigate to root if no backPage set', () => {
+      const relativeNavStub = sandbox.stub(GerritNav,
+          'navigateToRelativeUrl');
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          GerritNav.getUrlForRoot()));
+    });
+
+    test('U should navigate to backPage if set', () => {
+      const relativeNavStub = sandbox.stub(GerritNav,
+          'navigateToRelativeUrl');
+      element.backPage = '/dashboard/self';
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          '/dashboard/self'));
+    });
+
+    test('A fires an error event when not logged in', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      const loggedInErrorSpy = sandbox.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert.isTrue(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    test('shift A does not open reply overlay', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        done();
+      });
+    });
+
+    test('A toggles overlay when logged in', done => {
+      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
+          .returns(Promise.resolve({isLatest: true}));
+      element._change = {labels: {}};
+      const openSpy = sandbox.spy(element, '_openReplyDialog');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(element.$.replyOverlay.opened);
+        element.$.replyOverlay.close();
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert(openSpy.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openSpy.callCount, 1);
+        done();
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
+          },
+        },
       };
-      setup(() => {
-        // Fake computeDraftCount as its required for ChangeComments,
-        // see gr-comment-api#reloadDrafts.
-        reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
-            .returns(Promise.resolve({drafts, computeDraftCount: () => 1}));
-      });
+      sandbox.spy(element, '_handleHideBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
+    });
 
-      test('drafts are reloaded when reload-drafts fired', done => {
-        element.$.fileList.fire('reload-drafts', {
-          resolve: () => {
-            assert.isTrue(reloadStub.called);
-            assert.deepEqual(element._diffDrafts, drafts);
-            done();
+    test('fullscreen-overlay-closed shows content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
           },
+        },
+      };
+      sandbox.spy(element, '_handleShowBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-closed', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('expand all messages when expand-diffs fired', () => {
+      const handleExpand =
+          sandbox.stub(element.$.fileList, 'expandAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+          new CustomEvent('expand-diffs', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(handleExpand.called);
+    });
+
+    test('collapse all messages when collapse-diffs fired', () => {
+      const handleCollapse =
+      sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+          new CustomEvent('collapse-diffs', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(handleCollapse.called);
+    });
+
+    test('X should expand all messages', done => {
+      flush(() => {
+        const handleExpand = sandbox.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
+        assert(handleExpand.calledWith(true));
+        done();
+      });
+    });
+
+    test('Z should collapse all messages', done => {
+      flush(() => {
+        const handleExpand = sandbox.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
+        assert(handleExpand.calledWith(false));
+        done();
+      });
+    });
+
+    test('shift + R should fetch and navigate to the latest patch set',
+        done => {
+          element._changeNum = '42';
+          element._patchRange = {
+            basePatchNum: 'PARENT',
+            patchNum: 1,
+          };
+          element._change = {
+            change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+            _number: 42,
+            revisions: {
+              rev1: {_number: 1, commit: {parents: []}},
+            },
+            current_revision: 'rev1',
+            status: 'NEW',
+            labels: {},
+            actions: {},
+          };
+
+          navigateToChangeStub.restore();
+          navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange',
+              (change, patchNum, basePatchNum) => {
+                assert.equal(change, element._change);
+                assert.isUndefined(patchNum);
+                assert.isUndefined(basePatchNum);
+                done();
+              });
+
+          MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
         });
-      });
 
-      test('drafts are reloaded when comment-refresh fired', () => {
-        element.fire('comment-refresh');
-        assert.isTrue(reloadStub.called);
-      });
+    test('d should open download overlay', () => {
+      const stub = sandbox.stub(element.$.downloadOverlay, 'open');
+      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      assert.isTrue(stub.called);
     });
 
-    test('diff comments modified', () => {
-      sandbox.spy(element, '_handleReloadCommentThreads');
-      return element._reloadComments().then(() => {
-        element.fire('diff-comments-modified');
-        assert.isTrue(element._handleReloadCommentThreads.called);
-      });
+    test(', should open diff preferences', () => {
+      const stub = sandbox.stub(
+          element.$.fileList.$.diffPreferencesDialog, 'open');
+      element._loggedIn = false;
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element.disableDiffPrefs = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isTrue(stub.called);
     });
 
-    test('thread list modified', () => {
-      sandbox.spy(element, '_handleReloadDiffComments');
-      element._showMessagesView = false;
+    test('m should toggle diff mode', () => {
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const setModeStub = sandbox.stub(element.$.fileListHeader,
+          'setDiffViewMode');
+      const e = {preventDefault: () => {}};
       flushAsynchronousOperations();
 
-      return element._reloadComments().then(() => {
-        element.threadList.fire('thread-list-modified');
-        assert.isTrue(element._handleReloadDiffComments.called);
+      element.viewState.diffMode = 'SIDE_BY_SIDE';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
 
-        let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-            .returns(1);
-        assert.equal(element._computeTotalCommentCounts(5,
-            element._changeComments), '5 unresolved, 1 draft');
-        assert.equal(element._computeTotalCommentCounts(0,
-            element._changeComments), '1 draft');
-        draftStub.restore();
-        draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-            .returns(0);
-        assert.equal(element._computeTotalCommentCounts(0,
-            element._changeComments), '');
-        assert.equal(element._computeTotalCommentCounts(1,
-            element._changeComments), '1 unresolved');
-        draftStub.restore();
-        draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-            .returns(2);
-        assert.equal(element._computeTotalCommentCounts(1,
-            element._changeComments), '1 unresolved, 2 drafts');
-        draftStub.restore();
-      });
+      element.viewState.diffMode = 'UNIFIED_DIFF';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
+    });
+  });
+
+  suite('reloading drafts', () => {
+    let reloadStub;
+    const drafts = {
+      'testfile.txt': [
+        {
+          patch_set: 5,
+          id: 'dd2982f5_c01c9e6a',
+          line: 1,
+          updated: '2017-11-08 18:47:45.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+    };
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
+          .returns(Promise.resolve({
+            drafts,
+            getAllThreadsForChange: () => ([]),
+            computeDraftCount: () => 1,
+          }));
     });
 
-    test('thread list and change log tabs', done => {
+    test('drafts are reloaded when reload-drafts fired', done => {
+      element.$.fileList.dispatchEvent(
+          new CustomEvent('reload-drafts', {
+            detail: {
+              resolve: () => {
+                assert.isTrue(reloadStub.called);
+                assert.deepEqual(element._diffDrafts, drafts);
+                done();
+              },
+            },
+            composed: true, bubbles: true,
+          }));
+    });
+
+    test('drafts are reloaded when comment-refresh fired', () => {
+      element.dispatchEvent(
+          new CustomEvent('comment-refresh', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(reloadStub.called);
+    });
+  });
+
+  test('diff comments modified', () => {
+    sandbox.spy(element, '_handleReloadCommentThreads');
+    return element._reloadComments().then(() => {
+      element.dispatchEvent(
+          new CustomEvent('diff-comments-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleReloadCommentThreads.called);
+    });
+  });
+
+  test('thread list modified', () => {
+    sandbox.spy(element, '_handleReloadDiffComments');
+    element._activeTabs = [PrimaryTabs.FILES, SecondaryTabs.COMMENT_THREADS];
+    flushAsynchronousOperations();
+
+    return element._reloadComments().then(() => {
+      element.threadList.dispatchEvent(
+          new CustomEvent('thread-list-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleReloadDiffComments.called);
+
+      let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(1);
+      assert.equal(element._computeTotalCommentCounts(5,
+          element._changeComments), '5 unresolved, 1 draft');
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '1 draft');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(0);
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '');
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(2);
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved, 2 drafts');
+      draftStub.restore();
+    });
+  });
+
+  suite('thread list and change log tabs', () => {
+    setup(() => {
       element._changeNum = '1';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -435,1405 +796,1559 @@
       sandbox.stub(element, '_reload').returns(Promise.resolve());
       sandbox.spy(element, '_paramsChanged');
       element.params = {view: 'change', changeNum: '1'};
+    });
 
-      // When the change is hard reloaded, paramsChanged will not set the tab.
-      // It will be set in postLoadTasks, which requires the flush() to detect.
+    test('tab switch works correctly', done => {
       assert.isTrue(element._paramsChanged.called);
-      assert.isUndefined(element.$.commentTabs.selected);
+      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
 
-      // Wait for tab to get selected
+      const commentTab = element.shadowRoot.querySelector(
+          'paper-tab.commentThreads'
+      );
+      // Switch to comment thread tab
+      MockInteractions.tap(commentTab);
+      assert.equal(element._activeTabs[1], SecondaryTabs.COMMENT_THREADS);
+
+      // Switch back to 'Change Log' tab
+      element._paramsChanged(element.params);
       flush(() => {
-        assert.equal(element.$.commentTabs.selected, 0);
-        assert.isTrue(element._showMessagesView);
-        // Switch to comment thread tab
-        MockInteractions.tap(element.$$('paper-tab.commentThreads'));
-        assert.equal(element.$.commentTabs.selected, 1);
-        assert.isFalse(element._showMessagesView);
-
-        // 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._activeTabs[1], SecondaryTabs.CHANGE_LOG);
         done();
       });
     });
 
-    test('reply button is not visible when logged out', () => {
-      assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
-      element._loggedIn = true;
-      assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+    test('show-secondary-tab event works', () => {
+      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
+      // Switch to comment thread tab
+      element.fire('show-secondary-tab', {tab: SecondaryTabs.COMMENT_THREADS});
+      assert.equal(element._activeTabs[1], SecondaryTabs.COMMENT_THREADS);
     });
 
-    test('download tap calls _handleOpenDownloadDialog', () => {
-      sandbox.stub(element, '_handleOpenDownloadDialog');
-      element.$.actions.fire('download-tap');
-      assert.isTrue(element._handleOpenDownloadDialog.called);
-    });
-
-    test('fetches the server config on attached', done => {
+    test('param change should switched secondary tab correctly', done => {
+      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
+      const queryMap = new Map();
+      queryMap.set('secondaryTab', SecondaryTabs.COMMENT_THREADS);
+      // view is required
+      element.params = Object.assign(
+          {view: GerritNav.View.CHANGE},
+          element.params, {queryMap}
+      );
       flush(() => {
-        assert.equal(element._serverConfig.test, 'config');
+        assert.equal(element._activeTabs[1], SecondaryTabs.COMMENT_THREADS);
         done();
       });
     });
 
-    test('_changeStatuses', () => {
-      sandbox.stub(element, 'changeStatuses').returns(
-          ['Merged', 'WIP']);
-      element._loading = false;
+    test('invalid secondaryTab should not switch tab', done => {
+      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
+      const queryMap = new Map();
+      queryMap.set('secondaryTab', 'random');
+      // view is required
+      element.params = Object.assign({
+        view: GerritNav.View.CHANGE,
+      }, element.params, {queryMap});
+      flush(() => {
+        assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
+        done();
+      });
+    });
+  });
+
+  suite('Findings comment tab', () => {
+    setup(done => {
       element._change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        current_revision: 'rev3',
-        labels: {
-          test: {
-            all: [],
-            default_value: 0,
-            values: [],
-            approved: {},
-          },
-        },
-      };
-      element._mergeable = true;
-      expectedStatuses = ['Merged', 'WIP'];
-      assert.deepEqual(element._changeStatuses, expectedStatuses);
-      assert.equal(element._changeStatus, expectedStatuses.join(', '));
-      flushAsynchronousOperations();
-      const statusChips = Polymer.dom(element.root)
-          .querySelectorAll('gr-change-status');
-      assert.equal(statusChips.length, 2);
-    });
-
-    test('diff preferences open when open-diff-prefs is fired', () => {
-      const overlayOpenStub = sandbox.stub(element.$.fileList,
-          'openDiffPrefs');
-      element.$.fileListHeader.fire('open-diff-prefs');
-      assert.isTrue(overlayOpenStub.called);
-    });
-
-    test('_prepareCommitMsgForLinkify', () => {
-      let commitMessage = 'R=test@google.com';
-      let result = element._prepareCommitMsgForLinkify(commitMessage);
-      assert.equal(result, 'R=\u200Btest@google.com');
-
-      commitMessage = 'R=test@google.com\nR=test@google.com';
-      result = element._prepareCommitMsgForLinkify(commitMessage);
-      assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
-
-      commitMessage = 'CC=test@google.com';
-      result = element._prepareCommitMsgForLinkify(commitMessage);
-      assert.equal(result, 'CC=\u200Btest@google.com');
-    }),
-
-    test('_isSubmitEnabled', () => {
-      assert.isFalse(element._isSubmitEnabled({}));
-      assert.isFalse(element._isSubmitEnabled({submit: {}}));
-      assert.isTrue(element._isSubmitEnabled(
-          {submit: {enabled: true}}));
-    });
-
-    test('_reload is called when an approved label is removed', () => {
-      const vote = {_account_id: 1, name: 'bojack', value: 1};
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {email: 'abc@def'},
-        revisions: {
           rev2: {_number: 2, commit: {parents: []}},
           rev1: {_number: 1, commit: {parents: []}},
           rev13: {_number: 13, commit: {parents: []}},
           rev3: {_number: 3, commit: {parents: []}},
+          rev4: {_number: 4, commit: {parents: []}},
         },
-        current_revision: 'rev3',
-        status: 'NEW',
-        labels: {
-          test: {
-            all: [vote],
-            default_value: 0,
-            values: [],
-            approved: {},
-          },
-        },
+        current_revision: 'rev4',
       };
-      flushAsynchronousOperations();
-      const reloadStub = sandbox.stub(element, '_reload');
-      element.splice('_change.labels.test.all', 0, 1);
-      assert.isFalse(reloadStub.called);
-      element._change.labels.test.all.push(vote);
-      element._change.labels.test.all.push(vote);
-      element._change.labels.test.approved = vote;
-      flushAsynchronousOperations();
-      element.splice('_change.labels.test.all', 0, 2);
-      assert.isTrue(reloadStub.called);
-      assert.isTrue(reloadStub.calledOnce);
-    });
-
-    test('reply button has updated count when there are drafts', () => {
-      const getLabel = element._computeReplyButtonLabel;
-
-      assert.equal(getLabel(null, false), 'Reply');
-      assert.equal(getLabel(null, true), 'Start review');
-
-      const changeRecord = {base: null};
-      assert.equal(getLabel(changeRecord, false), 'Reply');
-
-      changeRecord.base = {};
-      assert.equal(getLabel(changeRecord, false), 'Reply');
-
-      changeRecord.base = {
-        'file1.txt': [{}],
-        'file2.txt': [{}, {}],
-      };
-      assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-    });
-
-    test('start review button when owner of WIP change', () => {
-      assert.equal(
-          element._computeReplyButtonLabel(null, true),
-          'Start review');
-    });
-
-    test('comment events properly update diff drafts', () => {
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      const draft = {
-        __draft: true,
-        id: 'id1',
-        path: '/foo/bar.txt',
-        text: 'hello',
-      };
-      element._handleCommentSave({detail: {comment: draft}});
-      draft.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-      draft.patch_set = null;
-      draft.text = 'hello, there';
-      element._handleCommentSave({detail: {comment: draft}});
-      draft.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-      const draft2 = {
-        __draft: true,
-        id: 'id2',
-        path: '/foo/bar.txt',
-        text: 'hola',
-      };
-      element._handleCommentSave({detail: {comment: draft2}});
-      draft2.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-      draft.patch_set = null;
-      element._handleCommentDiscard({detail: {comment: draft}});
-      draft.patch_set = 2;
-      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-      element._handleCommentDiscard({detail: {comment: draft2}});
-      assert.deepEqual(element._diffDrafts, {});
-    });
-
-    test('change num change', () => {
-      element._changeNum = null;
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        labels: {},
-      };
-      element.viewState.changeNum = null;
-      element.viewState.diffMode = 'UNIFIED';
-      assert.equal(element.viewState.numFilesShown, 200);
-      assert.equal(element._numFilesShown, 200);
-      element._numFilesShown = 150;
-      flushAsynchronousOperations();
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      assert.equal(element.viewState.numFilesShown, 150);
-
-      element._changeNum = '1';
-      element.params = {changeNum: '1'};
-      element._change.newProp = '1';
-      flushAsynchronousOperations();
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      assert.equal(element.viewState.changeNum, '1');
-
-      element._changeNum = '2';
-      element.params = {changeNum: '2'};
-      element._change.newProp = '2';
-      flushAsynchronousOperations();
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      assert.equal(element.viewState.changeNum, '2');
-      assert.equal(element.viewState.numFilesShown, 200);
-      assert.equal(element._numFilesShown, 200);
-    });
-
-    test('_setDiffViewMode is called with reset when new change is loaded',
-        () => {
-          sandbox.stub(element, '_setDiffViewMode');
-          element.viewState = {changeNum: 1};
-          element._changeNum = 2;
-          element._resetFileListViewState();
-          assert.isTrue(
-              element._setDiffViewMode.lastCall.calledWithExactly(true));
-        });
-
-    test('diffViewMode is propagated from file list header', () => {
-      element.viewState = {diffMode: 'UNIFIED'};
-      element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
-      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-    });
-
-    test('diffMode defaults to side by side without preferences', done => {
-      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-          Promise.resolve({}));
-      // No user prefs or diff view mode set.
-
-      element._setDiffViewMode().then(() => {
-        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
-
-    test('diffMode defaults to preference when not already set', done => {
-      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-          Promise.resolve({default_diff_view: 'UNIFIED'}));
-
-      element._setDiffViewMode().then(() => {
-        assert.equal(element.viewState.diffMode, 'UNIFIED');
-        done();
-      });
-    });
-
-    test('existing diffMode overrides preference', done => {
-      element.viewState.diffMode = 'SIDE_BY_SIDE';
-      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-          Promise.resolve({default_diff_view: 'UNIFIED'}));
-      element._setDiffViewMode().then(() => {
-        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
-
-    test('don’t reload entire page when patchRange changes', () => {
-      const reloadStub = sandbox.stub(element, '_reload',
-          () => { return Promise.resolve(); });
-      const reloadPatchDependentStub = sandbox.stub(element,
-          '_reloadPatchNumDependentResources',
-          () => { return Promise.resolve(); });
-      const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
-      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-
-      const value = {
-        view: Gerrit.Nav.View.CHANGE,
-        patchNum: '1',
-      };
-      element._paramsChanged(value);
-      assert.isTrue(reloadStub.calledOnce);
-      assert.isTrue(relatedClearSpy.calledOnce);
-
-      element._initialLoadComplete = true;
-
-      value.basePatchNum = '1';
-      value.patchNum = '2';
-      element._paramsChanged(value);
-      assert.isFalse(reloadStub.calledTwice);
-      assert.isTrue(reloadPatchDependentStub.calledOnce);
-      assert.isTrue(relatedClearSpy.calledOnce);
-      assert.isTrue(collapseStub.calledTwice);
-    });
-
-    test('reload entire page when patchRange doesnt change', () => {
-      const reloadStub = sandbox.stub(element, '_reload',
-          () => { return Promise.resolve(); });
-      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-      const value = {
-        view: Gerrit.Nav.View.CHANGE,
-      };
-      element._paramsChanged(value);
-      assert.isTrue(reloadStub.calledOnce);
-      element._initialLoadComplete = true;
-      element._paramsChanged(value);
-      assert.isTrue(reloadStub.calledTwice);
-      assert.isTrue(collapseStub.calledTwice);
-    });
-
-    test('related changes are updated and new patch selected after rebase',
-        done => {
-          element._changeNum = '42';
-          sandbox.stub(element, 'computeLatestPatchNum', () => {
-            return 1;
-          });
-          sandbox.stub(element, '_reload',
-              () => { return Promise.resolve(); });
-          const e = {detail: {action: 'rebase'}};
-          element._handleReloadChange(e).then(() => {
-            assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-                element._change));
-            done();
-          });
-        });
-
-    test('related changes are not updated after other action', done => {
-      sandbox.stub(element, '_reload', () => { return Promise.resolve(); });
-      sandbox.stub(element.$.relatedChanges, 'reload');
-      const e = {detail: {action: 'abandon'}};
-      element._handleReloadChange(e).then(() => {
-        assert.isFalse(navigateToChangeStub.called);
-        done();
-      });
-    });
-
-    test('_computeMergedCommitInfo', () => {
-      const dummyRevs = {
-        1: {commit: {commit: 1}},
-        2: {commit: {}},
-      };
-      assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
-      assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
-          dummyRevs[1].commit);
-
-      // Regression test for issue 5337.
-      const commit = element._computeMergedCommitInfo(2, dummyRevs);
-      assert.notDeepEqual(commit, dummyRevs[2]);
-      assert.deepEqual(commit, {commit: 2});
-    });
-
-    test('get latest revision', () => {
-      let change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev3: {_number: 3},
-        },
-        current_revision: 'rev3',
-      };
-      assert.equal(element._getLatestRevisionSHA(change), 'rev3');
-      change = {
-        revisions: {
-          rev1: {_number: 1},
-        },
-      };
-      assert.equal(element._getLatestRevisionSHA(change), 'rev1');
-    });
-
-    test('show commit message edit button', () => {
-      const _change = {
-        status: element.ChangeStatus.MERGED,
-      };
-      assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
-      assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
-      assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
-      assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
-      assert.isTrue(element._computeHideEditCommitMessage(true, false,
-          _change));
-      assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
-          true));
-      assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
-          false));
-    });
-
-    test('_handleCommitMessageSave trims trailing whitespace', () => {
-      const putStub = sandbox.stub(element.$.restAPI, 'putChangeCommitMessage')
-          .returns(Promise.resolve({}));
-
-      const mockEvent = content => { return {detail: {content}}; };
-
-      element._handleCommitMessageSave(mockEvent('test \n  test '));
-      assert.equal(putStub.lastCall.args[1], 'test\n  test');
-
-      element._handleCommitMessageSave(mockEvent('  test\ntest'));
-      assert.equal(putStub.lastCall.args[1], '  test\ntest');
-
-      element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
-      assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
-    });
-
-    test('_computeChangeIdCommitMessageError', () => {
-      let commitMessage =
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          null);
-
-      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'mismatch');
-
-      commitMessage = 'This is the greatest change.';
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'missing');
-    });
-
-    test('multiple change Ids in commit message picks last', () => {
-      const commitMessage = [
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-      ].join('\n');
-      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          null);
-      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'mismatch');
-    });
-
-    test('does not count change Id that starts mid line', () => {
-      const commitMessage = [
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-      ].join(' and ');
-      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          null);
-      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-      assert.equal(
-          element._computeChangeIdCommitMessageError(commitMessage, change),
-          'mismatch');
-    });
-
-    test('_computeTitleAttributeWarning', () => {
-      let changeIdCommitMessageError = 'missing';
-      assert.equal(
-          element._computeTitleAttributeWarning(changeIdCommitMessageError),
-          'No Change-Id in commit message');
-
-      changeIdCommitMessageError = 'mismatch';
-      assert.equal(
-          element._computeTitleAttributeWarning(changeIdCommitMessageError),
-          'Change-Id mismatch');
-    });
-
-    test('_computeChangeIdClass', () => {
-      let changeIdCommitMessageError = 'missing';
-      assert.equal(
-          element._computeChangeIdClass(changeIdCommitMessageError), '');
-
-      changeIdCommitMessageError = 'mismatch';
-      assert.equal(
-          element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
-    });
-
-    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: {}}},
-        });
-      });
-
-      element._getChangeDetail().then(() => {
-        assert.isNull(element._change.topic);
-        done();
-      });
-    });
-
-    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: {}}},
-        });
-      });
-
-      element._getChangeDetail().then(() => {
-        assert.equal('foo', element._commitInfo.commit);
-        done();
-      });
-    });
-
-    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'},
-        });
-      });
-      element._patchRange = {};
-
-      return element._getChangeDetail().then(() => {
-        const revs = element._change.revisions;
-        assert.equal(Object.keys(revs).length, 2);
-        assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
-        assert.deepEqual(revs['bar'], {
-          _number: element.EDIT_NAME,
-          basePatchNum: 1,
-          commit: {commit: 'bar'},
-          fetch: undefined,
-        });
-      });
-    });
-
-    test('_getBasePatchNum', () => {
-      const _change = {
-        _number: 42,
-        revisions: {
-          '98da160735fb81604b4c40e93c368f380539dd0e': {
-            _number: 1,
-            commit: {
-              parents: [],
-            },
-          },
-        },
-      };
-      const _patchRange = {
-        basePatchNum: 'PARENT',
-      };
-      assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
-
-      element._prefs = {
-        default_base_for_merges: 'FIRST_PARENT',
-      };
-
-      const _change2 = {
-        _number: 42,
-        revisions: {
-          '98da160735fb81604b4c40e93c368f380539dd0e': {
-            _number: 1,
-            commit: {
-              parents: [
-                {
-                  commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
-                  subject: 'test',
-                },
-                {
-                  commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
-                  subject: 'test3',
-                },
-              ],
-            },
-          },
-        },
-      };
-      assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
-
-      _patchRange.patchNum = 1;
-      assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
-    });
-
-    test('_openReplyDialog called with `ANY` when coming from tap event',
-        () => {
-          const openStub = sandbox.stub(element, '_openReplyDialog');
-          element._serverConfig = {};
-          MockInteractions.tap(element.$.replyBtn);
-          assert(openStub.lastCall.calledWithExactly(
-              element.$.replyDialog.FocusTarget.ANY),
-          '_openReplyDialog should have been passed ANY');
-          assert.equal(openStub.callCount, 1);
-        });
-
-    test('_openReplyDialog called with `BODY` when coming from message reply' +
-        'event', done => {
+      element._commentThreads = THREADS;
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
       flush(() => {
-        const openStub = sandbox.stub(element, '_openReplyDialog');
-        element.messagesList.fire('reply',
-            {message: {message: 'text'}});
-        assert(openStub.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.BODY),
-        '_openReplyDialog should have been passed BODY');
-        assert.equal(openStub.callCount, 1);
         done();
       });
     });
 
-    test('reply dialog focus can be controlled', () => {
-      const FocusTarget = element.$.replyDialog.FocusTarget;
-      const openStub = sandbox.stub(element, '_openReplyDialog');
-
-      const e = {detail: {}};
-      element._handleShowReplyDialog(e);
-      assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
-          '_openReplyDialog should have been passed REVIEWERS');
-      assert.equal(openStub.callCount, 1);
-
-      e.detail.value = {ccsOnly: true};
-      element._handleShowReplyDialog(e);
-      assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
-          '_openReplyDialog should have been passed CCS');
-      assert.equal(openStub.callCount, 2);
-    });
-
-    test('getUrlParameter functionality', () => {
-      const locationStub = sandbox.stub(element, '_getLocationSearch');
-
-      locationStub.returns('?test');
-      assert.equal(element._getUrlParameter('test'), 'test');
-      locationStub.returns('?test2=12&test=3');
-      assert.equal(element._getUrlParameter('test'), 'test');
-      locationStub.returns('');
-      assert.isNull(element._getUrlParameter('test'));
-      locationStub.returns('?');
-      assert.isNull(element._getUrlParameter('test'));
-      locationStub.returns('?test2');
-      assert.isNull(element._getUrlParameter('test'));
-    });
-
-    test('revert dialog opened with revert param', done => {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn', () => {
-        return Promise.resolve(true);
-      });
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded', () => {
-        return Promise.resolve();
-      });
-
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
+    test('robot comments count per patchset', () => {
+      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const expectedCount = {
+        2: 1,
+        3: 1,
+        4: 2,
       };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1, commit: {parents: []}},
-          rev2: {_number: 2, commit: {parents: []}},
-        },
-        current_revision: 'rev1',
-        status: element.ChangeStatus.MERGED,
-        labels: {},
-        actions: {},
-      };
-
-      sandbox.stub(element, '_getUrlParameter',
-          param => {
-            assert.equal(param, 'revert');
-            return param;
-          });
-
-      sandbox.stub(element.$.actions, 'showRevertDialog',
-          done);
-
-      element._maybeShowRevertDialog();
-      assert.isTrue(Gerrit.awaitPluginsLoaded.called);
+      assert.deepEqual(count, expectedCount);
+      assert.equal(element._computeText({_number: 2}, THREADS),
+          'Patchset 2 (1 finding)');
+      assert.equal(element._computeText({_number: 4}, THREADS),
+          'Patchset 4 (2 findings)');
+      assert.equal(element._computeText({_number: 5}, THREADS),
+          'Patchset 5');
     });
 
-    suite('scroll related tests', () => {
-      test('document scrolling calls function to set scroll height', done => {
-        const originalHeight = document.body.scrollHeight;
-        const scrollStub = sandbox.stub(element, '_handleScroll',
-            () => {
-              assert.isTrue(scrollStub.called);
-              document.body.style.height = originalHeight + 'px';
-              scrollStub.restore();
-              done();
-            });
-        document.body.style.height = '10000px';
-        element._handleScroll();
-      });
-
-      test('scrollTop is set correctly', () => {
-        element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
-
-        sandbox.stub(element, '_reload', () => {
-          // When element is reloaded, ensure that the history
-          // state has the scrollTop set earlier. This will then
-          // be reset.
-          assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
-          return Promise.resolve({});
-        });
-
-        // simulate reloading component, which is done when route
-        // changes to match a regex of change view type.
-        element._paramsChanged({view: Gerrit.Nav.View.CHANGE});
-      });
-
-      test('scrollTop is reset when new change is loaded', () => {
-        element._resetFileListViewState();
-        assert.equal(element.viewState.scrollTop, 0);
-      });
+    test('only robot comments are rendered', () => {
+      assert.equal(element._robotCommentThreads.length, 2);
+      assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
+          'rc1');
+      assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
+          'rc2');
     });
 
-    suite('reply dialog tests', () => {
-      setup(() => {
-        sandbox.stub(element.$.replyDialog, '_draftChanged');
-        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
-            () => { return Promise.resolve({isLatest: true}); });
-        element._change = {labels: {}};
-      });
-
-      test('reply from comment adds quote text', () => {
-        const e = {detail: {message: {message: 'quote text'}}};
-        element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-
-      test('reply from comment replaces quote text', () => {
-        element.$.replyDialog.draft = '> old quote text\n\n some draft text';
-        element.$.replyDialog.quote = '> old quote text\n\n';
-        const e = {detail: {message: {message: 'quote text'}}};
-        element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-
-      test('reply from same comment preserves quote text', () => {
-        element.$.replyDialog.draft = '> quote text\n\n some draft text';
-        element.$.replyDialog.quote = '> quote text\n\n';
-        const e = {detail: {message: {message: 'quote text'}}};
-        element._handleMessageReply(e);
-        assert.equal(element.$.replyDialog.draft,
-            '> quote text\n\n some draft text');
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-
-      test('reply from top of page contains previous draft', () => {
-        const div = document.createElement('div');
-        element.$.replyDialog.draft = '> quote text\n\n some draft text';
-        element.$.replyDialog.quote = '> quote text\n\n';
-        const e = {target: div, preventDefault: sandbox.spy()};
-        element._handleReplyTap(e);
-        assert.equal(element.$.replyDialog.draft,
-            '> quote text\n\n some draft text');
-        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-      });
-    });
-
-    test('reply button is disabled until server config is loaded', () => {
-      assert.isTrue(element._replyDisabled);
-      element._serverConfig = {};
-      assert.isFalse(element._replyDisabled);
-    });
-
-    suite('commit message expand/collapse', () => {
-      setup(() => {
-        sandbox.stub(element, 'fetchChangeUpdates',
-            () => { return Promise.resolve({isLatest: false}); });
-      });
-
-      test('commitCollapseToggle hidden for short commit message', () => {
-        element._latestCommitMessage = '';
-        assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
-      });
-
-      test('commitCollapseToggle shown for long commit message', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
-      });
-
-      test('commitCollapseToggle functions', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        assert.isTrue(element._commitCollapsed);
-        assert.isTrue(
-            element.$.commitMessage.classList.contains('collapsed'));
-        MockInteractions.tap(element.$.commitCollapseToggleButton);
-        assert.isFalse(element._commitCollapsed);
-        assert.isFalse(
-            element.$.commitMessage.classList.contains('collapsed'));
-      });
-    });
-
-    suite('related changes expand/collapse', () => {
-      let updateHeightSpy;
-      setup(() => {
-        updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
-      });
-
-      test('relatedChangesToggle shown height greater than changeInfo height',
-          () => {
-            assert.isFalse(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            sandbox.stub(element, '_getOffsetHeight', () => 50);
-            sandbox.stub(element, '_getScrollHeight', () => 60);
-            sandbox.stub(element, '_getLineHeight', () => 5);
-            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
-            element.$.relatedChanges.dispatchEvent(
-                new CustomEvent('new-section-loaded'));
-            assert.isTrue(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            assert.equal(updateHeightSpy.callCount, 1);
-          });
-
-      test('relatedChangesToggle hidden height less than changeInfo height',
-          () => {
-            assert.isFalse(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            sandbox.stub(element, '_getOffsetHeight', () => 50);
-            sandbox.stub(element, '_getScrollHeight', () => 40);
-            sandbox.stub(element, '_getLineHeight', () => 5);
-            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
-            element.$.relatedChanges.dispatchEvent(
-                new CustomEvent('new-section-loaded'));
-            assert.isFalse(element.$.relatedChangesToggle.classList
-                .contains('showToggle'));
-            assert.equal(updateHeightSpy.callCount, 1);
-          });
-
-      test('relatedChangesToggle functions', () => {
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
-        element._relatedChangesLoading = false;
-        assert.isTrue(element._relatedChangesCollapsed);
-        assert.isTrue(
-            element.$.relatedChanges.classList.contains('collapsed'));
-        MockInteractions.tap(element.$.relatedChangesToggleButton);
-        assert.isFalse(element._relatedChangesCollapsed);
-        assert.isFalse(
-            element.$.relatedChanges.classList.contains('collapsed'));
-      });
-
-      test('_updateRelatedChangeMaxHeight without commit toggle', () => {
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
-
-        // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
-        // 20 (max existing height)  % 12 (line height) = 6 (remainder).
-        // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
-
-        element._updateRelatedChangeMaxHeight();
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '12px');
-        assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-            '');
-      });
-
-      test('_updateRelatedChangeMaxHeight with commit toggle', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
-
-        // 50 (existing height) % 12 (line height) = 2 (remainder).
-        // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
-
-        element._updateRelatedChangeMaxHeight();
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '48px');
-        assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-            '2px');
-      });
-
-      test('_updateRelatedChangeMaxHeight in small screen mode', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => ({matches: true}));
-
-        element._updateRelatedChangeMaxHeight();
-
-        // 400 (new height) % 12 (line height) = 4 (remainder).
-        // 400 (new height) - 4 (remainder) = 396.
-
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '396px');
-      });
-
-      test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => {
-          if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
-            return {matches: true};
-          } else {
-            return {matches: false};
-          }
-        });
-
-        // 100 (new height) % 12 (line height) = 4 (remainder).
-        // 100 (new height) - 4 (remainder) = 96.
-        element._updateRelatedChangeMaxHeight();
-        assert.equal(getCustomCssValue('--relation-chain-max-height'),
-            '96px');
-      });
-
-
-      suite('update checks', () => {
-        setup(() => {
-          sandbox.spy(element, '_startUpdateCheckTimer');
-          sandbox.stub(element, 'async', f => {
-            // Only fire the async callback one time.
-            if (element.async.callCount > 1) { return; }
-            f.call(element);
-          });
-        });
-
-        test('_startUpdateCheckTimer negative delay', () => {
-          sandbox.stub(element, 'fetchChangeUpdates');
-
-          element._serverConfig = {change: {update_delay: -1}};
-
-          assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isFalse(element.fetchChangeUpdates.called);
-        });
-
-        test('_startUpdateCheckTimer up-to-date', () => {
-          sandbox.stub(element, 'fetchChangeUpdates',
-              () => { return Promise.resolve({isLatest: true}); });
-
-          element._serverConfig = {change: {update_delay: 12345}};
-
-          assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isTrue(element.fetchChangeUpdates.called);
-          assert.equal(element.async.lastCall.args[1], 12345 * 1000);
-        });
-
-        test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-          sandbox.stub(element, 'fetchChangeUpdates',
-              () => { return Promise.resolve({isLatest: false}); });
-          element.addEventListener('show-alert', e => {
-            assert.equal(e.detail.message,
-                'A newer patch set has been uploaded');
-            done();
-          });
-          element._serverConfig = {change: {update_delay: 12345}};
-        });
-
-        test('_startUpdateCheckTimer new status shows an alert', done => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({
-                isLatest: true,
-                newStatus: element.ChangeStatus.MERGED,
-              }));
-          element.addEventListener('show-alert', e => {
-            assert.equal(e.detail.message, 'This change has been merged');
-            done();
-          });
-          element._serverConfig = {change: {update_delay: 12345}};
-        });
-
-        test('_startUpdateCheckTimer new messages shows an alert', done => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({
-                isLatest: true,
-                newMessages: true,
-              }));
-          element.addEventListener('show-alert', e => {
-            assert.equal(e.detail.message,
-                'There are new messages on this change');
-            done();
-          });
-          element._serverConfig = {change: {update_delay: 12345}};
-        });
-      });
-
-      test('canStartReview computation', () => {
-        const change1 = {};
-        const change2 = {
-          actions: {
-            ready: {
-              enabled: true,
-            },
-          },
-        };
-        const change3 = {
-          actions: {
-            ready: {
-              label: 'Ready for Review',
-            },
-          },
-        };
-        assert.isFalse(element._computeCanStartReview(change1));
-        assert.isTrue(element._computeCanStartReview(change2));
-        assert.isFalse(element._computeCanStartReview(change3));
-      });
-    });
-
-    test('header class computation', () => {
-      assert.equal(element._computeHeaderClass(), 'header');
-      assert.equal(element._computeHeaderClass(true), 'header editMode');
-    });
-
-    test('_maybeScrollToMessage', done => {
+    test('changing patchsets resets robot comments', done => {
+      element.set('_change.current_revision', 'rev3');
       flush(() => {
-        const scrollStub = sandbox.stub(element.messagesList,
-            'scrollToMessage');
-
-        element._maybeScrollToMessage('');
-        assert.isFalse(scrollStub.called);
-        element._maybeScrollToMessage('message');
-        assert.isFalse(scrollStub.called);
-        element._maybeScrollToMessage('#message-TEST');
-        assert.isTrue(scrollStub.called);
-        assert.equal(scrollStub.lastCall.args[0], 'TEST');
+        assert.equal(element._robotCommentThreads.length, 1);
         done();
       });
     });
 
-    test('topic update reloads related changes', () => {
-      sandbox.stub(element.$.relatedChanges, 'reload');
-      element.dispatchEvent(new CustomEvent('topic-changed'));
-      assert.isTrue(element.$.relatedChanges.reload.calledOnce);
+    test('Show more button is hidden', () => {
+      assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
     });
 
-    test('_computeEditMode', () => {
-      const callCompute = (range, params) =>
-        element._computeEditMode({base: range}, {base: params});
-      assert.isFalse(callCompute({}, {}));
-      assert.isTrue(callCompute({}, {edit: true}));
-      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
-      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
-      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
-    });
-
-    test('_processEdit', () => {
-      element._patchRange = {};
-      const change = {
-        current_revision: 'foo',
-        revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
-      };
-      let mockChange;
-
-      // With no edit, mockChange should be unmodified.
-      element._processEdit(mockChange = _.cloneDeep(change), null);
-      assert.deepEqual(mockChange, change);
-
-      // When edit is not based on the latest PS, current_revision should be
-      // unmodified.
-      const edit = {
-        base_patch_set_number: 1,
-        commit: {commit: 'bar'},
-        fetch: true,
-      };
-      element._processEdit(mockChange = _.cloneDeep(change), edit);
-      assert.notDeepEqual(mockChange, change);
-      assert.equal(mockChange.revisions.bar._number, element.EDIT_NAME);
-      assert.equal(mockChange.current_revision, change.current_revision);
-      assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
-      assert.notOk(mockChange.revisions.bar.actions);
-
-      edit.base_revision = 'foo';
-      element._processEdit(mockChange = _.cloneDeep(change), edit);
-      assert.notDeepEqual(mockChange, change);
-      assert.equal(mockChange.current_revision, 'bar');
-      assert.deepEqual(mockChange.revisions.bar.actions,
-          mockChange.revisions.foo.actions);
-
-      // If _patchRange.patchNum is defined, do not load edit.
-      element._patchRange.patchNum = 'baz';
-      change.current_revision = 'baz';
-      element._processEdit(mockChange = _.cloneDeep(change), edit);
-      assert.equal(element._patchRange.patchNum, 'baz');
-      assert.notOk(mockChange.revisions.bar.actions);
-    });
-
-    test('file-action-tap handling', () => {
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      const fileList = element.$.fileList;
-      const Actions = GrEditConstants.Actions;
-      const controls = element.$.fileListHeader.$.editControls;
-      sandbox.stub(controls, 'openDeleteDialog');
-      sandbox.stub(controls, 'openRenameDialog');
-      sandbox.stub(controls, 'openRestoreDialog');
-      sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff');
-      sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
-
-      // Delete
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.DELETE.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(controls.openDeleteDialog.called);
-      assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
-
-      // Restore
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.RESTORE.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(controls.openRestoreDialog.called);
-      assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
-
-      // Rename
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.RENAME.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(controls.openRenameDialog.called);
-      assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
-
-      // Open
-      fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-        detail: {action: Actions.OPEN.id, path: 'foo'},
-        bubbles: true,
-        composed: true,
-      }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
-      assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[1], 'foo');
-      assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[2], '1');
-      assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.called);
-    });
-
-    test('_selectedRevision updates when patchNum is changed', () => {
-      const revision1 = {_number: 1, commit: {parents: []}};
-      const revision2 = {_number: 2, commit: {parents: []}};
-      sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
-          Promise.resolve({
-            revisions: {
-              aaa: revision1,
-              bbb: revision2,
-            },
-            labels: {},
-            actions: {},
-            current_revision: 'bbb',
-            change_id: 'loremipsumdolorsitamet',
-          }));
-      sandbox.stub(element, '_getEdit').returns(Promise.resolve());
-      sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
-      element._patchRange = {patchNum: '2'};
-      return element._getChangeDetail().then(() => {
-        assert.strictEqual(element._selectedRevision, revision2);
-
-        element.set('_patchRange.patchNum', '1');
-        assert.strictEqual(element._selectedRevision, revision1);
-      });
-    });
-
-    test('_sendShowChangeEvent', () => {
-      element._change = {labels: {}};
-      element._patchRange = {patchNum: 4};
-      element._mergeable = true;
-      const showStub = sandbox.stub(element.$.jsAPI, 'handleEvent');
-      element._sendShowChangeEvent();
-      assert.isTrue(showStub.calledOnce);
-      assert.equal(
-          showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
-      assert.deepEqual(showStub.lastCall.args[1], {
-        change: {labels: {}},
-        patchNum: 4,
-        info: {mergeable: true},
-      });
-    });
-
-    suite('_handleEditTap', () => {
-      let fireEdit;
-
-      setup(() => {
-        fireEdit = () => {
-          element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
-        };
-        navigateToChangeStub.restore();
-
-        element._change = {revisions: {rev1: {_number: 1}}};
-      });
-
-      test('edit exists in revisions', done => {
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 2);
-          assert.equal(args[1], element.EDIT_NAME); // patchNum
-          done();
-        });
-
-        element.set('_change.revisions.rev2', {_number: element.EDIT_NAME});
-        flushAsynchronousOperations();
-
-        fireEdit();
-      });
-
-      test('no edit exists in revisions, non-latest patchset', done => {
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 4);
-          assert.equal(args[1], 1); // patchNum
-          assert.equal(args[3], true); // opt_isEdit
-          done();
-        });
-
-        element.set('_change.revisions.rev2', {_number: 2});
-        element._patchRange = {patchNum: 1};
-        flushAsynchronousOperations();
-
-        fireEdit();
-      });
-
-      test('no edit exists in revisions, latest patchset', done => {
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 4);
-          // No patch should be specified when patchNum == latest.
-          assert.isNotOk(args[1]); // patchNum
-          assert.equal(args[3], true); // opt_isEdit
-          done();
-        });
-
-        element.set('_change.revisions.rev2', {_number: 2});
-        element._patchRange = {patchNum: 2};
-        flushAsynchronousOperations();
-
-        fireEdit();
-      });
-    });
-
-    test('_handleStopEditTap', done => {
-      sandbox.stub(element.$.metadata, '_computeLabelNames');
-      navigateToChangeStub.restore();
-      sandbox.stub(element, 'computeLatestPatchNum').returns(1);
-      sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-        assert.equal(args.length, 2);
-        assert.equal(args[1], 1); // patchNum
-        done();
-      });
-
-      element._patchRange = {patchNum: 1};
-      element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
-          {bubbles: false}));
-    });
-
-    suite('plugin endpoints', () => {
-      test('endpoint params', done => {
-        element._change = {labels: {}};
-        element._selectedRevision = {};
-        let hookEl;
-        let plugin;
-        Gerrit.install(
-            p => {
-              plugin = p;
-              plugin.hook('change-view-integration').getLastAttached().then(
-                  el => hookEl = el);
-            },
-            '0.1',
-            'http://some/plugins/url.html');
+    suite('robot comments show more button', () => {
+      setup(done => {
+        const arr = [];
+        for (let i = 0; i <= 30; i++) {
+          arr.push(...THREADS);
+        }
+        element._commentThreads = arr;
         flush(() => {
-          assert.strictEqual(hookEl.plugin, plugin);
-          assert.strictEqual(hookEl.change, element._change);
-          assert.strictEqual(hookEl.revision, element._selectedRevision);
-          done();
-        });
-      });
-    });
-
-    suite('_getMergeability', () => {
-      let getMergeableStub;
-
-      setup(() => {
-        element._change = {labels: {}};
-        getMergeableStub = sandbox.stub(element.$.restAPI, 'getMergeable')
-            .returns(Promise.resolve({mergeable: true}));
-      });
-
-      test('merged change', () => {
-        element._mergeable = null;
-        element._change.status = element.ChangeStatus.MERGED;
-        return element._getMergeability().then(() => {
-          assert.isFalse(element._mergeable);
-          assert.isFalse(getMergeableStub.called);
-        });
-      });
-
-      test('abandoned change', () => {
-        element._mergeable = null;
-        element._change.status = element.ChangeStatus.ABANDONED;
-        return element._getMergeability().then(() => {
-          assert.isFalse(element._mergeable);
-          assert.isFalse(getMergeableStub.called);
-        });
-      });
-
-      test('open change', () => {
-        element._mergeable = null;
-        return element._getMergeability().then(() => {
-          assert.isTrue(element._mergeable);
-          assert.isTrue(getMergeableStub.called);
-        });
-      });
-    });
-
-    test('_paramsChanged sets in projectLookup', () => {
-      sandbox.stub(element.$.relatedChanges, 'reload');
-      sandbox.stub(element, '_reload').returns(Promise.resolve());
-      const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-      element._paramsChanged({
-        view: Gerrit.Nav.View.CHANGE,
-        changeNum: 101,
-        project: 'test-project',
-      });
-      assert.isTrue(setStub.calledOnce);
-      assert.isTrue(setStub.calledWith(101, 'test-project'));
-    });
-
-    test('_handleToggleStar called when star is tapped', () => {
-      element._change = {
-        owner: {_account_id: 1},
-        starred: false,
-      };
-      element._loggedIn = true;
-      const stub = sandbox.stub(element, '_handleToggleStar');
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.$.changeStar.$$('button'));
-      assert.isTrue(stub.called);
-    });
-
-    suite('gr-reporting tests', () => {
-      setup(() => {
-        element._patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 1,
-        };
-        sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve());
-        sandbox.stub(element, '_getProjectConfig').returns(Promise.resolve());
-        sandbox.stub(element, '_reloadComments').returns(Promise.resolve());
-        sandbox.stub(element, '_getMergeability').returns(Promise.resolve());
-        sandbox.stub(element, '_getLatestCommitMessage')
-            .returns(Promise.resolve());
-      });
-
-      test('don\'t report changedDisplayed on reply', done => {
-        const changeDisplayStub =
-          sandbox.stub(element.$.reporting, 'changeDisplayed');
-        const changeFullyLoadedStub =
-          sandbox.stub(element.$.reporting, 'changeFullyLoaded');
-        element._handleReplySent();
-        flush(() => {
-          assert.isFalse(changeDisplayStub.called);
-          assert.isFalse(changeFullyLoadedStub.called);
           done();
         });
       });
 
-      test('report changedDisplayed on _paramsChanged', done => {
-        const changeDisplayStub =
-          sandbox.stub(element.$.reporting, 'changeDisplayed');
-        const changeFullyLoadedStub =
-          sandbox.stub(element.$.reporting, 'changeFullyLoaded');
-        element._paramsChanged({
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: 101,
-          project: 'test-project',
-        });
+      test('Show more button is rendered', () => {
+        assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
+        assert.equal(element._robotCommentThreads.length,
+            ROBOT_COMMENTS_LIMIT);
+      });
+
+      test('Clicking show more button renders all comments', done => {
+        MockInteractions.tap(element.shadowRoot.querySelector(
+            '.show-robot-comments'));
         flush(() => {
-          assert.isTrue(changeDisplayStub.called);
-          assert.isTrue(changeFullyLoadedStub.called);
+          assert.equal(element._robotCommentThreads.length, 62);
           done();
         });
       });
     });
   });
+
+  test('reply button is not visible when logged out', () => {
+    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
+    element._loggedIn = true;
+    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+  });
+
+  test('download tap calls _handleOpenDownloadDialog', () => {
+    sandbox.stub(element, '_handleOpenDownloadDialog');
+    element.$.actions.dispatchEvent(
+        new CustomEvent('download-tap', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(element._handleOpenDownloadDialog.called);
+  });
+
+  test('fetches the server config on attached', done => {
+    flush(() => {
+      assert.equal(element._serverConfig.test, 'config');
+      done();
+    });
+  });
+
+  test('_changeStatuses', () => {
+    sandbox.stub(element, 'changeStatuses').returns(
+        ['Merged', 'WIP']);
+    element._loading = false;
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev2: {_number: 2},
+        rev1: {_number: 1},
+        rev13: {_number: 13},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+      labels: {
+        test: {
+          all: [],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    element._mergeable = true;
+    const expectedStatuses = ['Merged', 'WIP'];
+    assert.deepEqual(element._changeStatuses, expectedStatuses);
+    assert.equal(element._changeStatus, expectedStatuses.join(', '));
+    flushAsynchronousOperations();
+    const statusChips = dom(element.root)
+        .querySelectorAll('gr-change-status');
+    assert.equal(statusChips.length, 2);
+  });
+
+  test('diff preferences open when open-diff-prefs is fired', () => {
+    const overlayOpenStub = sandbox.stub(element.$.fileList,
+        'openDiffPrefs');
+    element.$.fileListHeader.dispatchEvent(
+        new CustomEvent('open-diff-prefs', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(overlayOpenStub.called);
+  });
+
+  test('_prepareCommitMsgForLinkify', () => {
+    let commitMessage = 'R=test@google.com';
+    let result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com');
+
+    commitMessage = 'R=test@google.com\nR=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+    commitMessage = 'CC=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'CC=\u200Btest@google.com');
+  }),
+
+  test('_isSubmitEnabled', () => {
+    assert.isFalse(element._isSubmitEnabled({}));
+    assert.isFalse(element._isSubmitEnabled({submit: {}}));
+    assert.isTrue(element._isSubmitEnabled(
+        {submit: {enabled: true}}));
+  });
+
+  test('_reload is called when an approved label is removed', () => {
+    const vote = {_account_id: 1, name: 'bojack', value: 1};
+    element._changeNum = '42';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {email: 'abc@def'},
+      revisions: {
+        rev2: {_number: 2, commit: {parents: []}},
+        rev1: {_number: 1, commit: {parents: []}},
+        rev13: {_number: 13, commit: {parents: []}},
+        rev3: {_number: 3, commit: {parents: []}},
+      },
+      current_revision: 'rev3',
+      status: 'NEW',
+      labels: {
+        test: {
+          all: [vote],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    flushAsynchronousOperations();
+    const reloadStub = sandbox.stub(element, '_reload');
+    element.splice('_change.labels.test.all', 0, 1);
+    assert.isFalse(reloadStub.called);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.approved = vote;
+    flushAsynchronousOperations();
+    element.splice('_change.labels.test.all', 0, 2);
+    assert.isTrue(reloadStub.called);
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  test('reply button has updated count when there are drafts', () => {
+    const getLabel = element._computeReplyButtonLabel;
+
+    assert.equal(getLabel(null, false), 'Reply');
+    assert.equal(getLabel(null, true), 'Start Review');
+
+    const changeRecord = {base: null};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {
+      'file1.txt': [{}],
+      'file2.txt': [{}, {}],
+    };
+    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
+  });
+
+  test('comment events properly update diff drafts', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    const draft = {
+      __draft: true,
+      id: 'id1',
+      path: '/foo/bar.txt',
+      text: 'hello',
+    };
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    draft.patch_set = null;
+    draft.text = 'hello, there';
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    const draft2 = {
+      __draft: true,
+      id: 'id2',
+      path: '/foo/bar.txt',
+      text: 'hola',
+    };
+    element._handleCommentSave({detail: {comment: draft2}});
+    draft2.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+    draft.patch_set = null;
+    element._handleCommentDiscard({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+    element._handleCommentDiscard({detail: {comment: draft2}});
+    assert.deepEqual(element._diffDrafts, {});
+  });
+
+  test('change num change', () => {
+    element._changeNum = null;
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      labels: {},
+    };
+    element.viewState.changeNum = null;
+    element.viewState.diffMode = 'UNIFIED';
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+    element._numFilesShown = 150;
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.numFilesShown, 150);
+
+    element._changeNum = '1';
+    element.params = {changeNum: '1'};
+    element._change.newProp = '1';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '1');
+
+    element._changeNum = '2';
+    element.params = {changeNum: '2'};
+    element._change.newProp = '2';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '2');
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+  });
+
+  test('_setDiffViewMode is called with reset when new change is loaded',
+      () => {
+        sandbox.stub(element, '_setDiffViewMode');
+        element.viewState = {changeNum: 1};
+        element._changeNum = 2;
+        element._resetFileListViewState();
+        assert.isTrue(
+            element._setDiffViewMode.lastCall.calledWithExactly(true));
+      });
+
+  test('diffViewMode is propagated from file list header', () => {
+    element.viewState = {diffMode: 'UNIFIED'};
+    element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
+    assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+  });
+
+  test('diffMode defaults to side by side without preferences', done => {
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({}));
+    // No user prefs or diff view mode set.
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('diffMode defaults to preference when not already set', done => {
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+      done();
+    });
+  });
+
+  test('existing diffMode overrides preference', done => {
+    element.viewState.diffMode = 'SIDE_BY_SIDE';
+    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('don’t reload entire page when patchRange changes', () => {
+    const reloadStub = sandbox.stub(element, '_reload',
+        () => Promise.resolve());
+    const reloadPatchDependentStub = sandbox.stub(element,
+        '_reloadPatchNumDependentResources',
+        () => Promise.resolve());
+    const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value = {
+      view: GerritNav.View.CHANGE,
+      patchNum: '1',
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = '1';
+    value.patchNum = '2';
+    element._paramsChanged(value);
+    assert.isFalse(reloadStub.calledTwice);
+    assert.isTrue(reloadPatchDependentStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('reload entire page when patchRange doesnt change', () => {
+    const reloadStub = sandbox.stub(element, '_reload',
+        () => Promise.resolve());
+    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+    const value = {
+      view: GerritNav.View.CHANGE,
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    element._initialLoadComplete = true;
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledTwice);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('related changes are updated and new patch selected after rebase',
+      done => {
+        element._changeNum = '42';
+        sandbox.stub(element, 'computeLatestPatchNum', () => 1);
+        sandbox.stub(element, '_reload',
+            () => Promise.resolve());
+        const e = {detail: {action: 'rebase'}};
+        element._handleReloadChange(e).then(() => {
+          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+              element._change));
+          done();
+        });
+      });
+
+  test('related changes are not updated after other action', done => {
+    sandbox.stub(element, '_reload', () => Promise.resolve());
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    const e = {detail: {action: 'abandon'}};
+    element._handleReloadChange(e).then(() => {
+      assert.isFalse(navigateToChangeStub.called);
+      done();
+    });
+  });
+
+  test('_computeMergedCommitInfo', () => {
+    const dummyRevs = {
+      1: {commit: {commit: 1}},
+      2: {commit: {}},
+    };
+    assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
+    assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
+        dummyRevs[1].commit);
+
+    // Regression test for issue 5337.
+    const commit = element._computeMergedCommitInfo(2, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2]);
+    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(GerritNav, 'getUrlForChange')
+        .returns('/change/123');
+    assert.equal(
+        element._computeCopyTextForTitle(change),
+        `123: test subject | http://${location.host}/change/123`
+    );
+  });
+
+  test('get latest revision', () => {
+    let change = {
+      revisions: {
+        rev1: {_number: 1},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    change = {
+      revisions: {
+        rev1: {_number: 1},
+      },
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+  });
+
+  test('show commit message edit button', () => {
+    const _change = {
+      status: element.ChangeStatus.MERGED,
+    };
+    assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false,
+        _change));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
+        true));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
+        false));
+  });
+
+  test('_handleCommitMessageSave trims trailing whitespace', () => {
+    const putStub = sandbox.stub(element.$.restAPI, 'putChangeCommitMessage')
+        .returns(Promise.resolve({}));
+
+    const mockEvent = content => { return {detail: {content}}; };
+
+    element._handleCommitMessageSave(mockEvent('test \n  test '));
+    assert.equal(putStub.lastCall.args[1], 'test\n  test');
+
+    element._handleCommitMessageSave(mockEvent('  test\ntest'));
+    assert.equal(putStub.lastCall.args[1], '  test\ntest');
+
+    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
+  });
+
+  test('_computeChangeIdCommitMessageError', () => {
+    let commitMessage =
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+
+    commitMessage = 'This is the greatest change.';
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'missing');
+  });
+
+  test('multiple change Ids in commit message picks last', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join('\n');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('does not count change Id that starts mid line', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join(' and ');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('_computeTitleAttributeWarning', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'No Change-Id in commit message');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'Change-Id mismatch');
+  });
+
+  test('_computeChangeIdClass', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), '');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
+  });
+
+  test('topic is coalesced to null', done => {
+    sandbox.stub(element, '_changeChanged');
+    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+      id: '123456789',
+      labels: {},
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}}},
+    }));
+
+    element._getChangeDetail().then(() => {
+      assert.isNull(element._change.topic);
+      done();
+    });
+  });
+
+  test('commit sha is populated from getChangeDetail', done => {
+    sandbox.stub(element, '_changeChanged');
+    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);
+      done();
+    });
+  });
+
+  test('edit is added to change', () => {
+    sandbox.stub(element, '_changeChanged');
+    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(() => {
+      const revs = element._change.revisions;
+      assert.equal(Object.keys(revs).length, 2);
+      assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
+      assert.deepEqual(revs['bar'], {
+        _number: element.EDIT_NAME,
+        basePatchNum: 1,
+        commit: {commit: 'bar'},
+        fetch: undefined,
+      });
+    });
+  });
+
+  test('_getBasePatchNum', () => {
+    const _change = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [],
+          },
+        },
+      },
+    };
+    const _patchRange = {
+      basePatchNum: 'PARENT',
+    };
+    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+    element._prefs = {
+      default_base_for_merges: 'FIRST_PARENT',
+    };
+
+    const _change2 = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [
+              {
+                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
+                subject: 'test',
+              },
+              {
+                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
+                subject: 'test3',
+              },
+            ],
+          },
+        },
+      },
+    };
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+    _patchRange.patchNum = 1;
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+  });
+
+  test('_openReplyDialog called with `ANY` when coming from tap event',
+      () => {
+        const openStub = sandbox.stub(element, '_openReplyDialog');
+        element._serverConfig = {};
+        MockInteractions.tap(element.$.replyBtn);
+        assert(openStub.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openStub.callCount, 1);
+      });
+
+  test('_openReplyDialog called with `BODY` when coming from message reply' +
+      'event', done => {
+    flush(() => {
+      const openStub = sandbox.stub(element, '_openReplyDialog');
+      element.messagesList.dispatchEvent(
+          new CustomEvent('reply', {
+            detail:
+          {message: {message: 'text'}},
+            composed: true, bubbles: true,
+          }));
+      assert(openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.BODY),
+      '_openReplyDialog should have been passed BODY');
+      assert.equal(openStub.callCount, 1);
+      done();
+    });
+  });
+
+  test('reply dialog focus can be controlled', () => {
+    const FocusTarget = element.$.replyDialog.FocusTarget;
+    const openStub = sandbox.stub(element, '_openReplyDialog');
+
+    const e = {detail: {}};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+        '_openReplyDialog should have been passed REVIEWERS');
+    assert.equal(openStub.callCount, 1);
+
+    e.detail.value = {ccsOnly: true};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
+        '_openReplyDialog should have been passed CCS');
+    assert.equal(openStub.callCount, 2);
+  });
+
+  test('getUrlParameter functionality', () => {
+    const locationStub = sandbox.stub(element, '_getLocationSearch');
+
+    locationStub.returns('?test');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('?test2=12&test=3');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?test2');
+    assert.isNull(element._getUrlParameter('test'));
+  });
+
+  test('revert dialog opened with revert param', done => {
+    sandbox.stub(element.$.restAPI, 'getLoggedIn', () => Promise.resolve(true));
+    sandbox.stub(pluginLoader, 'awaitPluginsLoaded', () => Promise.resolve());
+
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1, commit: {parents: []}},
+        rev2: {_number: 2, commit: {parents: []}},
+      },
+      current_revision: 'rev1',
+      status: element.ChangeStatus.MERGED,
+      labels: {},
+      actions: {},
+    };
+
+    sandbox.stub(element, '_getUrlParameter',
+        param => {
+          assert.equal(param, 'revert');
+          return param;
+        });
+
+    sandbox.stub(element.$.actions, 'showRevertDialog',
+        done);
+
+    element._maybeShowRevertDialog();
+    assert.isTrue(pluginLoader.awaitPluginsLoaded.called);
+  });
+
+  suite('scroll related tests', () => {
+    test('document scrolling calls function to set scroll height', done => {
+      const originalHeight = document.body.scrollHeight;
+      const scrollStub = sandbox.stub(element, '_handleScroll',
+          () => {
+            assert.isTrue(scrollStub.called);
+            document.body.style.height = originalHeight + 'px';
+            scrollStub.restore();
+            done();
+          });
+      document.body.style.height = '10000px';
+      element._handleScroll();
+    });
+
+    test('scrollTop is set correctly', () => {
+      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
+
+      sandbox.stub(element, '_reload', () => {
+        // When element is reloaded, ensure that the history
+        // state has the scrollTop set earlier. This will then
+        // be reset.
+        assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
+        return Promise.resolve({});
+      });
+
+      // simulate reloading component, which is done when route
+      // changes to match a regex of change view type.
+      element._paramsChanged({view: GerritNav.View.CHANGE});
+    });
+
+    test('scrollTop is reset when new change is loaded', () => {
+      element._resetFileListViewState();
+      assert.equal(element.viewState.scrollTop, 0);
+    });
+  });
+
+  suite('reply dialog tests', () => {
+    setup(() => {
+      sandbox.stub(element.$.replyDialog, '_draftChanged');
+      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: true}));
+      element._change = {labels: {}};
+    });
+
+    test('reply from comment adds quote text', () => {
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from comment replaces quote text', () => {
+      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> old quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from same comment preserves quote text', () => {
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from top of page contains previous draft', () => {
+      const div = document.createElement('div');
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {target: div, preventDefault: sandbox.spy()};
+      element._handleReplyTap(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+  });
+
+  test('reply button is disabled until server config is loaded', () => {
+    assert.isTrue(element._replyDisabled);
+    element._serverConfig = {};
+    assert.isFalse(element._replyDisabled);
+  });
+
+  suite('commit message expand/collapse', () => {
+    setup(() => {
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => Promise.resolve({isLatest: false}));
+    });
+
+    test('commitCollapseToggle hidden for short commit message', () => {
+      element._latestCommitMessage = '';
+      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle shown for long commit message', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle functions', () => {
+      element._latestCommitMessage = _.times(35, String).join('\n');
+      assert.isTrue(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isTrue(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+      MockInteractions.tap(element.$.commitCollapseToggleButton);
+      assert.isFalse(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isFalse(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+    });
+  });
+
+  suite('related changes expand/collapse', () => {
+    let updateHeightSpy;
+    setup(() => {
+      updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
+    });
+
+    test('relatedChangesToggle shown height greater than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sandbox.stub(element, '_getOffsetHeight', () => 50);
+          sandbox.stub(element, '_getScrollHeight', () => 60);
+          sandbox.stub(element, '_getLineHeight', () => 5);
+          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isTrue(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle hidden height less than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sandbox.stub(element, '_getOffsetHeight', () => 50);
+          sandbox.stub(element, '_getScrollHeight', () => 40);
+          sandbox.stub(element, '_getLineHeight', () => 5);
+          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle functions', () => {
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
+      element._relatedChangesLoading = false;
+      assert.isTrue(element._relatedChangesCollapsed);
+      assert.isTrue(
+          element.$.relatedChanges.classList.contains('collapsed'));
+      MockInteractions.tap(element.$.relatedChangesToggleButton);
+      assert.isFalse(element._relatedChangesCollapsed);
+      assert.isFalse(
+          element.$.relatedChanges.classList.contains('collapsed'));
+    });
+
+    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      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).
+      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '12px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '');
+    });
+
+    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      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).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '48px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '2px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
+
+      element._updateRelatedChangeMaxHeight();
+
+      // 400 (new height) % 12 (line height) = 4 (remainder).
+      // 400 (new height) - 4 (remainder) = 396.
+
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '396px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sandbox.stub(element, '_getOffsetHeight', () => 50);
+      sandbox.stub(element, '_getLineHeight', () => 12);
+      sandbox.stub(window, 'matchMedia', () => {
+        if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
+          return {matches: true};
+        } else {
+          return {matches: false};
+        }
+      });
+
+      // 100 (new height) % 12 (line height) = 4 (remainder).
+      // 100 (new height) - 4 (remainder) = 96.
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '96px');
+    });
+
+    suite('update checks', () => {
+      setup(() => {
+        sandbox.spy(element, '_startUpdateCheckTimer');
+        sandbox.stub(element, 'async', f => {
+          // Only fire the async callback one time.
+          if (element.async.callCount > 1) { return; }
+          f.call(element);
+        });
+      });
+
+      test('_startUpdateCheckTimer negative delay', () => {
+        sandbox.stub(element, 'fetchChangeUpdates');
+
+        element._serverConfig = {change: {update_delay: -1}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isFalse(element.fetchChangeUpdates.called);
+      });
+
+      test('_startUpdateCheckTimer up-to-date', () => {
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => Promise.resolve({isLatest: true}));
+
+        element._serverConfig = {change: {update_delay: 12345}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isTrue(element.fetchChangeUpdates.called);
+        assert.equal(element.async.lastCall.args[1], 12345 * 1000);
+      });
+
+      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => Promise.resolve({isLatest: false}));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'A newer patch set has been uploaded');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new status shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates')
+            .returns(Promise.resolve({
+              isLatest: true,
+              newStatus: element.ChangeStatus.MERGED,
+            }));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'This change has been merged');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new messages shows an alert', done => {
+        sandbox.stub(element, 'fetchChangeUpdates')
+            .returns(Promise.resolve({
+              isLatest: true,
+              newMessages: true,
+            }));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'There are new messages on this change');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+    });
+
+    test('canStartReview computation', () => {
+      const change1 = {};
+      const change2 = {
+        actions: {
+          ready: {
+            enabled: true,
+          },
+        },
+      };
+      const change3 = {
+        actions: {
+          ready: {
+            label: 'Ready for Review',
+          },
+        },
+      };
+      assert.isFalse(element._computeCanStartReview(change1));
+      assert.isTrue(element._computeCanStartReview(change2));
+      assert.isFalse(element._computeCanStartReview(change3));
+    });
+  });
+
+  test('header class computation', () => {
+    assert.equal(element._computeHeaderClass(), 'header');
+    assert.equal(element._computeHeaderClass(true), 'header editMode');
+  });
+
+  test('_maybeScrollToMessage', done => {
+    flush(() => {
+      const scrollStub = sandbox.stub(element.messagesList,
+          'scrollToMessage');
+
+      element._maybeScrollToMessage('');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('message');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('#message-TEST');
+      assert.isTrue(scrollStub.called);
+      assert.equal(scrollStub.lastCall.args[0], 'TEST');
+      done();
+    });
+  });
+
+  test('topic update reloads related changes', () => {
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    element.dispatchEvent(new CustomEvent('topic-changed'));
+    assert.isTrue(element.$.relatedChanges.reload.calledOnce);
+  });
+
+  test('_computeEditMode', () => {
+    const callCompute = (range, params) =>
+      element._computeEditMode({base: range}, {base: params});
+    assert.isFalse(callCompute({}, {}));
+    assert.isTrue(callCompute({}, {edit: true}));
+    assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
+    assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
+    assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
+  });
+
+  test('_processEdit', () => {
+    element._patchRange = {};
+    const change = {
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
+    };
+    let mockChange;
+
+    // With no edit, mockChange should be unmodified.
+    element._processEdit(mockChange = _.cloneDeep(change), null);
+    assert.deepEqual(mockChange, change);
+
+    // When edit is not based on the latest PS, current_revision should be
+    // unmodified.
+    const edit = {
+      base_patch_set_number: 1,
+      commit: {commit: 'bar'},
+      fetch: true,
+    };
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.revisions.bar._number, element.EDIT_NAME);
+    assert.equal(mockChange.current_revision, change.current_revision);
+    assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
+    assert.notOk(mockChange.revisions.bar.actions);
+
+    edit.base_revision = 'foo';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.current_revision, 'bar');
+    assert.deepEqual(mockChange.revisions.bar.actions,
+        mockChange.revisions.foo.actions);
+
+    // If _patchRange.patchNum is defined, do not load edit.
+    element._patchRange.patchNum = 'baz';
+    change.current_revision = 'baz';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.equal(element._patchRange.patchNum, 'baz');
+    assert.notOk(mockChange.revisions.bar.actions);
+  });
+
+  test('file-action-tap handling', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const fileList = element.$.fileList;
+    const Actions = GrEditConstants.Actions;
+    const controls = element.$.fileListHeader.$.editControls;
+    sandbox.stub(controls, 'openDeleteDialog');
+    sandbox.stub(controls, 'openRenameDialog');
+    sandbox.stub(controls, 'openRestoreDialog');
+    sandbox.stub(GerritNav, 'getEditUrlForDiff');
+    sandbox.stub(GerritNav, 'navigateToRelativeUrl');
+
+    // Delete
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.DELETE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openDeleteDialog.called);
+    assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
+
+    // Restore
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RESTORE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRestoreDialog.called);
+    assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
+
+    // Rename
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RENAME.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRenameDialog.called);
+    assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
+
+    // Open
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.OPEN.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(GerritNav.getEditUrlForDiff.called);
+    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[1], 'foo');
+    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[2], '1');
+    assert.isTrue(GerritNav.navigateToRelativeUrl.called);
+  });
+
+  test('_selectedRevision updates when patchNum is changed', () => {
+    const revision1 = {_number: 1, commit: {parents: []}};
+    const revision2 = {_number: 2, commit: {parents: []}};
+    sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
+        Promise.resolve({
+          revisions: {
+            aaa: revision1,
+            bbb: revision2,
+          },
+          labels: {},
+          actions: {},
+          current_revision: 'bbb',
+          change_id: 'loremipsumdolorsitamet',
+        }));
+    sandbox.stub(element, '_getEdit').returns(Promise.resolve());
+    sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
+    element._patchRange = {patchNum: '2'};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision2);
+
+      element.set('_patchRange.patchNum', '1');
+      assert.strictEqual(element._selectedRevision, revision1);
+    });
+  });
+
+  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};
+    element._mergeable = true;
+    const showStub = sandbox.stub(element.$.jsAPI, 'handleEvent');
+    element._sendShowChangeEvent();
+    assert.isTrue(showStub.calledOnce);
+    assert.equal(
+        showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
+    assert.deepEqual(showStub.lastCall.args[1], {
+      change: {labels: {}},
+      patchNum: 4,
+      info: {mergeable: true},
+    });
+  });
+
+  suite('_handleEditTap', () => {
+    let fireEdit;
+
+    setup(() => {
+      fireEdit = () => {
+        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+      };
+      navigateToChangeStub.restore();
+
+      element._change = {revisions: {rev1: {_number: 1}}};
+    });
+
+    test('edit exists in revisions', done => {
+      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 2);
+        assert.equal(args[1], element.EDIT_NAME); // patchNum
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: element.EDIT_NAME});
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, non-latest patchset', done => {
+      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 4);
+        assert.equal(args[1], 1); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 1};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, latest patchset', done => {
+      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 4);
+        // No patch should be specified when patchNum == latest.
+        assert.isNotOk(args[1]); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 2};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+  });
+
+  test('_handleStopEditTap', done => {
+    sandbox.stub(element.$.metadata, '_computeLabelNames');
+    navigateToChangeStub.restore();
+    sandbox.stub(element, 'computeLatestPatchNum').returns(1);
+    sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
+      assert.equal(args.length, 2);
+      assert.equal(args[1], 1); // patchNum
+      done();
+    });
+
+    element._patchRange = {patchNum: 1};
+    element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
+        {bubbles: false}));
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element._change = {labels: {}};
+      element._selectedRevision = {};
+      let hookEl;
+      let plugin;
+      pluginApi.install(
+          p => {
+            plugin = p;
+            plugin.hook('change-view-integration').getLastAttached()
+                .then(
+                    el => hookEl = el);
+          },
+          '0.1',
+          'http://some/plugins/url.html');
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element._change);
+        assert.strictEqual(hookEl.revision, element._selectedRevision);
+        done();
+      });
+    });
+  });
+
+  suite('_getMergeability', () => {
+    let getMergeableStub;
+
+    setup(() => {
+      element._change = {labels: {}};
+      getMergeableStub = sandbox.stub(element.$.restAPI, 'getMergeable')
+          .returns(Promise.resolve({mergeable: true}));
+    });
+
+    test('merged change', () => {
+      element._mergeable = null;
+      element._change.status = element.ChangeStatus.MERGED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('abandoned change', () => {
+      element._mergeable = null;
+      element._change.status = element.ChangeStatus.ABANDONED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('open change', () => {
+      element._mergeable = null;
+      return element._getMergeability().then(() => {
+        assert.isTrue(element._mergeable);
+        assert.isTrue(getMergeableStub.called);
+      });
+    });
+  });
+
+  test('_paramsChanged sets in projectLookup', () => {
+    sandbox.stub(element.$.relatedChanges, 'reload');
+    sandbox.stub(element, '_reload').returns(Promise.resolve());
+    const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+    element._paramsChanged({
+      view: GerritNav.View.CHANGE,
+      changeNum: 101,
+      project: 'test-project',
+    });
+    assert.isTrue(setStub.calledOnce);
+    assert.isTrue(setStub.calledWith(101, 'test-project'));
+  });
+
+  test('_handleToggleStar called when star is tapped', () => {
+    element._change = {
+      owner: {_account_id: 1},
+      starred: false,
+    };
+    element._loggedIn = true;
+    const stub = sandbox.stub(element, '_handleToggleStar');
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.$.changeStar.shadowRoot
+        .querySelector('button'));
+    assert.isTrue(stub.called);
+  });
+
+  suite('gr-reporting tests', () => {
+    setup(() => {
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve());
+      sandbox.stub(element, '_getProjectConfig').returns(Promise.resolve());
+      sandbox.stub(element, '_reloadComments').returns(Promise.resolve());
+      sandbox.stub(element, '_getMergeability').returns(Promise.resolve());
+      sandbox.stub(element, '_getLatestCommitMessage')
+          .returns(Promise.resolve());
+    });
+
+    test('don\'t report changedDisplayed on reply', done => {
+      const changeDisplayStub =
+        sandbox.stub(element.$.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sandbox.stub(element.$.reporting, 'changeFullyLoaded');
+      element._handleReplySent();
+      flush(() => {
+        assert.isFalse(changeDisplayStub.called);
+        assert.isFalse(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+
+    test('report changedDisplayed on _paramsChanged', done => {
+      const changeDisplayStub =
+        sandbox.stub(element.$.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sandbox.stub(element.$.reporting, 'changeFullyLoaded');
+      element._paramsChanged({
+        view: GerritNav.View.CHANGE,
+        changeNum: 101,
+        project: 'test-project',
+      });
+      flush(() => {
+        assert.isTrue(changeDisplayStub.called);
+        assert.isTrue(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
deleted file mode 100644
index a7e65a3..0000000
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ /dev/null
@@ -1,93 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<!--
-  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
-  width of formatted text blocks that are not code.
--->
-
-<dom-module id="gr-comment-list">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        word-wrap: break-word;
-      }
-      .file {
-        border-top: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-        margin: 10px 0 3px;
-        padding: 10px 0 5px;
-      }
-      .container {
-        display: flex;
-        margin: var(--spacing-m) 0;
-      }
-      .lineNum {
-        margin-right: var(--spacing-m);
-        min-width: 10em;
-        text-align: right;
-      }
-      .message {
-        flex: 1;
-        --gr-formatted-text-prose-max-width: 80ch;
-      }
-      @media screen and (max-width: 50em) {
-        .container {
-          flex-direction: column;
-          margin: 0 0 var(--spacing-m) var(--spacing-m);
-        }
-        .lineNum {
-          min-width: initial;
-          text-align: left;
-        }
-      }
-    </style>
-    <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
-      <div class="file">[[computeDisplayPath(file)]]:</div>
-      <template is="dom-repeat"
-                items="[[_computeCommentsForFile(comments, file)]]" as="comment">
-        <div class="container">
-          <a class="lineNum"
-             href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]">
-             <span hidden$="[[!comment.line]]">
-               <span>[[_computePatchDisplayName(comment)]]</span>
-               Line <span>[[comment.line]]</span>:
-             </span>
-             <span hidden$="[[comment.line]]">
-               File comment:
-             </span>
-          </a>
-          <gr-formatted-text
-              class="message"
-              no-trailing-margin
-              content="[[comment.message]]"
-              config="[[projectConfig.commentlinks]]"></gr-formatted-text>
-        </div>
-      </template>
-    </template>
-  </template>
-  <script src="gr-comment-list.js"></script>
-</dom-module>
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..7ca9d6b 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,57 +14,97 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
-  Polymer({
-    is: 'gr-comment-list',
+/*
+  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
+  width of formatted text blocks that are not code.
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-formatted-text/gr-formatted-text.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment-list_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrCommentList extends mixinBehaviors( [
+  BaseUrlBehavior,
+  PathListBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-comment-list'; }
+
+  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);
-    },
+  _computeFilesFromComments(comments) {
+    const arr = Object.keys(comments || {});
+    return arr.sort(this.specialFilePathCompare);
+  }
 
-    _isOnParent(comment) {
-      return comment.side === 'PARENT';
-    },
+  _isOnParent(comment) {
+    return comment.side === 'PARENT';
+  }
 
-    _computeDiffLineURL(file, changeNum, patchNum, comment) {
-      const basePatchNum = comment.hasOwnProperty('parent') ?
-        -comment.parent : null;
-      return Gerrit.Nav.getUrlForDiffById(this.changeNum, this.projectName,
-          file, patchNum, basePatchNum, comment.line,
-          this._isOnParent(comment));
-    },
+  _computeDiffURL(filePath, changeNum, allComments) {
+    if ([filePath, changeNum, allComments].some(arg => arg === undefined)) {
+      return;
+    }
+    const fileComments = this._computeCommentsForFile(allComments, filePath);
+    // This can happen for files that don't exist anymore in the current ps.
+    if (fileComments.length === 0) return;
+    return GerritNav.getUrlForDiffById(changeNum, this.projectName,
+        filePath, fileComments[0].patch_set);
+  }
 
-    _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();
-    },
+  _computeDiffLineURL(filePath, changeNum, patchNum, comment) {
+    const basePatchNum = comment.hasOwnProperty('parent') ?
+      -comment.parent : null;
+    return GerritNav.getUrlForDiffById(changeNum, this.projectName,
+        filePath, patchNum, basePatchNum, comment.line,
+        this._isOnParent(comment));
+  }
 
-    _computePatchDisplayName(comment) {
-      if (this._isOnParent(comment)) {
-        return 'Base, ';
-      }
-      if (comment.patch_set != this.patchNum) {
-        return `PS${comment.patch_set}, `;
-      }
-      return '';
-    },
-  });
-})();
+  _computeCommentsForFile(comments, filePath) {
+    // 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[filePath] || []).slice();
+  }
+
+  _computePatchDisplayName(comment) {
+    if (this._isOnParent(comment)) {
+      return 'Base, ';
+    }
+    if (comment.patch_set != this.patchNum) {
+      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_html.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
new file mode 100644
index 0000000..60b83ee
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      word-wrap: break-word;
+    }
+    .file {
+      padding: var(--spacing-s) 0;
+    }
+    .container {
+      display: flex;
+      padding: var(--spacing-s) 0;
+    }
+    .lineNum {
+      margin-right: var(--spacing-s);
+      min-width: 135px;
+      text-align: right;
+    }
+    .message {
+      flex: 1;
+      --gr-formatted-text-prose-max-width: 80ch;
+    }
+    @media screen and (max-width: 50em) {
+      .container {
+        flex-direction: column;
+      }
+      .lineNum {
+        margin-right: 0;
+        min-width: initial;
+        text-align: left;
+      }
+    }
+  </style>
+  <template
+    is="dom-repeat"
+    items="[[_computeFilesFromComments(comments)]]"
+    as="file"
+  >
+    <div class="file">
+      <a class="fileLink" href="[[_computeDiffURL(file, changeNum, comments)]]"
+        >[[computeDisplayPath(file)]]</a
+      >
+    </div>
+    <template
+      is="dom-repeat"
+      items="[[_computeCommentsForFile(comments, file)]]"
+      as="comment"
+    >
+      <div class="container">
+        <a
+          class="lineNum"
+          href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]"
+        >
+          <span hidden$="[[!comment.line]]">
+            <span>[[_computePatchDisplayName(comment)]]</span>
+            Line <span>[[comment.line]]</span>
+          </span>
+          <span hidden$="[[comment.line]]">
+            File comment:
+          </span>
+        </a>
+        <gr-formatted-text
+          class="message"
+          no-trailing-margin=""
+          content="[[comment.message]]"
+          config="[[projectConfig.commentlinks]]"
+        ></gr-formatted-text>
+      </div>
+    </template>
+  </template>
+`;
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..075b883 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-comment-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,97 +31,102 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-comment-list tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-comment-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
-    });
+suite('gr-comment-list tests', () => {
+  let element;
+  let sandbox;
 
-    teardown(() => { sandbox.restore(); });
-
-    test('_computeFilesFromComments w/ special file path sorting', () => {
-      const comments = {
-        'file_b.html': [],
-        'file_c.css': [],
-        'file_a.js': [],
-        'test.cc': [],
-        'test.h': [],
-      };
-      const expected = [
-        'file_a.js',
-        'file_b.html',
-        'file_c.css',
-        'test.h',
-        'test.cc',
-      ];
-      const actual = element._computeFilesFromComments(comments);
-      assert.deepEqual(actual, expected);
-
-      assert.deepEqual(element._computeFilesFromComments(null), []);
-    });
-
-    test('_computePatchDisplayName', () => {
-      const comment = {line: 123, side: 'REVISION', patch_set: 10};
-
-      element.patchNum = 10;
-      assert.equal(element._computePatchDisplayName(comment), '');
-
-      element.patchNum = 9;
-      assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
-
-      comment.side = 'PARENT';
-      assert.equal(element._computePatchDisplayName(comment), 'Base, ');
-    });
-
-    test('config commentlinks propagate to formatted text', () => {
-      element.comments = {
-        'test.h': [{
-          author: {name: 'foo'},
-          patch_set: 4,
-          line: 10,
-          updated: '2017-10-30 20:48:40.000000000',
-          message: 'Ideadbeefdeadbeef',
-          unresolved: true,
-        }],
-      };
-      element.projectConfig = {
-        commentlinks: {foo: {link: '#/q/$1', match: '(I[0-9a-f]{8,40})'}},
-      };
-      flushAsynchronousOperations();
-      const formattedText = Polymer.dom(element.root).querySelector(
-          'gr-formatted-text.message');
-      assert.isOk(formattedText.config);
-      assert.deepEqual(formattedText.config,
-          element.projectConfig.commentlinks);
-    });
-
-    test('_computeDiffLineURL', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
-      element.projectName = 'proj';
-      element.changeNum = 123;
-
-      const comment = {line: 456};
-      element._computeDiffLineURL('foo.cc', 123, 4, comment);
-      assert.isTrue(getUrlStub.calledOnce);
-      assert.deepEqual(getUrlStub.lastCall.args,
-          [123, 'proj', 'foo.cc', 4, null, 456, false]);
-
-      comment.side = 'PARENT';
-      element._computeDiffLineURL('foo.cc', 123, 4, comment);
-      assert.isTrue(getUrlStub.calledTwice);
-      assert.deepEqual(getUrlStub.lastCall.args,
-          [123, 'proj', 'foo.cc', 4, null, 456, true]);
-
-      comment.parent = 12;
-      element._computeDiffLineURL('foo.cc', 123, 4, comment);
-      assert.isTrue(getUrlStub.calledThrice);
-      assert.deepEqual(getUrlStub.lastCall.args,
-          [123, 'proj', 'foo.cc', 4, -12, 456, true]);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(GerritNav, 'mapCommentlinks', x => x);
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_computeFilesFromComments w/ special file path sorting', () => {
+    const comments = {
+      'file_b.html': [],
+      'file_c.css': [],
+      'file_a.js': [],
+      'test.cc': [],
+      'test.h': [],
+    };
+    const expected = [
+      'file_a.js',
+      'file_b.html',
+      'file_c.css',
+      'test.h',
+      'test.cc',
+    ];
+    const actual = element._computeFilesFromComments(comments);
+    assert.deepEqual(actual, expected);
+
+    assert.deepEqual(element._computeFilesFromComments(null), []);
+  });
+
+  test('_computePatchDisplayName', () => {
+    const comment = {line: 123, side: 'REVISION', patch_set: 10};
+
+    element.patchNum = 10;
+    assert.equal(element._computePatchDisplayName(comment), '');
+
+    element.patchNum = 9;
+    assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
+
+    comment.side = 'PARENT';
+    assert.equal(element._computePatchDisplayName(comment), 'Base, ');
+  });
+
+  test('config commentlinks propagate to formatted text', () => {
+    element.comments = {
+      'test.h': [{
+        author: {name: 'foo'},
+        patch_set: 4,
+        line: 10,
+        updated: '2017-10-30 20:48:40.000000000',
+        message: 'Ideadbeefdeadbeef',
+        unresolved: true,
+      }],
+    };
+    element.projectConfig = {
+      commentlinks: {foo: {link: '#/q/$1', match: '(I[0-9a-f]{8,40})'}},
+    };
+    flushAsynchronousOperations();
+    const formattedText = dom(element.root).querySelector(
+        'gr-formatted-text.message');
+    assert.isOk(formattedText.config);
+    assert.deepEqual(formattedText.config,
+        element.projectConfig.commentlinks);
+  });
+
+  test('_computeDiffLineURL', () => {
+    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
+    element.projectName = 'proj';
+    element.changeNum = 123;
+
+    const comment = {line: 456};
+    element._computeDiffLineURL('foo.cc', 123, 4, comment);
+    assert.isTrue(getUrlStub.calledOnce);
+    assert.deepEqual(getUrlStub.lastCall.args,
+        [123, 'proj', 'foo.cc', 4, null, 456, false]);
+
+    comment.side = 'PARENT';
+    element._computeDiffLineURL('foo.cc', 123, 4, comment);
+    assert.isTrue(getUrlStub.calledTwice);
+    assert.deepEqual(getUrlStub.lastCall.args,
+        [123, 'proj', 'foo.cc', 4, null, 456, true]);
+
+    comment.parent = 12;
+    element._computeDiffLineURL('foo.cc', 123, 4, comment);
+    assert.isTrue(getUrlStub.calledThrice);
+    assert.deepEqual(getUrlStub.lastCall.args,
+        [123, 'proj', 'foo.cc', 4, -12, 456, true]);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
deleted file mode 100644
index 902bf41..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
+++ /dev/null
@@ -1,47 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-
-<dom-module id="gr-commit-info">
-  <template>
-    <style include="shared-styles">
-      .container {
-        align-items: center;
-        display: flex;
-      }
-    </style>
-    <div class="container">
-      <template is="dom-if" if="[[_showWebLink]]">
-        <a target="_blank" rel="noopener"
-            href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
-      </template>
-      <template is="dom-if" if="[[!_showWebLink]]">
-        [[_computeShortHash(commitInfo)]]
-      </template>
-      <gr-copy-clipboard
-          has-tooltip
-          button-title="Copy full SHA to clipboard"
-          hide-input
-          text="[[commitInfo.commit]]">
-      </gr-copy-clipboard>
-    </div>
-  </template>
-  <script src="gr-commit-info.js"></script>
-</dom-module>
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..4dba3af 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
@@ -14,13 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-commit-info',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-commit-info_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrCommitInfo extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-commit-info'; }
+
+  static get properties() {
+    return {
       change: Object,
       /** @type {?} */
       commitInfo: Object,
@@ -33,42 +46,44 @@
         type: String,
         computed: '_computeWebLink(change, commitInfo, serverConfig)',
       },
-    },
+    };
+  }
 
-    _getWeblink(change, commitInfo, config) {
-      return Gerrit.Nav.getPatchSetWeblink(
-          change.project,
-          commitInfo.commit,
-          {
-            weblinks: commitInfo.web_links,
-            config,
-          });
-    },
+  _getWeblink(change, commitInfo, config) {
+    return GerritNav.getPatchSetWeblink(
+        change.project,
+        commitInfo.commit,
+        {
+          weblinks: commitInfo.web_links,
+          config,
+        });
+  }
 
-    _computeShowWebLink(change, commitInfo, serverConfig) {
-      // Polymer 2: check for undefined
-      if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
-        return undefined;
-      }
+  _computeShowWebLink(change, commitInfo, serverConfig) {
+    // Polymer 2: check for undefined
+    if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-      const weblink = this._getWeblink(change, commitInfo, serverConfig);
-      return !!weblink && !!weblink.url;
-    },
+    const weblink = this._getWeblink(change, commitInfo, serverConfig);
+    return !!weblink && !!weblink.url;
+  }
 
-    _computeWebLink(change, commitInfo, serverConfig) {
-      // Polymer 2: check for undefined
-      if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
-        return undefined;
-      }
+  _computeWebLink(change, commitInfo, serverConfig) {
+    // Polymer 2: check for undefined
+    if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-      const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
-      return url;
-    },
+    const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
+    return url;
+  }
 
-    _computeShortHash(commitInfo) {
-      const {name} =
-            this._getWeblink(this.change, commitInfo, this.serverConfig) || {};
-      return name;
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
new file mode 100644
index 0000000..608d12b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .container {
+      align-items: center;
+      display: flex;
+    }
+  </style>
+  <div class="container">
+    <template is="dom-if" if="[[_showWebLink]]">
+      <a target="_blank" rel="noopener" href$="[[_webLink]]"
+        >[[_computeShortHash(commitInfo)]]</a
+      >
+    </template>
+    <template is="dom-if" if="[[!_showWebLink]]">
+      [[_computeShortHash(commitInfo)]]
+    </template>
+    <gr-copy-clipboard
+      has-tooltip=""
+      button-title="Copy full SHA to clipboard"
+      hide-input=""
+      text="[[commitInfo.commit]]"
+    >
+    </gr-copy-clipboard>
+  </div>
+`;
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..d9664ec 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../core/gr-router/gr-router.html">
-<link rel="import" href="gr-commit-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,105 +31,109 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-commit-info tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-commit-info.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+suite('gr-commit-info tests', () => {
+  let element;
+  let sandbox;
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('weblinks use Gerrit.Nav interface', () => {
-      const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-          .returns([{name: 'stubb', url: '#s'}]);
-      element.change = {};
-      element.commitInfo = {};
-      element.serverConfig = {};
-      assert.isTrue(weblinksStub.called);
-    });
-
-    test('no web link when unavailable', () => {
-      element.commitInfo = {};
-      element.serverConfig = {};
-      element.change = {labels: [], project: ''};
-
-      assert.isNotOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-    });
-
-    test('use web link when available', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.change = {labels: [], project: ''};
-      element.commitInfo =
-          {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'link-url');
-    });
-
-    test('does not relativize web links that begin with scheme', () => {
-      const router = document.createElement('gr-router');
-      sandbox.stub(Gerrit.Nav, '_generateWeblinks',
-          router._generateWeblinks.bind(router));
-
-      element.change = {labels: [], project: ''};
-      element.commitInfo = {
-        commit: 'commitsha',
-        web_links: [{name: 'gitweb', url: 'https://link-url'}],
-      };
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          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',
-          router._generateWeblinks.bind(router));
-
-      element.change = {project: 'project-name'};
-      element.commitInfo = {
-        commit: 'commit-sha',
-        web_links: [
-          {
-            name: 'ignore',
-            url: 'ignore',
-          },
-          {
-            name: 'gitiles',
-            url: 'https://link-url',
-          },
-        ],
-      };
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'https://link-url');
-
-      // Remove gitiles link.
-      element.commitInfo.web_links.splice(1, 1);
-      assert.isNotOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sandbox.stub(GerritNav, '_generateWeblinks')
+        .returns([{name: 'stubb', url: '#s'}]);
+    element.change = {};
+    element.commitInfo = {};
+    element.serverConfig = {};
+    assert.isTrue(weblinksStub.called);
+  });
+
+  test('no web link when unavailable', () => {
+    element.commitInfo = {};
+    element.serverConfig = {};
+    element.change = {labels: [], project: ''};
+
+    assert.isNotOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+  });
+
+  test('use web link when available', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(GerritNav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.change = {labels: [], project: ''};
+    element.commitInfo =
+        {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'link-url');
+  });
+
+  test('does not relativize web links that begin with scheme', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(GerritNav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.change = {labels: [], project: ''};
+    element.commitInfo = {
+      commit: 'commitsha',
+      web_links: [{name: 'gitweb', url: 'https://link-url'}],
+    };
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'https://link-url');
+  });
+
+  test('ignore web links that are neither gitweb nor gitiles', () => {
+    const router = document.createElement('gr-router');
+    sandbox.stub(GerritNav, '_generateWeblinks',
+        router._generateWeblinks.bind(router));
+
+    element.change = {project: 'project-name'};
+    element.commitInfo = {
+      commit: 'commit-sha',
+      web_links: [
+        {
+          name: 'ignore',
+          url: 'ignore',
+        },
+        {
+          name: 'gitiles',
+          url: 'https://link-url',
+        },
+      ],
+    };
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'https://link-url');
+
+    // Remove gitiles link.
+    element.commitInfo.web_links.splice(1, 1);
+    assert.isNotOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig));
+  });
+});
 </script>
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
deleted file mode 100644
index 05a2bb2..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ /dev/null
@@ -1,75 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<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">
-
-<dom-module id="gr-confirm-abandon-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      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);
-        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
-        confirm-label="Abandon"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Abandon Change</div>
-      <div class="main" slot="main">
-        <label for="messageInput">Abandon Message</label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            placeholder="<Insert reasoning here>"
-            bind-value="{{message}}"></iron-autogrow-textarea>
-      </div>
-    </gr-dialog>
-  </template>
-  <script src="gr-confirm-abandon-dialog.js"></script>
-</dom-module>
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..d28e2b7 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
@@ -14,59 +14,81 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  Polymer({
-    is: 'gr-confirm-abandon-dialog',
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-abandon-dialog_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmAbandonDialog extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-confirm-abandon-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
       message: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
+  get keyBindings() {
+    return {
       'ctrl+enter meta+enter': '_handleEnterKey',
-    },
+    };
+  }
 
-    resetFocus() {
-      this.$.messageInput.textarea.focus();
-    },
+  resetFocus() {
+    this.$.messageInput.textarea.focus();
+  }
 
-    _handleEnterKey(e) {
-      this._confirm();
-    },
+  _handleEnterKey(e) {
+    this._confirm();
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this._confirm();
-    },
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this._confirm();
+  }
 
-    _confirm() {
-      this.fire('confirm', {reason: this.message}, {bubbles: false});
-    },
+  _confirm() {
+    this.dispatchEvent(new CustomEvent('confirm', {
+      detail: {reason: this.message},
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
-  });
-})();
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, bubbles: false,
+    }));
+  }
+}
+
+customElements.define(GrConfirmAbandonDialog.is, GrConfirmAbandonDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
new file mode 100644
index 0000000..050df25
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    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="Abandon"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Abandon Change</div>
+    <div class="main" slot="main">
+      <label for="messageInput">Abandon Message</label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        placeholder="<Insert reasoning here>"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+`;
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..8010814 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-abandon-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,43 +31,53 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-abandon-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-abandon-dialog.js';
+suite('gr-confirm-abandon-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_handleConfirmTap', () => {
-      const confirmHandler = sandbox.stub();
-      element.addEventListener('confirm', confirmHandler);
-      sandbox.spy(element, '_handleConfirmTap');
-      sandbox.spy(element, '_confirm');
-      element.$$('gr-dialog').fire('confirm');
-      assert.isTrue(confirmHandler.called);
-      assert.isTrue(confirmHandler.calledOnce);
-      assert.isTrue(element._handleConfirmTap.called);
-      assert.isTrue(element._confirm.called);
-      assert.isTrue(element._confirm.called);
-      assert.isTrue(element._confirm.calledOnce);
-    });
-
-    test('_handleCancelTap', () => {
-      const cancelHandler = sandbox.stub();
-      element.addEventListener('cancel', cancelHandler);
-      sandbox.spy(element, '_handleCancelTap');
-      element.$$('gr-dialog').fire('cancel');
-      assert.isTrue(cancelHandler.called);
-      assert.isTrue(cancelHandler.calledOnce);
-      assert.isTrue(element._handleCancelTap.called);
-      assert.isTrue(element._handleCancelTap.calledOnce);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sandbox.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sandbox.spy(element, '_handleConfirmTap');
+    sandbox.spy(element, '_confirm');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._confirm.called);
+    assert.isTrue(element._confirm.called);
+    assert.isTrue(element._confirm.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sandbox.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sandbox.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html
deleted file mode 100644
index b9e9155..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.html
+++ /dev/null
@@ -1,52 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.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">
-
-<dom-module id="gr-confirm-cherrypick-conflict-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-    </style>
-    <gr-dialog
-        confirm-label="Continue"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Cherry Pick Conflict!</div>
-      <div class="main" slot="main">
-        <span>Cherry Pick failed! (merge conflicts)</span>
-
-        <span>Please select "Continue" to continue with conflicts or select "cancel" to close the dialog.</span>
-      </div>
-    </gr-dialog>
-  </template>
-  <script src="gr-confirm-cherrypick-conflict-dialog.js"></script>
-</dom-module>
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..480e6cf 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
@@ -14,38 +14,52 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-confirm-cherrypick-conflict-dialog',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html.js';
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmCherrypickConflictDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+  static get is() { return 'gr-confirm-cherrypick-conflict-dialog'; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    },
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
-  });
-})();
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {
+      composed: true, bubbles: false,
+    }));
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, 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_html.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
new file mode 100644
index 0000000..c7fb70c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Continue"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Cherry Pick Conflict!</div>
+    <div class="main" slot="main">
+      <span>Cherry Pick failed! (merge conflicts)</span>
+
+      <span
+        >Please select "Continue" to continue with conflicts or select "cancel"
+        to close the dialog.</span
+      >
+    </div>
+  </gr-dialog>
+`;
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..e0016f0 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-cherrypick-conflict-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,38 +31,48 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-cherrypick-conflict-dialog.js';
+suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('_handleConfirmTap', () => {
-      const confirmHandler = sandbox.stub();
-      element.addEventListener('confirm', confirmHandler);
-      sandbox.spy(element, '_handleConfirmTap');
-      element.$$('gr-dialog').fire('confirm');
-      assert.isTrue(confirmHandler.called);
-      assert.isTrue(confirmHandler.calledOnce);
-      assert.isTrue(element._handleConfirmTap.called);
-      assert.isTrue(element._handleConfirmTap.calledOnce);
-    });
-
-    test('_handleCancelTap', () => {
-      const cancelHandler = sandbox.stub();
-      element.addEventListener('cancel', cancelHandler);
-      sandbox.spy(element, '_handleCancelTap');
-      element.$$('gr-dialog').fire('cancel');
-      assert.isTrue(cancelHandler.called);
-      assert.isTrue(cancelHandler.calledOnce);
-      assert.isTrue(element._handleCancelTap.called);
-      assert.isTrue(element._handleCancelTap.calledOnce);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sandbox.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sandbox.spy(element, '_handleConfirmTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._handleConfirmTap.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sandbox.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sandbox.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+});
 </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
deleted file mode 100644
index 8ddcd83..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ /dev/null
@@ -1,108 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.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-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-confirm-cherrypick-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      label {
-        cursor: pointer;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      .main label,
-      .main input[type="text"] {
-        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;
-        };
-      }
-    </style>
-    <gr-dialog
-        confirm-label="Cherry Pick"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Cherry Pick Change to Another Branch</div>
-      <div class="main" slot="main">
-        <label for="branchInput">
-          Cherry Pick to branch
-        </label>
-        <gr-autocomplete
-            id="branchInput"
-            text="{{branch}}"
-            query="[[_query]]"
-            placeholder="Destination branch">
-        </gr-autocomplete>
-        <label for="baseInput">
-          Provide base commit sha1 for cherry-pick
-        </label>
-        <iron-input
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}">
-          <input
-              is="iron-input"
-              id="baseCommitInput"
-              maxlength="40"
-              placeholder="(optional)"
-              bind-value="{{baseCommit}}">
-        </iron-input>
-        <label for="messageInput">
-          Cherry Pick Commit Message
-        </label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            rows="4"
-            max-rows="15"
-            bind-value="{{message}}"></iron-autogrow-textarea>
-      </div>
-    </gr-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-cherrypick-dialog.js"></script>
-</dom-module>
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..2802046 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
@@ -14,105 +14,297 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const SUGGESTIONS_LIMIT = 15;
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-confirm-cherrypick-dialog',
+const SUGGESTIONS_LIMIT = 15;
+const CHANGE_SUBJECT_LIMIT = 50;
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmCherrypickDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-confirm-cherrypick-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
-      branch: String,
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
+      branch: {
+        type: String,
+        observer: '_updateBranch',
+      },
       baseCommit: String,
       changeStatus: String,
       commitMessage: String,
       commitNum: String,
       message: String,
       project: String,
+      changes: Array,
       _query: {
         type: Function,
         value() {
           return this._getProjectBranchesSuggestions.bind(this);
         },
       },
-    },
+      _showCherryPickTopic: {
+        type: Boolean,
+        value: false,
+      },
+      _changesCount: Number,
+      _cherryPickType: {
+        type: Number,
+        value: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+      },
+      _duplicateProjectChanges: {
+        type: Boolean,
+        value: false,
+      },
+      // Status of each change that is being cherry picked together
+      _statuses: Object,
+      _invalidBranch: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_computeMessage(changeStatus, commitNum, commitMessage)',
-    ],
+    ];
+  }
 
-    _computeMessage(changeStatus, commitNum, commitMessage) {
-      // Polymer 2: check for undefined
-      if ([
-        changeStatus,
-        commitNum,
-        commitMessage,
-      ].some(arg => arg === undefined)) {
-        return;
+  updateChanges(changes) {
+    this.changes = changes;
+    this._statuses = {};
+    const projects = {};
+    this._duplicateProjectChanges = false;
+    changes.forEach(change => {
+      if (projects[change.project]) {
+        this._duplicateProjectChanges = true;
       }
+      projects[change.project] = true;
+    });
+    this._changesCount = changes.length;
+    this._showCherryPickTopic = changes.length > 1;
+  }
 
-      let newMessage = commitMessage;
+  _updateBranch(branch) {
+    const invalidChars = [',', ' '];
+    this._invalidBranch = branch && invalidChars.some(c => branch.includes(c));
+  }
 
-      if (changeStatus === 'MERGED') {
-        newMessage += '(cherry picked from commit ' + commitNum + ')';
-      }
-      this.message = newMessage;
-    },
+  _computeTopicErrorMessage(duplicateProjectChanges) {
+    if (duplicateProjectChanges) {
+      return 'Two changes cannot be of the same project';
+    }
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    },
+  updateStatus(change, status) {
+    this._statuses = Object.assign({}, this._statuses, {[change.id]: status});
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
+  _computeStatus(change, statuses) {
+    if (!change || !statuses || !statuses[change.id]) return 'NOT STARTED';
+    return statuses[change.id].status;
+  }
 
-    resetFocus() {
-      this.$.branchInput.focus();
-    },
+  _computeStatusClass(change, statuses) {
+    if (!change || !statuses || !statuses[change.id]) return '';
+    return statuses[change.id].status === 'FAILED' ? 'error': '';
+  }
 
-    _getProjectBranchesSuggestions(input) {
-      if (input.startsWith('refs/heads/')) {
-        input = input.substring('refs/heads/'.length);
-      }
-      return this.$.restAPI.getRepoBranches(
-          input, this.project, SUGGESTIONS_LIMIT).then(response => {
-        const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!response.hasOwnProperty(key)) { continue; }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
+  _computeError(change, statuses) {
+    if (!change || !statuses || !statuses[change.id]) return '';
+    if (statuses[change.id].status === 'FAILED') {
+      return statuses[change.id].msg;
+    }
+  }
+
+  _getChangeId(change) {
+    return change.change_id.substring(0, 10);
+  }
+
+  _getTrimmedChangeSubject(subject) {
+    if (!subject) return '';
+    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+  }
+
+  _computeCancelLabel(statuses) {
+    const isRunningChange = Object.values(statuses).
+        some(v => v.status === 'RUNNING');
+    return isRunningChange ? 'Close' : 'Cancel';
+  }
+
+  _computeDisableCherryPick(cherryPickType, duplicateProjectChanges,
+      statuses) {
+    const duplicateProject = (cherryPickType === CHERRY_PICK_TYPES.TOPIC) &&
+      duplicateProjectChanges;
+    if (duplicateProject) return true;
+    if (!statuses) return false;
+    const isRunningChange = Object.values(statuses).
+        some(v => v.status === 'RUNNING');
+    return isRunningChange;
+  }
+
+  _computeIfSinglecherryPick(cherryPickType) {
+    return cherryPickType === CHERRY_PICK_TYPES.SINGLE_CHANGE;
+  }
+
+  _computeIfCherryPickTopic(cherryPickType) {
+    return cherryPickType === CHERRY_PICK_TYPES.TOPIC;
+  }
+
+  _handlecherryPickSingleChangeClicked(e) {
+    this._cherryPickType = CHERRY_PICK_TYPES.SINGLE_CHANGE;
+  }
+
+  _handlecherryPickTopicClicked(e) {
+    this._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
+  }
+
+  _computeMessage(changeStatus, commitNum, commitMessage) {
+    // Polymer 2: check for undefined
+    if ([
+      changeStatus,
+      commitNum,
+      commitMessage,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    let newMessage = commitMessage;
+
+    if (changeStatus === 'MERGED') {
+      newMessage += '(cherry picked from commit ' + commitNum + ')';
+    }
+    this.message = newMessage;
+  }
+
+  _generateRandomCherryPickTopic(change) {
+    const randomString = Math.random().toString(36)
+        .substr(2, 10);
+    const message = `cherrypick-${change.topic}-${randomString}`;
+    return message;
+  }
+
+  _handleCherryPickFailed(change, response) {
+    response.text().then(errText => {
+      this.updateStatus(change,
+          {status: 'FAILED', msg: errText});
+    });
+  }
+
+  _handleCherryPickTopic() {
+    const topic = this._generateRandomCherryPickTopic(
+        this.changes[0]);
+    this.changes.forEach(change => {
+      this.updateStatus(change,
+          {status: 'RUNNING'});
+      const payload = {
+        destination: this.branch,
+        base: null,
+        topic,
+        allow_conflicts: true,
+        allow_empty: true,
+      };
+      const handleError = response => {
+        this._handleCherryPickFailed(change, response);
+      };
+      const patchNum = change.revisions[change.current_revision]._number;
+      this.$.restAPI.executeChangeAction(change._number, 'POST', '/cherrypick',
+          patchNum, payload, handleError).then(response => {
+        this.updateStatus(change, {status: 'SUCCESSFUL'});
+        const failedOrPending = Object.values(this._statuses).find(
+            v => v.status !== 'SUCCESSFUL');
+        if (!failedOrPending) {
+          /* This needs some more work, as the new topic may not always be
+          created, instead we may end up creating a new patchset */
+          GerritNav.navigateToSearchQuery(`topic: "${topic}"`);
         }
-        return branches;
       });
-    },
-  });
-})();
+    });
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this._cherryPickType === CHERRY_PICK_TYPES.TOPIC) {
+      this.$.reporting.reportInteraction('cherry-pick-topic-clicked');
+      this._handleCherryPickTopic();
+      return;
+    }
+    // Cherry pick single change
+    this.dispatchEvent(new CustomEvent('confirm', {
+      composed: true, bubbles: false,
+    }));
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, bubbles: false,
+    }));
+  }
+
+  resetFocus() {
+    this.$.branchInput.focus();
+  }
+
+  _getProjectBranchesSuggestions(input) {
+    if (input.startsWith('refs/heads/')) {
+      input = input.substring('refs/heads/'.length);
+    }
+    return this.$.restAPI.getRepoBranches(
+        input, this.project, SUGGESTIONS_LIMIT).then(response => {
+      const branches = [];
+      let branch;
+      for (const key in response) {
+        if (!response.hasOwnProperty(key)) { continue; }
+        if (response[key].ref.startsWith('refs/heads/')) {
+          branch = response[key].ref.substring('refs/heads/'.length);
+        } else {
+          branch = response[key].ref;
+        }
+        branches.push({
+          name: branch,
+        });
+      }
+      return branches;
+    });
+  }
+}
+
+customElements.define(GrConfirmCherrypickDialog.is,
+    GrConfirmCherrypickDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
new file mode 100644
index 0000000..aeb8061
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
@@ -0,0 +1,220 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    .main label,
+    .main input[type='text'] {
+      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. */
+    }
+    .cherryPickTopicLayout {
+      display: flex;
+    }
+    .cherryPickSingleChange,
+    .cherryPickTopic {
+      margin-left: var(--spacing-m);
+      margin-bottom: var(--spacing-m);
+    }
+    .cherry-pick-topic-message {
+      margin-bottom: var(--spacing-m);
+    }
+    label[for='messageInput'],
+    label[for='baseInput'] {
+      margin-top: var(--spacing-m);
+    }
+    .title {
+      font-weight: var(--font-weight-bold);
+    }
+    tr > td {
+      padding: var(--spacing-m);
+    }
+    th {
+      color: var(--deemphasized-text-color);
+    }
+    table {
+      border-collapse: collapse;
+    }
+    tr {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .error {
+      color: var(--error-text-color);
+    }
+    .error-message {
+      color: var(--error-text-color);
+      margin: var(--spacing-m) 0 var(--spacing-m) 0;
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Cherry Pick"
+    cancel-label="[[_computeCancelLabel(_statuses)]]"
+    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses)]]"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header title" slot="header">
+      Cherry Pick Change to Another Branch
+    </div>
+    <div class="main" slot="main">
+      <template is="dom-if" if="[[_showCherryPickTopic]]">
+        <div class="cherryPickTopicLayout">
+          <input
+            name="cherryPickOptions"
+            type="radio"
+            id="cherryPickSingleChange"
+            on-change="_handlecherryPickSingleChangeClicked"
+            checked=""
+          />
+          <label for="cherryPickSingleChange" class="cherryPickSingleChange">
+            Cherry Pick single change
+          </label>
+        </div>
+        <div class="cherryPickTopicLayout">
+          <input
+            name="cherryPickOptions"
+            type="radio"
+            id="cherryPickTopic"
+            on-change="_handlecherryPickTopicClicked"
+          />
+          <label for="cherryPickTopic" class="cherryPickTopic">
+            Cherry Pick entire topic ([[_changesCount]] Changes)
+          </label>
+        </div></template
+      >
+
+      <label for="branchInput">
+        Cherry Pick to branch
+      </label>
+      <gr-autocomplete
+        id="branchInput"
+        text="{{branch}}"
+        query="[[_query]]"
+        placeholder="Destination branch"
+      >
+      </gr-autocomplete>
+      <template is="dom-if" if="[[_invalidBranch]]">
+        <span class="error"> Branch name cannot contain space or commas. </span>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
+      >
+        <label for="baseInput">
+          Provide base commit sha1 for cherry-pick
+        </label>
+        <iron-input
+          maxlength="40"
+          placeholder="(optional)"
+          bind-value="{{baseCommit}}"
+        >
+          <input
+            is="iron-input"
+            id="baseCommitInput"
+            maxlength="40"
+            placeholder="(optional)"
+            bind-value="{{baseCommit}}"
+          />
+        </iron-input>
+        <label for="messageInput">
+          Cherry Pick Commit Message
+        </label>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
+      >
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          rows="4"
+          max-rows="15"
+          bind-value="{{message}}"
+        ></iron-autogrow-textarea>
+      </template>
+      <template is="dom-if" if="[[_computeIfCherryPickTopic(_cherryPickType)]]">
+        <span class="error-message"
+          >[[_computeTopicErrorMessage(_duplicateProjectChanges)]]</span
+        >
+        <span class="cherry-pick-topic-message">
+          Commit Message will be auto generated
+        </span>
+        <table>
+          <thead>
+            <tr>
+              <th>Change</th>
+              <th>Subject</th>
+              <th>Project</th>
+              <th>Status</th>
+              <!-- Error Message -->
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[changes]]">
+              <tr>
+                <td><span> [[_getChangeId(item)]] </span></td>
+                <td>
+                  <span> [[_getTrimmedChangeSubject(item.subject)]] </span>
+                </td>
+                <td><span> [[item.project]] </span></td>
+                <td>
+                  <span class$="[[_computeStatusClass(item, _statuses)]]">
+                    [[_computeStatus(item, _statuses)]]
+                  </span>
+                </td>
+                <td>
+                  <span class="error">
+                    [[_computeError(item, _statuses)]]
+                  </span>
+                </td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
+  </gr-dialog>
+  <gr-reporting
+    id="reporting"
+    category="confirm-cherry-pick-dialog"
+  ></gr-reporting>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..000718b 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-cherrypick-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,84 +31,155 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-cherrypick-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-cherrypick-dialog.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getRepoBranches(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                ref: 'refs/heads/test-branch',
-                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-                can_delete: true,
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+suite('gr-confirm-cherrypick-dialog tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
+    });
+    element = fixture('basic');
+    element.project = 'test-project';
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('with merged change', () => {
+    element.changeStatus = 'MERGED';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flushAsynchronousOperations();
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with unmerged change', () => {
+    element.changeStatus = 'OPEN';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flushAsynchronousOperations();
+    const expectedMessage = 'message\n';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with updated commit message', () => {
+    element.changeStatus = 'OPEN';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flushAsynchronousOperations();
+    assert.equal(element.message, myNewMessage);
+  });
+
+  test('_getProjectBranchesSuggestions empty', done => {
+    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
+    });
+  });
+
+  suite('cherry pick topic', () => {
+    const changes = [
+      {
+        change_id: '12345678901234', topic: 'T', subject: 'random',
+        project: 'A',
+        _number: 1,
+        revisions: {
+          a: {_number: 1},
         },
-      });
-      element = fixture('basic');
-      element.project = 'test-project';
+        current_revision: 'a',
+      },
+      {
+        change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
+        project: 'B',
+        _number: 2,
+        revisions: {
+          a: {_number: 1},
+        },
+        current_revision: 'a',
+      },
+    ];
+    setup(() => {
+      element.updateChanges(changes);
+      element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
     });
 
-    teardown(() => { sandbox.restore(); });
-
-    test('with merged change', () => {
-      element.changeStatus = 'MERGED';
-      element.commitMessage = 'message\n';
-      element.commitNum = '123';
+    test('cherry pick topic submit', done => {
       element.branch = 'master';
-      flushAsynchronousOperations();
-      const expectedMessage = 'message\n(cherry picked from commit 123)';
-      assert.equal(element.message, expectedMessage);
-    });
-
-    test('with unmerged change', () => {
-      element.changeStatus = 'OPEN';
-      element.commitMessage = 'message\n';
-      element.commitNum = '123';
-      element.branch = 'master';
-      flushAsynchronousOperations();
-      const expectedMessage = 'message\n';
-      assert.equal(element.message, expectedMessage);
-    });
-
-    test('with updated commit message', () => {
-      element.changeStatus = 'OPEN';
-      element.commitMessage = 'message\n';
-      element.commitNum = '123';
-      element.branch = 'master';
-      const myNewMessage = 'updated commit message';
-      element.message = myNewMessage;
-      flushAsynchronousOperations();
-      assert.equal(element.message, myNewMessage);
-    });
-
-    test('_getProjectBranchesSuggestions empty', done => {
-      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-        assert.equal(branches.length, 0);
+      const executeChangeActionStub = sandbox.stub(element.$.restAPI,
+          'executeChangeAction').returns(Promise.resolve([]));
+      MockInteractions.tap(element.shadowRoot.
+          querySelector('gr-dialog').$.confirm);
+      flush(() => {
+        const args = executeChangeActionStub.args[0];
+        assert.equal(args[0], 1);
+        assert.equal(args[1], 'POST');
+        assert.equal(args[2], '/cherrypick');
+        assert.equal(args[4].destination, 'master');
+        assert.isTrue(args[4].allow_conflicts);
+        assert.isTrue(args[4].allow_empty);
         done();
       });
     });
 
-    test('resetFocus', () => {
-      const focusStub = sandbox.stub(element.$.branchInput, 'focus');
-      element.resetFocus();
-      assert.isTrue(focusStub.called);
+    test('_computeStatusClass', () => {
+      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
+      }), '');
+      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'FAILED'}}
+      ), 'error');
     });
 
-    test('_getProjectBranchesSuggestions non-empty', done => {
-      element._getProjectBranchesSuggestions('test-branch').then(branches => {
-        assert.equal(branches.length, 1);
-        assert.equal(branches[0].name, 'test-branch');
+    test('submit button is blocked while cherry picks is running', done => {
+      console.log(element);
+      const confirmButton = element.shadowRoot.querySelector('gr-dialog').$
+          .confirm;
+      assert.isFalse(confirmButton.hasAttribute('disabled'));
+      element.updateStatus(changes[0], {status: 'RUNNING'});
+      flush(() => {
+        assert.isTrue(confirmButton.hasAttribute('disabled'));
         done();
       });
     });
   });
+
+  test('resetFocus', () => {
+    const focusStub = sandbox.stub(element.$.branchInput, 'focus');
+    element.resetFocus();
+    assert.isTrue(focusStub.called);
+  });
+
+  test('_getProjectBranchesSuggestions non-empty', done => {
+    element._getProjectBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+});
 </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
deleted file mode 100644
index 24c1132..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
+++ /dev/null
@@ -1,93 +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.
--->
-
-<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="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-confirm-move-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        width: 30em;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      label {
-        cursor: pointer;
-      }
-      iron-autogrow-textarea {
-        padding: 0;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      .main label,
-      .main input[type="text"] {
-        display: block;
-        width: 100%;
-      }
-      .main .message {
-        width: 100%;
-      }
-      .warning {
-        color: var(--error-text-color);
-      }
-    </style>
-    <gr-dialog
-        confirm-label="Move Change"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Move Change to Another Branch</div>
-      <div class="main" slot="main">
-        <p class="warning">
-          Warning: moving a change will not change its parents.
-        </p>
-        <label for="branchInput">
-          Move change to branch
-        </label>
-        <gr-autocomplete
-            id="branchInput"
-            text="{{branch}}"
-            query="[[_query]]"
-            placeholder="Destination branch">
-        </gr-autocomplete>
-        <label for="messageInput">
-          Move Change Message
-        </label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            rows="4"
-            max-rows="15"
-            bind-value="{{message}}"></iron-autogrow-textarea>
-      </div>
-    </gr-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-move-dialog.js"></script>
-</dom-module>
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..25beb2d 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
@@ -14,27 +14,47 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  const SUGGESTIONS_LIMIT = 15;
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-move-dialog_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  Polymer({
-    is: 'gr-confirm-move-dialog',
+const SUGGESTIONS_LIMIT = 15;
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmMoveDialog extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-confirm-move-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
       branch: String,
       message: String,
       project: String,
@@ -44,45 +64,53 @@
           return this._getProjectBranchesSuggestions.bind(this);
         },
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  get keyBindings() {
+    return {
+      'ctrl+enter meta+enter': '_handleConfirmTap',
+    };
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    },
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _getProjectBranchesSuggestions(input) {
-      if (input.startsWith('refs/heads/')) {
-        input = input.substring('refs/heads/'.length);
-      }
-      return this.$.restAPI.getRepoBranches(
-          input, this.project, SUGGESTIONS_LIMIT).then(response => {
-        const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!response.hasOwnProperty(key)) { continue; }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
+  _getProjectBranchesSuggestions(input) {
+    if (input.startsWith('refs/heads/')) {
+      input = input.substring('refs/heads/'.length);
+    }
+    return this.$.restAPI.getRepoBranches(
+        input, this.project, SUGGESTIONS_LIMIT).then(response => {
+      const branches = [];
+      let branch;
+      for (const key in response) {
+        if (!response.hasOwnProperty(key)) { continue; }
+        if (response[key].ref.startsWith('refs/heads/')) {
+          branch = response[key].ref.substring('refs/heads/'.length);
+        } else {
+          branch = response[key].ref;
         }
-        return branches;
-      });
-    },
-  });
-})();
+        branches.push({
+          name: branch,
+        });
+      }
+      return branches;
+    });
+  }
+}
+
+customElements.define(GrConfirmMoveDialog.is, GrConfirmMoveDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
new file mode 100644
index 0000000..f5ddf41
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 30em;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    .main label,
+    .main input[type='text'] {
+      display: block;
+      width: 100%;
+    }
+    .main .message {
+      width: 100%;
+    }
+    .warning {
+      color: var(--error-text-color);
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Move Change"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Move Change to Another Branch</div>
+    <div class="main" slot="main">
+      <p class="warning">
+        Warning: moving a change will not change its parents.
+      </p>
+      <label for="branchInput">
+        Move change to branch
+      </label>
+      <gr-autocomplete
+        id="branchInput"
+        text="{{branch}}"
+        query="[[_query]]"
+        placeholder="Destination branch"
+      >
+      </gr-autocomplete>
+      <label for="messageInput">
+        Move Change Message
+      </label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        rows="4"
+        max-rows="15"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..a8392aa 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-move-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,51 +31,53 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-move-dialog tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-move-dialog.js';
+suite('gr-confirm-move-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getRepoBranches(input) {
-          if (input.startsWith('test')) {
-            return Promise.resolve([
-              {
-                ref: 'refs/heads/test-branch',
-                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-                can_delete: true,
-              },
-            ]);
-          } else {
-            return Promise.resolve({});
-          }
-        },
-      });
-      element = fixture('basic');
-      element.project = 'test-project';
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
     });
+    element = fixture('basic');
+    element.project = 'test-project';
+  });
 
-    test('with updated commit message', () => {
-      element.branch = 'master';
-      const myNewMessage = 'updated commit message';
-      element.message = myNewMessage;
-      flushAsynchronousOperations();
-      assert.equal(element.message, myNewMessage);
-    });
+  test('with updated commit message', () => {
+    element.branch = 'master';
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flushAsynchronousOperations();
+    assert.equal(element.message, myNewMessage);
+  });
 
-    test('_getProjectBranchesSuggestions empty', done => {
-      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-        assert.equal(branches.length, 0);
-        done();
-      });
-    });
-
-    test('_getProjectBranchesSuggestions non-empty', done => {
-      element._getProjectBranchesSuggestions('test-branch').then(branches => {
-        assert.equal(branches.length, 1);
-        assert.equal(branches[0].name, 'test-branch');
-        done();
-      });
+  test('_getProjectBranchesSuggestions empty', done => {
+    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
     });
   });
+
+  test('_getProjectBranchesSuggestions non-empty', done => {
+    element._getProjectBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
deleted file mode 100644
index cf2721a..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ /dev/null
@@ -1,119 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-rebase-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        width: 30em;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      label {
-        cursor: pointer;
-      }
-      .message {
-        font-style: italic;
-      }
-      .parentRevisionContainer label,
-      .parentRevisionContainer input[type="text"] {
-        display: block;
-        width: 100%;
-      }
-      .parentRevisionContainer label {
-        margin-bottom: var(--spacing-xs);
-      }
-      .rebaseOption {
-        margin: var(--spacing-m) 0;
-      }
-    </style>
-    <gr-dialog
-        id="confirmDialog"
-        confirm-label="Rebase"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Confirm rebase</div>
-      <div class="main" slot="main">
-        <div id="rebaseOnParent" class="rebaseOption"
-            hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]">
-          <input id="rebaseOnParentInput"
-              name="rebaseOptions"
-              type="radio"
-              on-click="_handleRebaseOnParent">
-          <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
-            Rebase on parent change
-          </label>
-        </div>
-        <div id="parentUpToDateMsg" class="message"
-            hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]">
-          This change is up to date with its parent.
-        </div>
-        <div id="rebaseOnTip" class="rebaseOption"
-            hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]">
-          <input id="rebaseOnTipInput"
-              name="rebaseOptions"
-              type="radio"
-              disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-              on-click="_handleRebaseOnTip">
-          <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
-            Rebase on top of the [[branch]]
-            branch<span hidden$="[[!hasParent]]">
-              (breaks relation chain)
-            </span>
-          </label>
-        </div>
-        <div id="tipUpToDateMsg" class="message"
-            hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]">
-          Change is up to date with the target branch already ([[branch]])
-        </div>
-        <div id="rebaseOnOther" class="rebaseOption">
-          <input id="rebaseOnOtherInput"
-              name="rebaseOptions"
-              type="radio"
-              on-click="_handleRebaseOnOther">
-          <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-            Rebase on a specific change, ref, or commit <span hidden$="[[!hasParent]]">
-              (breaks relation chain)
-            </span>
-          </label>
-        </div>
-        <div class="parentRevisionContainer">
-          <gr-autocomplete
-              id="parentInput"
-              query="[[_query]]"
-              no-debounce
-              text="{{_text}}"
-              on-click="_handleEnterChangeNumberClick"
-              allow-non-suggested-values
-              placeholder="Change number, ref, or commit hash">
-          </gr-autocomplete>
-        </div>
-      </div>
-    </gr-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-rebase-dialog.js"></script>
-</dom-module>
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..e451034 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
@@ -14,25 +14,38 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-confirm-rebase-dialog',
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-rebase-dialog_html.js';
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/** @extends Polymer.Element */
+class GrConfirmRebaseDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-confirm-rebase-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
       branch: String,
       changeNumber: Number,
       hasParent: Boolean,
@@ -45,118 +58,122 @@
         },
       },
       _recentChanges: Array,
-    },
+    };
+  }
 
-    observers: [
+  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
-    // updated input, this one gets all recent changes once and then filters
-    // them by the input. The query is re-run each time the dialog is opened
-    // in case there are new/updated changes in the generic query since the
-    // last time it was run.
-    fetchRecentChanges() {
-      return this.$.restAPI.getChanges(null, `is:open -age:90d`)
-          .then(response => {
-            const changes = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              changes.push({
-                name: `${response[key]._number}: ${response[key].subject}`,
-                value: response[key]._number,
-              });
-            }
-            this._recentChanges = changes;
-            return this._recentChanges;
-          });
-    },
+  // This is called by gr-change-actions every time the rebase dialog is
+  // re-opened. Unlike other autocompletes that make a request with each
+  // updated input, this one gets all recent changes once and then filters
+  // them by the input. The query is re-run each time the dialog is opened
+  // in case there are new/updated changes in the generic query since the
+  // last time it was run.
+  fetchRecentChanges() {
+    return this.$.restAPI.getChanges(null, `is:open -age:90d`)
+        .then(response => {
+          const changes = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            changes.push({
+              name: `${response[key]._number}: ${response[key].subject}`,
+              value: response[key]._number,
+            });
+          }
+          this._recentChanges = changes;
+          return this._recentChanges;
+        });
+  }
 
-    _getRecentChanges() {
-      if (this._recentChanges) {
-        return Promise.resolve(this._recentChanges);
-      }
-      return this.fetchRecentChanges();
-    },
+  _getRecentChanges() {
+    if (this._recentChanges) {
+      return Promise.resolve(this._recentChanges);
+    }
+    return this.fetchRecentChanges();
+  }
 
-    _getChangeSuggestions(input) {
-      return this._getRecentChanges().then(changes =>
-        this._filterChanges(input, changes));
-    },
+  _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);
-    },
+  _filterChanges(input, changes) {
+    return changes.filter(change => change.name.includes(input) &&
+        change.value !== this.changeNumber);
+  }
 
-    _displayParentOption(rebaseOnCurrent, hasParent) {
-      return hasParent && rebaseOnCurrent;
-    },
+  _displayParentOption(rebaseOnCurrent, hasParent) {
+    return hasParent && rebaseOnCurrent;
+  }
 
-    _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
-      return hasParent && !rebaseOnCurrent;
-    },
+  _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
+    return hasParent && !rebaseOnCurrent;
+  }
 
-    _displayTipOption(rebaseOnCurrent, hasParent) {
-      return !(!rebaseOnCurrent && !hasParent);
-    },
+  _displayTipOption(rebaseOnCurrent, hasParent) {
+    return !(!rebaseOnCurrent && !hasParent);
+  }
 
-    /**
-     * There is a subtle but important difference between setting the base to an
-     * empty string and omitting it entirely from the payload. An empty string
-     * implies that the parent should be cleared and the change should be
-     * rebased on top of the target branch. Leaving out the base implies that it
-     * should be rebased on top of its current parent.
-     */
-    _getSelectedBase() {
-      if (this.$.rebaseOnParentInput.checked) { return null; }
-      if (this.$.rebaseOnTipInput.checked) { return ''; }
-      // Change numbers will have their description appended by the
-      // autocomplete.
-      return this._text.split(':')[0];
-    },
+  /**
+   * There is a subtle but important difference between setting the base to an
+   * empty string and omitting it entirely from the payload. An empty string
+   * implies that the parent should be cleared and the change should be
+   * rebased on top of the target branch. Leaving out the base implies that it
+   * should be rebased on top of its current parent.
+   */
+  _getSelectedBase() {
+    if (this.$.rebaseOnParentInput.checked) { return null; }
+    if (this.$.rebaseOnTipInput.checked) { return ''; }
+    // Change numbers will have their description appended by the
+    // autocomplete.
+    return this._text.split(':')[0];
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('confirm',
-          {detail: {base: this._getSelectedBase()}}));
-      this._text = '';
-    },
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm',
+        {detail: {base: this._getSelectedBase()}}));
+    this._text = '';
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('cancel'));
-      this._text = '';
-    },
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel'));
+    this._text = '';
+  }
 
-    _handleRebaseOnOther() {
-      this.$.parentInput.focus();
-    },
+  _handleRebaseOnOther() {
+    this.$.parentInput.focus();
+  }
 
-    _handleEnterChangeNumberClick() {
+  _handleEnterChangeNumberClick() {
+    this.$.rebaseOnOtherInput.checked = true;
+  }
+
+  /**
+   * Sets the default radio button based on the state of the app and
+   * the corresponding value to be submitted.
+   */
+  _updateSelectedOption(rebaseOnCurrent, hasParent) {
+    // Polymer 2: check for undefined
+    if ([rebaseOnCurrent, hasParent].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
+      this.$.rebaseOnParentInput.checked = true;
+    } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
+      this.$.rebaseOnTipInput.checked = true;
+    } else {
       this.$.rebaseOnOtherInput.checked = true;
-    },
+    }
+  }
+}
 
-    /**
-     * Sets the default radio button based on the state of the app and
-     * the corresponding value to be submitted.
-     */
-    _updateSelectedOption(rebaseOnCurrent, hasParent) {
-      // Polymer 2: check for undefined
-      if ([rebaseOnCurrent, hasParent].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
-        this.$.rebaseOnParentInput.checked = true;
-      } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
-        this.$.rebaseOnTipInput.checked = true;
-      } 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_html.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
new file mode 100644
index 0000000..e9a8424
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 30em;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+    }
+    .message {
+      font-style: italic;
+    }
+    .parentRevisionContainer label,
+    .parentRevisionContainer input[type='text'] {
+      display: block;
+      width: 100%;
+    }
+    .parentRevisionContainer label {
+      margin-bottom: var(--spacing-xs);
+    }
+    .rebaseOption {
+      margin: var(--spacing-m) 0;
+    }
+  </style>
+  <gr-dialog
+    id="confirmDialog"
+    confirm-label="Rebase"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Confirm rebase</div>
+    <div class="main" slot="main">
+      <div
+        id="rebaseOnParent"
+        class="rebaseOption"
+        hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
+      >
+        <input
+          id="rebaseOnParentInput"
+          name="rebaseOptions"
+          type="radio"
+          on-click="_handleRebaseOnParent"
+        />
+        <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
+          Rebase on parent change
+        </label>
+      </div>
+      <div
+        id="parentUpToDateMsg"
+        class="message"
+        hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]"
+      >
+        This change is up to date with its parent.
+      </div>
+      <div
+        id="rebaseOnTip"
+        class="rebaseOption"
+        hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
+      >
+        <input
+          id="rebaseOnTipInput"
+          name="rebaseOptions"
+          type="radio"
+          disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
+          on-click="_handleRebaseOnTip"
+        />
+        <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
+          Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
+            (breaks relation chain)
+          </span>
+        </label>
+      </div>
+      <div
+        id="tipUpToDateMsg"
+        class="message"
+        hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]"
+      >
+        Change is up to date with the target branch already ([[branch]])
+      </div>
+      <div id="rebaseOnOther" class="rebaseOption">
+        <input
+          id="rebaseOnOtherInput"
+          name="rebaseOptions"
+          type="radio"
+          on-click="_handleRebaseOnOther"
+        />
+        <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
+          Rebase on a specific change, ref, or commit
+          <span hidden$="[[!hasParent]]">
+            (breaks relation chain)
+          </span>
+        </label>
+      </div>
+      <div class="parentRevisionContainer">
+        <gr-autocomplete
+          id="parentInput"
+          query="[[_query]]"
+          no-debounce=""
+          text="{{_text}}"
+          on-click="_handleEnterChangeNumberClick"
+          allow-non-suggested-values=""
+          placeholder="Change number, ref, or commit hash"
+        >
+        </gr-autocomplete>
+      </div>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..080a7e0 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-rebase-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,159 +31,174 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-rebase-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-rebase-dialog.js';
+suite('gr-confirm-rebase-dialog tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('controls with parent and rebase on current available', () => {
+    element.rebaseOnCurrent = true;
+    element.hasParent = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnParentInput.checked);
+    assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls with parent rebase on current not available', () => {
+    element.rebaseOnCurrent = false;
+    element.hasParent = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnTipInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls without parent and rebase on current available', () => {
+    element.rebaseOnCurrent = true;
+    element.hasParent = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnTipInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls without parent rebase on current not available', () => {
+    element.rebaseOnCurrent = false;
+    element.hasParent = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnOtherInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('input cleared on cancel or submit', () => {
+    element._text = '123';
+    element.$.confirmDialog.dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true, bubbles: true,
+        }));
+    assert.equal(element._text, '');
+
+    element._text = '123';
+    element.$.confirmDialog.dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true, bubbles: true,
+        }));
+    assert.equal(element._text, '');
+  });
+
+  test('_getSelectedBase', () => {
+    element._text = '5fab321c';
+    element.$.rebaseOnParentInput.checked = true;
+    assert.equal(element._getSelectedBase(), null);
+    element.$.rebaseOnParentInput.checked = false;
+    element.$.rebaseOnTipInput.checked = true;
+    assert.equal(element._getSelectedBase(), '');
+    element.$.rebaseOnTipInput.checked = false;
+    assert.equal(element._getSelectedBase(), element._text);
+    element._text = '101: Test';
+    assert.equal(element._getSelectedBase(), '101');
+  });
+
+  suite('parent suggestions', () => {
+    let recentChanges;
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+      recentChanges = [
+        {
+          name: '123: my first awesome change',
+          value: 123,
+        },
+        {
+          name: '124: my second awesome change',
+          value: 124,
+        },
+        {
+          name: '245: my third awesome change',
+          value: 245,
+        },
+      ];
+
+      sandbox.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
+          [
+            {
+              _number: 123,
+              subject: 'my first awesome change',
+            },
+            {
+              _number: 124,
+              subject: 'my second awesome change',
+            },
+            {
+              _number: 245,
+              subject: 'my third awesome change',
+            },
+          ]
+      ));
     });
 
-    teardown(() => {
-      sandbox.restore();
+    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);
+          });
     });
 
-    test('controls with parent and rebase on current available', () => {
-      element.rebaseOnCurrent = true;
-      element.hasParent = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnParentInput.checked);
-      assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    test('_filterChanges', () => {
+      assert.equal(element._filterChanges('123', recentChanges).length, 1);
+      assert.equal(element._filterChanges('12', recentChanges).length, 2);
+      assert.equal(element._filterChanges('awesome', recentChanges).length,
+          3);
+      assert.equal(element._filterChanges('third', recentChanges).length,
+          1);
+
+      element.changeNumber = 123;
+      assert.equal(element._filterChanges('123', recentChanges).length, 0);
+      assert.equal(element._filterChanges('124', recentChanges).length, 1);
+      assert.equal(element._filterChanges('awesome', recentChanges).length,
+          2);
     });
 
-    test('controls with parent rebase on current not available', () => {
-      element.rebaseOnCurrent = false;
-      element.hasParent = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnTipInput.checked);
-      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-    });
-
-    test('controls without parent and rebase on current available', () => {
-      element.rebaseOnCurrent = true;
-      element.hasParent = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnTipInput.checked);
-      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-    });
-
-    test('controls without parent rebase on current not available', () => {
-      element.rebaseOnCurrent = false;
-      element.hasParent = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.rebaseOnOtherInput.checked);
-      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-      assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
-      assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-    });
-
-    test('input cleared on cancel or submit', () => {
-      element._text = '123';
-      element.$.confirmDialog.fire('confirm');
-      assert.equal(element._text, '');
-
-      element._text = '123';
-      element.$.confirmDialog.fire('cancel');
-      assert.equal(element._text, '');
-    });
-
-    test('_getSelectedBase', () => {
-      element._text = '5fab321c';
-      element.$.rebaseOnParentInput.checked = true;
-      assert.equal(element._getSelectedBase(), null);
-      element.$.rebaseOnParentInput.checked = false;
-      element.$.rebaseOnTipInput.checked = true;
-      assert.equal(element._getSelectedBase(), '');
-      element.$.rebaseOnTipInput.checked = false;
-      assert.equal(element._getSelectedBase(), element._text);
-      element._text = '101: Test';
-      assert.equal(element._getSelectedBase(), '101');
-    });
-
-    suite('parent suggestions', () => {
-      let recentChanges;
-      setup(() => {
-        recentChanges = [
-          {
-            name: '123: my first awesome change',
-            value: 123,
-          },
-          {
-            name: '124: my second awesome change',
-            value: 124,
-          },
-          {
-            name: '245: my third awesome change',
-            value: 245,
-          },
-        ];
-
-        sandbox.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
-            [
-              {
-                _number: 123,
-                subject: 'my first awesome change',
-              },
-              {
-                _number: 124,
-                subject: 'my second awesome change',
-              },
-              {
-                _number: 245,
-                subject: 'my third awesome change',
-              },
-            ]
-        ));
-      });
-
-      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);
-        });
-      });
-
-      test('_filterChanges', () => {
-        assert.equal(element._filterChanges('123', recentChanges).length, 1);
-        assert.equal(element._filterChanges('12', recentChanges).length, 2);
-        assert.equal(element._filterChanges('awesome', recentChanges).length,
-            3);
-        assert.equal(element._filterChanges('third', recentChanges).length,
-            1);
-
-        element.changeNumber = 123;
-        assert.equal(element._filterChanges('123', recentChanges).length, 0);
-        assert.equal(element._filterChanges('124', recentChanges).length, 1);
-        assert.equal(element._filterChanges('awesome', recentChanges).length,
-            2);
-      });
-
-      test('input text change triggers function', () => {
-        sandbox.spy(element, '_getRecentChanges');
-        element.$.parentInput.noDebounce = true;
-        element._text = '1';
-        assert.isTrue(element._getRecentChanges.calledOnce);
-        element._text = '12';
-        assert.isTrue(element._getRecentChanges.calledTwice);
-      });
+    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';
+      assert.isTrue(element._getRecentChanges.calledTwice);
     });
   });
+});
 </script>
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
deleted file mode 100644
index 2e1e6ae..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ /dev/null
@@ -1,75 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<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="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-
-<dom-module id="gr-confirm-revert-dialog">
-  <template>
-    <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);
-        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
-        confirm-label="Revert"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Revert Merged Change</div>
-      <div class="main" slot="main">
-        <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>
-        </gr-endpoint-decorator>
-      </div>
-    </gr-dialog>
-  </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..6eb4c82 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
@@ -14,59 +14,201 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  const ERR_COMMIT_NOT_FOUND =
-      'Unable to find the commit hash of this change.';
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-revert-dialog_html.js';
 
-  Polymer({
-    is: 'gr-confirm-revert-dialog',
+const ERR_COMMIT_NOT_FOUND =
+    'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+// TODO(dhruvsri): clean up repeated definitions after moving to js modules
+const REVERT_TYPES = {
+  REVERT_SINGLE_CHANGE: 1,
+  REVERT_SUBMISSION: 2,
+};
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmRevertDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      message: String,
-    },
+  static get is() { return 'gr-confirm-revert-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
 
-    populateRevertMessage(message, commitHash) {
-      // Figure out what the revert title should be.
-      const originalTitle = message.split('\n')[0];
-      const revertTitle = `Revert "${originalTitle}"`;
-      if (!commitHash) {
-        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
-        return;
-      }
-      const revertCommitText = `This reverts commit ${commitHash}.`;
+  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 []; },
+      },
+    };
+  }
 
-      this.message = `${revertTitle}\n\n${revertCommitText}\n\n` +
-          `Reason for revert: <INSERT REASONING HERE>\n`;
-    },
+  _computeIfSingleRevert(revertType) {
+    return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    },
+  _computeIfRevertSubmission(revertType) {
+    return revertType === REVERT_TYPES.REVERT_SUBMISSION;
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
-  });
-})();
+  _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 = (commitMessage || '').split('\n')[0];
+    const revertTitle = `Revert "${originalTitle}"`;
+    if (!commitHash) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_COMMIT_NOT_FOUND},
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    const revertCommitText = `This reverts commit ${commitHash}.`;
+
+    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.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_COMMIT_NOT_FOUND},
+        composed: true, bubbles: true,
+      }));
+      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();
+    if (this._message === this._originalRevertMessages[this._revertType]) {
+      this._showErrorMessage = true;
+      return;
+    }
+    this.dispatchEvent(new CustomEvent('confirm', {
+      detail: {revertType: this._revertType,
+        message: this._message},
+      composed: true, bubbles: false,
+    }));
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      detail: {revertType: this._revertType},
+      composed: true, bubbles: false,
+    }));
+  }
+}
+
+customElements.define(GrConfirmRevertDialog.is, GrConfirmRevertDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
new file mode 100644
index 0000000..7875fa7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+      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);
+      width: 73ch; /* Add a char to account for the border. */
+    }
+    .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="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>
+        </div></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>
+      </gr-endpoint-decorator>
+    </div>
+  </gr-dialog>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
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..3a341c5 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,68 +31,71 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-confirm-revert-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-revert-dialog.js';
+suite('gr-confirm-revert-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.populateRevertMessage('not a commitHash in sight', undefined);
-      assert.isTrue(alertStub.calledOnce);
-    });
-
-    test('single line', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
-          '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);
-    });
-
-    test('multi line', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
-          '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);
-    });
-
-    test('issue above change id', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
-          '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);
-    });
-
-    test('revert a revert', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
-          '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);
-    });
+  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._populateRevertSingleChangeMessage({},
+        'not a commitHash in sight', undefined);
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    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);
+  });
+
+  test('multi line', () => {
+    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);
+  });
+
+  test('issue above change id', () => {
+    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);
+  });
+
+  test('revert a revert', () => {
+    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);
+  });
+});
 </script>
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..1437c76
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html.js';
+
+const ERR_COMMIT_NOT_FOUND =
+    'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
+
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmRevertSubmissionDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  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) {
+    if (change === undefined) {
+      return;
+    }
+    // Follow the same convention of the revert
+    const commitHash = change.current_revision;
+    if (!commitHash) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_COMMIT_NOT_FOUND},
+        composed: true, bubbles: true,
+      }));
+      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';
+    changes = changes || [];
+    if (changes.length) {
+      this.message += 'Reverted Changes:\n';
+      changes.forEach(change => {
+        this.message += change.change_id.substring(0, 10) + ': ' +
+          this._getTrimmedChangeSubject(change.subject) + '\n';
+      });
+    }
+    this.message = this._modifyRevertSubmissionMsg(change);
+  }
+
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {
+      composed: true, bubbles: false,
+    }));
+  }
+
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, 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_html.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
new file mode 100644
index 0000000..48051a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <!-- TODO(taoalpha): move all shared styles to a style module. -->
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+      display: block;
+      width: 100%;
+    }
+    iron-autogrow-textarea {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      width: 73ch; /* Add a char to account for the border. */
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Revert Submission"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Revert Submission</div>
+    <div class="main" slot="main">
+      <label for="messageInput">
+        Revert Commit Message
+      </label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        max-rows="15"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
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..a11d996
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
@@ -0,0 +1,99 @@
+<!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">
+<meta charset="utf-8">
+<title>gr-confirm-revert-submission-dialog</title>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-revert-submission-dialog>
+    </gr-confirm-revert-submission-dialog>
+  </template>
+</test-fixture>
+
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-revert-submission-dialog.js';
+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',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+});
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
deleted file mode 100644
index 02385cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
+++ /dev/null
@@ -1,71 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-confirm-submit-dialog">
-  <template>
-    <style include="shared-styles">
-      #dialog {
-        min-width: 40em;
-      }
-      p {
-        margin-bottom: var(--spacing-l);
-      }
-      @media screen and (max-width: 50em) {
-        #dialog {
-          min-width: inherit;
-          width: 100%;
-        }
-      }
-    </style>
-    <gr-dialog
-        id="dialog"
-        confirm-label="Continue"
-        confirm-on-enter
-        on-cancel="_handleCancelTap"
-        on-confirm="_handleConfirmTap">
-      <div class="header" slot="header">
-        [[action.label]]
-      </div>
-      <div class="main" slot="main">
-        <gr-endpoint-decorator name="confirm-submit-change">
-          <p>Ready to submit &ldquo;<strong>[[change.subject]]</strong>&rdquo;?</p>
-          <template is="dom-if" if="[[change.is_private]]">
-            <p><strong>Heads Up!</strong> Submitting this private change will also make it public.</p>
-          </template>
-          <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
-              <iron-icon icon="gr-icons:error" class="warningBeforeSubmit"></iron-icon>
-              Your unpublished edit will not be submitted. Did you
-              forget to click <b>PUBLISH</b>?
-          </template>
-          <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-          <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </gr-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-confirm-submit-dialog.js"></script>
-</dom-module>
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 8c93778..5d599b7 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
@@ -14,30 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-confirm-submit-dialog',
+import '@polymer/iron-icon/iron-icon.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-submit-dialog_html.js';
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/** @extends Polymer.Element */
+class GrConfirmSubmitDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-confirm-submit-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
       /**
-       * @type {{
-       *    is_private: boolean,
-       *    subject: string,
-       *  }}
+       * @type {Gerrit.Change}
        */
       change: Object,
 
@@ -47,27 +60,35 @@
        *  }}
        */
       action: Object,
-    },
+    };
+  }
 
-    resetFocus(e) {
-      this.$.dialog.resetFocus();
-    },
+  resetFocus(e) {
+    this.$.dialog.resetFocus();
+  }
 
-    _computeHasChangeEdit(change) {
-      return !!change.revisions &&
-          Object.values(change.revisions).some(rev => rev._number === 'edit');
-    },
+  _computeHasChangeEdit(change) {
+    return !!change.revisions &&
+        Object.values(change.revisions).some(rev => rev._number == 'edit');
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
-    },
+  _computeUnresolvedCommentsWarning(change) {
+    const unresolvedCount = change.unresolved_comment_count;
+    const plural = unresolvedCount > 1 ? 's' : '';
+    return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
new file mode 100644
index 0000000..cf1a332
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #dialog {
+      min-width: 40em;
+    }
+    p {
+      margin-bottom: var(--spacing-l);
+    }
+    .warningBeforeSubmit {
+      color: var(--error-text-color);
+      vertical-align: top;
+      margin-right: var(--spacing-s);
+    }
+    @media screen and (max-width: 50em) {
+      #dialog {
+        min-width: inherit;
+        width: 100%;
+      }
+    }
+  </style>
+  <gr-dialog
+    id="dialog"
+    confirm-label="Continue"
+    confirm-on-enter=""
+    on-cancel="_handleCancelTap"
+    on-confirm="_handleConfirmTap"
+  >
+    <div class="header" slot="header">
+      [[action.label]]
+    </div>
+    <div class="main" slot="main">
+      <gr-endpoint-decorator name="confirm-submit-change">
+        <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
+        <template is="dom-if" if="[[change.is_private]]">
+          <p>
+            <iron-icon
+              icon="gr-icons:error"
+              class="warningBeforeSubmit"
+            ></iron-icon>
+            <strong>Heads Up!</strong>
+            Submitting this private change will also make it public.
+          </p>
+        </template>
+        <template is="dom-if" if="[[change.unresolved_comment_count]]">
+          <p>
+            <iron-icon
+              icon="gr-icons:error"
+              class="warningBeforeSubmit"
+            ></iron-icon>
+            [[_computeUnresolvedCommentsWarning(change)]]
+          </p>
+        </template>
+        <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
+          <iron-icon
+            icon="gr-icons:error"
+            class="warningBeforeSubmit"
+          ></iron-icon>
+          Your unpublished edit will not be submitted. Did you forget to click
+          <b>PUBLISH</b>?
+        </template>
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+        <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 56a9485..30699d5 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
@@ -17,18 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-confirm-submit-dialog.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,55 +32,69 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-file-list-header tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-confirm-submit-dialog.js';
+suite('gr-file-list-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('display', () => {
-      element.action = {label: 'my-label'};
-      element.change = {
-        subject: 'my-subject',
-        revisions: {},
-      };
-      flushAsynchronousOperations();
-      const header = element.$$('.header');
-      assert.equal(header.textContent.trim(), 'my-label');
-
-      const message = element.$$('.main p');
-      assert.notEqual(message.textContent.length, 0);
-      assert.notEqual(message.textContent.indexOf('my-subject'), -1);
-    });
-
-    test('_computeHasChangeEdit', () => {
-      const change = {
-        revisions: {
-          d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-            _number: 'edit',
-          },
-        },
-        unresolved_comment_count: 0,
-      };
-
-      assert.equal(element._computeHasChangeEdit(change), true);
-
-      const change2 = {
-        revisions: {
-          d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-            _number: 2,
-          },
-        },
-      };
-      assert.equal(element._computeHasChangeEdit(change2), false);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('display', () => {
+    element.action = {label: 'my-label'};
+    element.change = {
+      subject: 'my-subject',
+      revisions: {},
+    };
+    flushAsynchronousOperations();
+    const header = element.shadowRoot
+        .querySelector('.header');
+    assert.equal(header.textContent.trim(), 'my-label');
+
+    const message = element.shadowRoot
+        .querySelector('.main p');
+    assert.notEqual(message.textContent.length, 0);
+    assert.notEqual(message.textContent.indexOf('my-subject'), -1);
+  });
+
+  test('_computeUnresolvedCommentsWarning', () => {
+    const change = {unresolved_comment_count: 1};
+    assert.equal(element._computeUnresolvedCommentsWarning(change),
+        'Heads Up! 1 unresolved comment.');
+
+    const change2 = {unresolved_comment_count: 2};
+    assert.equal(element._computeUnresolvedCommentsWarning(change2),
+        'Heads Up! 2 unresolved comments.');
+  });
+
+  test('_computeHasChangeEdit', () => {
+    const change = {
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          _number: 'edit',
+        },
+      },
+      unresolved_comment_count: 0,
+    };
+
+    assert.equal(element._computeHasChangeEdit(change), true);
+
+    const change2 = {
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          _number: 2,
+        },
+      },
+    };
+    assert.equal(element._computeHasChangeEdit(change2), false);
+  });
+});
 </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
deleted file mode 100644
index e20bbd7..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ /dev/null
@@ -1,129 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
-
-<dom-module id="gr-download-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--dialog-background-color);
-        display: block;
-      }
-      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;
-        padding-top: var(--spacing-m);
-      }
-      .footer {
-        justify-content: flex-end;
-      }
-      .closeButtonContainer {
-        align-items: flex-end;
-        display: flex;
-        flex: 0;
-        justify-content: flex-end;
-      }
-      .patchFiles,
-      .archivesContainer {
-        padding-bottom: var(--spacing-m);
-      }
-      .patchFiles {
-        margin-right: var(--spacing-xxl);
-      }
-      .patchFiles a,
-      .archives a {
-        display: inline-block;
-        margin-right: var(--spacing-l);
-      }
-      .patchFiles a:last-of-type,
-      .archives a:last-of-type {
-        margin-right: 0;
-      }
-      .title {
-        flex: 1;
-        font-weight: var(--font-weight-bold);
-      }
-      .hidden {
-        display: none;
-      }
-    </style>
-    <section>
-      <span class="title">
-        Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
-      </span>
-    </section>
-    <section class$="[[_computeShowDownloadCommands(_schemes)]]">
-      <gr-download-commands
-          id="downloadCommands"
-          commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
-          schemes="[[_schemes]]"
-          selected-scheme="{{_selectedScheme}}"></gr-download-commands>
-    </section>
-    <section class="flexContainer">
-      <div class="patchFiles" hidden="[[_computeHidePatchFile(change, patchNum)]]" hidden>
-        <label>Patch file</label>
-        <div>
-          <a
-              id="download"
-              href$="[[_computeDownloadLink(change, patchNum)]]"
-              download>
-            [[_computeDownloadFilename(change, patchNum)]]
-          </a>
-          <a
-              href$="[[_computeZipDownloadLink(change, patchNum)]]"
-              download>
-            [[_computeZipDownloadFilename(change, patchNum)]]
-          </a>
-        </div>
-      </div>
-      <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden>
-        <label>Archive</label>
-        <div id="archives" class="archives">
-          <template is="dom-repeat" items="[[config.archives]]" as="format">
-            <a
-                href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
-                download>
-              [[format]]
-            </a>
-          </template>
-        </div>
-      </div>
-    </section>
-    <section class="footer">
-      <span class="closeButtonContainer">
-        <gr-button id="closeButton"
-            link
-            on-click="_handleCloseTap">Close</gr-button>
-      </span>
-    </section>
-  </template>
-  <script src="gr-download-dialog.js"></script>
-</dom-module>
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..4c457a1 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
@@ -14,20 +14,39 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-download-dialog',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-download-commands/gr-download-commands.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-download-dialog_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrDownloadDialog extends mixinBehaviors( [
+  PatchSetBehavior,
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /** @type {{ revisions: Array }} */
+  static get is() { return 'gr-download-dialog'; }
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  static get properties() {
+    return {
+    /** @type {{ revisions: Array }} */
       change: Object,
       patchNum: String,
       /** @type {?} */
@@ -40,177 +59,177 @@
         observer: '_schemesChanged',
       },
       _selectedScheme: String,
-    },
+    };
+  }
 
-    hostAttributes: {
-      role: 'dialog',
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+  focus() {
+    if (this._schemes.length) {
+      this.$.downloadCommands.focusOnCopy();
+    } else {
+      this.$.download.focus();
+    }
+  }
 
-    focus() {
-      if (this._schemes.length) {
-        this.$.downloadCommands.focusOnCopy();
-      } else {
-        this.$.download.focus();
+  getFocusStops() {
+    const links = this.shadowRoot
+        .querySelector('#archives').querySelectorAll('a');
+    return {
+      start: this.$.closeButton,
+      end: links[links.length - 1],
+    };
+  }
+
+  _computeDownloadCommands(change, patchNum, _selectedScheme) {
+    let commandObj;
+    if (!change) return [];
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum) &&
+          rev && rev.fetch && rev.fetch.hasOwnProperty(_selectedScheme)) {
+        commandObj = rev.fetch[_selectedScheme].commands;
+        break;
       }
-    },
+    }
+    const commands = [];
+    for (const title in commandObj) {
+      if (!commandObj || !commandObj.hasOwnProperty(title)) { continue; }
+      commands.push({
+        title,
+        command: commandObj[title],
+      });
+    }
+    return commands;
+  }
 
-    getFocusStops() {
-      const links = this.$$('#archives').querySelectorAll('a');
-      return {
-        start: this.$.closeButton,
-        end: links[links.length - 1],
-      };
-    },
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   *
+   * @return {string}
+   */
+  _computeZipDownloadLink(change, patchNum) {
+    return this._computeDownloadLink(change, patchNum, true);
+  }
 
-    _computeDownloadCommands(change, patchNum, _selectedScheme) {
-      let commandObj;
-      if (!change) return [];
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum) &&
-            rev && rev.fetch && rev.fetch.hasOwnProperty(_selectedScheme)) {
-          commandObj = rev.fetch[_selectedScheme].commands;
-          break;
-        }
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   *
+   * @return {string}
+   */
+  _computeZipDownloadFilename(change, patchNum) {
+    return this._computeDownloadFilename(change, patchNum, true);
+  }
+
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   * @param {boolean=} opt_zip
+   *
+   * @return {string} Not sure why there was a mismatch
+   */
+  _computeDownloadLink(change, patchNum, opt_zip) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return '';
+    }
+    return this.changeBaseURL(change.project, change._number, patchNum) +
+        '/patch?' + (opt_zip ? 'zip' : 'download');
+  }
+
+  /**
+   * @param {!Object} change
+   * @param {number|string} patchNum
+   * @param {boolean=} opt_zip
+   *
+   * @return {string}
+   */
+  _computeDownloadFilename(change, patchNum, opt_zip) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return '';
+    }
+
+    let shortRev = '';
+    for (const rev in change.revisions) {
+      if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
+        shortRev = rev.substr(0, 7);
+        break;
       }
-      const commands = [];
-      for (const title in commandObj) {
-        if (!commandObj || !commandObj.hasOwnProperty(title)) { continue; }
-        commands.push({
-          title,
-          command: commandObj[title],
-        });
-      }
-      return commands;
-    },
+    }
+    return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
+  }
 
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     *
-     * @return {string}
-     */
-    _computeZipDownloadLink(change, patchNum) {
-      return this._computeDownloadLink(change, patchNum, true);
-    },
-
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     *
-     * @return {string}
-     */
-    _computeZipDownloadFilename(change, patchNum) {
-      return this._computeDownloadFilename(change, patchNum, true);
-    },
-
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     * @param {boolean=} opt_zip
-     *
-     * @return {string} Not sure why there was a mismatch
-     */
-    _computeDownloadLink(change, patchNum, opt_zip) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return '';
-      }
-      return this.changeBaseURL(change.project, change._number, patchNum) +
-          '/patch?' + (opt_zip ? 'zip' : 'download');
-    },
-
-
-    /**
-     * @param {!Object} change
-     * @param {number|string} patchNum
-     * @param {boolean=} opt_zip
-     *
-     * @return {string}
-     */
-    _computeDownloadFilename(change, patchNum, opt_zip) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return '';
-      }
-
-      let shortRev = '';
-      for (const rev in change.revisions) {
-        if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
-          shortRev = rev.substr(0, 7);
-          break;
-        }
-      }
-      return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
-    },
-
-    _computeHidePatchFile(change, patchNum) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return false;
-      }
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          const parentLength = rev.commit && rev.commit.parents ?
-            rev.commit.parents.length : 0;
-          return parentLength == 0;
-        }
-      }
+  _computeHidePatchFile(change, patchNum) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
       return false;
-    },
-
-    _computeArchiveDownloadLink(change, patchNum, format) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum, format].some(arg => arg === undefined)) {
-        return '';
+    }
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        const parentLength = rev.commit && rev.commit.parents ?
+          rev.commit.parents.length : 0;
+        return parentLength == 0;
       }
-      return this.changeBaseURL(change.project, change._number, patchNum) +
-          '/archive?format=' + format;
-    },
+    }
+    return false;
+  }
 
-    _computeSchemes(change, patchNum) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return [];
-      }
+  _computeArchiveDownloadLink(change, patchNum, format) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum, format].some(arg => arg === undefined)) {
+      return '';
+    }
+    return this.changeBaseURL(change.project, change._number, patchNum) +
+        '/archive?format=' + format;
+  }
 
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          const fetch = rev.fetch;
-          if (fetch) {
-            return Object.keys(fetch).sort();
-          }
-          break;
-        }
-      }
+  _computeSchemes(change, patchNum) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
       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];
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        const fetch = rev.fetch;
+        if (fetch) {
+          return Object.keys(fetch).sort();
+        }
+        break;
       }
-    },
+    }
+    return [];
+  }
 
-    _computeShowDownloadCommands(schemes) {
-      return schemes.length ? '' : 'hidden';
-    },
-  });
-})();
+  _computePatchSetQuantity(revisions) {
+    if (!revisions) { return 0; }
+    return Object.keys(revisions).length;
+  }
+
+  _handleCloseTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('close', {
+      composed: true, 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_html.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
new file mode 100644
index 0000000..9569c03
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      padding: var(--spacing-m) 0;
+    }
+    section {
+      display: flex;
+      padding: var(--spacing-m) var(--spacing-xl);
+    }
+    .flexContainer {
+      display: flex;
+      justify-content: space-between;
+      padding-top: var(--spacing-m);
+    }
+    .footer {
+      justify-content: flex-end;
+    }
+    .closeButtonContainer {
+      align-items: flex-end;
+      display: flex;
+      flex: 0;
+      justify-content: flex-end;
+    }
+    .patchFiles,
+    .archivesContainer {
+      padding-bottom: var(--spacing-m);
+    }
+    .patchFiles {
+      margin-right: var(--spacing-xxl);
+    }
+    .patchFiles a,
+    .archives a {
+      display: inline-block;
+      margin-right: var(--spacing-l);
+    }
+    .patchFiles a:last-of-type,
+    .archives a:last-of-type {
+      margin-right: 0;
+    }
+    .title {
+      flex: 1;
+      font-weight: var(--font-weight-bold);
+    }
+    .hidden {
+      display: none;
+    }
+  </style>
+  <section>
+    <h3 class="title">
+      Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
+    </h3>
+  </section>
+  <section class$="[[_computeShowDownloadCommands(_schemes)]]">
+    <gr-download-commands
+      id="downloadCommands"
+      commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
+      schemes="[[_schemes]]"
+      selected-scheme="{{_selectedScheme}}"
+    ></gr-download-commands>
+  </section>
+  <section class="flexContainer">
+    <div
+      class="patchFiles"
+      hidden="[[_computeHidePatchFile(change, patchNum)]]"
+    >
+      <label>Patch file</label>
+      <div>
+        <a
+          id="download"
+          href$="[[_computeDownloadLink(change, patchNum)]]"
+          download=""
+        >
+          [[_computeDownloadFilename(change, patchNum)]]
+        </a>
+        <a href$="[[_computeZipDownloadLink(change, patchNum)]]" download="">
+          [[_computeZipDownloadFilename(change, patchNum)]]
+        </a>
+      </div>
+    </div>
+    <div
+      class="archivesContainer"
+      hidden$="[[!config.archives.length]]"
+      hidden=""
+    >
+      <label>Archive</label>
+      <div id="archives" class="archives">
+        <template is="dom-repeat" items="[[config.archives]]" as="format">
+          <a
+            href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
+            download=""
+          >
+            [[format]]
+          </a>
+        </template>
+      </div>
+    </div>
+  </section>
+  <section class="footer">
+    <span class="closeButtonContainer">
+      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
+        >Close</gr-button
+      >
+    </span>
+  </section>
+`;
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..46c57fe 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-download-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -40,182 +37,186 @@
   </template>
 </test-fixture>
 
-<script>
-  function getChangeObject() {
-    return {
-      current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-      revisions: {
-        '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-          _number: 1,
-          commit: {
-            parents: [],
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-download-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+function getChangeObject() {
+  return {
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    revisions: {
+      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+        _number: 1,
+        commit: {
+          parents: [],
+        },
+        fetch: {
+          repo: {
+            commands: {
+              repo: 'repo download test-project 5/1',
+            },
           },
-          fetch: {
-            repo: {
-              commands: {
-                repo: 'repo download test-project 5/1',
-              },
+          ssh: {
+            commands: {
+              'Checkout':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+              'Cherry Pick':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+              'Format Patch':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 ' +
+                '&& git format-patch -1 --stdout FETCH_HEAD',
+              'Pull':
+                'git pull ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1',
             },
-            ssh: {
-              commands: {
-                'Checkout':
-                  'git fetch ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
-                'Cherry Pick':
-                  'git fetch ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-                'Format Patch':
-                  'git fetch ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1 ' +
-                  '&& git format-patch -1 --stdout FETCH_HEAD',
-                'Pull':
-                  'git pull ' +
-                  'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1',
-              },
-            },
-            http: {
-              commands: {
-                'Checkout':
-                  'git fetch ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
-                'Cherry Pick':
-                  'git fetch ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-                'Format Patch':
-                  'git fetch ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1 && ' +
-                  'git format-patch -1 --stdout FETCH_HEAD',
-                'Pull':
-                  'git pull ' +
-                  'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1',
-              },
+          },
+          http: {
+            commands: {
+              'Checkout':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+              'Cherry Pick':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+              'Format Patch':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && ' +
+                'git format-patch -1 --stdout FETCH_HEAD',
+              'Pull':
+                'git pull ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1',
             },
           },
         },
       },
-    };
-  }
+    },
+  };
+}
 
-  function getChangeObjectNoFetch() {
-    return {
-      current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-      revisions: {
-        '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-          _number: 1,
-          commit: {
-            parents: [],
-          },
-          fetch: {},
+function getChangeObjectNoFetch() {
+  return {
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    revisions: {
+      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+        _number: 1,
+        commit: {
+          parents: [],
         },
+        fetch: {},
       },
+    },
+  };
+}
+
+suite('gr-download-dialog', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+
+    element = fixture('basic');
+    element.patchNum = '1';
+    element.config = {
+      schemes: {
+        'anonymous http': {},
+        'http': {},
+        'repo': {},
+        'ssh': {},
+      },
+      archives: ['tgz', 'tar', 'tbz2', 'txz'],
     };
-  }
 
-  suite('gr-download-dialog', () => {
-    let element;
-    let sandbox;
+    flushAsynchronousOperations();
+  });
 
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('anchors use download attribute', () => {
+    const anchors = Array.from(
+        dom(element.root).querySelectorAll('a'));
+    assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
+  });
+
+  suite('gr-download-dialog tests with no fetch options', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      element = fixture('basic');
-      element.patchNum = '1';
-      element.config = {
-        schemes: {
-          'anonymous http': {},
-          'http': {},
-          'repo': {},
-          'ssh': {},
-        },
-        archives: ['tgz', 'tar', 'tbz2', 'txz'],
-      };
-
+      element.change = getChangeObjectNoFetch();
       flushAsynchronousOperations();
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('anchors use download attribute', () => {
-      const anchors = Array.from(
-          Polymer.dom(element.root).querySelectorAll('a'));
-      assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
-    });
-
-    suite('gr-download-dialog tests with no fetch options', () => {
-      setup(() => {
-        element.change = getChangeObjectNoFetch();
-        flushAsynchronousOperations();
-      });
-
-      test('focuses on first download link if no copy links', () => {
-        const focusStub = sandbox.stub(element.$.download, 'focus');
-        element.focus();
-        assert.isTrue(focusStub.called);
-        focusStub.restore();
-      });
-    });
-
-    suite('gr-download-dialog with fetch options', () => {
-      setup(() => {
-        element.change = getChangeObject();
-        flushAsynchronousOperations();
-      });
-
-      test('focuses on first copy link', () => {
-        const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
-        element.focus();
-        flushAsynchronousOperations();
-        assert.isTrue(focusStub.called);
-        focusStub.restore();
-      });
-
-      test('computed fields', () => {
-        assert.equal(element._computeArchiveDownloadLink(
-            {project: 'test/project', _number: 123}, 2, 'tgz'),
-        '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
-      });
-
-      test('close event', done => {
-        element.addEventListener('close', () => {
-          done();
-        });
-        MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
-      });
-    });
-
-    test('_computeShowDownloadCommands', () => {
-      assert.equal(element._computeShowDownloadCommands([]), 'hidden');
-      assert.equal(element._computeShowDownloadCommands(['test']), '');
-    });
-
-    test('_computeHidePatchFile', () => {
-      const patchNum = '1';
-
-      const change1 = {
-        revisions: {
-          r1: {_number: 1, commit: {parents: []}},
-        },
-      };
-      assert.isTrue(element._computeHidePatchFile(change1, patchNum));
-
-      const change2 = {
-        revisions: {
-          r1: {_number: 1, commit: {parents: [
-            {commit: 'p1'},
-          ]}},
-        },
-      };
-      assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+    test('focuses on first download link if no copy links', () => {
+      const focusStub = sandbox.stub(element.$.download, 'focus');
+      element.focus();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
     });
   });
+
+  suite('gr-download-dialog with fetch options', () => {
+    setup(() => {
+      element.change = getChangeObject();
+      flushAsynchronousOperations();
+    });
+
+    test('focuses on first copy link', () => {
+      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+      element.focus();
+      flushAsynchronousOperations();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+
+    test('computed fields', () => {
+      assert.equal(element._computeArchiveDownloadLink(
+          {project: 'test/project', _number: 123}, 2, 'tgz'),
+      '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
+    });
+
+    test('close event', done => {
+      element.addEventListener('close', () => {
+        done();
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.closeButtonContainer gr-button'));
+    });
+  });
+
+  test('_computeShowDownloadCommands', () => {
+    assert.equal(element._computeShowDownloadCommands([]), 'hidden');
+    assert.equal(element._computeShowDownloadCommands(['test']), '');
+  });
+
+  test('_computeHidePatchFile', () => {
+    const patchNum = '1';
+
+    const change1 = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: []}},
+      },
+    };
+    assert.isTrue(element._computeHidePatchFile(change1, patchNum));
+
+    const change2 = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+        ]}},
+      },
+    };
+    assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.html b/polygerrit-ui/app/elements/change/gr-file-list-constants.html
deleted file mode 100644
index 8bdcf7a..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.html
+++ /dev/null
@@ -1,31 +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>
-  (function(window) {
-    'use strict';
-
-    const GrFileListConstants = window.GrFileListConstants || {};
-
-    GrFileListConstants.FilesExpandedState = {
-      ALL: 'all',
-      NONE: 'none',
-      SOME: 'some',
-    };
-
-    window.GrFileListConstants = GrFileListConstants;
-  })(window);
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.js b/polygerrit-ui/app/elements/change/gr-file-list-constants.js
new file mode 100644
index 0000000..5bba786
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.js
@@ -0,0 +1,25 @@
+/**
+ * @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.
+ */
+
+export const GrFileListConstants = {
+  FilesExpandedState: {
+    ALL: 'all',
+    NONE: 'none',
+    SOME: 'some',
+  },
+};
+
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
deleted file mode 100644
index 9146125..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ /dev/null
@@ -1,274 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../diff/gr-diff-mode-selector/gr-diff-mode-selector.html">
-<link rel="import" href="../../diff/gr-patch-range-select/gr-patch-range-select.html">
-<link rel="import" href="../../edit/gr-edit-controls/gr-edit-controls.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
-<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../gr-file-list-constants.html">
-<link rel="import" href="../gr-commit-info/gr-commit-info.html">
-
-<dom-module id="gr-file-list-header">
-  <template>
-    <style include="shared-styles">
-      .prefsButton {
-        float: right;
-      }
-      .collapseToggleButton {
-        text-decoration: none;
-      }
-      .patchInfoOldPatchSet.patchInfo-header {
-        background-color: var(--emphasis-color);
-      }
-      .patchInfo-header {
-        align-items: center;
-        background-color: var(--table-header-background-color);
-        border-top: 1px solid var(--border-color);
-        display: flex;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .patchInfo-left {
-        align-items: baseline;
-        display: flex;
-      }
-      .patchInfoContent {
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-      }
-      .patchInfo-header .container.latestPatchContainer {
-        display: none;
-      }
-      .patchInfoOldPatchSet .container.latestPatchContainer {
-        display: initial;
-      }
-      .latestPatchContainer a {
-        text-decoration: none;
-      }
-      gr-editable-label.descriptionLabel {
-        max-width: 100%;
-      }
-      .mobile {
-        display: none;
-      }
-      .patchInfo-header .container {
-        align-items: center;
-        display: flex;
-      }
-      .downloadContainer,
-      .uploadContainer,
-      .includedInContainer {
-        margin-right: 16px;
-      }
-      .includedInContainer.hide,
-      .uploadContainer.hide {
-        display: none;
-      }
-      .rightControls {
-        align-self: flex-end;
-        margin: auto 0 auto auto;
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-        font-weight: normal;
-        justify-content: flex-end;
-      }
-      #collapseBtn,
-      .expanded #expandBtn,
-      .fileViewActions{
-        display: none;
-      }
-      .expanded #expandBtn {
-        display: none;
-      }
-      gr-linked-chip {
-        --linked-chip-text-color: var(--primary-text-color);
-      }
-      .expanded #collapseBtn,
-      .openFile .fileViewActions {
-        align-items: center;
-        display: flex;
-      }
-      .rightControls gr-button,
-      gr-patch-range-select {
-        margin: 0 -4px;
-      }
-      .fileViewActions gr-button {
-        margin: 0;
-        --gr-button: {
-          padding: 2px 4px;
-        }
-      }
-      .editMode .hideOnEdit {
-        display: none;
-      }
-      .showOnEdit {
-        display: none;
-      }
-      .editMode .showOnEdit {
-        display: initial;
-      }
-      .editMode .showOnEdit.flexContainer {
-        align-items: center;
-        display: flex;
-      }
-      .label {
-        font-weight: var(--font-weight-bold);
-        margin-right: 24px;
-      }
-      gr-commit-info,
-      gr-edit-controls {
-        margin-right: -5px;
-      }
-      .fileViewActionsLabel {
-        margin-right: var(--spacing-xs);
-      }
-      @media screen and (max-width: 50em) {
-        .patchInfo-header .desktop {
-          display: none;
-        }
-      }
-    </style>
-    <div class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]">
-      <div class="patchInfo-left">
-        <template is="dom-if"
-            if="[[showTitle]]">
-          <h3 class="label">Files</h3>
-        </template>
-        <div class="patchInfoContent">
-          <gr-patch-range-select
-              id="rangeSelect"
-              change-comments="[[changeComments]]"
-              change-num="[[changeNum]]"
-              patch-num="[[patchNum]]"
-              base-patch-num="[[basePatchNum]]"
-              available-patches="[[allPatchSets]]"
-              revisions="[[change.revisions]]"
-              revision-info="[[revisionInfo]]"
-              on-patch-range-change="_handlePatchChange">
-          </gr-patch-range-select>
-          <span class="separator"></span>
-          <gr-commit-info
-              change="[[change]]"
-              server-config="[[serverConfig]]"
-              commit-info="[[commitInfo]]"></gr-commit-info>
-          <span class="container latestPatchContainer">
-            <span class="separator"></span>
-            <a href$="[[changeUrl]]">Go to latest patch set</a>
-          </span>
-          <span class="container descriptionContainer hideOnEdit">
-            <span class="separator"></span>
-            <template
-                is="dom-if"
-                if="[[_patchsetDescription]]">
-              <gr-linked-chip
-                  id="descriptionChip"
-                  text="[[_patchsetDescription]]"
-                  removable="[[!_descriptionReadOnly]]"
-                  on-remove="_handleDescriptionRemoved"></gr-linked-chip>
-            </template>
-            <template
-                is="dom-if"
-                if="[[!_patchsetDescription]]">
-              <gr-editable-label
-                  id="descriptionLabel"
-                  uppercase
-                  class="descriptionLabel"
-                  label-text="Add patchset description"
-                  value="[[_patchsetDescription]]"
-                  placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-                  read-only="[[_descriptionReadOnly]]"
-                  on-changed="_handleDescriptionChanged"></gr-editable-label>
-            </template>
-          </span>
-        </div>
-      </div>
-      <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
-        <span class="showOnEdit flexContainer">
-          <gr-edit-controls
-              id="editControls"
-              patch-num="[[patchNum]]"
-              change="[[change]]"></gr-edit-controls>
-          <span class="separator"></span>
-        </span>
-        <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
-          <gr-button link
-              class="upload"
-              on-click="_handleUploadTap">Update Change</gr-button>
-        </span>
-        <span class="downloadContainer desktop">
-          <gr-button link
-              class="download"
-              on-click="_handleDownloadTap">Download</gr-button>
-        </span>
-        <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
-          <gr-button link
-              class="includedIn"
-              on-click="_handleIncludedInTap">Included In</gr-button>
-        </span>
-        <template is="dom-if"
-            if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
-          <gr-button
-              id="expandBtn"
-              link
-              on-click="_expandAllDiffs">Expand All</gr-button>
-          <gr-button
-              id="collapseBtn"
-              link
-              on-click="_collapseAllDiffs">Collapse All</gr-button>
-        </template>
-        <template is="dom-if"
-            if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
-          <div class="warning">
-            Bulk actions disabled because there are too many files.
-          </div>
-        </template>
-        <div class="fileViewActions">
-          <span class="separator"></span>
-          <span class="fileViewActionsLabel">Diff view:</span>
-          <gr-diff-mode-selector
-              id="modeSelect"
-              mode="{{diffViewMode}}"
-              save-on-change="[[!diffPrefsDisabled]]"></gr-diff-mode-selector>
-          <span id="diffPrefsContainer"
-              class="hideOnEdit"
-              hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
-              hidden>
-            <gr-button
-                link
-                has-tooltip
-                title="Diff preferences"
-                class="prefsButton desktop"
-                on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
-          </span>
-        </div>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-file-list-header.js"></script>
-</dom-module>
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..73c6721 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
@@ -14,41 +14,72 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Maximum length for patch set descriptions.
-  const PATCH_DESC_MAX_LENGTH = 500;
-  const MERGED_STATUS = 'MERGED';
+import '../../../styles/shared-styles.js';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector.js';
+import '../../diff/gr-patch-range-select/gr-patch-range-select.js';
+import '../../edit/gr-edit-controls/gr-edit-controls.js';
+import '../../shared/gr-editable-label/gr-editable-label.js';
+import '../../shared/gr-linked-chip/gr-linked-chip.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../gr-commit-info/gr-commit-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-file-list-header_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-file-list-header',
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+const MERGED_STATUS = 'MERGED';
 
-    /**
-     * @event expand-diffs
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrFileListHeader extends mixinBehaviors( [
+  PatchSetBehavior,
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * @event collapse-diffs
-     */
+  static get is() { return 'gr-file-list-header'; }
+  /**
+   * @event expand-diffs
+   */
 
-    /**
-     * @event open-diff-prefs
-     */
+  /**
+   * @event collapse-diffs
+   */
 
-    /**
-     * @event open-included-in-dialog
-     */
+  /**
+   * @event open-diff-prefs
+   */
 
-    /**
-     * @event open-download-dialog
-     */
+  /**
+   * @event open-included-in-dialog
+   */
 
-    /**
-     * @event open-upload-help-dialog
-     */
+  /**
+   * @event open-download-dialog
+   */
 
-    properties: {
+  /**
+   * @event open-upload-help-dialog
+   */
+
+  static get properties() {
+    return {
       account: Object,
       allPatchSets: Array,
       /** @type {?} */
@@ -81,188 +112,192 @@
         type: String,
         value: '',
       },
-      showTitle: {
-        type: Boolean,
-        value: true,
-      },
       _descriptionReadOnly: {
         type: Boolean,
         computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
       },
       revisionInfo: Object,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_computePatchSetDescription(change, patchNum)',
-    ],
+    ];
+  }
 
-    setDiffViewMode(mode) {
-      this.$.modeSelect.setMode(mode);
-    },
+  setDiffViewMode(mode) {
+    this.$.modeSelect.setMode(mode);
+  }
 
-    _expandAllDiffs() {
-      this._expanded = true;
-      this.fire('expand-diffs');
-    },
+  _expandAllDiffs() {
+    this._expanded = true;
+    this.dispatchEvent(new CustomEvent('expand-diffs', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _collapseAllDiffs() {
-      this._expanded = false;
-      this.fire('collapse-diffs');
-    },
+  _collapseAllDiffs() {
+    this._expanded = false;
+    this.dispatchEvent(new CustomEvent('collapse-diffs', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _computeExpandedClass(filesExpanded) {
-      const classes = [];
-      if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
-        classes.push('expanded');
+  _computeExpandedClass(filesExpanded) {
+    const classes = [];
+    if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
+      classes.push('expanded');
+    }
+    if (filesExpanded === GrFileListConstants.FilesExpandedState.SOME ||
+          filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
+      classes.push('openFile');
+    }
+    return classes.join(' ');
+  }
+
+  _computeDescriptionPlaceholder(readOnly) {
+    return (readOnly ? 'No' : 'Add') + ' patchset description';
+  }
+
+  _computeDescriptionReadOnly(loggedIn, change, account) {
+    // Polymer 2: check for undefined
+    if ([loggedIn, change, account].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return !(loggedIn && (account._account_id === change.owner._account_id));
+  }
+
+  _computePatchSetDescription(change, patchNum) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum].some(arg => arg === undefined)) {
+      return;
+    }
+
+    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
+   * @param {?Object} patchSet A revision already fetched from {revisions}
+   * @return {string|undefined} the SHA hash corresponding to the revision.
+   */
+  _getPatchsetHash(revisions, patchSet) {
+    for (const rev in revisions) {
+      if (revisions.hasOwnProperty(rev) &&
+          revisions[rev] === patchSet) {
+        return rev;
       }
-      if (filesExpanded === GrFileListConstants.FilesExpandedState.SOME ||
-            filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
-        classes.push('openFile');
-      }
-      return classes.join(' ');
-    },
+    }
+  }
 
-    _computeDescriptionPlaceholder(readOnly) {
-      return (readOnly ? 'No' : 'Add') + ' patchset description';
-    },
+  _handleDescriptionChanged(e) {
+    const desc = e.detail.trim();
+    this._updateDescription(desc, e);
+  }
 
-    _computeDescriptionReadOnly(loggedIn, change, account) {
-      // Polymer 2: check for undefined
-      if ([loggedIn, change, account].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return !(loggedIn && (account._account_id === change.owner._account_id));
-    },
-
-    _computePatchSetDescription(change, patchNum) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum].some(arg => arg === undefined)) {
-        return;
-      }
-
-      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
-     * @param {?Object} patchSet A revision already fetched from {revisions}
-     * @return {string|undefined} the SHA hash corresponding to the revision.
-     */
-    _getPatchsetHash(revisions, patchSet) {
-      for (const rev in revisions) {
-        if (revisions.hasOwnProperty(rev) &&
-            revisions[rev] === patchSet) {
-          return rev;
-        }
-      }
-    },
-
-    _handleDescriptionChanged(e) {
-      const desc = e.detail.trim();
-      this._updateDescription(desc, e);
-    },
-
-    /**
-     * Update the patchset description with the rest API.
-     *
-     * @param {string} desc
-     * @param {?(Event|Node)} e
-     * @return {!Promise}
-     */
-    _updateDescription(desc, e) {
-      const target = Polymer.dom(e).rootTarget;
-      if (target) { target.disabled = true; }
-      const rev = this.getRevisionByPatchNum(this.change.revisions,
-          this.patchNum);
-      const sha = this._getPatchsetHash(this.change.revisions, rev);
-      return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
-          .then(res => {
-            if (res.ok) {
-              if (target) { target.disabled = false; }
-              this.set(['change', 'revisions', sha, 'description'], desc);
-              this._patchsetDescription = desc;
-            }
-          }).catch(err => {
+  /**
+   * Update the patchset description with the rest API.
+   *
+   * @param {string} desc
+   * @param {?(Event|Node)} e
+   * @return {!Promise}
+   */
+  _updateDescription(desc, e) {
+    const target = dom(e).rootTarget;
+    if (target) { target.disabled = true; }
+    const rev = this.getRevisionByPatchNum(this.change.revisions,
+        this.patchNum);
+    const sha = this._getPatchsetHash(this.change.revisions, rev);
+    return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
+        .then(res => {
+          if (res.ok) {
             if (target) { target.disabled = false; }
-            return;
-          });
-    },
+            this.set(['change', 'revisions', sha, 'description'], desc);
+            this._patchsetDescription = desc;
+          }
+        })
+        .catch(err => {
+          if (target) { target.disabled = false; }
+          return;
+        });
+  }
 
-    _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
-      return diffPrefsDisabled || !prefs;
-    },
+  _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
+    return diffPrefsDisabled || !prefs;
+  }
 
-    _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
-      return shownFileCount <= maxFilesForBulkActions;
-    },
+  _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);
-    },
+  _handlePatchChange(e) {
+    const {basePatchNum, patchNum} = e.detail;
+    if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
+        this.patchNumEquals(patchNum, this.patchNum)) { return; }
+    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+  }
 
-    _handlePrefsTap(e) {
-      e.preventDefault();
-      this.fire('open-diff-prefs');
-    },
+  _handlePrefsTap(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('open-diff-prefs', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _handleIncludedInTap(e) {
-      e.preventDefault();
-      this.fire('open-included-in-dialog');
-    },
+  _handleIncludedInTap(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('open-included-in-dialog', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _handleDownloadTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.dispatchEvent(
-          new CustomEvent('open-download-dialog', {bubbles: false}));
-    },
+  _handleDownloadTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+        new CustomEvent('open-download-dialog', {bubbles: false}));
+  }
 
-    _computeEditModeClass(editMode) {
-      return editMode ? 'editMode' : '';
-    },
+  _computeEditModeClass(editMode) {
+    return editMode ? 'editMode' : '';
+  }
 
-    _computePatchInfoClass(patchNum, allPatchSets) {
-      const latestNum = this.computeLatestPatchNum(allPatchSets);
-      if (this.patchNumEquals(patchNum, latestNum)) {
-        return '';
-      }
-      return 'patchInfoOldPatchSet';
-    },
+  _computePatchInfoClass(patchNum, allPatchSets) {
+    const latestNum = this.computeLatestPatchNum(allPatchSets);
+    if (this.patchNumEquals(patchNum, latestNum)) {
+      return '';
+    }
+    return 'patchInfoOldPatchSet';
+  }
 
-    _hideIncludedIn(change) {
-      return change && change.status === MERGED_STATUS ? '' : 'hide';
-    },
+  _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}));
-    },
+  _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;
-      const ownerId = change && change.owner && change.owner._account_id ?
-        change.owner._account_id : null;
-      const userId = account && account._account_id;
-      const userIsOwner = ownerId && userId && ownerId === userId;
-      const hideContainer = !userIsOwner || changeIsMerged;
-      return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
-    },
-  });
-})();
+  _computeUploadHelpContainerClass(change, account) {
+    const changeIsMerged = change && change.status === MERGED_STATUS;
+    const ownerId = change && change.owner && change.owner._account_id ?
+      change.owner._account_id : null;
+    const userId = account && account._account_id;
+    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_html.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
new file mode 100644
index 0000000..10b8606
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
@@ -0,0 +1,270 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .prefsButton {
+      float: right;
+    }
+    .collapseToggleButton {
+      text-decoration: none;
+    }
+    .patchInfoOldPatchSet.patchInfo-header {
+      background-color: var(--emphasis-color);
+    }
+    .patchInfo-header {
+      align-items: center;
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    .patchInfo-left {
+      align-items: baseline;
+      display: flex;
+    }
+    .patchInfoContent {
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .patchInfo-header .container.latestPatchContainer {
+      display: none;
+    }
+    .patchInfoOldPatchSet .container.latestPatchContainer {
+      display: initial;
+    }
+    .latestPatchContainer a {
+      text-decoration: none;
+    }
+    gr-editable-label.descriptionLabel {
+      max-width: 100%;
+    }
+    .mobile {
+      display: none;
+    }
+    .patchInfo-header .container {
+      align-items: center;
+      display: flex;
+    }
+    .downloadContainer,
+    .uploadContainer,
+    .includedInContainer {
+      margin-right: 16px;
+    }
+    .includedInContainer.hide,
+    .uploadContainer.hide {
+      display: none;
+    }
+    .rightControls {
+      align-self: flex-end;
+      margin: auto 0 auto auto;
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+      font-weight: var(--font-weight-normal);
+      justify-content: flex-end;
+    }
+    #collapseBtn,
+    .expanded #expandBtn,
+    .fileViewActions {
+      display: none;
+    }
+    .expanded #expandBtn {
+      display: none;
+    }
+    gr-linked-chip {
+      --linked-chip-text-color: var(--primary-text-color);
+    }
+    .expanded #collapseBtn,
+    .openFile .fileViewActions {
+      align-items: center;
+      display: flex;
+    }
+    .rightControls gr-button,
+    gr-patch-range-select {
+      margin: 0 -4px;
+    }
+    .fileViewActions gr-button {
+      margin: 0;
+      --gr-button: {
+        padding: 2px 4px;
+      }
+    }
+    .editMode .hideOnEdit {
+      display: none;
+    }
+    .showOnEdit {
+      display: none;
+    }
+    .editMode .showOnEdit {
+      display: initial;
+    }
+    .editMode .showOnEdit.flexContainer {
+      align-items: center;
+      display: flex;
+    }
+    .label {
+      font-weight: var(--font-weight-bold);
+      margin-right: 24px;
+    }
+    gr-commit-info,
+    gr-edit-controls {
+      margin-right: -5px;
+    }
+    .fileViewActionsLabel {
+      margin-right: var(--spacing-xs);
+    }
+    @media screen and (max-width: 50em) {
+      .patchInfo-header .desktop {
+        display: none;
+      }
+    }
+  </style>
+  <div
+    class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]"
+  >
+    <div class="patchInfo-left">
+      <div class="patchInfoContent">
+        <gr-patch-range-select
+          id="rangeSelect"
+          change-comments="[[changeComments]]"
+          change-num="[[changeNum]]"
+          patch-num="[[patchNum]]"
+          base-patch-num="[[basePatchNum]]"
+          available-patches="[[allPatchSets]]"
+          revisions="[[change.revisions]]"
+          revision-info="[[revisionInfo]]"
+          on-patch-range-change="_handlePatchChange"
+        >
+        </gr-patch-range-select>
+        <span class="separator"></span>
+        <gr-commit-info
+          change="[[change]]"
+          server-config="[[serverConfig]]"
+          commit-info="[[commitInfo]]"
+        ></gr-commit-info>
+        <span class="container latestPatchContainer">
+          <span class="separator"></span>
+          <a href$="[[changeUrl]]">Go to latest patch set</a>
+        </span>
+        <span class="container descriptionContainer hideOnEdit">
+          <span class="separator"></span>
+          <template is="dom-if" if="[[_patchsetDescription]]">
+            <gr-linked-chip
+              id="descriptionChip"
+              text="[[_patchsetDescription]]"
+              removable="[[!_descriptionReadOnly]]"
+              on-remove="_handleDescriptionRemoved"
+            ></gr-linked-chip>
+          </template>
+          <template is="dom-if" if="[[!_patchsetDescription]]">
+            <gr-editable-label
+              id="descriptionLabel"
+              uppercase=""
+              class="descriptionLabel"
+              label-text="Add patchset description"
+              value="[[_patchsetDescription]]"
+              placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+              read-only="[[_descriptionReadOnly]]"
+              on-changed="_handleDescriptionChanged"
+            ></gr-editable-label>
+          </template>
+        </span>
+      </div>
+    </div>
+    <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
+      <span class="showOnEdit flexContainer">
+        <gr-edit-controls
+          id="editControls"
+          patch-num="[[patchNum]]"
+          change="[[change]]"
+        ></gr-edit-controls>
+        <span class="separator"></span>
+      </span>
+      <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
+        <gr-button link="" class="upload" on-click="_handleUploadTap"
+          >Update Change</gr-button
+        >
+      </span>
+      <span class="downloadContainer desktop">
+        <gr-button
+          link=""
+          class="download"
+          title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
+                ShortcutSection.ACTIONS)]]"
+          on-click="_handleDownloadTap"
+          >Download</gr-button
+        >
+      </span>
+      <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
+        <gr-button link="" class="includedIn" on-click="_handleIncludedInTap"
+          >Included In</gr-button
+        >
+      </span>
+      <template
+        is="dom-if"
+        if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
+      >
+        <gr-button
+          id="expandBtn"
+          link=""
+          title="[[createTitle(Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+                ShortcutSection.DIFFS)]]"
+          on-click="_expandAllDiffs"
+          >Expand All</gr-button
+        >
+        <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs"
+          >Collapse All</gr-button
+        >
+      </template>
+      <template
+        is="dom-if"
+        if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
+      >
+        <div class="warning">
+          Bulk actions disabled because there are too many files.
+        </div>
+      </template>
+      <div class="fileViewActions">
+        <span class="separator"></span>
+        <span class="fileViewActionsLabel">Diff view:</span>
+        <gr-diff-mode-selector
+          id="modeSelect"
+          mode="{{diffViewMode}}"
+          save-on-change="[[!diffPrefsDisabled]]"
+        ></gr-diff-mode-selector>
+        <span
+          id="diffPrefsContainer"
+          class="hideOnEdit"
+          hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
+          hidden=""
+        >
+          <gr-button
+            link=""
+            has-tooltip=""
+            title="Diff preferences"
+            class="prefsButton desktop"
+            on-click="_handlePrefsTap"
+            ><iron-icon icon="gr-icons:settings"></iron-icon
+          ></gr-button>
+        </span>
+      </div>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..19362d5 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
@@ -17,18 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-file-list-header.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -42,275 +38,286 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-file-list-header tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-file-list-header.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({test: 'config'}); },
-        getAccount() { return Promise.resolve(null); },
-        _fetchSharedCacheURL() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
+suite('gr-file-list-header tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
     });
+    element = fixture('basic');
+  });
 
-    teardown(done => {
-      flush(() => {
-        sandbox.restore();
-        done();
-      });
-    });
-
-    test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
-      element.diffPrefsDisabled = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element.diffPrefsDisabled = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element.diffPrefsDisabled = true;
-      element.diffPrefs = {font_size: '12'};
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element.diffPrefsDisabled = false;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.diffPrefsContainer.hidden);
-    });
-
-    test('_computeDescriptionReadOnly', () => {
-      assert.equal(element._computeDescriptionReadOnly(false,
-          {owner: {_account_id: 1}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 0}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 1}}, {_account_id: 1}), false);
-    });
-
-    test('_computeDescriptionPlaceholder', () => {
-      assert.equal(element._computeDescriptionPlaceholder(true),
-          'No patchset description');
-      assert.equal(element._computeDescriptionPlaceholder(false),
-          'Add patchset description');
-    });
-
-    test('description editing', () => {
-      const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
-          .returns(Promise.resolve({ok: true}));
-
-      element.changeNum = '42';
-      element.basePatchNum = 'PARENT';
-      element.patchNum = 1;
-
-      element.change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        actions: {},
-        owner: {_account_id: 1},
-      };
-      element.account = {_account_id: 1};
-      element.loggedIn = true;
-
-      flushAsynchronousOperations();
-
-      // The element has a description, so the account chip should be visible
-      // and the description label should not exist.
-      const chip = Polymer.dom(element.root).querySelector('#descriptionChip');
-      let label = Polymer.dom(element.root).querySelector('#descriptionLabel');
-
-      assert.equal(chip.text, 'test');
-      assert.isNotOk(label);
-
-      // 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, '');
-
-        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', () => {
-      element.shownFileCount = 1;
-      flushAsynchronousOperations();
-      sandbox.stub(element, '_expandAllDiffs');
-      MockInteractions.tap(Polymer.dom(element.root).querySelector(
-          '#expandBtn'));
-      assert.isTrue(element._expandAllDiffs.called);
-    });
-
-    test('collapseAllDiffs called when expand button clicked', () => {
-      element.shownFileCount = 1;
-      flushAsynchronousOperations();
-      sandbox.stub(element, '_collapseAllDiffs');
-      MockInteractions.tap(Polymer.dom(element.root).querySelector(
-          '#collapseBtn'));
-      assert.isTrue(element._collapseAllDiffs.called);
-    });
-
-    test('show/hide diffs disabled for large amounts of files', done => {
-      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-      element._files = [];
-      element.changeNum = '42';
-      element.basePatchNum = 'PARENT';
-      element.patchNum = '2';
-      element.shownFileCount = 1;
-      flush(() => {
-        assert.isTrue(computeSpy.lastCall.returnValue);
-        _.times(element._maxFilesForBulkActions + 1, () => {
-          element.shownFileCount = element.shownFileCount + 1;
-        });
-        assert.isFalse(computeSpy.lastCall.returnValue);
-        done();
-      });
-    });
-
-    test('fileViewActions are properly hidden', () => {
-      const actions = element.$$('.fileViewActions');
-      assert.equal(getComputedStyle(actions).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(actions).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(actions).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(actions).display, 'none');
-    });
-
-    test('expand/collapse buttons are toggled correctly', () => {
-      element.shownFileCount = 10;
-      flushAsynchronousOperations();
-      const expandBtn = element.$$('#expandBtn');
-      const collapseBtn = element.$$('#collapseBtn');
-      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-      assert.equal(getComputedStyle(collapseBtn).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-      assert.equal(getComputedStyle(collapseBtn).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(expandBtn).display, 'none');
-      assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
-      element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-      assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    });
-
-    test('navigateToChange called when range select changes', () => {
-      const navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      element.change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        status: 'NEW',
-        labels: {},
-      };
-      element.basePatchNum = 1;
-      element.patchNum = 2;
-
-      element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
-      assert.equal(navigateToChangeStub.callCount, 1);
-      assert.isTrue(navigateToChangeStub.lastCall
-          .calledWithExactly(element.change, 3, 1));
-    });
-
-    test('class is applied to file list on old patch set', () => {
-      const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
-      assert.equal(element._computePatchInfoClass('1', allPatchSets),
-          'patchInfoOldPatchSet');
-      assert.equal(element._computePatchInfoClass('2', allPatchSets),
-          'patchInfoOldPatchSet');
-      assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
-    });
-
-    suite('editMode behavior', () => {
-      setup(() => {
-        element.diffPrefsDisabled = false;
-        element.diffPrefs = {};
-      });
-
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
-      test('patch specific elements', () => {
-        element.editMode = true;
-        sandbox.stub(element, 'computeLatestPatchNum').returns('2');
-        flushAsynchronousOperations();
-
-        assert.isFalse(isVisible(element.$.diffPrefsContainer));
-        assert.isFalse(isVisible(element.$$('.descriptionContainer')));
-
-        element.editMode = false;
-        flushAsynchronousOperations();
-
-        assert.isTrue(isVisible(element.$$('.descriptionContainer')));
-        assert.isTrue(isVisible(element.$.diffPrefsContainer));
-      });
-
-      test('edit-controls visibility', () => {
-        element.editMode = true;
-        flushAsynchronousOperations();
-        assert.isTrue(isVisible(element.$.editControls.parentElement));
-
-        element.editMode = false;
-        flushAsynchronousOperations();
-        assert.isFalse(isVisible(element.$.editControls.parentElement));
-      });
-
-      test('_computeUploadHelpContainerClass', () => {
-        // Only show the upload helper button when an unmerged change is viewed
-        // by its owner.
-        const accountA = {_account_id: 1};
-        const accountB = {_account_id: 2};
-        assert.notInclude(element._computeUploadHelpContainerClass(
-            {owner: accountA}, accountA), 'hide');
-        assert.include(element._computeUploadHelpContainerClass(
-            {owner: accountA}, accountB), 'hide');
-      });
+  teardown(done => {
+    flush(() => {
+      sandbox.restore();
+      done();
     });
   });
+
+  test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
+    element.diffPrefsDisabled = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = true;
+    element.diffPrefs = {font_size: '12'};
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flushAsynchronousOperations();
+    assert.isFalse(element.$.diffPrefsContainer.hidden);
+  });
+
+  test('_computeDescriptionReadOnly', () => {
+    assert.equal(element._computeDescriptionReadOnly(false,
+        {owner: {_account_id: 1}}, {_account_id: 1}), true);
+    assert.equal(element._computeDescriptionReadOnly(true,
+        {owner: {_account_id: 0}}, {_account_id: 1}), true);
+    assert.equal(element._computeDescriptionReadOnly(true,
+        {owner: {_account_id: 1}}, {_account_id: 1}), false);
+  });
+
+  test('_computeDescriptionPlaceholder', () => {
+    assert.equal(element._computeDescriptionPlaceholder(true),
+        'No patchset description');
+    assert.equal(element._computeDescriptionPlaceholder(false),
+        'Add patchset description');
+  });
+
+  test('description editing', () => {
+    const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
+        .returns(Promise.resolve({ok: true}));
+
+    element.changeNum = '42';
+    element.basePatchNum = 'PARENT';
+    element.patchNum = 1;
+
+    element.change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      actions: {},
+      owner: {_account_id: 1},
+    };
+    element.account = {_account_id: 1};
+    element.loggedIn = true;
+
+    flushAsynchronousOperations();
+
+    // The element has a description, so the account chip should be visible
+    // and the description label should not exist.
+    const chip = dom(element.root).querySelector('#descriptionChip');
+    let label = dom(element.root).querySelector('#descriptionLabel');
+
+    assert.equal(chip.text, 'test');
+    assert.isNotOk(label);
+
+    // 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, '');
+
+          flushAsynchronousOperations();
+          // The editable label should now be visible and the chip hidden.
+          label = 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', () => {
+    element.shownFileCount = 1;
+    flushAsynchronousOperations();
+    sandbox.stub(element, '_expandAllDiffs');
+    MockInteractions.tap(dom(element.root).querySelector(
+        '#expandBtn'));
+    assert.isTrue(element._expandAllDiffs.called);
+  });
+
+  test('collapseAllDiffs called when expand button clicked', () => {
+    element.shownFileCount = 1;
+    flushAsynchronousOperations();
+    sandbox.stub(element, '_collapseAllDiffs');
+    MockInteractions.tap(dom(element.root).querySelector(
+        '#collapseBtn'));
+    assert.isTrue(element._collapseAllDiffs.called);
+  });
+
+  test('show/hide diffs disabled for large amounts of files', done => {
+    const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+    element._files = [];
+    element.changeNum = '42';
+    element.basePatchNum = 'PARENT';
+    element.patchNum = '2';
+    element.shownFileCount = 1;
+    flush(() => {
+      assert.isTrue(computeSpy.lastCall.returnValue);
+      _.times(element._maxFilesForBulkActions + 1, () => {
+        element.shownFileCount = element.shownFileCount + 1;
+      });
+      assert.isFalse(computeSpy.lastCall.returnValue);
+      done();
+    });
+  });
+
+  test('fileViewActions are properly hidden', () => {
+    const actions = element.shadowRoot
+        .querySelector('.fileViewActions');
+    assert.equal(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(actions).display, 'none');
+  });
+
+  test('expand/collapse buttons are toggled correctly', () => {
+    element.shownFileCount = 10;
+    flushAsynchronousOperations();
+    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;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(expandBtn).display, 'none');
+    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+  });
+
+  test('navigateToChange called when range select changes', () => {
+    const navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange');
+    element.change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev2: {_number: 2},
+        rev1: {_number: 1},
+        rev13: {_number: 13},
+        rev3: {_number: 3},
+      },
+      status: 'NEW',
+      labels: {},
+    };
+    element.basePatchNum = 1;
+    element.patchNum = 2;
+
+    element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
+    assert.equal(navigateToChangeStub.callCount, 1);
+    assert.isTrue(navigateToChangeStub.lastCall
+        .calledWithExactly(element.change, 3, 1));
+  });
+
+  test('class is applied to file list on old patch set', () => {
+    const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
+    assert.equal(element._computePatchInfoClass('1', allPatchSets),
+        'patchInfoOldPatchSet');
+    assert.equal(element._computePatchInfoClass('2', allPatchSets),
+        'patchInfoOldPatchSet');
+    assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+  });
+
+  suite('editMode behavior', () => {
+    setup(() => {
+      element.diffPrefsDisabled = false;
+      element.diffPrefs = {};
+    });
+
+    const isVisible = el => {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') !== 'none';
+    };
+
+    test('patch specific elements', () => {
+      element.editMode = true;
+      sandbox.stub(element, 'computeLatestPatchNum').returns('2');
+      flushAsynchronousOperations();
+
+      assert.isFalse(isVisible(element.$.diffPrefsContainer));
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+
+      element.editMode = false;
+      flushAsynchronousOperations();
+
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+      assert.isTrue(isVisible(element.$.diffPrefsContainer));
+    });
+
+    test('edit-controls visibility', () => {
+      element.editMode = true;
+      flushAsynchronousOperations();
+      assert.isTrue(isVisible(element.$.editControls.parentElement));
+
+      element.editMode = false;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.$.editControls.parentElement));
+    });
+
+    test('_computeUploadHelpContainerClass', () => {
+      // Only show the upload helper button when an unmerged change is viewed
+      // by its owner.
+      const accountA = {_account_id: 1};
+      const accountB = {_account_id: 2};
+      assert.notInclude(element._computeUploadHelpContainerClass(
+          {owner: accountA}, accountA), 'hide');
+      assert.include(element._computeUploadHelpContainerClass(
+          {owner: accountA}, accountB), 'hide');
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index 649aa53..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ /dev/null
@@ -1,530 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/async-foreach-behavior/async-foreach-behavior.html">
-<link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
-<link rel="import" href="../../diff/gr-diff-host/gr-diff-host.html">
-<link rel="import" href="../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html">
-<link rel="import" href="../../edit/gr-edit-file-controls/gr-edit-file-controls.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../gr-file-list-constants.html">
-
-<dom-module id="gr-file-list">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      .row {
-        align-items: center;
-        border-top: 1px solid var(--border-color);
-        display: flex;
-        min-height: calc(var(--line-height-normal) + 2*var(--spacing-s));
-        padding: var(--spacing-xs) var(--spacing-l) var(--spacing-xs) calc(var(--spacing-l) - .35rem);
-      }
-      :host(.loading) .row {
-        opacity: .5;
-      };
-      :host(.editMode) .hideOnEdit {
-        display: none;
-      }
-      .showOnEdit {
-        display: none;
-      }
-      :host(.editMode) .showOnEdit {
-        display: initial;
-      }
-      .invisible {
-        visibility: hidden;
-      }
-      .header-row {
-        font-weight: var(--font-weight-bold);
-      }
-      .controlRow {
-        align-items: center;
-        display: flex;
-        height: 2.25em;
-        justify-content: center;
-      }
-      .controlRow.invisible,
-      .show-hide.invisible {
-        display: none;
-      }
-      .reviewed,
-      .status {
-        align-items: center;
-        display: inline-flex;
-      }
-      .reviewed,
-      .status {
-        display: inline-block;
-        text-align: left;
-        width: 1.5em;
-      }
-      .file-row {
-        cursor: pointer;
-      }
-      .file-row.expanded {
-        border-bottom: 1px solid var(--border-color);
-        position: -webkit-sticky;
-        position: sticky;
-        top: 0;
-        /* Has to visible above the diff view, and by default has a lower
-         z-index. setting to 1 places it directly above. */
-        z-index: 1;
-      }
-      .file-row:hover {
-        background-color: var(--hover-background-color);
-      }
-      .file-row.selected {
-        background-color: var(--selection-background-color);
-      }
-      .file-row.expanded,
-      .file-row.expanded:hover {
-        background-color: var(--expanded-background-color);
-      }
-      .path {
-        cursor: pointer;
-        flex: 1;
-        text-decoration: none;
-        /* Wrap it into multiple lines if too long. */
-        white-space: normal;
-        word-break: break-word;
-      }
-      .path:hover :first-child {
-        text-decoration: underline;
-      }
-      .oldPath {
-        color: var(--deemphasized-text-color);
-      }
-      .header-stats {
-        text-align: center;
-        min-width: 7.5em;
-      }
-      .stats {
-        text-align: right;
-        min-width: 7.5em;
-      }
-      .comments {
-        padding-left: var(--spacing-l);
-        min-width: 7.5em;
-      }
-      .row:not(.header-row) .stats,
-      .total-stats {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        display: flex;
-      }
-      .sizeBars {
-        margin-left: var(--spacing-m);
-        min-width: 7em;
-        text-align: center;
-      }
-      .sizeBars.hide {
-        display: none;
-      }
-      .added,
-      .removed {
-        display: inline-block;
-        min-width: 3.5em;
-      }
-      .added {
-        color: var(--vote-text-color-recommended);
-      }
-      .removed {
-        color: var(--vote-text-color-disliked);
-        text-align: left;
-        min-width: 4em;
-        padding-left: var(--spacing-s);
-      }
-      .drafts {
-        color: #C62828;
-        font-weight: var(--font-weight-bold);
-      }
-      .show-hide {
-        margin-left: var(--spacing-s);
-        width: 1.9em;
-      }
-      .fileListButton {
-        margin: var(--spacing-m);
-      }
-      .totalChanges {
-        justify-content: flex-end;
-        text-align: right;
-      }
-      .warning {
-        color: var(--deemphasized-text-color);
-      }
-      input.show-hide {
-        display: none;
-      }
-      label.show-hide {
-        cursor: pointer;
-        display: block;
-        min-width: 2em;
-      }
-      gr-diff {
-        display: block;
-        overflow-x: auto;
-      }
-      .truncatedFileName {
-        display: none;
-      }
-      .mobile {
-        display: none;
-      }
-      .reviewed {
-        margin-left: var(--spacing-xxl);
-        width: 15em;
-      }
-      .reviewed label {
-        color: var(--link-color);
-        opacity: 0;
-        justify-content: flex-end;
-        width: 100%;
-      }
-      .reviewed label:hover {
-        cursor: pointer;
-        opacity: 100;
-      }
-      .row:focus {
-        outline: none;
-      }
-      .row:hover .reviewed label,
-      .row:focus .reviewed label,
-      .row.expanded .reviewed label {
-        opacity: 100;
-      }
-      .reviewed input {
-        display: none;
-      }
-      .reviewedLabel {
-        color: var(--deemphasized-text-color);
-        margin-right: var(--spacing-l);
-        opacity: 0;
-      }
-      .reviewedLabel.isReviewed {
-        display: initial;
-        opacity: 100;
-      }
-      .editFileControls {
-        width: 7em;
-      }
-      .markReviewed,
-      .pathLink {
-        display: inline-block;
-        margin: -2px 0;
-        padding: var(--spacing-s) 0;
-      }
-
-      /** small screen breakpoint: 768px */
-      @media screen and (max-width: 55em) {
-        .desktop {
-          display: none;
-        }
-        .mobile {
-          display: block;
-        }
-        .row.selected {
-          background-color: var(--view-background-color);
-        }
-        .stats {
-          display: none;
-        }
-        .reviewed,
-        .status {
-          justify-content: flex-start;
-        }
-        .reviewed {
-          display: none;
-        }
-        .comments {
-          min-width: initial;
-        }
-        .expanded .fullFileName,
-        .truncatedFileName {
-          display: block;
-        }
-        .expanded .truncatedFileName,
-        .fullFileName {
-          display: none;
-        }
-      }
-    </style>
-    <div
-        id="container"
-        on-click="_handleFileListClick">
-      <div class="header-row row">
-        <div class="status"></div>
-        <div class="path">File</div>
-        <div class="comments">Comments</div>
-        <div class="sizeBars">Size</div>
-        <div class="header-stats">Delta</div>
-        <template is="dom-if" if="[[_showDynamicColumns]]">
-          <template is="dom-repeat" items="[[_dynamicHeaderEndpoints]]" as="headerEndpoint">
-            <gr-endpoint-decorator name$="[[headerEndpoint]]">
-            </gr-endpoint-decorator>
-          </template>
-        </template>
-        <!-- Empty div here exists to keep spacing in sync with file rows. -->
-        <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-        <div class="editFileControls showOnEdit"></div>
-        <div class="show-hide"></div>
-      </div>
-
-      <template is="dom-repeat"
-          items="[[_shownFiles]]"
-          id="files"
-          as="file"
-          initial-count="[[fileListIncrement]]"
-          target-framerate="1">
-        [[_reportRenderedRow(index)]]
-        <div class="stickyArea">
-          <div class$="file-row row [[_computePathClass(file.__path, _expandedFilePaths.*)]]"
-              data-path$="[[file.__path]]" tabindex="-1">
-              <div class$="[[_computeClass('status', file.__path)]]"
-                  tabindex="0"
-                  title$="[[_computeFileStatusLabel(file.status)]]"
-                  aria-label$="[[_computeFileStatusLabel(file.status)]]">
-              [[_computeFileStatus(file.status)]]
-            </div>
-            <span
-                data-url="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path, editMode)]]"
-                class="path">
-              <a class="pathLink" href$="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path, editMode)]]">
-                <span title$="[[computeDisplayPath(file.__path)]]"
-                    class="fullFileName">
-                  [[computeDisplayPath(file.__path)]]
-                </span>
-                <span title$="[[computeDisplayPath(file.__path)]]"
-                    class="truncatedFileName">
-                  [[computeTruncatedPath(file.__path)]]
-                </span>
-              </a>
-              <div class="oldPath" hidden$="[[!file.old_path]]" hidden
-                  title$="[[file.old_path]]">
-                [[file.old_path]]
-              </div>
-            </span>
-            <div class="comments desktop">
-              <span class="drafts">
-                [[_computeDraftsString(changeComments, patchRange, file.__path)]]
-              </span>
-              [[_computeCommentsString(changeComments, patchRange, file.__path)]]
-            </div>
-            <div class="comments mobile">
-              <span class="drafts">
-                [[_computeDraftsStringMobile(changeComments, patchRange,
-                    file.__path)]]
-              </span>
-              [[_computeCommentsStringMobile(changeComments, patchRange,
-                  file.__path)]]
-            </div>
-            <div class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]">
-              <svg width="61" height="8">
-                <rect
-                    x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
-                    y="0"
-                    height="8"
-                    fill="#388E3C"
-                    width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]" />
-                <rect
-                    x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
-                    y="0"
-                    height="8"
-                    fill="#D32F2F"
-                    width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]" />
-              </svg>
-            </div>
-            <div class$="[[_computeClass('stats', file.__path)]]">
-              <span
-                  class="added"
-                  tabindex="0"
-                  aria-label$="[[file.lines_inserted]] lines added"
-                  hidden$=[[file.binary]]>
-                +[[file.lines_inserted]]
-              </span>
-              <span
-                  class="removed"
-                  tabindex="0"
-                  aria-label$="[[file.lines_deleted]] lines removed"
-                  hidden$=[[file.binary]]>
-                -[[file.lines_deleted]]
-              </span>
-              <span class$="[[_computeBinaryClass(file.size_delta)]]"
-                  hidden$=[[!file.binary]]>
-                [[_formatBytes(file.size_delta)]]
-                [[_formatPercentage(file.size, file.size_delta)]]
-              </span>
-            </div>
-            <template is="dom-if" if="[[_showDynamicColumns]]">
-              <template is="dom-repeat" items="[[_dynamicContentEndpoints]]" as="contentEndpoint">
-                <div class$="[[_computeClass('', file.__path)]]">
-                  <gr-endpoint-decorator name="[[contentEndpoint]]">
-                    <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                    </gr-endpoint-param>
-                    <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                    </gr-endpoint-param>
-                    <gr-endpoint-param name="path" value="[[file.__path]]">
-                    </gr-endpoint-param>
-                  </gr-endpoint-decorator>
-                </div>
-              </template>
-            </template>
-            <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden>
-              <span class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]">Reviewed</span>
-              <label>
-                <input class="reviewed" type="checkbox" checked="[[file.isReviewed]]">
-                <span class="markReviewed" title$="[[_reviewedTitle(file.isReviewed)]]">[[_computeReviewedText(file.isReviewed)]]</span>
-              </label>
-            </div>
-            <div class="editFileControls showOnEdit">
-              <template is="dom-if" if="[[editMode]]">
-                <gr-edit-file-controls
-                    class$="[[_computeClass('', file.__path)]]"
-                    file-path="[[file.__path]]"></gr-edit-file-controls>
-              </template>
-            </div>
-            <div class="show-hide">
-              <label class="show-hide" data-path$="[[file.__path]]"
-                  data-expand=true>
-                <input type="checkbox" class="show-hide"
-                    checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-                    data-path$="[[file.__path]]" data-expand=true>
-                  <iron-icon
-                      id="icon"
-                      icon="[[_computeShowHideIcon(file.__path, _expandedFilePaths.*)]]">
-                  </iron-icon>
-              </label>
-            </div>
-          </div>
-          <template is="dom-if"
-              if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
-            <gr-diff-host
-                no-auto-render
-                show-load-failure
-                display-line="[[_displayLine]]"
-                inline-index=[[index]]
-                hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-                change-num="[[changeNum]]"
-                patch-range="[[patchRange]]"
-                path="[[file.__path]]"
-                prefs="[[diffPrefs]]"
-                project-name="[[change.project]]"
-                on-line-selected="_onLineSelected"
-                no-render-on-prefs-change
-                view-mode="[[diffViewMode]]"></gr-diff-host>
-          </template>
-        </div>
-      </template>
-    </div>
-    <div
-        class="row totalChanges"
-        hidden$="[[_hideChangeTotals]]">
-      <div class="total-stats">
-        <span
-            class="added"
-            tabindex="0"
-            aria-label$="[[_patchChange.inserted]] lines added">
-          +[[_patchChange.inserted]]
-        </span>
-        <span
-            class="removed"
-            tabindex="0"
-            aria-label$="[[_patchChange.deleted]] lines removed">
-          -[[_patchChange.deleted]]
-        </span>
-      </div>
-      <template is="dom-if" if="[[_showDynamicColumns]]">
-        <template is="dom-repeat" items="[[_dynamicSummaryEndpoints]]" as="summaryEndpoint">
-          <gr-endpoint-decorator name="[[summaryEndpoint]]">
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <!-- Empty div here exists to keep spacing in sync with file rows. -->
-      <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-      <div class="editFileControls showOnEdit"></div>
-      <div class="show-hide"></div>
-    </div>
-    <div
-        class="row totalChanges"
-        hidden$="[[_hideBinaryChangeTotals]]">
-      <div class="total-stats">
-        <span class="added" aria-label="Total lines added">
-          [[_formatBytes(_patchChange.size_delta_inserted)]]
-          [[_formatPercentage(_patchChange.total_size,
-              _patchChange.size_delta_inserted)]]
-        </span>
-        <span class="removed" aria-label="Total lines removed">
-          [[_formatBytes(_patchChange.size_delta_deleted)]]
-          [[_formatPercentage(_patchChange.total_size,
-              _patchChange.size_delta_deleted)]]
-        </span>
-      </div>
-    </div>
-    <div class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]">
-      <gr-button
-          class="fileListButton"
-          id="incrementButton"
-          link on-click="_incrementNumFilesShown">
-        [[_computeIncrementText(numFilesShown, _files)]]
-      </gr-button>
-      <gr-tooltip-content
-          has-tooltip="[[_computeWarnShowAll(_files)]]"
-          show-icon="[[_computeWarnShowAll(_files)]]"
-          title$="[[_computeShowAllWarning(_files)]]">
-        <gr-button
-            class="fileListButton"
-            id="showAllButton"
-            link on-click="_showAllFiles">
-          [[_computeShowAllText(_files)]]
-        </gr-button><!--
-  --></gr-tooltip-content>
-    </div>
-    <gr-diff-preferences-dialog
-        id="diffPreferencesDialog"
-        diff-prefs="{{diffPrefs}}"
-        on-reload-diff-preference="_handleReloadingDiffPreference">
-    </gr-diff-preferences-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-    <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
-    <gr-cursor-manager
-        id="fileCursor"
-        scroll-behavior="keep-visible"
-        focus-on-move
-        cursor-target-class="selected"></gr-cursor-manager>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-file-list.js"></script>
-</dom-module>
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..eb85cd7 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,44 +14,112 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Maximum length for patch set descriptions.
-  const PATCH_DESC_MAX_LENGTH = 500;
-  const WARN_SHOW_ALL_THRESHOLD = 1000;
-  const LOADING_DEBOUNCE_INTERVAL = 100;
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
+import '../../diff/gr-diff-host/gr-diff-host.js';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-linked-text/gr-linked-text.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-file-list_html.js';
+import {AsyncForeachBehavior} from '../../../behaviors/async-foreach-behavior/async-foreach-behavior.js';
+import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-  const SIZE_BAR_MAX_WIDTH = 61;
-  const SIZE_BAR_GAP_WIDTH = 1;
-  const SIZE_BAR_MIN_WIDTH = 1.5;
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
 
-  const RENDER_TIMING_LABEL = 'FileListRenderTime';
-  const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-  const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-  const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
 
-  const FileStatus = {
-    A: 'Added',
-    C: 'Copied',
-    D: 'Deleted',
-    M: 'Modified',
-    R: 'Renamed',
-    W: 'Rewritten',
-    U: 'Unchanged',
-  };
+const RENDER_TIMING_LABEL = 'FileListRenderTime';
+const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
+const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
+const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
 
-  Polymer({
-    is: 'gr-file-list',
+const FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
 
-    /**
-     * Fired when a draft refresh should get triggered
-     *
-     * @event reload-drafts
-     */
+/**
+ * Type for FileInfo
+ *
+ * This should match with the type returned from `files` API plus
+ * additional info like `__path`.
+ *
+ * @typedef {Object} FileInfo
+ * @property {string} __path
+ * @property {?string} old_path
+ * @property {number} size
+ * @property {number} size_delta - fallback to 0 if not present in api
+ * @property {number} lines_deleted - fallback to 0 if not present in api
+ * @property {number} lines_inserted - fallback to 0 if not present in api
+ */
 
-    properties: {
-      /** @type {?} */
+/**
+ * Type for FileData
+ *
+ * This contains minimal info required about the file to get comments for
+ *
+ * @typedef {Object} FileData
+ * @property {string} path
+ * @property {?string} oldPath
+ */
+
+/**
+ * @extends Polymer.Element
+ */
+class GrFileList extends mixinBehaviors( [
+  AsyncForeachBehavior,
+  DomUtilBehavior,
+  KeyboardShortcutBehavior,
+  PatchSetBehavior,
+  PathListBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-file-list'; }
+  /**
+   * Fired when a draft refresh should get triggered
+   *
+   * @event reload-drafts
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
       patchRange: Object,
       patchNum: String,
       changeNum: String,
@@ -85,6 +153,8 @@
         notify: true,
       },
       _filesByPath: Object,
+
+      /** @type {!Array<FileInfo>} */
       _files: {
         type: Array,
         observer: '_filesChanged',
@@ -136,7 +206,8 @@
        */
       _reportinShownFilesIncrement: Number,
 
-      _expandedFilePaths: {
+      /** @type {!Array<FileData>} */
+      _expandedFiles: {
         type: Array,
         value() { return []; },
       },
@@ -163,7 +234,7 @@
       _showDynamicColumns: {
         type: Boolean,
         computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-                  '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+                '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
       },
       /** @type {Array<string>} */
       _dynamicHeaderEndpoints: {
@@ -177,1171 +248,1230 @@
       _dynamicSummaryEndpoints: {
         type: Array,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.AsyncForeachBehavior,
-      Gerrit.DomUtilBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.PathListBehavior,
-    ],
-
-    observers: [
-      '_expandedPathsChanged(_expandedFilePaths.splices)',
+  static get observers() {
+    return [
+      '_expandedFilesChanged(_expandedFiles.splices)',
       '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
-          '_loading)',
-    ],
+        '_loading)',
+    ];
+  }
 
-    keyBindings: {
+  get keyBindings() {
+    return {
       esc: '_handleEscKey',
-    },
+    };
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
-        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
-        [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-        [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-        [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-        [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-        [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
-        [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
-        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
-        [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-        [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-        [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
-        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-        [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+      [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+      [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+      [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
+      [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
+      [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+      [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+      [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
+      [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+      [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+      [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
 
-        // Final two are actually handled by gr-comment-thread.
-        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-      };
-    },
-    listeners: {
-      keydown: '_scopedKeydownHandler',
-    },
+      // Final two are actually handled by gr-comment-thread.
+      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
 
-    attached() {
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-view-file-list-header');
-        this._dynamicContentEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-view-file-list-content');
-        this._dynamicSummaryEndpoints = Gerrit._endpoints.getDynamicEndpoints(
-            'change-view-file-list-summary');
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown',
+        e => this._scopedKeydownHandler(e));
+  }
 
-        if (this._dynamicHeaderEndpoints.length !==
-            this._dynamicContentEndpoints.length) {
-          console.warn(
-              'Different number of dynamic file-list header and content.');
-        }
-        if (this._dynamicHeaderEndpoints.length !==
-            this._dynamicSummaryEndpoints.length) {
-          console.warn(
-              'Different number of dynamic file-list headers and summary.');
-        }
-      });
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      this._dynamicHeaderEndpoints = pluginEndpoints.getDynamicEndpoints(
+          'change-view-file-list-header');
+      this._dynamicContentEndpoints = pluginEndpoints.getDynamicEndpoints(
+          'change-view-file-list-content');
+      this._dynamicSummaryEndpoints = pluginEndpoints.getDynamicEndpoints(
+          'change-view-file-list-summary');
 
-    detached() {
-      this._cancelDiffs();
-    },
-
-    /**
-     * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-     * events must be scoped to a component level (e.g. `enter`) in order to not
-     * override native browser functionality.
-     *
-     * Context: Issue 7277
-     */
-    _scopedKeydownHandler(e) {
-      if (e.keyCode === 13) {
-        // Enter.
-        this._handleOpenFile(e);
+      if (this._dynamicHeaderEndpoints.length !==
+          this._dynamicContentEndpoints.length) {
+        console.warn(
+            'Different number of dynamic file-list header and content.');
       }
-    },
+      if (this._dynamicHeaderEndpoints.length !==
+          this._dynamicSummaryEndpoints.length) {
+        console.warn(
+            'Different number of dynamic file-list headers and summary.');
+      }
+    });
+  }
 
-    reload() {
-      if (!this.changeNum || !this.patchRange.patchNum) {
+  /** @override */
+  detached() {
+    super.detached();
+    this._cancelDiffs();
+  }
+
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7277
+   */
+  _scopedKeydownHandler(e) {
+    if (e.keyCode === 13) {
+      // Enter.
+      this._handleOpenFile(e);
+    }
+  }
+
+  reload() {
+    if (!this.changeNum || !this.patchRange.patchNum) {
+      return Promise.resolve();
+    }
+
+    this._loading = true;
+
+    this.collapseAllDiffs();
+    const promises = [];
+
+    promises.push(this._getFiles().then(filesByPath => {
+      this._filesByPath = filesByPath;
+    }));
+    promises.push(this._getLoggedIn()
+        .then(loggedIn => this._loggedIn = loggedIn)
+        .then(loggedIn => {
+          if (!loggedIn) { return; }
+
+          return this._getReviewedFiles().then(reviewed => {
+            this._reviewed = reviewed;
+          });
+        }));
+
+    promises.push(this._getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    }));
+
+    promises.push(this._getPreferences().then(prefs => {
+      this._userPrefs = prefs;
+    }));
+
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+      this._detectChromiteButler();
+      this.$.reporting.fileListDisplayed();
+    });
+  }
+
+  _detectChromiteButler() {
+    const hasButler = !!document.getElementById('butler-suggested-owners');
+    if (hasButler) {
+      this.$.reporting.reportExtension('butler');
+    }
+  }
+
+  get diffs() {
+    const diffs = dom(this.root).querySelectorAll('gr-diff-host');
+    // It is possible that a bogus diff element is hanging around invisibly
+    // from earlier with a different patch set choice and associated with a
+    // different entry in the files array. So filter on visible items only.
+    return Array.from(diffs).filter(
+        el => !!el && !!el.style && el.style.display !== 'none');
+  }
+
+  openDiffPrefs() {
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _calculatePatchChange(files) {
+    const magicFilesExcluded = files.filter(files =>
+      !this.isMagicPath(files.__path)
+    );
+
+    return magicFilesExcluded.reduce((acc, obj) => {
+      const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+      const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+      const total_size = (obj.size && obj.binary) ? obj.size : 0;
+      const size_delta_inserted =
+          obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+      const size_delta_deleted =
+          obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+      return {
+        inserted: acc.inserted + inserted,
+        deleted: acc.deleted + deleted,
+        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+        total_size: acc.total_size + total_size,
+      };
+    }, {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();
+  }
+
+  _toggleFileExpanded(file) {
+    // Is the path in the list of expanded diffs? IF so remove it, otherwise
+    // add it to the list.
+    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
+    if (pathIndex === -1) {
+      this.push('_expandedFiles', file);
+    } else {
+      this.splice('_expandedFiles', pathIndex, 1);
+    }
+  }
+
+  _toggleFileExpandedByIndex(index) {
+    this._toggleFileExpanded(this._computeFileData(this._files[index]));
+  }
+
+  _updateDiffPreferences() {
+    if (!this.diffs.length) { return; }
+    // Re-render all expanded diffs sequentially.
+    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this._renderInOrder(this._expandedFiles, this.diffs,
+        this._expandedFiles.length);
+  }
+
+  _forEachDiff(fn) {
+    const diffs = this.diffs;
+    for (let i = 0; i < diffs.length; i++) {
+      fn(diffs[i]);
+    }
+  }
+
+  expandAllDiffs() {
+    this._showInlineDiffs = true;
+
+    // Find the list of paths that are in the file list, but not in the
+    // expanded list.
+    const newFiles = [];
+    let path;
+    for (let i = 0; i < this._shownFiles.length; i++) {
+      path = this._shownFiles[i].__path;
+      if (!this._expandedFiles.some(f => f.path === path)) {
+        newFiles.push(this._computeFileData(this._shownFiles[i]));
+      }
+    }
+
+    this.splice(...['_expandedFiles', 0, 0].concat(newFiles));
+  }
+
+  collapseAllDiffs() {
+    this._showInlineDiffs = false;
+    this._expandedFiles = [];
+    this.filesExpanded = this._computeExpandedFiles(
+        this._expandedFiles.length, this._files.length);
+    this.$.diffCursor.handleDiffUpdate();
+  }
+
+  /**
+   * Computes a string with the number of comments and unresolved comments.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeCommentsString(changeComments, patchRange, path) {
+    const unresolvedCount =
+        changeComments.computeUnresolvedNum({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        changeComments.computeUnresolvedNum({
+          patchNum: patchRange.patchNum,
+          path,
+        });
+    const commentCount =
+        changeComments.computeCommentCount({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        changeComments.computeCommentCount({
+          patchNum: patchRange.patchNum,
+          path,
+        });
+    const commentString = GrCountStringFormatter.computePluralString(
+        commentCount, 'comment');
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
+
+    return commentString +
+        // Add a space if both comments and unresolved
+        (commentString && unresolvedString ? ' ' : '') +
+        // Add parentheses around unresolved if it exists.
+        (unresolvedString ? `(${unresolvedString})` : '');
+  }
+
+  /**
+   * Computes a string with the number of drafts.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeDraftsString(changeComments, patchRange, path) {
+    const draftCount =
+        changeComments.computeDraftCount({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        changeComments.computeDraftCount({
+          patchNum: patchRange.patchNum,
+          path,
+        });
+    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+  }
+
+  /**
+   * Computes a shortened string with the number of drafts.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeDraftsStringMobile(changeComments, patchRange, path) {
+    const draftCount =
+        changeComments.computeDraftCount({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        changeComments.computeDraftCount({
+          patchNum: patchRange.patchNum,
+          path,
+        });
+    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+  }
+
+  /**
+   * Computes a shortened string with the number of comments.
+   *
+   * @param {!Object} changeComments
+   * @param {!Object} patchRange
+   * @param {string} path
+   * @return {string}
+   */
+  _computeCommentsStringMobile(changeComments, patchRange, path) {
+    const commentCount =
+        changeComments.computeCommentCount({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        changeComments.computeCommentCount({
+          patchNum: patchRange.patchNum,
+          path,
+        });
+    return GrCountStringFormatter.computeShortString(commentCount, 'c');
+  }
+
+  /**
+   * @param {string} path
+   * @param {boolean=} opt_reviewed
+   */
+  _reviewFile(path, opt_reviewed) {
+    if (this.editMode) { return; }
+    const index = this._files.findIndex(file => file.__path === path);
+    const reviewed = opt_reviewed || !this._files[index].isReviewed;
+
+    this.set(['_files', index, 'isReviewed'], reviewed);
+    if (index < this._shownFiles.length) {
+      this.notifyPath(`_shownFiles.${index}.isReviewed`);
+    }
+
+    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
+   * valid.
+   *
+   * @returns {!Array<FileInfo>}
+   */
+  _normalizeChangeFilesResponse(response) {
+    if (!response) { return []; }
+    const paths = Object.keys(response).sort(this.specialFilePathCompare);
+    const files = [];
+    for (let i = 0; i < paths.length; i++) {
+      const info = response[paths[i]];
+      info.__path = paths[i];
+      info.lines_inserted = info.lines_inserted || 0;
+      info.lines_deleted = info.lines_deleted || 0;
+      info.size_delta = info.size_delta || 0;
+      files.push(info);
+    }
+    return files;
+  }
+
+  /**
+   * Handle all events from the file list dom-repeat so event handleers don't
+   * have to get registered for potentially very long lists.
+   */
+  _handleFileListClick(e) {
+    // Traverse upwards to find the row element if the target is not the row.
+    let row = e.target;
+    while (!row.classList.contains('row') && row.parentElement) {
+      row = row.parentElement;
+    }
+
+    // No action needed for item without a valid file
+    if (!row.dataset.file) {
+      return;
+    }
+
+    const file = JSON.parse(row.dataset.file);
+    const path = file.path;
+    // Handle checkbox mark as reviewed.
+    if (e.target.classList.contains('markReviewed')) {
+      e.preventDefault();
+      return this._reviewFile(path);
+    }
+
+    // If a path cannot be interpreted from the click target (meaning it's not
+    // somewhere in the row, e.g. diff content) or if the user clicked the
+    // link, defer to the native behavior.
+    if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }
+
+    // Disregard the event if the click target is in the edit controls.
+    if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
+
+    e.preventDefault();
+    this._toggleFileExpanded(file);
+  }
+
+  /**
+   * Generates file data from file info object.
+   *
+   * @param {FileInfo} file
+   * @returns {FileData}
+   */
+  _computeFileData(file) {
+    const fileData = {
+      path: file.__path,
+    };
+    if (file.old_path) {
+      fileData.oldPath = file.old_path;
+    }
+    return fileData;
+  }
+
+  _handleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveLeft();
+  }
+
+  _handleRightPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveRight();
+  }
+
+  _handleToggleInlineDiff(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e) ||
+        this.$.fileCursor.index === -1) { return; }
+
+    e.preventDefault();
+    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+  }
+
+  _handleToggleAllInlineDiffs(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this._toggleInlineDiffs();
+  }
+
+  _handleCursorNext(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveDown();
+      this._displayLine = true;
+    } else {
+      // Down key
+      if (this.getKeyboardEvent(e).keyCode === 40) { return; }
+      e.preventDefault();
+      this.$.fileCursor.next();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleCursorPrev(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveUp();
+      this._displayLine = true;
+    } else {
+      // Up key
+      if (this.getKeyboardEvent(e).keyCode === 38) { return; }
+      e.preventDefault();
+      this.$.fileCursor.previous();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleNewComment(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+    this.$.diffCursor.createCommentInPlace();
+  }
+
+  _handleOpenLastFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._openSelectedFile(this._files.length - 1);
+  }
+
+  _handleOpenFirstFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._openSelectedFile(0);
+  }
+
+  _handleOpenFile(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+
+    if (this._showInlineDiffs) {
+      this._openCursorFile();
+      return;
+    }
+
+    this._openSelectedFile();
+  }
+
+  _handleNextChunk(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
+        this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, 'shiftKey')) {
+      this.$.diffCursor.moveToNextCommentThread();
+    } else {
+      this.$.diffCursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunk(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
+        this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, 'shiftKey')) {
+      this.$.diffCursor.moveToPreviousCommentThread();
+    } else {
+      this.$.diffCursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleToggleFileReviewed(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (!this._files[this.$.fileCursor.index]) { return; }
+    this._reviewFile(this._files[this.$.fileCursor.index].__path);
+  }
+
+  _handleToggleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    this._forEachDiff(diff => {
+      diff.toggleLeftDiff();
+    });
+  }
+
+  _toggleInlineDiffs() {
+    if (this._showInlineDiffs) {
+      this.collapseAllDiffs();
+    } else {
+      this.expandAllDiffs();
+    }
+  }
+
+  _openCursorFile() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    GerritNav.navigateToDiff(this.change, diff.path,
+        diff.patchRange.patchNum, this.patchRange.basePatchNum);
+  }
+
+  /**
+   * @param {number=} opt_index
+   */
+  _openSelectedFile(opt_index) {
+    if (opt_index != null) {
+      this.$.fileCursor.setCursorAtIndex(opt_index);
+    }
+    if (!this._files[this.$.fileCursor.index]) { return; }
+    GerritNav.navigateToDiff(this.change,
+        this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
+        this.patchRange.basePatchNum);
+  }
+
+  _addDraftAtTarget() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    const target = this.$.diffCursor.getTargetLineElement();
+    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, patchRange, path, editMode) {
+    // Polymer 2: check for undefined
+    if ([change, patchRange, path, editMode]
+        .some(arg => arg === undefined)) {
+      return;
+    }
+    if (editMode && path !== this.MERGE_LIST_PATH) {
+      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
+          patchRange.basePatchNum);
+    }
+    return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
+        patchRange.basePatchNum);
+  }
+
+  _formatBytes(bytes) {
+    if (bytes == 0) return '+/-0 B';
+    const bits = 1024;
+    const decimals = 1;
+    const sizes =
+        ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+    const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+    const prepend = bytes > 0 ? '+' : '';
+    return prepend + parseFloat((bytes / Math.pow(bits, exponent))
+        .toFixed(decimals)) + ' ' + sizes[exponent];
+  }
+
+  _formatPercentage(size, delta) {
+    const oldSize = size - delta;
+
+    if (oldSize === 0) { return ''; }
+
+    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
+   * @param {string} path
+   */
+  _computeClass(baseClass, path) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
+      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
+    if ([
+      filesByPath,
+      changeComments,
+      patchRange,
+      reviewed,
+      loading,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    // Await all promises resolving from reload. @See Issue 9057
+    if (loading || !changeComments) { return; }
+
+    const commentedPaths = changeComments.getPaths(patchRange);
+    const files = Object.assign({}, filesByPath);
+    Object.keys(commentedPaths).forEach(commentedPath => {
+      if (files.hasOwnProperty(commentedPath)) { return; }
+      files[commentedPath] = {status: 'U'};
+    });
+    const reviewedSet = new Set(reviewed || []);
+    for (const filePath in files) {
+      if (!files.hasOwnProperty(filePath)) { continue; }
+      files[filePath].isReviewed = reviewedSet.has(filePath);
+    }
+
+    this._files = this._normalizeChangeFilesResponse(files);
+  }
+
+  _computeFilesShown(numFilesShown, files) {
+    // Polymer 2: check for undefined
+    if ([numFilesShown, files].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const previousNumFilesShown = this._shownFiles ?
+      this._shownFiles.length : 0;
+
+    const filesShown = files.slice(0, numFilesShown);
+    this.dispatchEvent(new CustomEvent('files-shown-changed', {
+      detail: {length: filesShown.length},
+      composed: true, bubbles: true,
+    }));
+
+    // Start the timer for the rendering work hwere because this is where the
+    // _shownFiles property is being set, and _shownFiles is used in the
+    // dom-repeat binding.
+    this.$.reporting.time(RENDER_TIMING_LABEL);
+
+    // How many more files are being shown (if it's an increase).
+    this._reportinShownFilesIncrement =
+        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) {
+      flush();
+      const files = Array.from(
+          dom(this.root).querySelectorAll('.file-row'));
+      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
+    if ([revisions, patchNum].some(arg => arg === undefined)) {
+      return '';
+    }
+
+    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
+   * ARIA label.
+   *
+   * @param {string} status
+   * @return {string}
+   */
+  _computeFileStatusLabel(status) {
+    const statusCode = this._computeFileStatus(status);
+    return FileStatus.hasOwnProperty(statusCode) ?
+      FileStatus[statusCode] : 'Status Unknown';
+  }
+
+  _isFileExpanded(path, expandedFilesRecord) {
+    return expandedFilesRecord.base.some(f => f.path === path);
+  }
+
+  _computeExpandedFiles(expandedCount, totalCount) {
+    if (expandedCount === 0) {
+      return GrFileListConstants.FilesExpandedState.NONE;
+    } else if (expandedCount === totalCount) {
+      return GrFileListConstants.FilesExpandedState.ALL;
+    }
+    return GrFileListConstants.FilesExpandedState.SOME;
+  }
+
+  /**
+   * Handle splices to the list of expanded file paths. If there are any new
+   * entries in the expanded list, then render each diff corresponding in
+   * order by waiting for the previous diff to finish before starting the next
+   * one.
+   *
+   * @param {!Array} record The splice record in the expanded paths list.
+   */
+  _expandedFilesChanged(record) {
+    // Clear content for any diffs that are not open so if they get re-opened
+    // the stale content does not flash before it is cleared and reloaded.
+    const collapsedDiffs = this.diffs.filter(diff =>
+      this._expandedFiles.findIndex(f => f.path === diff.path) === -1);
+    this._clearCollapsedDiffs(collapsedDiffs);
+
+    if (!record) { return; } // Happens after "Collapse all" clicked.
+
+    this.filesExpanded = this._computeExpandedFiles(
+        this._expandedFiles.length, this._files.length);
+
+    // Find the paths introduced by the new index splices:
+    const newFiles = record.indexSplices
+        .map(splice => splice.object.slice(
+            splice.index, splice.index + splice.addedCount))
+        .reduce((acc, paths) => acc.concat(paths), []);
+
+    // Required so that the newly created diff view is included in this.diffs.
+    flush();
+
+    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
+
+    if (newFiles.length) {
+      this._renderInOrder(newFiles, this.diffs, newFiles.length);
+    }
+
+    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
+   * for each path in order, awaiting the previous render to complete before
+   * continung.
+   *
+   * @param  {!Array<FileData>} files
+   * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
+   * @param  {number} initialCount The total number of paths in the pass. This
+   *   is used to generate log messages.
+   * @return {!Promise}
+   */
+  _renderInOrder(files, diffElements, initialCount) {
+    let iter = 0;
+
+    return (new Promise(resolve => {
+      this.dispatchEvent(new CustomEvent('reload-drafts', {
+        detail: {resolve},
+        composed: true, bubbles: true,
+      }));
+    })).then(() => this.asyncForeach(files, (file, cancel) => {
+      const path = file.path;
+      this._cancelForEachDiff = cancel;
+
+      iter++;
+      console.log('Expanding diff', iter, 'of', initialCount, ':',
+          path);
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        console.warn(`Did not find <gr-diff-host> element for ${path}`);
         return Promise.resolve();
       }
-
-      this._loading = true;
-
-      this.collapseAllDiffs();
-      const promises = [];
-
-      promises.push(this._getFiles().then(filesByPath => {
-        this._filesByPath = filesByPath;
-      }));
-      promises.push(this._getLoggedIn().then(loggedIn => {
-        return this._loggedIn = loggedIn;
-      }).then(loggedIn => {
-        if (!loggedIn) { return; }
-
-        return this._getReviewedFiles().then(reviewed => {
-          this._reviewed = reviewed;
-        });
-      }));
-
-      promises.push(this._getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      }));
-
-      promises.push(this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
-      }));
-
-      return Promise.all(promises).then(() => {
-        this._loading = false;
-        this._detectChromiteButler();
-        this.$.reporting.fileListDisplayed();
-      });
-    },
-
-    _detectChromiteButler() {
-      const hasButler = !!document.getElementById('butler-suggested-owners');
-      if (hasButler) {
-        this.$.reporting.reportExtension('butler');
+      diffElem.comments = this.changeComments.getCommentsBySideForFile(
+          file, this.patchRange, this.projectConfig);
+      const promises = [diffElem.reload()];
+      if (this._loggedIn && !this.diffPrefs.manual_review) {
+        promises.push(this._reviewFile(path, true));
       }
-    },
-
-    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';
-      });
-
-      return magicFilesExcluded.reduce((acc, obj) => {
-        const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
-        const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
-        const total_size = (obj.size && obj.binary) ? obj.size : 0;
-        const size_delta_inserted =
-            obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
-        const size_delta_deleted =
-            obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
-
-        return {
-          inserted: acc.inserted + inserted,
-          deleted: acc.deleted + deleted,
-          size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
-          size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
-          total_size: acc.total_size + total_size,
-        };
-      }, {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
-      // add it to the list.
-      const pathIndex = this._expandedFilePaths.indexOf(path);
-      if (pathIndex === -1) {
-        this.push('_expandedFilePaths', path);
-      } else {
-        this.splice('_expandedFilePaths', pathIndex, 1);
-      }
-    },
-
-    _togglePathExpandedByIndex(index) {
-      this._togglePathExpanded(this._files[index].__path);
-    },
-
-    _updateDiffPreferences() {
-      if (!this.diffs.length) { return; }
-      // Re-render all expanded diffs sequentially.
-      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;
-
-      // Find the list of paths that are in the file list, but not in the
-      // expanded list.
-      const newPaths = [];
-      let path;
-      for (let i = 0; i < this._shownFiles.length; i++) {
-        path = this._shownFiles[i].__path;
-        if (!this._expandedFilePaths.includes(path)) {
-          newPaths.push(path);
-        }
-      }
-
-      this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
-    },
-
-    collapseAllDiffs() {
-      this._showInlineDiffs = false;
-      this._expandedFilePaths = [];
-      this.filesExpanded = this._computeExpandedFiles(
-          this._expandedFilePaths.length, this._files.length);
+      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();
-    },
+    }));
+  }
 
-    /**
-     * Computes a string with the number of comments and unresolved comments.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeCommentsString(changeComments, patchRange, path) {
-      const unresolvedCount =
-          changeComments.computeUnresolvedNum(patchRange.basePatchNum, path) +
-          changeComments.computeUnresolvedNum(patchRange.patchNum, path);
-      const commentCount =
-          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
-          changeComments.computeCommentCount(patchRange.patchNum, path);
-      const commentString = GrCountStringFormatter.computePluralString(
-          commentCount, 'comment');
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
+  /** Cancel the rendering work of every diff in the list */
+  _cancelDiffs() {
+    if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
+    this._forEachDiff(d => d.cancel());
+  }
 
-      return commentString +
-          // Add a space if both comments and unresolved
-          (commentString && unresolvedString ? ' ' : '') +
-          // Add parentheses around unresolved if it exists.
-          (unresolvedString ? `(${unresolvedString})` : '');
-    },
-
-    /**
-     * Computes a string with the number of drafts.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeDraftsString(changeComments, patchRange, path) {
-      const draftCount =
-          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
-          changeComments.computeDraftCount(patchRange.patchNum, path);
-      return GrCountStringFormatter.computePluralString(draftCount, 'draft');
-    },
-
-    /**
-     * Computes a shortened string with the number of drafts.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeDraftsStringMobile(changeComments, patchRange, path) {
-      const draftCount =
-          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
-          changeComments.computeDraftCount(patchRange.patchNum, path);
-      return GrCountStringFormatter.computeShortString(draftCount, 'd');
-    },
-
-    /**
-     * Computes a shortened string with the number of comments.
-     *
-     * @param {!Object} changeComments
-     * @param {!Object} patchRange
-     * @param {string} path
-     * @return {string}
-     */
-    _computeCommentsStringMobile(changeComments, patchRange, path) {
-      const commentCount =
-          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
-          changeComments.computeCommentCount(patchRange.patchNum, path);
-      return GrCountStringFormatter.computeShortString(commentCount, 'c');
-    },
-
-    /**
-     * @param {string} path
-     * @param {boolean=} opt_reviewed
-     */
-    _reviewFile(path, opt_reviewed) {
-      if (this.editMode) { return; }
-      const index = this._files.findIndex(file => file.__path === path);
-      const reviewed = opt_reviewed || !this._files[index].isReviewed;
-
-      this.set(['_files', index, 'isReviewed'], reviewed);
-      if (index < this._shownFiles.length) {
-        this.notifyPath(`_shownFiles.${index}.isReviewed`);
+  /**
+   * In the given NodeList of diff elements, find the diff for the given path.
+   *
+   * @param  {string} path
+   * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
+   * @return {!Object|undefined} (GrDiffElement)
+   */
+  _findDiffByPath(path, diffElements) {
+    for (let i = 0; i < diffElements.length; i++) {
+      if (diffElements[i].path === path) {
+        return diffElements[i];
       }
+    }
+  }
 
-      this._saveReviewedState(path, reviewed);
-    },
+  /**
+   * Reset the comments of a modified thread
+   *
+   * @param  {string} rootId
+   * @param  {string} path
+   */
+  reloadCommentsForThreadWithRootId(rootId, path) {
+    // Don't bother continuing if we already know that the path that contains
+    // the updated comment thread is not expanded.
+    if (!this._expandedFiles.some(f => f.path === path)) { return; }
+    const diff = this.diffs.find(d => d.path === path);
 
-    _saveReviewedState(path, reviewed) {
-      return this.$.restAPI.saveFileReviewed(this.changeNum,
-          this.patchRange.patchNum, path, reviewed);
-    },
+    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+    if (!threadEl) { return; }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
+    const newComments = this.changeComments.getCommentsForThread(rootId);
 
-    _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
-     * valid.
-     *
-     * @suppress {checkTypes}
-     */
-    _normalizeChangeFilesResponse(response) {
-      if (!response) { return []; }
-      const paths = Object.keys(response).sort(this.specialFilePathCompare);
-      const files = [];
-      for (let i = 0; i < paths.length; i++) {
-        const info = response[paths[i]];
-        info.__path = paths[i];
-        info.lines_inserted = info.lines_inserted || 0;
-        info.lines_deleted = info.lines_deleted || 0;
-        files.push(info);
-      }
-      return files;
-    },
-
-    /**
-     * Handle all events from the file list dom-repeat so event handleers don't
-     * have to get registered for potentially very long lists.
-     */
-    _handleFileListClick(e) {
-      // Traverse upwards to find the row element if the target is not the row.
-      let row = e.target;
-      while (!row.classList.contains('row') && row.parentElement) {
-        row = row.parentElement;
-      }
-
-      const path = row.dataset.path;
-      // Handle checkbox mark as reviewed.
-      if (e.target.classList.contains('markReviewed')) {
-        e.preventDefault();
-        return this._reviewFile(path);
-      }
-
-      // If a path cannot be interpreted from the click target (meaning it's not
-      // somewhere in the row, e.g. diff content) or if the user clicked the
-      // link, defer to the native behavior.
-      if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }
-
-      // Disregard the event if the click target is in the edit controls.
-      if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
-
-      e.preventDefault();
-      this._togglePathExpanded(path);
-    },
-
-    _handleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      this.$.diffCursor.moveLeft();
-    },
-
-    _handleRightPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      this.$.diffCursor.moveRight();
-    },
-
-    _handleToggleInlineDiff(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) ||
-          this.$.fileCursor.index === -1) { return; }
-
-      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)) {
-        return;
-      }
-
-      if (this._showInlineDiffs) {
-        e.preventDefault();
-        this.$.diffCursor.moveDown();
-        this._displayLine = true;
-      } else {
-        // Down key
-        if (this.getKeyboardEvent(e).keyCode === 40) { return; }
-        e.preventDefault();
-        this.$.fileCursor.next();
-        this.selectedIndex = this.$.fileCursor.index;
-      }
-    },
-
-    _handleCursorPrev(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-        return;
-      }
-
-      if (this._showInlineDiffs) {
-        e.preventDefault();
-        this.$.diffCursor.moveUp();
-        this._displayLine = true;
-      } else {
-        // Up key
-        if (this.getKeyboardEvent(e).keyCode === 38) { return; }
-        e.preventDefault();
-        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();
-      }
-    },
-
-    _handleOpenLastFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._openSelectedFile(this._files.length - 1);
-    },
-
-    _handleOpenFirstFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._openSelectedFile(0);
-    },
-
-    _handleOpenFile(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      e.preventDefault();
-
-      if (this._showInlineDiffs) {
-        this._openCursorFile();
-        return;
-      }
-
-      this._openSelectedFile();
-    },
-
-    _handleNextChunk(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-          this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      if (this.isModifierPressed(e, 'shiftKey')) {
-        this.$.diffCursor.moveToNextCommentThread();
-      } else {
-        this.$.diffCursor.moveToNextChunk();
-      }
-    },
-
-    _handlePrevChunk(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-          this._noDiffsExpanded()) {
-        return;
-      }
-
-      e.preventDefault();
-      if (this.isModifierPressed(e, 'shiftKey')) {
-        this.$.diffCursor.moveToPreviousCommentThread();
-      } else {
-        this.$.diffCursor.moveToPreviousChunk();
-      }
-    },
-
-    _handleToggleFileReviewed(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-        return;
-      }
-
-      e.preventDefault();
-      if (!this._files[this.$.fileCursor.index]) { return; }
-      this._reviewFile(this._files[this.$.fileCursor.index].__path);
-    },
-
-    _handleToggleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      this._forEachDiff(diff => {
-        diff.toggleLeftDiff();
-      });
-    },
-
-    _toggleInlineDiffs() {
-      if (this._showInlineDiffs) {
-        this.collapseAllDiffs();
-      } 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
-     */
-    _openSelectedFile(opt_index) {
-      if (opt_index != null) {
-        this.$.fileCursor.setCursorAtIndex(opt_index);
-      }
-      if (!this._files[this.$.fileCursor.index]) { return; }
-      Gerrit.Nav.navigateToDiff(this.change,
-          this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
-          this.patchRange.basePatchNum);
-    },
-
-    _addDraftAtTarget() {
-      const diff = this.$.diffCursor.getTargetDiffElement();
-      const target = this.$.diffCursor.getTargetLineElement();
-      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
-      if ([change, patchNum, basePatchNum, path, editMode]
-          .some(arg => arg === undefined)) {
-        return;
-      }
-      // TODO(kaspern): Fix editing for commit messages and merge lists.
-      if (editMode && path !== this.COMMIT_MESSAGE_PATH &&
-          path !== this.MERGE_LIST_PATH) {
-        return Gerrit.Nav.getEditUrlForDiff(change, path, patchNum,
-            basePatchNum);
-      }
-      return Gerrit.Nav.getUrlForDiff(change, path, patchNum, basePatchNum);
-    },
-
-    _formatBytes(bytes) {
-      if (bytes == 0) return '+/-0 B';
-      const bits = 1024;
-      const decimals = 1;
-      const sizes =
-          ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
-      const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
-      const prepend = bytes > 0 ? '+' : '';
-      return prepend + parseFloat((bytes / Math.pow(bits, exponent))
-          .toFixed(decimals)) + ' ' + sizes[exponent];
-    },
-
-    _formatPercentage(size, delta) {
-      const oldSize = size - delta;
-
-      if (oldSize === 0) { return ''; }
-
-      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
-     * @param {string} path
-     */
-    _computeClass(baseClass, path) {
-      const classes = [];
-      if (baseClass) {
-        classes.push(baseClass);
-      }
-      if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
-        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
-      if ([
-        filesByPath,
-        changeComments,
-        patchRange,
-        reviewed,
-        loading,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      // Await all promises resolving from reload. @See Issue 9057
-      if (loading || !changeComments) { return; }
-
-      const commentedPaths = changeComments.getPaths(patchRange);
-      const files = Object.assign({}, filesByPath);
-      Object.keys(commentedPaths).forEach(commentedPath => {
-        if (files.hasOwnProperty(commentedPath)) { return; }
-        files[commentedPath] = {status: 'U'};
-      });
-      const reviewedSet = new Set(reviewed || []);
-      for (const filePath in files) {
-        if (!files.hasOwnProperty(filePath)) { continue; }
-        files[filePath].isReviewed = reviewedSet.has(filePath);
-      }
-
-      this._files = this._normalizeChangeFilesResponse(files);
-    },
-
-    _computeFilesShown(numFilesShown, files) {
-      // Polymer 2: check for undefined
-      if ([numFilesShown, files].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const previousNumFilesShown = this._shownFiles ?
-        this._shownFiles.length : 0;
-
-      const filesShown = files.slice(0, numFilesShown);
-      this.fire('files-shown-changed', {length: filesShown.length});
-
-      // Start the timer for the rendering work hwere because this is where the
-      // _shownFiles property is being set, and _shownFiles is used in the
-      // dom-repeat binding.
-      this.$.reporting.time(RENDER_TIMING_LABEL);
-
-      // How many more files are being shown (if it's an increase).
-      this._reportinShownFilesIncrement =
-          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) {
-        Polymer.dom.flush();
-        const files = Array.from(
-            Polymer.dom(this.root).querySelectorAll('.file-row'));
-        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
-      if ([revisions, patchNum].some(arg => arg === undefined)) {
-        return '';
-      }
-
-      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
-     * ARIA label.
-     *
-     * @param {string} status
-     * @return {string}
-     */
-    _computeFileStatusLabel(status) {
-      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) {
-        return GrFileListConstants.FilesExpandedState.NONE;
-      } else if (expandedCount === totalCount) {
-        return GrFileListConstants.FilesExpandedState.ALL;
-      }
-      return GrFileListConstants.FilesExpandedState.SOME;
-    },
-
-    /**
-     * Handle splices to the list of expanded file paths. If there are any new
-     * entries in the expanded list, then render each diff corresponding in
-     * order by waiting for the previous diff to finish before starting the next
-     * one.
-     *
-     * @param {!Array} record The splice record in the expanded paths list.
-     */
-    _expandedPathsChanged(record) {
-      // Clear content for any diffs that are not open so if they get re-opened
-      // the stale content does not flash before it is cleared and reloaded.
-      const collapsedDiffs = this.diffs.filter(diff =>
-        this._expandedFilePaths.indexOf(diff.path) === -1);
-      this._clearCollapsedDiffs(collapsedDiffs);
-
-      if (!record) { return; } // Happens after "Collapse all" clicked.
-
-      this.filesExpanded = this._computeExpandedFiles(
-          this._expandedFilePaths.length, this._files.length);
-
-      // Find the paths introduced by the new index splices:
-      const newPaths = record.indexSplices
-          .map(splice => splice.object.slice(
-              splice.index, splice.index + splice.addedCount))
-          .reduce((acc, paths) => acc.concat(paths), []);
-
-      // Required so that the newly created diff view is included in this.diffs.
-      Polymer.dom.flush();
-
-      this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
-
-      if (newPaths.length) {
-        this._renderInOrder(newPaths, this.diffs, newPaths.length);
-      }
-
-      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
-     * for each path in order, awaiting the previous render to complete before
-     * continung.
-     *
-     * @param  {!Array<string>} paths
-     * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
-     * @param  {number} initialCount The total number of paths in the pass. This
-     *   is used to generate log messages.
-     * @return {!Promise}
-     */
-    _renderInOrder(paths, diffElements, initialCount) {
-      let iter = 0;
-
-      return (new Promise(resolve => {
-        this.fire('reload-drafts', {resolve});
-      })).then(() => {
-        return 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();
-        });
-      });
-    },
-
-    /** 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.
-     *
-     * @param  {string} path
-     * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
-     * @return {!Object|undefined} (GrDiffElement)
-     */
-    _findDiffByPath(path, diffElements) {
-      for (let i = 0; i < diffElements.length; i++) {
-        if (diffElements[i].path === path) {
-          return diffElements[i];
-        }
-      }
-    },
-
-    /**
-     * Reset the comments of a modified thread
-     *
-     * @param  {string} rootId
-     * @param  {string} path
-     */
-    reloadCommentsForThreadWithRootId(rootId, path) {
-      // Don't bother continuing if we already know that the path that contains
-      // the updated comment thread is not expanded.
-      if (!this._expandedFilePaths.includes(path)) { return; }
-      const diff = this.diffs.find(d => d.path === path);
-
-      const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-      if (!threadEl) { return; }
-
-      const newComments = this.changeComments.getCommentsForThread(rootId);
-
-      // If newComments is null, it means that a single draft was
-      // removed from a thread in the thread view, and the thread should
-      // no longer exist. Remove the existing thread element in the diff
-      // view.
-      if (!newComments) {
-        threadEl.fireRemoveSelf();
-        return;
-      }
-
-      // Comments are not returned with the commentSide attribute from
-      // the api, but it's necessary to be stored on the diff's
-      // 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});
-      });
-      Polymer.dom.flush();
+    // If newComments is null, it means that a single draft was
+    // removed from a thread in the thread view, and the thread should
+    // no longer exist. Remove the existing thread element in the diff
+    // view.
+    if (!newComments) {
+      threadEl.fireRemoveSelf();
       return;
-    },
+    }
 
-    _handleEscKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      e.preventDefault();
-      this._displayLine = false;
-    },
+    // Comments are not returned with the commentSide attribute from
+    // the api, but it's necessary to be stored on the diff's
+    // 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 => Object.assign(
+        c, {__commentSide: threadEl.commentSide}
+    ));
+    flush();
+    return;
+  }
 
-    /**
-     * Update the loading class for the file list rows. The update is inside a
-     * debouncer so that the file list doesn't flash gray when the API requests
-     * are reasonably fast.
-     *
-     * @param {boolean} loading
-     */
-    _loadingChanged(loading) {
-      this.debounce('loading-change', () => {
-        // Only show set the loading if there have been files loaded to show. In
-        // this way, the gray loading style is not shown on initial loads.
-        this.classList.toggle('loading', loading && !!this._files.length);
-      }, LOADING_DEBOUNCE_INTERVAL);
-    },
+  _handleEscKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+    this._displayLine = false;
+  }
 
-    _editModeChanged(editMode) {
-      this.classList.toggle('editMode', editMode);
-    },
+  /**
+   * Update the loading class for the file list rows. The update is inside a
+   * debouncer so that the file list doesn't flash gray when the API requests
+   * are reasonably fast.
+   *
+   * @param {boolean} loading
+   */
+  _loadingChanged(loading) {
+    this.debounce('loading-change', () => {
+      // Only show set the loading if there have been files loaded to show. In
+      // this way, the gray loading style is not shown on initial loads.
+      this.classList.toggle('loading', loading && !!this._files.length);
+    }, LOADING_DEBOUNCE_INTERVAL);
+  }
 
-    _computeReviewedClass(isReviewed) {
-      return isReviewed ? 'isReviewed' : '';
-    },
+  _editModeChanged(editMode) {
+    this.classList.toggle('editMode', editMode);
+  }
 
-    _computeReviewedText(isReviewed) {
-      return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-    },
+  _computeReviewedClass(isReviewed) {
+    return isReviewed ? 'isReviewed' : '';
+  }
 
-    /**
-     * Given a file path, return whether that path should have visible size bars
-     * and be included in the size bars calculation.
-     *
-     * @param {string} path
-     * @return {boolean}
-     */
-    _showBarsForPath(path) {
-      return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
-    },
+  _computeReviewedText(isReviewed) {
+    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+  }
 
-    /**
-     * Compute size bar layout values from the file list.
-     *
-     * @return {Gerrit.LayoutStats|undefined}
-     *
-     */
-    _computeSizeBarLayout(shownFilesRecord) {
-      if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
-      const stats = {
-        maxInserted: 0,
-        maxDeleted: 0,
-        maxAdditionWidth: 0,
-        maxDeletionWidth: 0,
-        deletionOffset: 0,
-      };
-      shownFilesRecord.base
-          .filter(f => this._showBarsForPath(f.__path))
-          .forEach(f => {
-            if (f.lines_inserted) {
-              stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
-            }
-            if (f.lines_deleted) {
-              stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
-            }
-          });
-      const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
-      if (!isNaN(ratio)) {
-        stats.maxAdditionWidth =
-            (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
-        stats.maxDeletionWidth =
-            SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
-        stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
-      }
-      return stats;
-    },
+  /**
+   * Given a file path, return whether that path should have visible size bars
+   * and be included in the size bars calculation.
+   *
+   * @param {string} path
+   * @return {boolean}
+   */
+  _showBarsForPath(path) {
+    return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
+  }
 
-    /**
-     * Get the width of the addition bar for a file.
-     *
-     * @param {Object} file
-     * @param {Gerrit.LayoutStats} stats
-     * @return {number}
-     */
-    _computeBarAdditionWidth(file, stats) {
-      if (stats.maxInserted === 0 ||
-          !file.lines_inserted ||
-          !this._showBarsForPath(file.__path)) {
-        return 0;
-      }
-      const width =
-          stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
-      return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-    },
+  /**
+   * Compute size bar layout values from the file list.
+   *
+   * @return {Gerrit.LayoutStats|undefined}
+   *
+   */
+  _computeSizeBarLayout(shownFilesRecord) {
+    if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
+    const stats = {
+      maxInserted: 0,
+      maxDeleted: 0,
+      maxAdditionWidth: 0,
+      maxDeletionWidth: 0,
+      deletionOffset: 0,
+    };
+    shownFilesRecord.base
+        .filter(f => this._showBarsForPath(f.__path))
+        .forEach(f => {
+          if (f.lines_inserted) {
+            stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
+          }
+          if (f.lines_deleted) {
+            stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
+          }
+        });
+    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
+    if (!isNaN(ratio)) {
+      stats.maxAdditionWidth =
+          (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
+      stats.maxDeletionWidth =
+          SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
+      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+    }
+    return stats;
+  }
 
-    /**
-     * Get the x-offset of the addition bar for a file.
-     *
-     * @param {Object} file
-     * @param {Gerrit.LayoutStats} stats
-     * @return {number}
-     */
-    _computeBarAdditionX(file, stats) {
-      return stats.maxAdditionWidth -
-          this._computeBarAdditionWidth(file, stats);
-    },
+  /**
+   * Get the width of the addition bar for a file.
+   *
+   * @param {Object} file
+   * @param {Gerrit.LayoutStats} stats
+   * @return {number}
+   */
+  _computeBarAdditionWidth(file, stats) {
+    if (stats.maxInserted === 0 ||
+        !file.lines_inserted ||
+        !this._showBarsForPath(file.__path)) {
+      return 0;
+    }
+    const width =
+        stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
 
-    /**
-     * Get the width of the deletion bar for a file.
-     *
-     * @param {Object} file
-     * @param {Gerrit.LayoutStats} stats
-     * @return {number}
-     */
-    _computeBarDeletionWidth(file, stats) {
-      if (stats.maxDeleted === 0 ||
-          !file.lines_deleted ||
-          !this._showBarsForPath(file.__path)) {
-        return 0;
-      }
-      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 addition bar for a file.
+   *
+   * @param {Object} file
+   * @param {Gerrit.LayoutStats} stats
+   * @return {number}
+   */
+  _computeBarAdditionX(file, stats) {
+    return stats.maxAdditionWidth -
+        this._computeBarAdditionWidth(file, stats);
+  }
 
-    /**
-     * Get the x-offset of the deletion bar for a file.
-     *
-     * @param {Gerrit.LayoutStats} stats
-     *
-     * @return {number}
-     */
-    _computeBarDeletionX(stats) {
-      return stats.deletionOffset;
-    },
+  /**
+   * Get the width of the deletion bar for a file.
+   *
+   * @param {Object} file
+   * @param {Gerrit.LayoutStats} stats
+   * @return {number}
+   */
+  _computeBarDeletionWidth(file, stats) {
+    if (stats.maxDeleted === 0 ||
+        !file.lines_deleted ||
+        !this._showBarsForPath(file.__path)) {
+      return 0;
+    }
+    const width =
+        stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
 
-    _computeShowSizeBars(userPrefs) {
-      return !!userPrefs.size_bar_in_change_table;
-    },
+  /**
+   * Get the x-offset of the deletion bar for a file.
+   *
+   * @param {Gerrit.LayoutStats} stats
+   *
+   * @return {number}
+   */
+  _computeBarDeletionX(stats) {
+    return stats.deletionOffset;
+  }
 
-    _computeSizeBarsClass(showSizeBars, path) {
-      let hideClass = '';
-      if (!showSizeBars) {
-        hideClass = 'hide';
-      } else if (!this._showBarsForPath(path)) {
-        hideClass = 'invisible';
-      }
-      return `sizeBars desktop ${hideClass}`;
-    },
+  _computeShowSizeBars(userPrefs) {
+    return !!userPrefs.size_bar_in_change_table;
+  }
 
-    /**
-     * Shows registered dynamic columns iff the 'header', 'content' and
-     * 'summary' endpoints are regiestered the exact same number of times.
-     * Ideally, there should be a better way to enforce the expectation of the
-     * dependencies between dynamic endpoints.
-     */
-    _computeShowDynamicColumns(
-        headerEndpoints, contentEndpoints, summaryEndpoints) {
-      return headerEndpoints && contentEndpoints && summaryEndpoints &&
-             headerEndpoints.length === contentEndpoints.length &&
-             headerEndpoints.length === summaryEndpoints.length;
-    },
+  _computeSizeBarsClass(showSizeBars, path) {
+    let hideClass = '';
+    if (!showSizeBars) {
+      hideClass = 'hide';
+    } else if (!this._showBarsForPath(path)) {
+      hideClass = 'invisible';
+    }
+    return `sizeBars desktop ${hideClass}`;
+  }
 
-    /**
-     * Returns true if none of the inline diffs have been expanded.
-     *
-     * @return {boolean}
-     */
-    _noDiffsExpanded() {
-      return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
-    },
+  /**
+   * Shows registered dynamic columns iff the 'header', 'content' and
+   * 'summary' endpoints are regiestered the exact same number of times.
+   * Ideally, there should be a better way to enforce the expectation of the
+   * dependencies between dynamic endpoints.
+   */
+  _computeShowDynamicColumns(
+      headerEndpoints, contentEndpoints, summaryEndpoints) {
+    return headerEndpoints && contentEndpoints && summaryEndpoints &&
+           headerEndpoints.length === contentEndpoints.length &&
+           headerEndpoints.length === summaryEndpoints.length;
+  }
 
-    /**
-     * Method to call via binding when each file list row is rendered. This
-     * allows approximate detection of when the dom-repeat has completed
-     * rendering.
-     *
-     * @param {number} index The index of the row being rendered.
-     * @return {string} an empty string.
-     */
-    _reportRenderedRow(index) {
-      if (index === this._shownFiles.length - 1) {
-        this.async(() => {
-          this.$.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
-              RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
-        }, 1);
-      }
-      return '';
-    },
+  /**
+   * Returns true if none of the inline diffs have been expanded.
+   *
+   * @return {boolean}
+   */
+  _noDiffsExpanded() {
+    return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
+  }
 
-    _reviewedTitle(reviewed) {
-      if (reviewed) {
-        return 'Mark as not reviewed (shortcut: r)';
-      }
+  /**
+   * Method to call via binding when each file list row is rendered. This
+   * allows approximate detection of when the dom-repeat has completed
+   * rendering.
+   *
+   * @param {number} index The index of the row being rendered.
+   * @return {string} an empty string.
+   */
+  _reportRenderedRow(index) {
+    if (index === this._shownFiles.length - 1) {
+      this.async(() => {
+        this.$.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
+            RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
+      }, 1);
+    }
+    return '';
+  }
 
-      return 'Mark as reviewed (shortcut: r)';
-    },
+  _reviewedTitle(reviewed) {
+    if (reviewed) {
+      return 'Mark as not reviewed (shortcut: r)';
+    }
 
-    _handleReloadingDiffPreference() {
-      this._getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      });
-    },
-  });
-})();
+    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_html.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
new file mode 100644
index 0000000..a9a785e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
@@ -0,0 +1,591 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    .row {
+      align-items: center;
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
+      padding: var(--spacing-xs) var(--spacing-l) var(--spacing-xs)
+        calc(var(--spacing-l) - 0.35rem);
+    }
+    :host(.loading) .row {
+      opacity: 0.5;
+    }
+    :host(.editMode) .hideOnEdit {
+      display: none;
+    }
+    .showOnEdit {
+      display: none;
+    }
+    :host(.editMode) .showOnEdit {
+      display: initial;
+    }
+    .invisible {
+      visibility: hidden;
+    }
+    .header-row {
+      background-color: var(--background-color-secondary);
+    }
+    .controlRow {
+      align-items: center;
+      display: flex;
+      height: 2.25em;
+      justify-content: center;
+    }
+    .controlRow.invisible,
+    .show-hide.invisible {
+      display: none;
+    }
+    .reviewed,
+    .status {
+      align-items: center;
+      display: inline-flex;
+    }
+    .reviewed,
+    .status {
+      display: inline-block;
+      text-align: left;
+      width: 1.5em;
+    }
+    .file-row {
+      cursor: pointer;
+    }
+    .file-row.expanded {
+      border-bottom: 1px solid var(--border-color);
+      position: -webkit-sticky;
+      position: sticky;
+      top: 0;
+      /* Has to visible above the diff view, and by default has a lower
+         z-index. setting to 1 places it directly above. */
+      z-index: 1;
+    }
+    .file-row:hover {
+      background-color: var(--hover-background-color);
+    }
+    .file-row.selected {
+      background-color: var(--selection-background-color);
+    }
+    .file-row.expanded,
+    .file-row.expanded:hover {
+      background-color: var(--expanded-background-color);
+    }
+    .path {
+      cursor: pointer;
+      flex: 1;
+      /* Wrap it into multiple lines if too long. */
+      white-space: normal;
+      word-break: break-word;
+    }
+    .oldPath {
+      color: var(--deemphasized-text-color);
+    }
+    .header-stats {
+      text-align: center;
+      min-width: 7.5em;
+    }
+    .stats {
+      text-align: right;
+      min-width: 7.5em;
+    }
+    .comments {
+      padding-left: var(--spacing-l);
+      min-width: 7.5em;
+    }
+    .row:not(.header-row) .stats,
+    .total-stats {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      display: flex;
+    }
+    .sizeBars {
+      margin-left: var(--spacing-m);
+      min-width: 7em;
+      text-align: center;
+    }
+    .sizeBars.hide {
+      display: none;
+    }
+    .added,
+    .removed {
+      display: inline-block;
+      min-width: 3.5em;
+    }
+    .added {
+      color: var(--vote-text-color-recommended);
+    }
+    .removed {
+      color: var(--vote-text-color-disliked);
+      text-align: left;
+      min-width: 4em;
+      padding-left: var(--spacing-s);
+    }
+    .drafts {
+      color: #c62828;
+      font-weight: var(--font-weight-bold);
+    }
+    .show-hide {
+      margin-left: var(--spacing-s);
+      width: 1.9em;
+    }
+    .fileListButton {
+      margin: var(--spacing-m);
+    }
+    .totalChanges {
+      justify-content: flex-end;
+      text-align: right;
+    }
+    .warning {
+      color: var(--deemphasized-text-color);
+    }
+    input.show-hide {
+      display: none;
+    }
+    label.show-hide {
+      cursor: pointer;
+      display: block;
+      min-width: 2em;
+    }
+    gr-diff {
+      display: block;
+      overflow-x: auto;
+    }
+    .truncatedFileName {
+      display: none;
+    }
+    .mobile {
+      display: none;
+    }
+    .reviewed {
+      margin-left: var(--spacing-xxl);
+      width: 15em;
+    }
+    .reviewed label {
+      color: var(--link-color);
+      opacity: 0;
+      justify-content: flex-end;
+      width: 100%;
+    }
+    .reviewed label:hover {
+      cursor: pointer;
+      opacity: 100;
+    }
+    .row:focus {
+      outline: none;
+    }
+    .row:hover .reviewed label,
+    .row:focus .reviewed label,
+    .row.expanded .reviewed label {
+      opacity: 100;
+    }
+    .reviewed input {
+      display: none;
+    }
+    .reviewedLabel {
+      color: var(--deemphasized-text-color);
+      margin-right: var(--spacing-l);
+      opacity: 0;
+    }
+    .reviewedLabel.isReviewed {
+      display: initial;
+      opacity: 100;
+    }
+    .editFileControls {
+      width: 7em;
+    }
+    .markReviewed,
+    .pathLink {
+      display: inline-block;
+      margin: -2px 0;
+      padding: var(--spacing-s) 0;
+      text-decoration: none;
+    }
+    .pathLink:hover {
+      text-decoration: underline;
+    }
+
+    /** copy on file path **/
+    .pathLink gr-copy-clipboard,
+    .oldPath gr-copy-clipboard {
+      display: inline-block;
+      visibility: hidden;
+      vertical-align: bottom;
+      text-decoration: none;
+      --gr-button: {
+        padding: 0px;
+      }
+    }
+    .pathLink:hover gr-copy-clipboard,
+    .oldPath:hover gr-copy-clipboard {
+      visibility: visible;
+    }
+
+    /** small screen breakpoint: 768px */
+    @media screen and (max-width: 55em) {
+      .desktop {
+        display: none;
+      }
+      .mobile {
+        display: block;
+      }
+      .row.selected {
+        background-color: var(--view-background-color);
+      }
+      .stats {
+        display: none;
+      }
+      .reviewed,
+      .status {
+        justify-content: flex-start;
+      }
+      .reviewed {
+        display: none;
+      }
+      .comments {
+        min-width: initial;
+      }
+      .expanded .fullFileName,
+      .truncatedFileName {
+        display: inline;
+      }
+      .expanded .truncatedFileName,
+      .fullFileName {
+        display: none;
+      }
+    }
+  </style>
+  <div id="container" on-click="_handleFileListClick">
+    <div class="header-row row">
+      <div class="status"></div>
+      <div class="path">File</div>
+      <div class="comments">Comments</div>
+      <div class="sizeBars">Size</div>
+      <div class="header-stats">Delta</div>
+      <template is="dom-if" if="[[_showDynamicColumns]]">
+        <template
+          is="dom-repeat"
+          items="[[_dynamicHeaderEndpoints]]"
+          as="headerEndpoint"
+        >
+          <gr-endpoint-decorator name$="[[headerEndpoint]]">
+          </gr-endpoint-decorator>
+        </template>
+      </template>
+      <!-- Empty div here exists to keep spacing in sync with file rows. -->
+      <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
+      <div class="editFileControls showOnEdit"></div>
+      <div class="show-hide"></div>
+    </div>
+
+    <template
+      is="dom-repeat"
+      items="[[_shownFiles]]"
+      id="files"
+      as="file"
+      initial-count="[[fileListIncrement]]"
+      target-framerate="1"
+    >
+      [[_reportRenderedRow(index)]]
+      <div class="stickyArea">
+        <div
+          class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
+          data-file$="[[_computeFileData(file)]]"
+          tabindex="-1"
+        >
+          <div
+            class$="[[_computeClass('status', file.__path)]]"
+            tabindex="0"
+            title$="[[_computeFileStatusLabel(file.status)]]"
+            aria-label$="[[_computeFileStatusLabel(file.status)]]"
+          >
+            [[_computeFileStatus(file.status)]]
+          </div>
+          <!-- TODO: Remove data-url as it appears its not used -->
+          <span
+            data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
+            class="path"
+          >
+            <a
+              class="pathLink"
+              href$="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
+            >
+              <span
+                title$="[[computeDisplayPath(file.__path)]]"
+                class="fullFileName"
+              >
+                [[computeDisplayPath(file.__path)]]
+              </span>
+              <span
+                title$="[[computeDisplayPath(file.__path)]]"
+                class="truncatedFileName"
+              >
+                [[computeTruncatedPath(file.__path)]]
+              </span>
+              <gr-copy-clipboard
+                hide-input=""
+                text="[[file.__path]]"
+              ></gr-copy-clipboard>
+            </a>
+            <template is="dom-if" if="[[file.old_path]]">
+              <div class="oldPath" title$="[[file.old_path]]">
+                [[file.old_path]]
+                <gr-copy-clipboard
+                  hide-input=""
+                  text="[[file.old_path]]"
+                ></gr-copy-clipboard>
+              </div>
+            </template>
+          </span>
+          <div class="comments desktop">
+            <span class="drafts">
+              [[_computeDraftsString(changeComments, patchRange, file.__path)]]
+            </span>
+            [[_computeCommentsString(changeComments, patchRange, file.__path)]]
+          </div>
+          <div class="comments mobile">
+            <span class="drafts">
+              [[_computeDraftsStringMobile(changeComments, patchRange,
+              file.__path)]]
+            </span>
+            [[_computeCommentsStringMobile(changeComments, patchRange,
+            file.__path)]]
+          </div>
+          <div class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]">
+            <svg width="61" height="8">
+              <rect
+                x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
+                y="0"
+                height="8"
+                fill="#388E3C"
+                width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
+              ></rect>
+              <rect
+                x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
+                y="0"
+                height="8"
+                fill="#D32F2F"
+                width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
+              ></rect>
+            </svg>
+          </div>
+          <div class$="[[_computeClass('stats', file.__path)]]">
+            <span
+              class="added"
+              tabindex="0"
+              aria-label$="[[file.lines_inserted]] lines added"
+              hidden$="[[file.binary]]"
+            >
+              +[[file.lines_inserted]]
+            </span>
+            <span
+              class="removed"
+              tabindex="0"
+              aria-label$="[[file.lines_deleted]] lines removed"
+              hidden$="[[file.binary]]"
+            >
+              -[[file.lines_deleted]]
+            </span>
+            <span
+              class$="[[_computeBinaryClass(file.size_delta)]]"
+              hidden$="[[!file.binary]]"
+            >
+              [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
+              file.size_delta)]]
+            </span>
+          </div>
+          <template is="dom-if" if="[[_showDynamicColumns]]">
+            <template
+              is="dom-repeat"
+              items="[[_dynamicContentEndpoints]]"
+              as="contentEndpoint"
+            >
+              <div class$="[[_computeClass('', file.__path)]]">
+                <gr-endpoint-decorator name="[[contentEndpoint]]">
+                  <gr-endpoint-param name="changeNum" value="[[changeNum]]">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param name="path" value="[[file.__path]]">
+                  </gr-endpoint-param>
+                </gr-endpoint-decorator>
+              </div>
+            </template>
+          </template>
+          <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden="">
+            <span
+              class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
+              >Reviewed</span
+            >
+            <label>
+              <input
+                class="reviewed"
+                type="checkbox"
+                checked="[[file.isReviewed]]"
+              />
+              <span
+                class="markReviewed"
+                title$="[[_reviewedTitle(file.isReviewed)]]"
+                >[[_computeReviewedText(file.isReviewed)]]</span
+              >
+            </label>
+          </div>
+          <div class="editFileControls showOnEdit">
+            <template is="dom-if" if="[[editMode]]">
+              <gr-edit-file-controls
+                class$="[[_computeClass('', file.__path)]]"
+                file-path="[[file.__path]]"
+              ></gr-edit-file-controls>
+            </template>
+          </div>
+          <div class="show-hide">
+            <label
+              class="show-hide"
+              data-path$="[[file.__path]]"
+              data-expand="true"
+            >
+              <input
+                type="checkbox"
+                class="show-hide"
+                checked$="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
+                data-path$="[[file.__path]]"
+                data-expand="true"
+              />
+              <iron-icon
+                id="icon"
+                icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
+              >
+              </iron-icon>
+            </label>
+          </div>
+        </div>
+        <template
+          is="dom-if"
+          if="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
+        >
+          <gr-diff-host
+            no-auto-render=""
+            show-load-failure=""
+            display-line="[[_displayLine]]"
+            hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
+            change-num="[[changeNum]]"
+            patch-range="[[patchRange]]"
+            path="[[file.__path]]"
+            prefs="[[diffPrefs]]"
+            project-name="[[change.project]]"
+            no-render-on-prefs-change=""
+            view-mode="[[diffViewMode]]"
+          ></gr-diff-host>
+        </template>
+      </div>
+    </template>
+  </div>
+  <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
+    <div class="total-stats">
+      <span
+        class="added"
+        tabindex="0"
+        aria-label$="[[_patchChange.inserted]] lines added"
+      >
+        +[[_patchChange.inserted]]
+      </span>
+      <span
+        class="removed"
+        tabindex="0"
+        aria-label$="[[_patchChange.deleted]] lines removed"
+      >
+        -[[_patchChange.deleted]]
+      </span>
+    </div>
+    <template is="dom-if" if="[[_showDynamicColumns]]">
+      <template
+        is="dom-repeat"
+        items="[[_dynamicSummaryEndpoints]]"
+        as="summaryEndpoint"
+      >
+        <gr-endpoint-decorator name="[[summaryEndpoint]]">
+        </gr-endpoint-decorator>
+      </template>
+    </template>
+    <!-- Empty div here exists to keep spacing in sync with file rows. -->
+    <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
+    <div class="editFileControls showOnEdit"></div>
+    <div class="show-hide"></div>
+  </div>
+  <div class="row totalChanges" hidden$="[[_hideBinaryChangeTotals]]">
+    <div class="total-stats">
+      <span class="added" aria-label="Total lines added">
+        [[_formatBytes(_patchChange.size_delta_inserted)]]
+        [[_formatPercentage(_patchChange.total_size,
+        _patchChange.size_delta_inserted)]]
+      </span>
+      <span class="removed" aria-label="Total lines removed">
+        [[_formatBytes(_patchChange.size_delta_deleted)]]
+        [[_formatPercentage(_patchChange.total_size,
+        _patchChange.size_delta_deleted)]]
+      </span>
+    </div>
+  </div>
+  <div
+    class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]"
+  >
+    <gr-button
+      class="fileListButton"
+      id="incrementButton"
+      link=""
+      on-click="_incrementNumFilesShown"
+    >
+      [[_computeIncrementText(numFilesShown, _files)]]
+    </gr-button>
+    <gr-tooltip-content
+      has-tooltip="[[_computeWarnShowAll(_files)]]"
+      show-icon="[[_computeWarnShowAll(_files)]]"
+      title$="[[_computeShowAllWarning(_files)]]"
+    >
+      <gr-button
+        class="fileListButton"
+        id="showAllButton"
+        link=""
+        on-click="_showAllFiles"
+      >
+        [[_computeShowAllText(_files)]] </gr-button
+      ><!--
+  --></gr-tooltip-content>
+  </div>
+  <gr-diff-preferences-dialog
+    id="diffPreferencesDialog"
+    diff-prefs="{{diffPrefs}}"
+    on-reload-diff-preference="_handleReloadingDiffPreference"
+  >
+  </gr-diff-preferences-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+  <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
+  <gr-cursor-manager
+    id="fileCursor"
+    scroll-behavior="keep-visible"
+    focus-on-move=""
+    cursor-target-class="selected"
+  ></gr-cursor-manager>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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..32945e4 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
@@ -17,21 +17,15 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-file-list.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/components/web-component-tester/data/a11ySuite.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <dom-module id="comment-api-mock">
   <template>
@@ -40,8 +34,7 @@
         on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
-  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
-</dom-module>
+  </dom-module>
 
 <test-fixture id="basic">
   <template>
@@ -49,32 +42,43 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-file-list tests', () => {
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
-    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-    kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
-    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
-    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
-    kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
-    kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
-    kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
-    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
-    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
+import './gr-file-list.js';
+import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    let element;
-    let commentApiWrapper;
-    let sandbox;
-    let saveStub;
-    let loadCommentSpy;
+suite('gr-file-list tests', () => {
+  const kb = KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
+  kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+  kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+  kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+  kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
+  kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
+  kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
+  kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
+  kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
+  kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
+  kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
+  kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+  kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
 
+  let element;
+  let commentApiWrapper;
+  let sandbox;
+  let saveStub;
+  let loadCommentSpy;
+
+  suite('basic tests', () => {
     setup(done => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
@@ -115,7 +119,7 @@
         patchNum: '2',
       };
       saveStub = sandbox.stub(element, '_saveReviewedState',
-          () => { return Promise.resolve(); });
+          () => Promise.resolve());
     });
 
     teardown(() => {
@@ -132,9 +136,10 @@
 
       flushAsynchronousOperations();
       assert.equal(
-          Polymer.dom(element.root).querySelectorAll('.file-row').length,
+          dom(element.root).querySelectorAll('.file-row').length,
           element.numFilesShown);
-      const controlRow = element.$$('.controlRow');
+      const controlRow = element.shadowRoot
+          .querySelector('.controlRow');
       assert.isFalse(controlRow.classList.contains('invisible'));
       assert.equal(element.$.incrementButton.textContent.trim(),
           'Show 300 more');
@@ -158,7 +163,7 @@
           }, {});
       flushAsynchronousOperations();
       assert.equal(
-          Polymer.dom(element.root).querySelectorAll('.file-row').length, 10);
+          dom(element.root).querySelectorAll('.file-row').length, 10);
       assert.equal(renderedStub.callCount, 10);
     });
 
@@ -463,8 +468,11 @@
           element._computeCommentsString(element.changeComments, _1To2,
               'myfile.txt', 'comment'), '3 comments');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo1,
-              'myfile.txt'), '1c');
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'myfile.txt'
+          ), '1c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
               'myfile.txt'), '3c');
@@ -487,8 +495,11 @@
           element._computeCommentsString(element.changeComments, _1To2,
               'file_added_in_rev2.txt', 'comment'), '');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'file_added_in_rev2.txt'
+          ), '');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
               'file_added_in_rev2.txt'), '');
@@ -511,8 +522,11 @@
           element._computeCommentsString(element.changeComments, _1To2,
               '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo2,
-              '/COMMIT_MSG'), '1c');
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              '/COMMIT_MSG'
+          ), '1c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
               '/COMMIT_MSG'), '3c');
@@ -523,8 +537,11 @@
           element._computeDraftsString(element.changeComments, _1To2,
               '/COMMIT_MSG'), '2 drafts');
       assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              '/COMMIT_MSG'), '2d');
+          element._computeDraftsStringMobile(
+              element.changeComments,
+              parentTo1,
+              '/COMMIT_MSG'
+          ), '2d');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               '/COMMIT_MSG'), '2d');
@@ -535,8 +552,11 @@
           element._computeCommentsString(element.changeComments, _1To2,
               'myfile.txt', 'comment'), '3 comments');
       assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo2,
-              'myfile.txt'), '2c');
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              'myfile.txt'
+          ), '2c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
               'myfile.txt'), '3c');
@@ -601,7 +621,7 @@
       test('keyboard shortcuts', () => {
         flushAsynchronousOperations();
 
-        const items = Polymer.dom(element.root).querySelectorAll('.file-row');
+        const items = dom(element.root).querySelectorAll('.file-row');
         element.$.fileCursor.stops = items;
         element.$.fileCursor.setCursorAtIndex(0);
         assert.equal(items.length, 3);
@@ -620,7 +640,7 @@
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
-        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+        const navStub = sandbox.stub(GerritNav, 'navigateToDiff');
         assert.equal(element.$.fileCursor.index, 2);
         assert.equal(element.selectedIndex, 2);
 
@@ -647,45 +667,62 @@
         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', () => {
-        sandbox.stub(element, '_expandedPathsChanged');
+        const paths = Object.keys(element._filesByPath);
+        sandbox.stub(element, '_expandedFilesChanged');
         flushAsynchronousOperations();
-        const files = Polymer.dom(element.root).querySelectorAll('.file-row');
+        const files = dom(element.root).querySelectorAll('.file-row');
         element.$.fileCursor.stops = files;
         element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+
         MockInteractions.keyUpOn(element, 73, null, 'i');
         flushAsynchronousOperations();
-        assert.include(element._expandedFilePaths, element.diffs[0].path);
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[0]);
+        assert.equal(element._expandedFiles.length, 1);
+        assert.equal(element._expandedFiles[0].path, paths[0]);
+
         MockInteractions.keyUpOn(element, 73, null, 'i');
         flushAsynchronousOperations();
-        assert.notInclude(element._expandedFilePaths, element.diffs[0].path);
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+
         element.$.fileCursor.setCursorAtIndex(1);
         MockInteractions.keyUpOn(element, 73, null, 'i');
         flushAsynchronousOperations();
-        assert.include(element._expandedFilePaths, element.diffs[1].path);
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[1]);
+        assert.equal(element._expandedFiles.length, 1);
+        assert.equal(element._expandedFiles[0].path, paths[1]);
 
         MockInteractions.keyUpOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
+        assert.equal(element.diffs.length, paths.length);
+        assert.equal(element._expandedFiles.length, paths.length);
         for (const index in element.diffs) {
           if (!element.diffs.hasOwnProperty(index)) { continue; }
-          assert.include(element._expandedFilePaths, element.diffs[index].path);
+          assert.isTrue(
+              element._expandedFiles
+                  .some(f => f.path === element.diffs[index].path)
+          );
         }
+
         MockInteractions.keyUpOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
-        for (const index in element.diffs) {
-          if (!element.diffs.hasOwnProperty(index)) { continue; }
-          assert.notInclude(element._expandedFilePaths,
-              element.diffs[index].path);
-        }
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
       });
 
       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();
 
@@ -711,7 +748,7 @@
           sandbox.stub(element, 'modifierPressed').returns(false);
           const openCursorStub = sandbox.stub(element, '_openCursorFile');
           const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
-          const expandStub = sandbox.stub(element, '_togglePathExpanded');
+          const expandStub = sandbox.stub(element, '_toggleFileExpanded');
 
           interact = function(opt_payload) {
             openCursorStub.reset();
@@ -802,7 +839,7 @@
 
       flushAsynchronousOperations();
       const fileRows =
-          Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
+          dom(element.root).querySelectorAll('.row:not(.header-row)');
       const checkSelector = 'input.reviewed[type="checkbox"]';
       const commitMsg = fileRows[0].querySelector(checkSelector);
       const fileAdded = fileRows[1].querySelector(checkSelector);
@@ -850,12 +887,12 @@
 
       const clickSpy = sandbox.spy(element, '_handleFileListClick');
       const reviewStub = sandbox.stub(element, '_reviewFile');
-      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
+      const toggleExpandSpy = sandbox.spy(element, '_toggleFileExpanded');
 
-      const row = Polymer.dom(element.root)
-          .querySelector('.row[data-path="f1.txt"]');
+      const row = dom(element.root)
+          .querySelector(`.row[data-file='{"path":"f1.txt"}']`);
 
-      // Click on the expand button, resulting in _togglePathExpanded being
+      // Click on the expand button, resulting in _toggleFileExpanded being
       // called and not resulting in a call to _reviewFile.
       row.querySelector('div.show-hide').click();
       assert.isTrue(clickSpy.calledOnce);
@@ -863,14 +900,15 @@
       assert.isFalse(reviewStub.called);
 
       // Click inside the diff. This should result in no additional calls to
-      // _togglePathExpanded or _reviewFile.
-      Polymer.dom(element.root).querySelector('gr-diff-host').click();
+      // _toggleFileExpanded or _reviewFile.
+      dom(element.root).querySelector('gr-diff-host')
+          .click();
       assert.isTrue(clickSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
 
       // Click the reviewed checkbox, resulting in a call to _reviewFile, but
-      // no additional call to _togglePathExpanded.
+      // no additional call to _toggleFileExpanded.
       row.querySelector('.markReviewed').click();
       assert.isTrue(clickSpy.calledThrice);
       assert.isTrue(toggleExpandSpy.calledOnce);
@@ -891,10 +929,11 @@
       element.editMode = true;
       flushAsynchronousOperations();
       const clickSpy = sandbox.spy(element, '_handleFileListClick');
-      const toggleExpandSpy = sandbox.spy(element, '_togglePathExpanded');
+      const toggleExpandSpy = sandbox.spy(element, '_toggleFileExpanded');
 
       // Tap the edit controls. Should be ignored by _handleFileListClick.
-      MockInteractions.tap(element.$$('.editFileControls'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.editFileControls'));
       assert.isTrue(clickSpy.calledOnce);
       assert.isFalse(toggleExpandSpy.called);
     });
@@ -930,10 +969,10 @@
         patchNum: '2',
       };
       element.$.fileCursor.setCursorAtIndex(0);
-      sandbox.stub(element, '_expandedPathsChanged');
+      sandbox.stub(element, '_expandedFilesChanged');
       flushAsynchronousOperations();
       const fileRows =
-          Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
+          dom(element.root).querySelectorAll('.row:not(.header-row)');
       // Because the label surrounds the input, the tap event is triggered
       // there first.
       const showHideLabel = fileRows[0].querySelector('label.show-hide');
@@ -942,7 +981,9 @@
       assert.isNotOk(showHideCheck.checked);
       MockInteractions.tap(showHideLabel);
       assert.isOk(showHideCheck.checked);
-      assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
+      assert.notEqual(
+          element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
+          -1);
     });
 
     test('diff mode correctly toggles the diffs', () => {
@@ -959,7 +1000,7 @@
       flushAsynchronousOperations();
 
       // Tap on a file to generate the diff.
-      const row = Polymer.dom(element.root)
+      const row = dom(element.root)
           .querySelectorAll('.row:not(.header-row) label.show-hide')[0];
 
       MockInteractions.tap(row);
@@ -975,7 +1016,8 @@
       element._filesByPath = {
         '/COMMIT_MSG': {},
       };
-      assert.isNotOk(element.$$('.expanded'));
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
     });
 
     test('tapping row ignores links', () => {
@@ -987,44 +1029,49 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      sandbox.stub(element, '_expandedPathsChanged');
+      sandbox.stub(element, '_expandedFilesChanged');
       flushAsynchronousOperations();
-      const commitMsgFile = Polymer.dom(element.root)
+      const commitMsgFile = dom(element.root)
           .querySelectorAll('.row:not(.header-row) a.pathLink')[0];
 
       // Remove href attribute so the app doesn't route to a diff view
       commitMsgFile.removeAttribute('href');
-      const togglePathSpy = sandbox.spy(element, '_togglePathExpanded');
+      const togglePathSpy = sandbox.spy(element, '_toggleFileExpanded');
 
       MockInteractions.tap(commitMsgFile);
       flushAsynchronousOperations();
       assert(togglePathSpy.notCalled, 'file is opened as diff view');
-      assert.isNotOk(element.$$('.expanded'));
-      assert.notEqual(getComputedStyle(element.$$('.show-hide')).display,
-          'none');
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.show-hide')).display,
+      'none');
     });
 
-    test('_togglePathExpanded', () => {
+    test('_toggleFileExpanded', () => {
       const path = 'path/to/my/file.txt';
       element._filesByPath = {[path]: {}};
       const renderSpy = sandbox.spy(element, '_renderInOrder');
       const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
 
-      assert.equal(element.$$('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(element._expandedFilePaths.length, 0);
-      element._togglePathExpanded(path);
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(element._expandedFiles.length, 0);
+      element._toggleFileExpanded({path});
       flushAsynchronousOperations();
       assert.equal(collapseStub.lastCall.args[0].length, 0);
-      assert.equal(element.$$('iron-icon').icon, 'gr-icons:expand-less');
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
 
       assert.equal(renderSpy.callCount, 1);
-      assert.include(element._expandedFilePaths, path);
-      element._togglePathExpanded(path);
+      assert.isTrue(element._expandedFiles.some(f => f.path === path));
+      element._toggleFileExpanded({path});
       flushAsynchronousOperations();
 
-      assert.equal(element.$$('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
       assert.equal(renderSpy.callCount, 1);
-      assert.notInclude(element._expandedFilePaths, path);
+      assert.isFalse(element._expandedFiles.some(f => f.path === path));
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
@@ -1043,28 +1090,34 @@
 
       element.collapseAllDiffs();
       flushAsynchronousOperations();
-      assert.equal(element._expandedFilePaths.length, 0);
+      assert.equal(element._expandedFiles.length, 0);
       assert.isFalse(element._showInlineDiffs);
       assert.isTrue(cursorUpdateStub.calledTwice);
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
-    test('_expandedPathsChanged', done => {
+    test('_expandedFilesChanged', done => {
       sandbox.stub(element, '_reviewFile');
       const path = 'path/to/my/file.txt';
       const diffs = [{
         path,
+        style: {},
         reload() {
           done();
         },
         cancel() {},
         getCursorStops() { return []; },
-        addEventListener(eventName, callback) { callback(new Event(eventName)); },
+        addEventListener(eventName, callback) {
+          if (['render-start', 'render-content', 'scroll']
+              .indexOf(eventName) >= 0) {
+            callback(new Event(eventName));
+          }
+        },
       }];
       sinon.stub(element, 'diffs', {
         get() { return diffs; },
       });
-      element.push('_expandedFilePaths', path);
+      element.push('_expandedFiles', {path});
     });
 
     test('_clearCollapsedDiffs', () => {
@@ -1085,11 +1138,11 @@
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
           GrFileListConstants.FilesExpandedState.NONE);
-      element.push('_expandedFilePaths', 'baz.bar');
+      element.push('_expandedFiles', {path: 'baz.bar'});
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
           GrFileListConstants.FilesExpandedState.SOME);
-      element.push('_expandedFilePaths', 'foo.bar');
+      element.push('_expandedFiles', {path: 'foo.bar'});
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
           GrFileListConstants.FilesExpandedState.ALL);
@@ -1108,24 +1161,29 @@
       let callCount = 0;
       const diffs = [{
         path: 'p0',
+        style: {},
         reload() {
           assert.equal(callCount++, 2);
           return Promise.resolve();
         },
       }, {
         path: 'p1',
+        style: {},
         reload() {
           assert.equal(callCount++, 1);
           return Promise.resolve();
         },
       }, {
         path: 'p2',
+        style: {},
         reload() {
           assert.equal(callCount++, 0);
           return Promise.resolve();
         },
       }];
-      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
+      element._renderInOrder([
+        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
+      ], diffs, 3)
           .then(() => {
             assert.isFalse(reviewStub.called);
             assert.isTrue(loadCommentSpy.called);
@@ -1139,6 +1197,7 @@
       let callCount = 0;
       const diffs = [{
         path: 'p0',
+        style: {},
         reload() {
           assert.equal(reviewStub.callCount, 2);
           assert.equal(callCount++, 2);
@@ -1146,6 +1205,7 @@
         },
       }, {
         path: 'p1',
+        style: {},
         reload() {
           assert.equal(reviewStub.callCount, 1);
           assert.equal(callCount++, 1);
@@ -1153,13 +1213,16 @@
         },
       }, {
         path: 'p2',
+        style: {},
         reload() {
           assert.equal(reviewStub.callCount, 0);
           assert.equal(callCount++, 0);
           return Promise.resolve();
         },
       }];
-      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
+      element._renderInOrder([
+        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
+      ], diffs, 3)
           .then(() => {
             assert.equal(reviewStub.callCount, 3);
             done();
@@ -1172,13 +1235,14 @@
       const reviewStub = sandbox.stub(element, '_reviewFile');
       const diffs = [{
         path: 'p',
+        style: {},
         reload() { return Promise.resolve(); },
       }];
 
-      return element._renderInOrder(['p'], diffs, 1).then(() => {
+      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
         assert.isFalse(reviewStub.called);
         delete element.diffPrefs.manual_review;
-        return element._renderInOrder(['p'], diffs, 1).then(() => {
+        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
           assert.isTrue(reviewStub.called);
           assert.isTrue(reviewStub.calledWithExactly('p', true));
         });
@@ -1212,131 +1276,201 @@
       element.flushDebouncer('loading-change');
       assert.isFalse(element.classList.contains('loading'));
     });
+  });
 
-    suite('size bars', () => {
-      test('_computeSizeBarLayout', () => {
-        assert.isUndefined(element._computeSizeBarLayout(null));
-        assert.isUndefined(element._computeSizeBarLayout({}));
-        assert.deepEqual(element._computeSizeBarLayout({base: []}), {
-          maxInserted: 0,
-          maxDeleted: 0,
-          maxAdditionWidth: 0,
-          maxDeletionWidth: 0,
-          deletionOffset: 0,
-        });
+  suite('diff url file list', () => {
+    test('diff url', () => {
+      const diffStub = sandbox.stub(GerritNav, 'getUrlForDiff')
+          .returns('/c/gerrit/+/1/1/index.php');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = 'index.php';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, false),
+          '/c/gerrit/+/1/1/index.php');
+      diffStub.restore();
+    });
 
-        const files = [
-          {__path: '/COMMIT_MSG', lines_inserted: 10000},
-          {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
-          {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
-        ];
-        const layout = element._computeSizeBarLayout({base: files});
-        assert.equal(layout.maxInserted, 5);
-        assert.equal(layout.maxDeleted, 10);
+    test('diff url commit msg', () => {
+      const diffStub = sandbox.stub(GerritNav, 'getUrlForDiff')
+          .returns('/c/gerrit/+/1/1//COMMIT_MSG');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = '/COMMIT_MSG';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, false),
+          '/c/gerrit/+/1/1//COMMIT_MSG');
+      diffStub.restore();
+    });
+
+    test('edit url', () => {
+      const editStub = sandbox.stub(GerritNav, 'getEditUrlForDiff')
+          .returns('/c/gerrit/+/1/edit/index.php,edit');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = 'index.php';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, true),
+          '/c/gerrit/+/1/edit/index.php,edit');
+      editStub.restore();
+    });
+
+    test('edit url commit msg', () => {
+      const editStub = sandbox.stub(GerritNav, 'getEditUrlForDiff')
+          .returns('/c/gerrit/+/1/edit//COMMIT_MSG,edit');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = '/COMMIT_MSG';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, true),
+          '/c/gerrit/+/1/edit//COMMIT_MSG,edit');
+      editStub.restore();
+    });
+  });
+
+  suite('size bars', () => {
+    test('_computeSizeBarLayout', () => {
+      assert.isUndefined(element._computeSizeBarLayout(null));
+      assert.isUndefined(element._computeSizeBarLayout({}));
+      assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+        maxInserted: 0,
+        maxDeleted: 0,
+        maxAdditionWidth: 0,
+        maxDeletionWidth: 0,
+        deletionOffset: 0,
       });
 
-      test('_computeBarAdditionWidth', () => {
-        const file = {
-          __path: 'foo/bar.baz',
-          lines_inserted: 5,
-          lines_deleted: 0,
-        };
-        const stats = {
-          maxInserted: 10,
-          maxDeleted: 0,
-          maxAdditionWidth: 60,
-          maxDeletionWidth: 0,
-          deletionOffset: 60,
-        };
+      const files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 10000},
+        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
+        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
+      ];
+      const layout = element._computeSizeBarLayout({base: files});
+      assert.equal(layout.maxInserted, 5);
+      assert.equal(layout.maxDeleted, 10);
+    });
 
-        // Uses half the space when file is half the largest addition and there
-        // are no deletions.
-        assert.equal(element._computeBarAdditionWidth(file, stats), 30);
+    test('_computeBarAdditionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
 
-        // If there are no insetions, there is no width.
-        stats.maxInserted = 0;
-        assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      // Uses half the space when file is half the largest addition and there
+      // are no deletions.
+      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
 
-        // If the insertions is not present on the file, there is no width.
-        stats.maxInserted = 10;
-        file.lines_inserted = undefined;
-        assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      // If there are no insetions, there is no width.
+      stats.maxInserted = 0;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
 
-        // If the file is a commit message, returns zero.
-        file.lines_inserted = 5;
-        file.__path = '/COMMIT_MSG';
-        assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+      // If the insertions is not present on the file, there is no width.
+      stats.maxInserted = 10;
+      file.lines_inserted = undefined;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
 
-        // Width bottoms-out at the minimum width.
-        file.__path = 'stuff.txt';
-        file.lines_inserted = 1;
-        stats.maxInserted = 1000000;
-        assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
-      });
+      // If the file is a commit message, returns zero.
+      file.lines_inserted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
 
-      test('_computeBarAdditionX', () => {
-        const file = {
-          __path: 'foo/bar.baz',
-          lines_inserted: 5,
-          lines_deleted: 0,
-        };
-        const stats = {
-          maxInserted: 10,
-          maxDeleted: 0,
-          maxAdditionWidth: 60,
-          maxDeletionWidth: 0,
-          deletionOffset: 60,
-        };
-        assert.equal(element._computeBarAdditionX(file, stats), 30);
-      });
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_inserted = 1;
+      stats.maxInserted = 1000000;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
+    });
 
-      test('_computeBarDeletionWidth', () => {
-        const file = {
-          __path: 'foo/bar.baz',
-          lines_inserted: 0,
-          lines_deleted: 5,
-        };
-        const stats = {
-          maxInserted: 10,
-          maxDeleted: 10,
-          maxAdditionWidth: 30,
-          maxDeletionWidth: 30,
-          deletionOffset: 31,
-        };
+    test('_computeBarAdditionX', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+      assert.equal(element._computeBarAdditionX(file, stats), 30);
+    });
 
-        // Uses a quarter the space when file is half the largest deletions and
-        // there are equal additions.
-        assert.equal(element._computeBarDeletionWidth(file, stats), 15);
+    test('_computeBarDeletionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 0,
+        lines_deleted: 5,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 10,
+        maxAdditionWidth: 30,
+        maxDeletionWidth: 30,
+        deletionOffset: 31,
+      };
 
-        // If there are no deletions, there is no width.
-        stats.maxDeleted = 0;
-        assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      // Uses a quarter the space when file is half the largest deletions and
+      // there are equal additions.
+      assert.equal(element._computeBarDeletionWidth(file, stats), 15);
 
-        // If the deletions is not present on the file, there is no width.
-        stats.maxDeleted = 10;
-        file.lines_deleted = undefined;
-        assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      // If there are no deletions, there is no width.
+      stats.maxDeleted = 0;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
 
-        // If the file is a commit message, returns zero.
-        file.lines_deleted = 5;
-        file.__path = '/COMMIT_MSG';
-        assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+      // If the deletions is not present on the file, there is no width.
+      stats.maxDeleted = 10;
+      file.lines_deleted = undefined;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
 
-        // Width bottoms-out at the minimum width.
-        file.__path = 'stuff.txt';
-        file.lines_deleted = 1;
-        stats.maxDeleted = 1000000;
-        assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
-      });
+      // If the file is a commit message, returns zero.
+      file.lines_deleted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
 
-      test('_computeSizeBarsClass', () => {
-        assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
-            'sizeBars desktop hide');
-        assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
-            'sizeBars desktop invisible');
-        assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
-            'sizeBars desktop ');
-      });
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_deleted = 1;
+      stats.maxDeleted = 1000000;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
+    });
+
+    test('_computeSizeBarsClass', () => {
+      assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
+          'sizeBars desktop hide');
+      assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
+          'sizeBars desktop invisible');
+      assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
+          'sizeBars desktop ');
     });
   });
 
@@ -1373,7 +1507,6 @@
     ];
 
     const setupDiff = function(diff) {
-      const mock = document.createElement('mock-diff-response');
       diff.comments = {
         left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
         right: [],
@@ -1401,13 +1534,13 @@
         theme: 'DEFAULT',
         ignore_whitespace: 'IGNORE_NONE',
       };
-      diff.diff = mock.diffResponse;
+      diff.diff = getMockDiffResponse();
       diff.$.diff.flushDebouncer('renderDiffTable');
     };
 
     const renderAndGetNewDiffs = function(index) {
       const diffs =
-          Polymer.dom(element.root).querySelectorAll('gr-diff-host');
+          dom(element.root).querySelectorAll('gr-diff-host');
 
       for (let i = index; i < diffs.length; i++) {
         setupDiff(diffs[i]);
@@ -1475,9 +1608,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      sandbox.stub(window, 'fetch', () => {
-        return Promise.resolve();
-      });
+      sandbox.stub(window, 'fetch', () => Promise.resolve());
       flushAsynchronousOperations();
     });
 
@@ -1498,7 +1629,7 @@
       assert.isFalse(diffStops[10].classList.contains('target-row'));
 
       // Tapping content on a line selects the line number.
-      MockInteractions.tap(Polymer.dom(
+      MockInteractions.tap(dom(
           diffStops[10]).querySelectorAll('.contentText')[0]);
       flushAsynchronousOperations();
       assert.isTrue(diffStops[10].classList.contains('target-row'));
@@ -1540,7 +1671,7 @@
       assert.isFalse(diffStops[10].classList.contains('target-row'));
 
       // Tapping content on a line selects the line number.
-      MockInteractions.tap(Polymer.dom(
+      MockInteractions.tap(dom(
           diffStops[10]).querySelectorAll('.contentText')[0]);
       flushAsynchronousOperations();
       assert.isTrue(diffStops[10].classList.contains('target-row'));
@@ -1570,7 +1701,7 @@
         nextChunkStub = sandbox.stub(element.$.diffCursor,
             'moveToNextChunk');
         fileRows =
-            Polymer.dom(element.root).querySelectorAll('.row:not(.header-row)');
+            dom(element.root).querySelectorAll('.row:not(.header-row)');
       });
 
       test('n key with some files expanded and no shift key', () => {
@@ -1632,7 +1763,7 @@
     test('_openSelectedFile behavior', () => {
       const _filesByPath = element._filesByPath;
       element.set('_filesByPath', {});
-      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const navStub = sandbox.stub(GerritNav, 'navigateToDiff');
       // Noop when there are no files.
       element._openSelectedFile();
       assert.isFalse(navStub.called);
@@ -1691,7 +1822,7 @@
 
     test('editing actions', () => {
       // Edit controls are guarded behind a dom-if initially and not rendered.
-      assert.isNotOk(Polymer.dom(element.root)
+      assert.isNotOk(dom(element.root)
           .querySelector('gr-edit-file-controls'));
 
       element.editMode = true;
@@ -1700,7 +1831,7 @@
       // Commit message should not have edit controls.
       const editControls =
           Array.from(
-              Polymer.dom(element.root)
+              dom(element.root)
                   .querySelectorAll('.row:not(.header-row)'))
               .map(row => row.querySelector('gr-edit-file-controls'));
       assert.isTrue(editControls[0].classList.contains('invisible'));
@@ -1807,4 +1938,5 @@
     });
   });
   a11ySuite('basic');
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
deleted file mode 100644
index 0afd214..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
+++ /dev/null
@@ -1,109 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.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-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-included-in-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--dialog-background-color);
-        display: block;
-        max-height: 80vh;
-        overflow-y: auto;
-        padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
-      }
-      header {
-        background-color: var(--dialog-background-color);
-        border-bottom: 1px solid var(--border-color);
-        left: 0;
-        padding: var(--spacing-l);
-        position: absolute;
-        right: 0;
-        top: 0;
-      }
-      #title {
-        display: inline-block;
-        font-size: var(--font-size-h3);
-        margin-top: var(--spacing-xs);
-      }
-      #filterInput {
-        display: inline-block;
-        float: right;
-        margin: 0 var(--spacing-l);
-        padding: var(--spacing-xs);
-      }
-      .closeButtonContainer {
-        float: right;
-      }
-      ul {
-        margin-bottom: var(--spacing-l);
-      }
-      ul li {
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        background: var(--chip-background-color);
-        display: inline-block;
-        margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
-        padding: var(--spacing-xs) var(--spacing-s);
-      }
-      .loading.loaded {
-        display: none;
-      }
-    </style>
-    <header>
-      <h1 id="title">Included In:</h1>
-      <span class="closeButtonContainer">
-        <gr-button id="closeButton"
-            link
-            on-click="_handleCloseTap">Close</gr-button>
-      </span>
-      <iron-input
-        id="filterInput"
-        placeholder="Filter"
-        bind-value="{{_filterText}}"
-      >
-        <input
-          is="iron-input"
-          placeholder="Filter"
-          bind-value="{{_filterText}}"
-        />
-      </iron-input>
-    </header>
-    <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
-    <template
-        is="dom-repeat"
-        items="[[_computeGroups(_includedIn, _filterText)]]"
-        as="group">
-      <div>
-        <span>[[group.title]]:</span>
-        <ul>
-          <template is="dom-repeat" items="[[group.items]]">
-            <li>[[item]]</li>
-          </template>
-        </ul>
-      </div>
-    </template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-included-in-dialog.js"></script>
-</dom-module>
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 9d9f654..bd262ec 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
@@ -14,20 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-included-in-dialog',
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-included-in-dialog_html.js';
 
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrIncludedInDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /** @type {?} */
+  static get is() { return 'gr-included-in-dialog'; }
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
       changeNum: {
         type: Object,
         observer: '_resetData',
@@ -42,59 +56,59 @@
         type: String,
         value: '',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  loadData() {
+    if (!this.changeNum) { return; }
+    this._filterText = '';
+    return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(
+        configs => {
+          if (!configs) { return; }
+          this._includedIn = configs;
+          this._loaded = true;
+        });
+  }
 
-    loadData() {
-      if (!this.changeNum) { return; }
-      this._filterText = '';
-      return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(
-          configs => {
-            if (!configs) { return; }
-            this._includedIn = configs;
-            this._loaded = true;
-          });
-    },
+  _resetData() {
+    this._includedIn = null;
+    this._loaded = false;
+  }
 
-    _resetData() {
-      this._includedIn = null;
-      this._loaded = false;
-    },
+  _computeGroups(includedIn, filterText) {
+    if (!includedIn || filterText === undefined) {
+      return [];
+    }
 
-    _computeGroups(includedIn, filterText) {
-      if (!includedIn || filterText === undefined) {
-        return [];
+    const filter = item => !filterText.length ||
+        item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+
+    const groups = [
+      {title: 'Branches', items: includedIn.branches.filter(filter)},
+      {title: 'Tags', items: includedIn.tags.filter(filter)},
+    ];
+    if (includedIn.external) {
+      for (const externalKey of Object.keys(includedIn.external)) {
+        groups.push({
+          title: externalKey,
+          items: includedIn.external[externalKey].filter(filter),
+        });
       }
+    }
+    return groups.filter(g => g.items.length);
+  }
 
-      const filter = item => !filterText.length ||
-          item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+  _handleCloseTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('close', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-      const groups = [
-        {title: 'Branches', items: includedIn.branches.filter(filter)},
-        {title: 'Tags', items: includedIn.tags.filter(filter)},
-      ];
-      if (includedIn.external) {
-        for (const externalKey of Object.keys(includedIn.external)) {
-          groups.push({
-            title: externalKey,
-            items: includedIn.external[externalKey].filter(filter),
-          });
-        }
-      }
-      return groups.filter(g => g.items.length);
-    },
+  _computeLoadingClass(loaded) {
+    return loaded ? 'loading loaded' : 'loading';
+  }
+}
 
-    _handleCloseTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('close', null, {bubbles: false});
-    },
-
-    _computeLoadingClass(loaded) {
-      return loaded ? 'loading loaded' : 'loading';
-    },
-  });
-})();
+customElements.define(GrIncludedInDialog.is, GrIncludedInDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
new file mode 100644
index 0000000..2faac52
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--dialog-background-color);
+      display: block;
+      max-height: 80vh;
+      overflow-y: auto;
+      padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
+    }
+    header {
+      background-color: var(--dialog-background-color);
+      border-bottom: 1px solid var(--border-color);
+      left: 0;
+      padding: var(--spacing-l);
+      position: absolute;
+      right: 0;
+      top: 0;
+    }
+    #title {
+      display: inline-block;
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+      margin-top: var(--spacing-xs);
+    }
+    #filterInput {
+      display: inline-block;
+      float: right;
+      margin: 0 var(--spacing-l);
+      padding: var(--spacing-xs);
+    }
+    .closeButtonContainer {
+      float: right;
+    }
+    ul {
+      margin-bottom: var(--spacing-l);
+    }
+    ul li {
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      background: var(--chip-background-color);
+      display: inline-block;
+      margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
+      padding: var(--spacing-xs) var(--spacing-s);
+    }
+    .loading.loaded {
+      display: none;
+    }
+  </style>
+  <header>
+    <h1 id="title">Included In:</h1>
+    <span class="closeButtonContainer">
+      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
+        >Close</gr-button
+      >
+    </span>
+    <iron-input
+      id="filterInput"
+      placeholder="Filter"
+      bind-value="{{_filterText}}"
+    >
+      <input
+        is="iron-input"
+        placeholder="Filter"
+        bind-value="{{_filterText}}"
+      />
+    </iron-input>
+  </header>
+  <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
+  <template
+    is="dom-repeat"
+    items="[[_computeGroups(_includedIn, _filterText)]]"
+    as="group"
+  >
+    <div>
+      <span>[[group.title]]:</span>
+      <ul>
+        <template is="dom-repeat" items="[[group.items]]">
+          <li>[[item]]</li>
+        </template>
+      </ul>
+    </div>
+  </template>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 ad2ad5a..5d5b1fc 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-included-in-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,68 +31,70 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-included-in-dialog', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-included-in-dialog.js';
+suite('gr-included-in-dialog', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => { sandbox.restore(); });
+  teardown(() => { sandbox.restore(); });
 
-    test('_computeGroups', () => {
-      const includedIn = {branches: [], tags: []};
-      let filterText = '';
-      assert.deepEqual(element._computeGroups(includedIn, filterText), []);
+  test('_computeGroups', () => {
+    const includedIn = {branches: [], tags: []};
+    let filterText = '';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), []);
 
-      includedIn.branches.push('master', 'development', 'stable-2.0');
-      includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
+    includedIn.branches.push('master', 'development', 'stable-2.0');
+    includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+    ]);
+
+    includedIn.external = {};
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+    ]);
+
+    includedIn.external.foo = ['abc', 'def', 'ghi'];
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+      {title: 'foo', items: ['abc', 'def', 'ghi']},
+    ]);
+
+    filterText = 'v2';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Tags', items: ['v2.0', 'v2.1']},
+    ]);
+
+    // Filtering is case-insensitive.
+    filterText = 'V2';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Tags', items: ['v2.0', 'v2.1']},
+    ]);
+  });
+
+  test('_computeGroups with .bindValue', done => {
+    element.$.filterInput.bindValue = 'stable-3.2';
+    const includedIn = {branches: [], tags: []};
+    includedIn.branches.push('master', 'stable-3.2');
+
+    setTimeout(() => {
+      const filterText = element._filterText;
       assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+        {title: 'Branches', items: ['stable-3.2']},
       ]);
 
-      includedIn.external = {};
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-      ]);
-
-      includedIn.external.foo = ['abc', 'def', 'ghi'];
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-        {title: 'foo', items: ['abc', 'def', 'ghi']},
-      ]);
-
-      filterText = 'v2';
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Tags', items: ['v2.0', 'v2.1']},
-      ]);
-
-      // Filtering is case-insensitive.
-      filterText = 'V2';
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Tags', items: ['v2.0', 'v2.1']},
-      ]);
-    });
-
-    test('_computeGroups with .bindValue', done => {
-      element.$.filterInput.bindValue = 'stable-3.2';
-      const includedIn = {branches: [], tags: []};
-      includedIn.branches.push('master', 'stable-3.2');
-
-      setTimeout(() => {
-        const filterText = element._filterText;
-        assert.deepEqual(element._computeGroups(includedIn, filterText), [
-          {title: 'Branches', items: ['stable-3.2']},
-        ]);
-
-        done();
-      });
+      done();
     });
   });
+});
 </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
deleted file mode 100644
index 220546b..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
+++ /dev/null
@@ -1,144 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-selector/iron-selector.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-label-score-row">
-  <template>
-    <style include="gr-voting-styles"></style>
-    <style include="shared-styles">
-      .labelContainer {
-        align-items: center;
-        display: flex;
-        margin-bottom: var(--spacing-m);
-      }
-      .labelName {
-        display: inline-block;
-        flex: 0 0 auto;
-        margin-right: var(--spacing-m);
-        min-width: 7em;
-        text-align: left;
-        width: 20%;
-      }
-      .labelMessage {
-        color: var(--deemphasized-text-color);
-      }
-      .placeholder::before {
-        content: ' ';
-      }
-      .selectedValueText {
-        color: var(--deemphasized-text-color);
-        font-style: italic;
-        margin: 0 var(--spacing-m);
-      }
-      .selectedValueText.hidden {
-        display: none;
-      }
-      .buttonWrapper {
-        flex: none;
-      }
-      gr-button {
-        min-width: 40px;
-        --gr-button: {
-          background-color: var(--button-background-color, var(--table-header-background-color));
-          color: var(--primary-text-color);
-          padding: var(--spacing-xs) var(--spacing-m);
-          @apply --vote-chip-styles;
-        }
-      }
-      gr-button.iron-selected.max {
-        --button-background-color: var(--vote-color-approved);
-      }
-      gr-button.iron-selected.positive {
-        --button-background-color: var(--vote-color-recommended);
-      }
-      gr-button.iron-selected.min {
-        --button-background-color: var(--vote-color-rejected);
-      }
-      gr-button.iron-selected.negative {
-        --button-background-color: var(--vote-color-disliked);
-      }
-      gr-button.iron-selected.neutral {
-        --button-background-color: var(--vote-color-neutral);
-      }
-      .placeholder {
-        display: inline-block;
-        width: 40px;
-      }
-      @media only screen and (max-width: 50em) {
-        .selectedValueText {
-          display: none;
-        }
-      }
-      @media only screen and (max-width: 25em) {
-        .labelName {
-          margin: 0;
-          text-align: center;
-          width: 100%;
-        }
-        .labelContainer {
-          display: block;
-        }
-      }
-    </style>
-    <div class="labelContainer">
-      <span class="labelName">[[label.name]]</span>
-      <div class="buttonWrapper">
-        <template is="dom-repeat"
-            items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
-            as="value">
-          <span class="placeholder" data-label$="[[label.name]]"></span>
-        </template>
-        <iron-selector
-            id="labelSelector"
-            attr-for-selected="value"
-            selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
-            hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-            on-selected-item-changed="_setSelectedValueText">
-          <template is="dom-repeat"
-              items="[[_items]]"
-              as="value">
-            <gr-button
-                class$="[[_computeButtonClass(value, index, _items.length)]]"
-                has-tooltip
-                name="[[label.name]]"
-                value$="[[value]]"
-                title$="[[_computeLabelValueTitle(labels, label.name, value)]]">
-              [[value]]</gr-button>
-          </template>
-        </iron-selector>
-        <template is="dom-repeat"
-            items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
-            as="value">
-          <span class="placeholder" data-label$="[[label.name]]"></span>
-        </template>
-        <span class="labelMessage"
-            hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-          You don't have permission to edit this label.
-        </span>
-      </div>
-      <div class$="selectedValueText [[_computeHiddenClass(permittedLabels, label.name)]]">
-        <span id="selectedValueLabel">[[_selectedValueText]]</span>
-      </div>
-    </div>
-  </template>
-  <script src="gr-label-score-row.js"></script>
-</dom-module>
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..8541840 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
@@ -14,22 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-label-score-row',
+import '@polymer/iron-selector/iron-selector.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../../styles/gr-voting-styles.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label-score-row_html.js';
 
+/** @extends Polymer.Element */
+class GrLabelScoreRow extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-label-score-row'; }
+  /**
+   * Fired when any label is changed.
+   *
+   * @event labels-changed
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when any label is changed.
-     *
-     * @event labels-changed
+     * @type {{ name: string }}
      */
-
-    properties: {
-      /**
-       * @type {{ name: string }}
-       */
       label: Object,
       labels: Object,
       name: {
@@ -46,130 +59,131 @@
         type: Array,
         computed: '_computePermittedLabelValues(permittedLabels, label.name)',
       },
-    },
+    };
+  }
 
-    get selectedItem() {
-      if (!this._ironSelector) { return undefined; }
-      return this._ironSelector.selectedItem;
-    },
+  get selectedItem() {
+    if (!this._ironSelector) { return undefined; }
+    return this._ironSelector.selectedItem;
+  }
 
-    get selectedValue() {
-      if (!this._ironSelector) { return undefined; }
-      return this._ironSelector.selected;
-    },
+  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);
-    },
+  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;
-    },
+  get _ironSelector() {
+    return this.$ && this.$.labelSelector;
+  }
 
-    _computeBlankItems(permittedLabels, label, side) {
-      if (!permittedLabels || !permittedLabels[label] ||
-          !permittedLabels[label].length || !this.labelValues ||
-          !Object.keys(this.labelValues).length) {
-        return [];
-      }
-      const startPosition = this.labelValues[parseInt(
-          permittedLabels[label][0], 10)];
-      if (side === 'start') {
-        return new Array(startPosition);
-      }
-      const endPosition = this.labelValues[parseInt(
-          permittedLabels[label][permittedLabels[label].length - 1], 10)];
-      return new Array(Object.keys(this.labelValues).length - endPosition - 1);
-    },
+  _computeBlankItems(permittedLabels, label, side) {
+    if (!permittedLabels || !permittedLabels[label] ||
+        !permittedLabels[label].length || !this.labelValues ||
+        !Object.keys(this.labelValues).length) {
+      return [];
+    }
+    const startPosition = this.labelValues[parseInt(
+        permittedLabels[label][0], 10)];
+    if (side === 'start') {
+      return new Array(startPosition);
+    }
+    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) {
-        return label.value;
-      } else if (labels[label.name].hasOwnProperty('default_value') &&
-                 permittedLabels.hasOwnProperty(label.name)) {
-        // default_value is an int, convert it to string label, e.g. "+1".
-        return permittedLabels[label.name].find(
-            value => parseInt(value, 10) === labels[label.name].default_value);
-      }
-    },
+  _getLabelValue(labels, permittedLabels, label) {
+    if (label.value) {
+      return label.value;
+    } else if (labels[label.name].hasOwnProperty('default_value') &&
+               permittedLabels.hasOwnProperty(label.name)) {
+      // default_value is an int, convert it to string label, e.g. "+1".
+      return permittedLabels[label.name].find(
+          value => parseInt(value, 10) === labels[label.name].default_value);
+    }
+  }
 
-    _computeButtonClass(value, index, totalItems) {
-      const classes = [];
-      if (value === this.selectedValue) {
-        classes.push('iron-selected');
-      }
+  /**
+   * Maps the label value to exactly one of: min, max, positive, negative,
+   * neutral. Used for the 'vote' attribute, because we don't want to
+   * interfere with <iron-selector> using the 'class' attribute for setting
+   * 'iron-selected'.
+   */
+  _computeVoteAttribute(value, index, totalItems) {
+    if (value < 0 && index === 0) {
+      return 'min';
+    } else if (value < 0) {
+      return 'negative';
+    } else if (value > 0 && index === totalItems - 1) {
+      return 'max';
+    } else if (value > 0) {
+      return 'positive';
+    } else {
+      return 'neutral';
+    }
+  }
 
-      if (value < 0 && index === 0) {
-        classes.push('min');
-      } else if (value < 0) {
-        classes.push('negative');
-      } else if (value > 0 && index === totalItems - 1) {
-        classes.push('max');
-      } else if (value > 0) {
-        classes.push('positive');
-      } else {
-        classes.push('neutral');
-      }
-
-      return classes.join(' ');
-    },
-
-    _computeLabelValue(labels, permittedLabels, label) {
-      if ([labels, permittedLabels, label].some(arg => arg === undefined)) {
-        return null;
-      }
-      if (!labels[label.name]) { return null; }
-      const labelValue = this._getLabelValue(labels, permittedLabels, label);
-      const len = permittedLabels[label.name] != null ?
-        permittedLabels[label.name].length : 0;
-      for (let i = 0; i < len; i++) {
-        const val = permittedLabels[label.name][i];
-        if (val === labelValue) {
-          return val;
-        }
-      }
+  _computeLabelValue(labels, permittedLabels, label) {
+    if ([labels, permittedLabels, label].some(arg => arg === undefined)) {
       return null;
-    },
-
-    _setSelectedValueText(e) {
-      // Needed because when the selected item changes, it first changes to
-      // nothing and then to the new item.
-      if (!e.target.selectedItem) { return; }
-      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');
-      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
-      if ([permittedLabels, label].some(arg => arg === undefined)) {
-        return undefined;
+    }
+    if (!labels[label.name]) { return null; }
+    const labelValue = this._getLabelValue(labels, permittedLabels, label);
+    const len = permittedLabels[label.name] != null ?
+      permittedLabels[label.name].length : 0;
+    for (let i = 0; i < len; i++) {
+      const val = permittedLabels[label.name][i];
+      if (val === labelValue) {
+        return val;
       }
+    }
+    return null;
+  }
 
-      return permittedLabels[label];
-    },
+  _setSelectedValueText(e) {
+    // Needed because when the selected item changes, it first changes to
+    // nothing and then to the new item.
+    if (!e.target.selectedItem) { return; }
+    this._selectedValueText = e.target.selectedItem.getAttribute('title');
+    // Needed to update the style of the selected button.
+    this.updateStyles();
+    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}));
+  }
 
-    _computeLabelValueTitle(labels, label, value) {
-      return labels[label] &&
-        labels[label].values &&
-        labels[label].values[value];
-    },
-  });
-})();
+  _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
+    if ([permittedLabels, label].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    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_html.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
new file mode 100644
index 0000000..77148ad
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
@@ -0,0 +1,144 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="gr-voting-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    .labelNameCell,
+    .buttonsCell,
+    .selectedValueCell {
+      padding: var(--spacing-s) var(--spacing-m);
+      display: table-cell;
+    }
+    /* We want the :hover highlight to extend to the border of the dialog. */
+    .labelNameCell {
+      padding-left: var(--spacing-xl);
+    }
+    .selectedValueCell {
+      padding-right: var(--spacing-xl);
+    }
+    /* This is a trick to let the selectedValueCell take the remaining width. */
+    .labelNameCell,
+    .buttonsCell {
+      white-space: nowrap;
+    }
+    .selectedValueCell {
+      width: 75%;
+    }
+    .labelMessage {
+      color: var(--deemphasized-text-color);
+    }
+    gr-button {
+      min-width: 42px;
+      box-sizing: border-box;
+      --gr-button: {
+        background-color: var(
+          --button-background-color,
+          var(--table-header-background-color)
+        );
+        color: var(--primary-text-color);
+        padding: 0 var(--spacing-m);
+        @apply --vote-chip-styles;
+      }
+    }
+    gr-button.iron-selected[vote='max'] {
+      --button-background-color: var(--vote-color-approved);
+    }
+    gr-button.iron-selected[vote='positive'] {
+      --button-background-color: var(--vote-color-recommended);
+    }
+    gr-button.iron-selected[vote='min'] {
+      --button-background-color: var(--vote-color-rejected);
+    }
+    gr-button.iron-selected[vote='negative'] {
+      --button-background-color: var(--vote-color-disliked);
+    }
+    gr-button.iron-selected[vote='neutral'] {
+      --button-background-color: var(--vote-color-neutral);
+    }
+    .placeholder {
+      display: inline-block;
+      width: 42px;
+      height: 1px;
+    }
+    .placeholder::before {
+      content: ' ';
+    }
+    .selectedValueCell {
+      color: var(--deemphasized-text-color);
+      font-style: italic;
+    }
+    .selectedValueCell.hidden {
+      display: none;
+    }
+    @media only screen and (max-width: 50em) {
+      .selectedValueCell {
+        display: none;
+      }
+    }
+  </style>
+  <span class="labelNameCell">[[label.name]]</span>
+  <div class="buttonsCell">
+    <template
+      is="dom-repeat"
+      items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
+      as="value"
+    >
+      <span class="placeholder" data-label$="[[label.name]]"></span>
+    </template>
+    <iron-selector
+      id="labelSelector"
+      attr-for-selected="data-value"
+      selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
+      hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
+      on-selected-item-changed="_setSelectedValueText"
+    >
+      <template is="dom-repeat" items="[[_items]]" as="value">
+        <gr-button
+          vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
+          has-tooltip=""
+          data-name$="[[label.name]]"
+          data-value$="[[value]]"
+          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
+        >
+          [[value]]</gr-button
+        >
+      </template>
+    </iron-selector>
+    <template
+      is="dom-repeat"
+      items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
+      as="value"
+    >
+      <span class="placeholder" data-label$="[[label.name]]"></span>
+    </template>
+    <span
+      class="labelMessage"
+      hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
+    >
+      You don't have permission to edit this label.
+    </span>
+  </div>
+  <div
+    class$="selectedValueCell [[_computeHiddenClass(permittedLabels, label.name)]]"
+  >
+    <span id="selectedValueLabel">[[_selectedValueText]]</span>
+  </div>
+`;
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..6e0a90d 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-label-score-row.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,337 +31,342 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-label-row-score tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-label-score-row.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-label-row-score tests', () => {
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.labels = {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-          value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
         },
-        'Verified': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
+        default_value: 0,
+        value: 1,
+        all: [{
+          _account_id: 123,
           value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
+        }],
+      },
+      'Verified': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
         },
-      };
+        default_value: 0,
+        value: 1,
+        all: [{
+          _account_id: 123,
+          value: 1,
+        }],
+      },
+    };
+
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+
+    element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
+
+    element.label = {
+      name: 'Verified',
+      value: '+1',
+    };
+
+    flush(done);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('label picker', () => {
+    const labelsChangedHandler = sandbox.stub();
+    element.addEventListener('labels-changed', labelsChangedHandler);
+    assert.ok(element.$.labelSelector);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector(
+            'gr-button[data-value="-1"]'));
+    flushAsynchronousOperations();
+    assert.strictEqual(element.selectedValue, '-1');
+    assert.strictEqual(element.selectedItem
+        .textContent.trim(), '-1');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'bad');
+    const detail = labelsChangedHandler.args[0][0].detail;
+    assert.equal(detail.name, 'Verified');
+    assert.equal(detail.value, '-1');
+  });
+
+  test('_computeVoteAttribute', () => {
+    let value = 1;
+    let index = 0;
+    const totalItems = 5;
+    // positive and first position
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'positive');
+    // negative and first position
+    value = -1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'min');
+    // negative but not first position
+    index = 1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'negative');
+    // neutral
+    value = 0;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'neutral');
+    // positive but not last position
+    value = 1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'positive');
+    // positive and last position
+    index = 4;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'max');
+    // negative and last position
+    value = -1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'negative');
+  });
+
+  test('correct item is selected', () => {
+    // 1 should be the value of the selected item
+    assert.strictEqual(element.$.labelSelector.selected, '+1');
+    assert.strictEqual(
+        element.$.labelSelector.selectedItem
+            .textContent.trim(), '+1');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'good');
+  });
+
+  test('do not display tooltips on touch devices', () => {
+    const verifiedBtn = element.shadowRoot
+        .querySelector(
+            'iron-selector > gr-button[data-value="-1"]');
+
+    // On touch devices, tooltips should not be shown.
+    verifiedBtn._isTouchDevice = true;
+    verifiedBtn._handleShowTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+    verifiedBtn._handleHideTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+
+    // On other devices, tooltips should be shown.
+    verifiedBtn._isTouchDevice = false;
+    verifiedBtn._handleShowTooltip();
+    assert.isOk(verifiedBtn._tooltip);
+    verifiedBtn._handleHideTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+  });
+
+  test('_computeLabelValue', () => {
+    assert.strictEqual(element._computeLabelValue(element.labels,
+        element.permittedLabels,
+        element.label), '+1');
+  });
+
+  test('_computeBlankItems', () => {
+    element.labelValues = {
+      '-2': 0,
+      '-1': 1,
+      '0': 2,
+      '1': 3,
+      '2': 4,
+    };
+
+    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+        'Code-Review').length, 0);
+
+    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+        'Verified').length, 1);
+  });
+
+  test('labelValues returns no keys', () => {
+    element.labelValues = {};
+
+    assert.deepEqual(element._computeBlankItems(element.permittedLabels,
+        'Code-Review'), []);
+  });
+
+  test('changes in label score are reflected in the DOM', () => {
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+      'Verified': {
+        values: {
+          ' 0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+    };
+    const selector = element.$.labelSelector;
+    element.set('label', {name: 'Verified', value: ' 0'});
+    flushAsynchronousOperations();
+    assert.strictEqual(selector.selected, ' 0');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'No score');
+  });
+
+  test('without permitted labels', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isFalse(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {};
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {Verified: []};
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+  });
+
+  test('asymetrical labels', done => {
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(() => {
+      assert.strictEqual(element.$.labelSelector
+          .items.length, 2);
+      assert.strictEqual(
+          dom(element.root).querySelectorAll('.placeholder').length,
+          3);
 
       element.permittedLabels = {
         'Code-Review': [
+          ' 0',
+          '+1',
+        ],
+        'Verified': [
           '-2',
           '-1',
           ' 0',
           '+1',
           '+2',
         ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-
-      element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
-
-      element.label = {
-        name: 'Verified',
-        value: '+1',
-      };
-
-      flush(done);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('label picker', () => {
-      const labelsChangedHandler = sandbox.stub();
-      element.addEventListener('labels-changed', labelsChangedHandler);
-      assert.ok(element.$.labelSelector);
-      MockInteractions.tap(element.$$(
-          'gr-button[value="-1"]'));
-      flushAsynchronousOperations();
-      assert.strictEqual(element.selectedValue, '-1');
-      assert.strictEqual(element.selectedItem
-          .textContent.trim(), '-1');
-      assert.strictEqual(
-          element.$.selectedValueLabel.textContent.trim(), 'bad');
-      const detail = labelsChangedHandler.args[0][0].detail;
-      assert.equal(detail.name, 'Verified');
-      assert.equal(detail.value, '-1');
-    });
-
-    test('_computeButtonClass', () => {
-      let value = 1;
-      let index = 0;
-      const totalItems = 5;
-      // positive and first position
-      assert.equal(element._computeButtonClass(value, index,
-          totalItems), 'positive');
-      // negative and first position
-      value = -1;
-      assert.equal(element._computeButtonClass(value, index,
-          totalItems), 'min');
-      // negative but not first position
-      index = 1;
-      assert.equal(element._computeButtonClass(value, index,
-          totalItems), 'negative');
-      // neutral
-      value = 0;
-      assert.equal(element._computeButtonClass(value, index,
-          totalItems), 'neutral');
-      // positive but not last position
-      value = 1;
-      assert.equal(element._computeButtonClass(value, index,
-          totalItems), 'positive');
-      // positive and last position
-      index = 4;
-      assert.equal(element._computeButtonClass(value, index,
-          totalItems), 'max');
-      // negative and last position
-      value = -1;
-      assert.equal(element._computeButtonClass(value, index,
-          totalItems), 'negative');
-    });
-
-    test('correct item is selected', () => {
-      // 1 should be the value of the selected item
-      assert.strictEqual(element.$.labelSelector.selected, '+1');
-      assert.strictEqual(
-          element.$.labelSelector.selectedItem
-              .textContent.trim(), '+1');
-      assert.strictEqual(
-          element.$.selectedValueLabel.textContent.trim(), 'good');
-    });
-
-    test('do not display tooltips on touch devices', () => {
-      const verifiedBtn = element.$$(
-          'iron-selector > gr-button[value="-1"]');
-
-      // On touch devices, tooltips should not be shown.
-      verifiedBtn._isTouchDevice = true;
-      verifiedBtn._handleShowTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-      verifiedBtn._handleHideTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-
-      // On other devices, tooltips should be shown.
-      verifiedBtn._isTouchDevice = false;
-      verifiedBtn._handleShowTooltip();
-      assert.isOk(verifiedBtn._tooltip);
-      verifiedBtn._handleHideTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-    });
-
-    test('_computeLabelValue', () => {
-      assert.strictEqual(element._computeLabelValue(element.labels,
-          element.permittedLabels,
-          element.label), '+1');
-    });
-
-    test('_computeBlankItems', () => {
-      element.labelValues = {
-        '-2': 0,
-        '-1': 1,
-        '0': 2,
-        '1': 3,
-        '2': 4,
-      };
-
-      assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-          'Code-Review').length, 0);
-
-      assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-          'Verified').length, 1);
-    });
-
-    test('labelValues returns no keys', () => {
-      element.labelValues = {};
-
-      assert.deepEqual(element._computeBlankItems(element.permittedLabels,
-          'Code-Review'), []);
-    });
-
-    test('changes in label score are reflected in the DOM', () => {
-      element.labels = {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-        'Verified': {
-          values: {
-            ' 0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-      };
-      const selector = element.$.labelSelector;
-      element.set('label', {name: 'Verified', value: ' 0'});
-      flushAsynchronousOperations();
-      assert.strictEqual(selector.selected, ' 0');
-      assert.strictEqual(
-          element.$.selectedValueLabel.textContent.trim(), 'No score');
-    });
-
-    test('without permitted labels', () => {
-      element.permittedLabels = {
-        Verified: [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      flushAsynchronousOperations();
-      assert.isOk(element.$.labelSelector);
-      assert.isFalse(element.$.labelSelector.hidden);
-
-      element.permittedLabels = {};
-      flushAsynchronousOperations();
-      assert.isOk(element.$.labelSelector);
-      assert.isTrue(element.$.labelSelector.hidden);
-
-      element.permittedLabels = {Verified: []};
-      flushAsynchronousOperations();
-      assert.isOk(element.$.labelSelector);
-      assert.isTrue(element.$.labelSelector.hidden);
-    });
-
-    test('asymetrical labels', done => {
-      element.permittedLabels = {
-        'Code-Review': [
-          '-2',
-          '-1',
-          ' 0',
-          '+1',
-          '+2',
-        ],
-        'Verified': [
-          ' 0',
-          '+1',
-        ],
       };
       flush(() => {
         assert.strictEqual(element.$.labelSelector
-            .items.length, 2);
+            .items.length, 5);
         assert.strictEqual(
-            Polymer.dom(element.root).querySelectorAll('.placeholder').length,
-            3);
-
-        element.permittedLabels = {
-          'Code-Review': [
-            ' 0',
-            '+1',
-          ],
-          'Verified': [
-            '-2',
-            '-1',
-            ' 0',
-            '+1',
-            '+2',
-          ],
-        };
-        flush(() => {
-          assert.strictEqual(element.$.labelSelector
-              .items.length, 5);
-          assert.strictEqual(
-              Polymer.dom(element.root).querySelectorAll('.placeholder').length,
-              0);
-          done();
-        });
+            dom(element.root).querySelectorAll('.placeholder').length,
+            0);
+        done();
       });
     });
-
-    test('default_value', () => {
-      element.permittedLabels = {
-        Verified: [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      element.labels = {
-        Verified: {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: -1,
-        },
-      };
-      element.label = {
-        name: 'Verified',
-        value: null,
-      };
-      flushAsynchronousOperations();
-      assert.strictEqual(element.selectedValue, '-1');
-    });
-
-    test('default_value is null if not permitted', () => {
-      element.permittedLabels = {
-        Verified: [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      element.labels = {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: -1,
-        },
-      };
-      element.label = {
-        name: 'Code-Review',
-        value: null,
-      };
-      flushAsynchronousOperations();
-      assert.isNull(element.selectedValue);
-    });
   });
+
+  test('default_value', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    element.labels = {
+      Verified: {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Verified',
+      value: null,
+    };
+    flushAsynchronousOperations();
+    assert.strictEqual(element.selectedValue, '-1');
+  });
+
+  test('default_value is null if not permitted', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Code-Review',
+      value: null,
+    };
+    flushAsynchronousOperations();
+    assert.isNull(element.selectedValue);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
deleted file mode 100644
index c607a9f..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
+++ /dev/null
@@ -1,55 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-label-score-row/gr-label-score-row.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-label-scores">
-  <template>
-    <style include="shared-styles">
-      .mergedMessage {
-        font-style: italic;
-        text-align: center;
-        width: 100%;
-      }
-      gr-label-score-row.no-access {
-        display: var(--label-no-access-display, initial);
-      }
-      @media only screen and (max-width: 25em) {
-        :host {
-          text-align: center;
-        }
-      }
-    </style>
-    <template is="dom-repeat" items="[[_labels]]" as="label">
-      <gr-label-score-row
-          class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
-          label="[[label]]"
-          name="[[label.name]]"
-          labels="[[change.labels]]"
-          permitted-labels="[[permittedLabels]]"
-          label-values="[[_labelValues]]"></gr-label-score-row>
-    </template>
-    <div class="mergedMessage"
-        hidden$="[[!_changeIsMerged(change.status)]]">
-      Because this change has been merged, votes may not be decreased.
-    </div>
-  </template>
-  <script src="gr-label-scores.js"></script>
-</dom-module>
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..2d6825b 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
@@ -14,13 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-label-scores',
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-label-score-row/gr-label-score-row.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label-scores_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrLabelScores extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-label-scores'; }
+
+  static get properties() {
+    return {
       _labels: {
         type: Array,
         computed: '_computeLabels(change.labels.*, account)',
@@ -31,108 +44,113 @@
       },
       /** @type {?} */
       change: Object,
+      /** @type {?} */
+      account: Object,
+
       _labelValues: Object,
-    },
+    };
+  }
 
-    getLabelValues() {
-      const labels = {};
-      for (const label in this.permittedLabels) {
-        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
+  getLabelValues() {
+    const labels = {};
+    for (const label in this.permittedLabels) {
+      if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
 
-        const selectorEl = this.$$(`gr-label-score-row[name="${label}"]`);
-        if (!selectorEl) { continue; }
+      const selectorEl = this.shadowRoot
+          .querySelector(`gr-label-score-row[name="${label}"]`);
+      if (!selectorEl) { continue; }
 
-        // The user may have not voted on this label.
-        if (!selectorEl.selectedItem) { continue; }
+      // The user may have not voted on this label.
+      if (!selectorEl.selectedItem) { continue; }
 
-        const selectedVal = parseInt(selectorEl.selectedValue, 10);
+      const selectedVal = parseInt(selectorEl.selectedValue, 10);
 
-        // Only send the selection if the user changed it.
-        let prevVal = this._getVoteForAccount(this.change.labels, label,
-            this.account);
-        if (prevVal !== null) {
-          prevVal = parseInt(prevVal, 10);
-        }
-        if (selectedVal !== prevVal) {
-          labels[label] = selectedVal;
+      // Only send the selection if the user changed it.
+      let prevVal = this._getVoteForAccount(this.change.labels, label,
+          this.account);
+      if (prevVal !== null) {
+        prevVal = parseInt(prevVal, 10);
+      }
+      if (selectedVal !== prevVal) {
+        labels[label] = selectedVal;
+      }
+    }
+    return labels;
+  }
+
+  _getStringLabelValue(labels, labelName, numberValue) {
+    for (const k in labels[labelName].values) {
+      if (parseInt(k, 10) === numberValue) {
+        return k;
+      }
+    }
+    return numberValue;
+  }
+
+  _getVoteForAccount(labels, labelName, account) {
+    const votes = labels[labelName];
+    if (votes.all && votes.all.length > 0) {
+      for (let i = 0; i < votes.all.length; i++) {
+        if (votes.all[i]._account_id == account._account_id) {
+          return this._getStringLabelValue(
+              labels, labelName, votes.all[i].value);
         }
       }
-      return labels;
-    },
+    }
+    return null;
+  }
 
-    _getStringLabelValue(labels, labelName, numberValue) {
-      for (const k in labels[labelName].values) {
-        if (parseInt(k, 10) === numberValue) {
-          return k;
-        }
+  _computeLabels(labelRecord, account) {
+    // Polymer 2: check for undefined
+    if ([labelRecord, account].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const labelsObj = labelRecord.base;
+    if (!labelsObj) { return []; }
+    return Object.keys(labelsObj).sort()
+        .map(key => {
+          return {
+            name: key,
+            value: this._getVoteForAccount(labelsObj, key, this.account),
+          };
+        });
+  }
+
+  _computeColumns(permittedLabels) {
+    const labels = Object.keys(permittedLabels);
+    const values = {};
+    for (const label of labels) {
+      for (const value of permittedLabels[label]) {
+        values[parseInt(value, 10)] = true;
       }
-      return numberValue;
-    },
+    }
 
-    _getVoteForAccount(labels, labelName, account) {
-      const votes = labels[labelName];
-      if (votes.all && votes.all.length > 0) {
-        for (let i = 0; i < votes.all.length; i++) {
-          if (votes.all[i]._account_id == account._account_id) {
-            return this._getStringLabelValue(
-                labels, labelName, votes.all[i].value);
-          }
-        }
-      }
-      return null;
-    },
+    const orderedValues = Object.keys(values).sort((a, b) => a - b);
 
-    _computeLabels(labelRecord, account) {
-      // Polymer 2: check for undefined
-      if ([labelRecord, account].some(arg => arg === undefined)) {
-        return undefined;
-      }
+    for (let i = 0; i < orderedValues.length; i++) {
+      values[orderedValues[i]] = i;
+    }
+    this._labelValues = values;
+  }
 
-      const labelsObj = labelRecord.base;
-      if (!labelsObj) { return []; }
-      return Object.keys(labelsObj).sort().map(key => {
-        return {
-          name: key,
-          value: this._getVoteForAccount(labelsObj, key, this.account),
-        };
-      });
-    },
+  _changeIsMerged(changeStatus) {
+    return changeStatus === 'MERGED';
+  }
 
-    _computeColumns(permittedLabels) {
-      const labels = Object.keys(permittedLabels);
-      const values = {};
-      for (const label of labels) {
-        for (const value of permittedLabels[label]) {
-          values[parseInt(value, 10)] = true;
-        }
-      }
+  /**
+   * @param {string|undefined} label
+   * @param {Object|undefined} permittedLabels
+   * @return {string}
+   */
+  _computeLabelAccessClass(label, permittedLabels) {
+    if (label == null || permittedLabels == null) {
+      return '';
+    }
 
-      const orderedValues = Object.keys(values).sort((a, b) => {
-        return a - b;
-      });
+    return permittedLabels.hasOwnProperty(label) &&
+      permittedLabels[label].length ? 'access' : 'no-access';
+  }
+}
 
-      for (let i = 0; i < orderedValues.length; i++) {
-        values[orderedValues[i]] = i;
-      }
-      this._labelValues = values;
-    },
-
-    _changeIsMerged(changeStatus) {
-      return changeStatus === 'MERGED';
-    },
-
-    /**
-     * @param {string|undefined} label
-     * @param {Object|undefined} permittedLabels
-     * @return {string}
-     */
-    _computeLabelAccessClass(label, permittedLabels) {
-      if (label == null || permittedLabels == null) {
-        return '';
-      }
-
-      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_html.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
new file mode 100644
index 0000000..1e3e7ec
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .scoresTable {
+      display: table;
+      width: 100%;
+    }
+    .mergedMessage {
+      font-style: italic;
+      text-align: center;
+      width: 100%;
+    }
+    gr-label-score-row:hover {
+      background-color: var(--hover-background-color);
+    }
+    gr-label-score-row {
+      display: table-row;
+    }
+    gr-label-score-row.no-access {
+      display: var(--label-no-access-display, table-row);
+    }
+  </style>
+  <div class="scoresTable">
+    <template is="dom-repeat" items="[[_labels]]" as="label">
+      <gr-label-score-row
+        class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
+        label="[[label]]"
+        name="[[label.name]]"
+        labels="[[change.labels]]"
+        permitted-labels="[[permittedLabels]]"
+        label-values="[[_labelValues]]"
+      ></gr-label-score-row>
+    </template>
+  </div>
+  <div class="mergedMessage" hidden$="[[!_changeIsMerged(change.status)]]">
+    Because this change has been merged, votes may not be decreased.
+  </div>
+`;
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..7ee86d6 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-label-scores.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,164 +31,166 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-label-scores tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-label-scores.js';
+suite('gr-label-scores tests', () => {
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-      element.change = {
-        _number: '123',
-        labels: {
-          'Code-Review': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
-            value: 1,
-            all: [{
-              _account_id: 123,
-              value: 1,
-            }],
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = fixture('basic');
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
           },
-          'Verified': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
             value: 1,
-            all: [{
-              _account_id: 123,
-              value: 1,
-            }],
-          },
+          }],
         },
-      };
+        'Verified': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
+            value: 1,
+          }],
+        },
+      },
+    };
 
-      element.account = {
-        _account_id: 123,
-      };
+    element.account = {
+      _account_id: 123,
+    };
 
-      element.permittedLabels = {
-        'Code-Review': [
-          '-2',
-          '-1',
-          ' 0',
-          '+1',
-          '+2',
-        ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      flush(done);
-    });
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(done);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('get and set label scores', () => {
-      for (const label in element.permittedLabels) {
-        if (element.permittedLabels.hasOwnProperty(label)) {
-          const row = element.$$('gr-label-score-row[name="' + label + '"]');
-          row.setSelectedValue(-1);
-        }
+  test('get and set label scores', () => {
+    for (const label in element.permittedLabels) {
+      if (element.permittedLabels.hasOwnProperty(label)) {
+        const row = element.shadowRoot
+            .querySelector('gr-label-score-row[name="' + label + '"]');
+        row.setSelectedValue(-1);
       }
-      assert.deepEqual(element.getLabelValues(), {
-        'Code-Review': -1,
-        'Verified': -1,
-      });
-    });
-
-    test('_getVoteForAccount', () => {
-      const labelName = 'Code-Review';
-      assert.strictEqual(element._getVoteForAccount(
-          element.change.labels, labelName, element.account),
-      '+1');
-    });
-
-    test('_computeColumns', () => {
-      element._computeColumns(element.permittedLabels);
-      assert.deepEqual(element._labelValues, {
-        '-2': 0,
-        '-1': 1,
-        '0': 2,
-        '1': 3,
-        '2': 4,
-      });
-    });
-
-    test('_computeLabelAccessClass undefined case', () => {
-      assert.strictEqual(
-          element._computeLabelAccessClass(undefined, undefined), '');
-      assert.strictEqual(
-          element._computeLabelAccessClass('', undefined), '');
-      assert.strictEqual(
-          element._computeLabelAccessClass(undefined, {}), '');
-    });
-
-    test('_computeLabelAccessClass has access', () => {
-      assert.strictEqual(
-          element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
-    });
-
-    test('_computeLabelAccessClass no access', () => {
-      assert.strictEqual(
-          element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
-    });
-
-    test('changes in label score are reflected in _labels', () => {
-      element.change = {
-        _number: '123',
-        labels: {
-          'Code-Review': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
-          },
-          'Verified': {
-            values: {
-              '0': 'No score',
-              '+1': 'good',
-              '+2': 'excellent',
-              '-1': 'bad',
-              '-2': 'terrible',
-            },
-            default_value: 0,
-          },
-        },
-      };
-      assert.deepEqual(element._labels [
-          {name: 'Code-Review', value: null},
-          {name: 'Verified', value: null}
-      ]);
-      element.set(['change', 'labels', 'Verified', 'all'],
-          [{_account_id: 123, value: 1}]);
-      assert.deepEqual(element._labels, [
-        {name: 'Code-Review', value: null},
-        {name: 'Verified', value: '+1'},
-      ]);
+    }
+    assert.deepEqual(element.getLabelValues(), {
+      'Code-Review': -1,
+      'Verified': -1,
     });
   });
+
+  test('_getVoteForAccount', () => {
+    const labelName = 'Code-Review';
+    assert.strictEqual(element._getVoteForAccount(
+        element.change.labels, labelName, element.account),
+    '+1');
+  });
+
+  test('_computeColumns', () => {
+    element._computeColumns(element.permittedLabels);
+    assert.deepEqual(element._labelValues, {
+      '-2': 0,
+      '-1': 1,
+      '0': 2,
+      '1': 3,
+      '2': 4,
+    });
+  });
+
+  test('_computeLabelAccessClass undefined case', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass(undefined, undefined), '');
+    assert.strictEqual(
+        element._computeLabelAccessClass('', undefined), '');
+    assert.strictEqual(
+        element._computeLabelAccessClass(undefined, {}), '');
+  });
+
+  test('_computeLabelAccessClass has access', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
+  });
+
+  test('_computeLabelAccessClass no access', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
+  });
+
+  test('changes in label score are reflected in _labels', () => {
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+        'Verified': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    assert.deepEqual(element._labels [
+        ({name: 'Code-Review', value: null}, {name: 'Verified', value: null})
+    ]);
+    element.set(['change', 'labels', 'Verified', 'all'],
+        [{_account_id: 123, value: 1}]);
+    assert.deepEqual(element._labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: '+1'},
+    ]);
+  });
+});
 </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
deleted file mode 100644
index 8317e2f..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ /dev/null
@@ -1,258 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<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="../../shared/gr-account-label/gr-account-label.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-
-<link rel="import" href="../gr-comment-list/gr-comment-list.html">
-
-<dom-module id="gr-message">
-  <template>
-    <style include="gr-voting-styles"></style>
-    <style include="shared-styles">
-      :host {
-        border-bottom: 1px solid var(--border-color);
-        display: block;
-        position: relative;
-        cursor: pointer;
-        overflow-y: hidden;
-      }
-      :host(.expanded) {
-        cursor: auto;
-      }
-      :host > div {
-        padding: 0 var(--spacing-l);
-      }
-      gr-avatar {
-        position: absolute;
-        left: var(--spacing-l);
-      }
-      .collapsed .contentContainer {
-        align-items: baseline;
-        color: var(--deemphasized-text-color);
-        display: flex;
-        white-space: nowrap;
-      }
-      .contentContainer {
-        margin-left: calc(var(--spacing-l) + 2.5em);
-        padding: var(--spacing-m) 0;
-      }
-      .showAvatar.collapsed .contentContainer {
-        margin-left: calc(var(--spacing-l) + 1.75em);
-      }
-      .hideAvatar.collapsed .contentContainer,
-      .hideAvatar.expanded .contentContainer {
-        margin-left: 0;
-      }
-      .showAvatar.collapsed .contentContainer,
-      .hideAvatar.collapsed .contentContainer,
-      .hideAvatar.expanded .contentContainer {
-        padding: var(--spacing-m) 0;
-      }
-      .collapsed gr-avatar {
-        top: var(--spacing-m);
-        height: var(--line-height-normal);
-        width: var(--line-height-normal);
-      }
-      .expanded gr-avatar {
-        top: var(--spacing-l);
-        height: var(--line-height-h1);
-        width: var(--line-height-h1);
-      }
-      .name {
-        font-weight: var(--font-weight-bold);
-      }
-      .message {
-        --gr-formatted-text-prose-max-width: 80ch;
-      }
-      .collapsed .message {
-        max-width: none;
-        overflow: hidden;
-        text-overflow: ellipsis;
-      }
-      .collapsed .author,
-      .collapsed .content,
-      .collapsed .message,
-      .collapsed .updateCategory,
-      gr-account-chip {
-        display: inline;
-      }
-      gr-button {
-        margin: 0 -4px;
-      }
-      .collapsed gr-comment-list,
-      .collapsed .replyContainer,
-      .collapsed .hideOnCollapsed,
-      .hideOnOpen {
-        display: none;
-      }
-      .collapsed .hideOnOpen {
-        display: block;
-      }
-      .collapsed .content {
-        flex: 1;
-        margin-right: var(--spacing-xs);
-        min-width: 0;
-        overflow: hidden;
-        text-overflow: ellipsis;
-      }
-      .collapsed .dateContainer {
-        position: static;
-      }
-      .collapsed .author {
-        overflow: hidden;
-        color: var(--primary-text-color);
-        margin-right: var(--spacing-s);
-      }
-      .expanded .author {
-        cursor: pointer;
-        margin-bottom: var(--spacing-s);
-      }
-      .dateContainer {
-        position: absolute;
-        right: var(--spacing-l);
-        top: 10px;
-      }
-      span.date {
-        color: var(--deemphasized-text-color);
-      }
-      span.date:hover {
-        text-decoration: underline;
-      }
-      .dateContainer iron-icon {
-        cursor: pointer;
-        vertical-align: top;
-      }
-      .replyContainer {
-        padding: var(--spacing-m) 0 0 0;
-      }
-      .score {
-        border: 1px solid rgba(0,0,0,.12);
-        border-radius: var(--border-radius);
-        color: var(--primary-text-color);
-        display: inline-block;
-        margin: -1px 0;
-        padding: 0 var(--spacing-xxs);
-      }
-      .score.negative {
-        background-color: var(--vote-color-disliked);
-      }
-      .score.negative.min {
-        background-color: var(--vote-color-rejected);
-      }
-      .score.positive {
-        background-color: var(--vote-color-recommended);
-      }
-      .score.positive.max {
-        background-color: var(--vote-color-approved);
-      }
-      gr-account-label {
-        --gr-account-label-text-style: {
-          font-weight: var(--font-weight-bold);
-        };
-      }
-    </style>
-    <div class$="[[_computeClass(_expanded, showAvatar, message)]]">
-      <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
-      <div class="contentContainer">
-        <div class="author" on-click="_handleAuthorClick">
-          <span hidden$="[[!showOnBehalfOf]]">
-            <span class="name">[[message.real_author.name]]</span>
-            on behalf of
-          </span>
-          <gr-account-label
-              account="[[author]]"
-              hide-avatar></gr-account-label>
-          <template is="dom-repeat" items="[[_getScores(message)]]" as="score">
-            <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
-              [[score.label]] [[score.value]]
-            </span>
-          </template>
-        </div>
-        <template is="dom-if" if="[[message.message]]">
-          <div class="content">
-            <div class="message hideOnOpen">[[message.message]]</div>
-            <gr-formatted-text
-                no-trailing-margin
-                class="message hideOnCollapsed"
-                content="[[message.message]]"
-                config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
-            <template is="dom-if" if="[[_expanded]]">
-              <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
-                <gr-button link small on-click="_handleReplyTap">Reply</gr-button>
-              </div>
-              <gr-comment-list
-                  comments="[[comments]]"
-                  change-num="[[changeNum]]"
-                  patch-num="[[message._revision_number]]"
-                  project-name="[[projectName]]"
-                  project-config="[[_projectConfig]]"></gr-comment-list>
-            </template>
-          </div>
-        </template>
-        <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
-          <div class="content">
-            <template is="dom-repeat" items="[[message.updates]]" as="update">
-              <div class="updateCategory">
-                [[update.message]]
-                <template
-                    is="dom-repeat" items="[[update.reviewers]]" as="reviewer">
-                  <gr-account-chip account="[[reviewer]]">
-                  </gr-account-chip>
-                </template>
-              </div>
-            </template>
-          </div>
-        </template>
-        <span class="dateContainer">
-          <template is="dom-if" if="[[!message.id]]">
-            <span class="date">
-              <gr-date-formatter
-                  has-tooltip
-                  show-date-and-time
-                  date-str="[[message.date]]"></gr-date-formatter>
-            </span>
-          </template>
-          <template is="dom-if" if="[[message.id]]">
-            <span class="date" on-click="_handleAnchorClick">
-              <gr-date-formatter
-                  has-tooltip
-                  show-date-and-time
-                  date-str="[[message.date]]"></gr-date-formatter>
-            </span>
-          </template>
-          <iron-icon
-              id="expandToggle"
-              on-click="_toggleExpanded"
-              title="Toggle expanded state"
-              icon="[[_computeExpandToggleIcon(_expanded)]]">
-        </span>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-message.js"></script>
-</dom-module>
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 06bbc93..906ed34 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,32 +14,54 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+: /;
-  const LABEL_TITLE_SCORE_PATTERN = /^([A-Za-z0-9-]+)([+-]\d+)$/;
+import '@polymer/iron-icon/iron-icon.js';
+import '../../shared/gr-account-label/gr-account-label.js';
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-formatted-text/gr-formatted-text.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-voting-styles.js';
+import '../gr-comment-list/gr-comment-list.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-message_html.js';
 
-  Polymer({
-    is: 'gr-message',
+const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
+const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
 
-    /**
-     * Fired when this message's reply link is tapped.
-     *
-     * @event reply
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrMessage extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the message's timestamp is tapped.
-     *
-     * @event message-anchor-tap
-     */
+  static get is() { return 'gr-message'; }
+  /**
+   * Fired when this message's reply link is tapped.
+   *
+   * @event reply
+   */
 
-    listeners: {
-      click: '_handleClick',
-    },
+  /**
+   * Fired when the message's timestamp is tapped.
+   *
+   * @event message-anchor-tap
+   */
 
-    properties: {
+  /**
+   * Fired when a change message is deleted.
+   *
+   * @event change-message-deleted
+   */
+
+  static get properties() {
+    return {
       changeNum: Number,
       /** @type {?} */
       message: Object,
@@ -64,10 +86,6 @@
         type: Boolean,
         computed: '_computeIsAutomated(message)',
       },
-      showAvatar: {
-        type: Boolean,
-        computed: '_computeShowAvatar(author, config)',
-      },
       showOnBehalfOf: {
         type: Boolean,
         computed: '_computeShowOnBehalfOf(message)',
@@ -96,156 +114,279 @@
         type: Object,
         computed: '_computeExpanded(message.expanded)',
       },
+      _messageContentExpanded: {
+        type: String,
+        computed:
+            '_computeMessageContentExpanded(message.message, message.tag)',
+      },
+      _messageContentCollapsed: {
+        type: String,
+        computed:
+            '_computeMessageContentCollapsed(message.message, message.tag)',
+      },
+      _commentCountText: {
+        type: Number,
+        computed: '_computeCommentCountText(comments)',
+      },
       _loggedIn: {
         type: Boolean,
         value: false,
       },
-    },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _isDeletingChangeMsg: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_updateExpandedClass(message.expanded)',
-    ],
+    ];
+  }
 
-    ready() {
-      this.$.restAPI.getConfig().then(config => {
-        this.config = config;
-      });
-      this.$.restAPI.getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('click',
+        e => this._handleClick(e));
+  }
 
-    _updateExpandedClass(expanded) {
-      if (expanded) {
-        this.classList.add('expanded');
-      } else {
-        this.classList.remove('expanded');
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.restAPI.getConfig().then(config => {
+      this.config = config;
+    });
+    this.$.restAPI.getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+    this.$.restAPI.getIsAdmin().then(isAdmin => {
+      this._isAdmin = isAdmin;
+    });
+  }
+
+  _updateExpandedClass(expanded) {
+    if (expanded) {
+      this.classList.add('expanded');
+    } else {
+      this.classList.remove('expanded');
+    }
+  }
+
+  _computeCommentCountText(comments) {
+    if (!comments) return undefined;
+    let count = 0;
+    for (const file in comments) {
+      if (comments.hasOwnProperty(file)) {
+        const commentArray = comments[file] || [];
+        count += commentArray.length;
       }
-    },
+    }
+    if (count === 0) {
+      return undefined;
+    } else if (count === 1) {
+      return '1 comment';
+    } else {
+      return `${count} comments`;
+    }
+  }
 
-    _computeAuthor(message) {
-      return message.author || message.updated_by;
-    },
+  _computeMessageContentExpanded(content, tag) {
+    return this._computeMessageContent(content, tag, true);
+  }
 
-    _computeShowAvatar(author, config) {
-      return !!(author && config && config.plugin && config.plugin.has_avatars);
-    },
+  _computeMessageContentCollapsed(content, tag) {
+    return this._computeMessageContent(content, tag, false);
+  }
 
-    _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;
-    },
-
-    _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 []; }
-      const line = message.message.split('\n', 1)[0];
-      const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-      if (!line.match(patchSetPrefix)) { return []; }
-      const scoresRaw = line.split(patchSetPrefix)[1];
-      if (!scoresRaw) { return []; }
-      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]}));
-    },
-
-    _computeScoreClass(score, labelExtremes) {
-      // Polymer 2: check for undefined
-      if ([score, labelExtremes].some(arg => arg === undefined)) {
-        return '';
+  _computeMessageContent(content, tag, isExpanded) {
+    content = content || '';
+    tag = tag || '';
+    const isNewPatchSet = tag.endsWith(':newPatchSet') ||
+        tag.endsWith(':newWipPatchSet');
+    const lines = content.split('\n');
+    const filteredLines = lines.filter(line => {
+      if (!isExpanded && line.startsWith('>')) {
+        return false;
       }
-      const classes = [];
-      if (score.value > 0) {
-        classes.push('positive');
-      } else if (score.value < 0) {
-        classes.push('negative');
+      if (line.startsWith('(') && line.endsWith(' comment)')) {
+        return false;
       }
-      const extremes = labelExtremes[score.label];
-      if (extremes) {
-        const intScore = parseInt(score.value, 10);
-        if (intScore === extremes.max) {
-          classes.push('max');
-        } else if (intScore === extremes.min) {
-          classes.push('min');
-        }
+      if (line.startsWith('(') && line.endsWith(' comments)')) {
+        return false;
       }
-      return classes.join(' ');
-    },
+      if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
+        return false;
+      }
+      return true;
+    });
+    const mappedLines = filteredLines.map(line => {
+      // The change message formatting is not very consistent, so
+      // unfortunately we have to do a bit of tweaking here:
+      //   Labels should be stripped from lines like this:
+      //     Patch Set 29: Verified+1
+      //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
+      //   lines like this:
+      //     Patch Set 27: Patch Set 26 was rebased
+      if (isNewPatchSet) {
+        line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
+      }
+      return line;
+    });
+    return mappedLines.join('\n').trim();
+  }
 
-    _computeClass(expanded, showAvatar, message) {
-      const classes = [];
-      classes.push(expanded ? 'expanded' : 'collapsed');
-      classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
-      return classes.join(' ');
-    },
+  _computeAuthor(message) {
+    return message.author || message.updated_by;
+  }
 
-    _handleAnchorClick(e) {
-      e.preventDefault();
-      this.dispatchEvent(new CustomEvent('message-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {id: this.message.id},
-      }));
-    },
+  _computeShowOnBehalfOf(message) {
+    const author = message.author || message.updated_by;
+    return !!(author && message.real_author &&
+        author._account_id != message.real_author._account_id);
+  }
 
-    _handleReplyTap(e) {
-      e.preventDefault();
-      this.fire('reply', {message: this.message});
-    },
+  _computeShowReplyButton(message, loggedIn) {
+    return message && !!message.message && loggedIn &&
+        !this._computeIsAutomated(message);
+  }
 
-    _projectNameChanged(name) {
-      this.$.restAPI.getProjectConfig(name).then(config => {
-        this._projectConfig = config;
-      });
-    },
+  _computeExpanded(expanded) {
+    return expanded;
+  }
 
-    _computeExpandToggleIcon(expanded) {
-      return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
-    },
+  _handleClick(e) {
+    if (this.message.expanded) { return; }
+    e.stopPropagation();
+    this.set('message.expanded', true);
+  }
 
-    _toggleExpanded(e) {
-      e.stopPropagation();
-      this.set('message.expanded', !this.message.expanded);
-    },
-  });
-})();
+  _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(message) {
+    return message.type === 'REVIEWER_UPDATE';
+  }
+
+  _getScores(message, labelExtremes) {
+    if (!message || !message.message || !labelExtremes) {
+      return [];
+    }
+    const line = message.message.split('\n', 1)[0];
+    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+    if (!line.match(patchSetPrefix)) {
+      return [];
+    }
+    const scoresRaw = line.split(patchSetPrefix)[1];
+    if (!scoresRaw) {
+      return [];
+    }
+    return scoresRaw.split(' ')
+        .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+        .filter(ms =>
+          ms && ms.length === 4 && labelExtremes.hasOwnProperty(ms[2]))
+        .map(ms => {
+          const label = ms[2];
+          const value = ms[1] === '-' ? 'removed' : ms[3];
+          return {label, value};
+        });
+  }
+
+  _computeScoreClass(score, labelExtremes) {
+    // Polymer 2: check for undefined
+    if ([score, labelExtremes].some(arg => arg === undefined)) {
+      return '';
+    }
+    if (score.value === 'removed') {
+      return 'removed';
+    }
+    const classes = [];
+    if (score.value > 0) {
+      classes.push('positive');
+    } else if (score.value < 0) {
+      classes.push('negative');
+    }
+    const extremes = labelExtremes[score.label];
+    if (extremes) {
+      const intScore = parseInt(score.value, 10);
+      if (intScore === extremes.max) {
+        classes.push('max');
+      } else if (intScore === extremes.min) {
+        classes.push('min');
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _computeClass(expanded) {
+    const classes = [];
+    classes.push(expanded ? 'expanded' : 'collapsed');
+    return classes.join(' ');
+  }
+
+  _handleAnchorClick(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('message-anchor-tap', {
+      bubbles: true,
+      composed: true,
+      detail: {id: this.message.id},
+    }));
+  }
+
+  _handleReplyTap(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('reply', {
+      detail: {message: this.message},
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _handleDeleteMessage(e) {
+    e.preventDefault();
+    if (!this.message || !this.message.id) return;
+    this._isDeletingChangeMsg = true;
+    this.$.restAPI.deleteChangeCommitMessage(this.changeNum, this.message.id)
+        .then(() => {
+          this._isDeletingChangeMsg = false;
+          this.dispatchEvent(new CustomEvent('change-message-deleted', {
+            detail: {message: this.message},
+            composed: true, bubbles: true,
+          }));
+        });
+  }
+
+  _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_html.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
new file mode 100644
index 0000000..44c12f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
@@ -0,0 +1,304 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="gr-voting-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    :host {
+      display: block;
+      position: relative;
+      cursor: pointer;
+      overflow-y: hidden;
+    }
+    :host(.expanded) {
+      cursor: auto;
+    }
+    .collapsed .contentContainer {
+      align-items: center;
+      color: var(--deemphasized-text-color);
+      display: flex;
+      white-space: nowrap;
+    }
+    .contentContainer {
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    .collapsed .contentContainer {
+      /* For expanded state we inherit the alternating background color
+           that is set in gr-messages-list. */
+      background-color: var(--background-color-primary);
+    }
+    .name {
+      font-weight: var(--font-weight-bold);
+    }
+    .message {
+      --gr-formatted-text-prose-max-width: 120ch;
+    }
+    .collapsed .message {
+      max-width: none;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+    .collapsed .author,
+    .collapsed .content,
+    .collapsed .message,
+    .collapsed .updateCategory,
+    gr-account-chip {
+      display: inline;
+    }
+    gr-button {
+      margin: 0 -4px;
+    }
+    .collapsed gr-comment-list,
+    .collapsed .replyBtn,
+    .collapsed .deleteBtn,
+    .collapsed .hideOnCollapsed,
+    .hideOnOpen {
+      display: none;
+    }
+    .replyBtn {
+      margin-right: var(--spacing-m);
+    }
+    .collapsed .hideOnOpen {
+      display: block;
+    }
+    .collapsed .content {
+      flex: 1;
+      margin-right: var(--spacing-m);
+      min-width: 0;
+      overflow: hidden;
+    }
+    .collapsed .content.messageContent {
+      text-overflow: ellipsis;
+    }
+    .collapsed .dateContainer {
+      position: static;
+    }
+    .collapsed .author {
+      overflow: hidden;
+      color: var(--primary-text-color);
+      margin-right: var(--spacing-s);
+    }
+    .authorLabel {
+      min-width: 160px;
+      display: inline-block;
+    }
+    .expanded .author {
+      cursor: pointer;
+      margin-bottom: var(--spacing-m);
+    }
+    .expanded .content {
+      padding-left: 40px;
+    }
+    .dateContainer {
+      position: absolute;
+      /* right and top values should match .contentContainer padding */
+      right: var(--spacing-l);
+      top: var(--spacing-m);
+    }
+    .dateContainer .patchset {
+      margin-right: var(--spacing-m);
+      color: var(--deemphasized-text-color);
+    }
+    .dateContainer .patchset:before {
+      content: 'Patchset ';
+    }
+    span.date {
+      color: var(--deemphasized-text-color);
+    }
+    span.date:hover {
+      text-decoration: underline;
+    }
+    .dateContainer iron-icon {
+      cursor: pointer;
+      vertical-align: top;
+    }
+    .score {
+      border-radius: var(--border-radius);
+      color: var(--primary-text-color);
+      display: inline-block;
+      padding: 0 var(--spacing-s);
+      text-align: center;
+    }
+    .score,
+    .commentsSummary {
+      margin-right: var(--spacing-s);
+      min-width: 115px;
+    }
+    .expanded .commentsSummary {
+      display: none;
+    }
+    .commentsIcon {
+      vertical-align: top;
+    }
+    .score.removed {
+      background-color: var(--vote-color-neutral);
+    }
+    .score.negative {
+      background-color: var(--vote-color-disliked);
+    }
+    .score.negative.min {
+      background-color: var(--vote-color-rejected);
+    }
+    .score.positive {
+      background-color: var(--vote-color-recommended);
+    }
+    .score.positive.max {
+      background-color: var(--vote-color-approved);
+    }
+    gr-account-label {
+      --gr-account-label-text-style: {
+        font-weight: var(--font-weight-bold);
+      }
+    }
+    @media screen and (max-width: 50em) {
+      .expanded .content {
+        padding-left: 0;
+      }
+      .score,
+      .commentsSummary,
+      .authorLabel {
+        min-width: 0px;
+      }
+      .dateContainer .patchset:before {
+        content: 'PS ';
+      }
+    }
+  </style>
+  <div class$="[[_computeClass(_expanded)]]">
+    <div class="contentContainer">
+      <div class="author" on-click="_handleAuthorClick">
+        <span hidden$="[[!showOnBehalfOf]]">
+          <span class="name">[[message.real_author.name]]</span>
+          on behalf of
+        </span>
+        <gr-account-label
+          account="[[author]]"
+          class="authorLabel"
+        ></gr-account-label>
+        <template
+          is="dom-repeat"
+          items="[[_getScores(message, labelExtremes)]]"
+          as="score"
+        >
+          <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
+            [[score.label]] [[score.value]]
+          </span>
+        </template>
+      </div>
+      <template is="dom-if" if="[[_commentCountText]]">
+        <div class="commentsSummary">
+          <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
+          <span class="numberOfComments">[[_commentCountText]]</span>
+        </div>
+      </template>
+      <template is="dom-if" if="[[message.message]]">
+        <div class="content messageContent">
+          <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
+          <gr-formatted-text
+            no-trailing-margin=""
+            class="message hideOnCollapsed"
+            content="[[_messageContentExpanded]]"
+            config="[[_projectConfig.commentlinks]]"
+          ></gr-formatted-text>
+          <template is="dom-if" if="[[_messageContentExpanded]]">
+            <div
+              class="replyActionContainer"
+              hidden$="[[!showReplyButton]]"
+              hidden=""
+            >
+              <gr-button
+                class="replyBtn"
+                link=""
+                small=""
+                on-click="_handleReplyTap"
+              >
+                Reply
+              </gr-button>
+              <gr-button
+                disabled$="[[_isDeletingChangeMsg]]"
+                class="deleteBtn"
+                hidden$="[[!_isAdmin]]"
+                hidden=""
+                link=""
+                small=""
+                on-click="_handleDeleteMessage"
+              >
+                Delete
+              </gr-button>
+            </div>
+          </template>
+          <gr-comment-list
+            comments="[[comments]]"
+            change-num="[[changeNum]]"
+            patch-num="[[message._revision_number]]"
+            project-name="[[projectName]]"
+            project-config="[[_projectConfig]]"
+          ></gr-comment-list>
+        </div>
+      </template>
+      <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
+        <div class="content">
+          <template is="dom-repeat" items="[[message.updates]]" as="update">
+            <div class="updateCategory">
+              [[update.message]]
+              <template
+                is="dom-repeat"
+                items="[[update.reviewers]]"
+                as="reviewer"
+              >
+                <gr-account-chip account="[[reviewer]]"> </gr-account-chip>
+              </template>
+            </div>
+          </template>
+        </div>
+      </template>
+      <span class="dateContainer">
+        <template is="dom-if" if="[[message._revision_number]]">
+          <span class="patchset">[[message._revision_number]]</span>
+        </template>
+        <template is="dom-if" if="[[!message.id]]">
+          <span class="date">
+            <gr-date-formatter
+              has-tooltip=""
+              show-date-and-time=""
+              date-str="[[message.date]]"
+            ></gr-date-formatter>
+          </span>
+        </template>
+        <template is="dom-if" if="[[message.id]]">
+          <span class="date" on-click="_handleAnchorClick">
+            <gr-date-formatter
+              has-tooltip=""
+              show-date-and-time=""
+              date-str="[[message.date]]"
+            ></gr-date-formatter>
+          </span>
+        </template>
+        <iron-icon
+          id="expandToggle"
+          on-click="_toggleExpanded"
+          title="Toggle expanded state"
+          icon="[[_computeExpandToggleIcon(_expanded)]]"
+        ></iron-icon>
+      </span>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 a90e3ab..78c2229 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-message.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,14 +31,21 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-message tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-message.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-message tests', () => {
+  let element;
 
+  suite('when admin and logged in', () => {
     setup(done => {
       stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
         getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(true); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
       });
       element = fixture('basic');
       flush(done);
@@ -66,7 +70,52 @@
         done();
       });
       flushAsynchronousOperations();
-      MockInteractions.tap(element.$$('.replyContainer gr-button'));
+      assert.isFalse(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      MockInteractions.tap(element.shadowRoot.querySelector('.replyBtn'));
+    });
+
+    test('can see delete button', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: false,
+      };
+
+      flushAsynchronousOperations();
+      assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
+    });
+
+    test('delete change message', done => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: false,
+      };
+
+      element.addEventListener('change-message-deleted', e => {
+        assert.deepEqual(e.detail.message, element.message);
+        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
+        done();
+      });
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
+      assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
     });
 
     test('autogenerated prefix hiding', () => {
@@ -167,9 +216,9 @@
           message: `Patch Set 1: ${label}+1`,
         };
         assert.isNotOk(
-            Polymer.dom(element.root).querySelector('.negativeVote'));
+            dom(element.root).querySelector('.negativeVote'));
         assert.isNotOk(
-            Polymer.dom(element.root).querySelector('.positiveVote'));
+            dom(element.root).querySelector('.positiveVote'));
       });
     });
 
@@ -184,7 +233,8 @@
       flushAsynchronousOperations();
       const stub = sinon.stub();
       element.addEventListener('message-anchor-tap', stub);
-      const dateEl = element.$$('.date');
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
       assert.ok(dateEl);
       MockInteractions.tap(dateEl);
 
@@ -192,19 +242,75 @@
       assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
     });
 
+    suite('compute messages', () => {
+      test('empty', () => {
+        assert.equal(element._computeMessageContent('', '', true), '');
+        assert.equal(element._computeMessageContent('', '', false), '');
+      });
+
+      test('new patchset', () => {
+        const original = 'Uploaded patch set 1.';
+        const tag = 'autogenerated:gerrit:newPatchSet';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, original);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, original);
+      });
+
+      test('new patchset rebased', () => {
+        const original = 'Patch Set 27: Patch Set 26 was rebased';
+        const tag = 'autogenerated:gerrit:newPatchSet';
+        const expected = 'Patch Set 26 was rebased';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('ready for review', () => {
+        const original = 'Patch Set 1:\n\nThis change is ready for review.';
+        const tag = undefined;
+        const expected = 'This change is ready for review.';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('vote', () => {
+        const original = 'Patch Set 1: Code-Style+1';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('comments', () => {
+        const original = 'Patch Set 1:\n\n(3 comments)';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+    });
+
     test('votes', () => {
       element.message = {
         author: {},
         expanded: false,
-        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Ready+1',
+        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
       };
       element.labelExtremes = {
         'Verified': {max: 1, min: -1},
         'Code-Review': {max: 2, min: -2},
-        'Trybot-Ready': {max: 3, min: 0},
+        'Trybot-Label3': {max: 3, min: 0},
       };
       flushAsynchronousOperations();
-      const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
+      const scoreChips = dom(element.root).querySelectorAll('.score');
       assert.equal(scoreChips.length, 3);
 
       assert.isTrue(scoreChips[0].classList.contains('positive'));
@@ -217,6 +323,25 @@
       assert.isFalse(scoreChips[2].classList.contains('min'));
     });
 
+    test('removed votes', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
+      };
+      element.labelExtremes = {
+        'Verified': {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Commit-Queue': {max: 3, min: 0},
+      };
+      flushAsynchronousOperations();
+      const scoreChips = dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[1].classList.contains('removed'));
+      assert.isTrue(scoreChips[2].classList.contains('removed'));
+    });
+
     test('false negative vote', () => {
       element.message = {
         author: {},
@@ -224,8 +349,108 @@
         message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
       };
       element.labelExtremes = {};
-      const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
+      const scoreChips = dom(element.root).querySelectorAll('.score');
       assert.equal(scoreChips.length, 0);
     });
   });
+
+  suite('when not logged in', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+        getPreferences() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(false); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+      flush(done);
+    });
+
+    test('reply and delete button should be hidden', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: false,
+      };
+
+      flushAsynchronousOperations();
+      assert.isTrue(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('.deleteBtn').hidden
+      );
+    });
+  });
+
+  suite('when logged in but not admin', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(false); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+      flush(done);
+    });
+
+    test('can see reply but not delete button', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: false,
+      };
+
+      flushAsynchronousOperations();
+      assert.isFalse(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('.deleteBtn').hidden
+      );
+    });
+
+    test('reply button shown when message is updated', () => {
+      element.message = undefined;
+      flushAsynchronousOperations();
+      let replyEl = element.shadowRoot.querySelector('.replyActionContainer');
+      // We don't even expect the button to show up in the DOM when the message
+      // is undefined.
+      assert.isNotOk(replyEl);
+
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'not empty',
+        _revision_number: 1,
+        expanded: false,
+      };
+      flushAsynchronousOperations();
+      replyEl = element.shadowRoot.querySelector('.replyActionContainer');
+      assert.isOk(replyEl);
+      assert.isFalse(replyEl.hidden);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
new file mode 100644
index 0000000..2da1432
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
@@ -0,0 +1,346 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../gr-message/gr-message.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-messages-list-experimental_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {util} from '../../../scripts/util.js';
+
+/**
+ * The content of the enum is also used in the UI for the button text.
+ *
+ * @enum {string}
+ */
+const ExpandAllState = {
+  EXPAND_ALL: 'Expand All',
+  COLLAPSE_ALL: 'Collapse All',
+};
+
+/**
+ * @extends Polymer.Element
+ */
+class GrMessagesListExperimental extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-messages-list-experimental'; }
+
+  static get properties() {
+    return {
+      changeNum: Number,
+      /**
+       * These are just the change messages. They are combined with reviewer
+       * updates below. So _combinedMessages is the more important property.
+       */
+      messages: {
+        type: Array,
+        value() { return []; },
+      },
+      /**
+       * These are just the reviewer updates. They are combined with change
+       * messages above. So _combinedMessages is the more important property.
+       */
+      reviewerUpdates: {
+        type: Array,
+        value() { return []; },
+      },
+      changeComments: Object,
+      projectName: String,
+      showReplyButtons: {
+        type: Boolean,
+        value: false,
+      },
+      labels: Object,
+
+      /**
+       * Keeps track of the state of the "Expand All" toggle button. Note that
+       * you can individually expand/collapse some messages without affecting
+       * the toggle button's state.
+       *
+       * @type {ExpandAllState}
+       */
+      _expandAllState: {
+        type: String,
+        value: ExpandAllState.EXPAND_ALL,
+      },
+      _expandAllTitle: {
+        type: String,
+        computed: '_computeExpandAllTitle(_expandAllState)',
+      },
+
+      _hideAutomated: {
+        type: Boolean,
+        value: false,
+        observer: '_hideAutomatedChanged',
+      },
+      /**
+       * The merged array of change messages and reviewer updates.
+       */
+      _combinedMessages: {
+        type: Array,
+        computed: '_computeCombinedMessages(messages, reviewerUpdates)',
+        observer: '_combinedMessagesChanged',
+      },
+
+      _labelExtremes: {
+        type: Object,
+        computed: '_computeLabelExtremes(labels.*)',
+      },
+    };
+  }
+
+  scrollToMessage(messageID) {
+    const selector = `[data-message-id="${messageID}"]`;
+    const el = this.shadowRoot.querySelector(selector);
+
+    if (!el && !this._hideAutomated) {
+      console.warn(`Failed to scroll to message: ${messageID}`);
+      return;
+    }
+    if (!el) {
+      this._hideAutomated = false;
+      setTimeout(() => this.scrollToMessage(messageID));
+      return;
+    }
+
+    el.set('message.expanded', true);
+    let top = el.offsetTop;
+    for (let offsetParent = el.offsetParent;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent) {
+      top += offsetParent.offsetTop;
+    }
+    window.scrollTo(0, top);
+    this._highlightEl(el);
+  }
+
+  _isAutomated(message) {
+    const isReviewerUpdate =
+        !!(message.reviewer || message.type === 'REVIEWER_UPDATE');
+    const isAutoGenerated =
+        !!(message.tag && message.tag.startsWith('autogenerated'));
+    return isReviewerUpdate || isAutoGenerated;
+  }
+
+  _hideAutomatedChanged(hideAutomated) {
+    // We have to call render() such that the dom-repeat filter picks up the
+    // change.
+    this.$.messageRepeat.render();
+  }
+
+  /**
+   * Filter for the dom-repeat of combinedMessages.
+   */
+  _isMessageVisible(message) {
+    return !(this._hideAutomated && this._isAutomated(message));
+  }
+
+  /**
+   * Merges change messages and reviewer updates into one array.
+   */
+  _computeCombinedMessages(messages, reviewerUpdates) {
+    messages = messages || [];
+    reviewerUpdates = reviewerUpdates || [];
+    let mi = 0;
+    let ri = 0;
+    let combinedMessages = [];
+    let mDate;
+    let rDate;
+    for (let i = 0; i < messages.length; i++) {
+      messages[i]._index = i;
+    }
+
+    while (mi < messages.length || ri < reviewerUpdates.length) {
+      if (mi >= messages.length) {
+        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+        break;
+      }
+      if (ri >= reviewerUpdates.length) {
+        combinedMessages = combinedMessages.concat(messages.slice(mi));
+        break;
+      }
+      mDate = mDate || util.parseDate(messages[mi].date);
+      rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
+      if (rDate < mDate) {
+        combinedMessages.push(reviewerUpdates[ri++]);
+        rDate = null;
+      } else {
+        combinedMessages.push(messages[mi++]);
+        mDate = null;
+      }
+    }
+    combinedMessages.forEach(m => {
+      if (m.expanded === undefined) {
+        m.expanded = false;
+      }
+    });
+    return combinedMessages;
+  }
+
+  _updateExpandedStateOfAllMessages(exp) {
+    if (this._combinedMessages) {
+      for (let i = 0; i < this._combinedMessages.length; i++) {
+        this._combinedMessages[i].expanded = exp;
+        this.notifyPath(`_combinedMessages.${i}.expanded`);
+      }
+    }
+  }
+
+  _computeExpandAllTitle(_expandAllState) {
+    if (_expandAllState === ExpandAllState.COLLAPSED_ALL) {
+      return this.createTitle(
+          this.Shortcut.COLLAPSE_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+    }
+    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+      return this.createTitle(
+          this.Shortcut.EXPAND_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+    }
+    return '';
+  }
+
+  _highlightEl(el) {
+    const highlightedEls =
+        dom(this.root).querySelectorAll('.highlighted');
+    for (const highlighedEl of highlightedEls) {
+      highlighedEl.classList.remove('highlighted');
+    }
+    function handleAnimationEnd() {
+      el.removeEventListener('animationend', handleAnimationEnd);
+      el.classList.remove('highlighted');
+    }
+    el.addEventListener('animationend', handleAnimationEnd);
+    el.classList.add('highlighted');
+  }
+
+  /**
+   * @param {boolean} expand
+   */
+  handleExpandCollapse(expand) {
+    this._expandAllState = expand ? ExpandAllState.COLLAPSE_ALL
+      : ExpandAllState.EXPAND_ALL;
+    this._updateExpandedStateOfAllMessages(expand);
+  }
+
+  _handleExpandCollapseTap(e) {
+    e.preventDefault();
+    this.handleExpandCollapse(
+        this._expandAllState === ExpandAllState.EXPAND_ALL);
+  }
+
+  _handleAnchorClick(e) {
+    this.scrollToMessage(e.detail.id);
+  }
+
+  _hasAutomatedMessages(messages) {
+    if (!messages) { return false; }
+    for (const message of messages) {
+      if (this._isAutomated(message)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Computes message author's file comments for change's message. The backend
+   * sets comment.change_message_id for matching, so this computation is fairly
+   * straightforward.
+   *
+   * @param {!Object} changeComments changeComment object, which includes
+   *     a method to get all published comments (including robot comments),
+   *     which returns a Hash of arrays of comments, filename as key.
+   * @param {!Object} message
+   * @return {!Object} Hash of arrays of comments, filename as key.
+   */
+  _computeCommentsForMessage(changeComments, message) {
+    if ([changeComments, message].some(arg => arg === undefined)) {
+      return {};
+    }
+    const comments = changeComments.getAllPublishedComments();
+    if (message._index === undefined || !comments || !this.messages) {
+      return {};
+    }
+    const idFilter = comment => comment.change_message_id === message.id;
+
+    const msgComments = {};
+    for (const file in comments) {
+      if (!comments.hasOwnProperty(file)) { continue; }
+      const filtered = comments[file].filter(idFilter);
+      if (filtered.length) msgComments[file] = filtered;
+    }
+    return msgComments;
+  }
+
+  /**
+   * This method is for reporting stats only.
+   */
+  _combinedMessagesChanged(combinedMessages) {
+    if (combinedMessages) {
+      if (combinedMessages.length === 0) return;
+      const tags = combinedMessages.map(
+          message => message.tag || message.type ||
+              (message.comments ? 'comments' : 'none'));
+      const tagsCounted = tags.reduce((acc, val) => {
+        acc[val] = (acc[val] || 0) + 1;
+        return acc;
+      }, {all: combinedMessages.length});
+      this.$.reporting.reportInteraction('messages-count', tagsCounted);
+    }
+  }
+
+  /**
+   * Compute a mapping from label name to objects representing the minimum and
+   * maximum possible values for that label.
+   */
+  _computeLabelExtremes(labelRecord) {
+    const extremes = {};
+    const labels = labelRecord.base;
+    if (!labels) { return extremes; }
+    for (const key of Object.keys(labels)) {
+      if (!labels[key] || !labels[key].values) { continue; }
+      const values = Object.keys(labels[key].values)
+          .map(v => parseInt(v, 10));
+      values.sort((a, b) => a - b);
+      if (!values.length) { continue; }
+      extremes[key] = {min: values[0], max: values[values.length - 1]};
+    }
+    return extremes;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapAutomatedMessageToggle(e) {
+    e.preventDefault();
+  }
+}
+
+customElements.define(GrMessagesListExperimental.is,
+    GrMessagesListExperimental);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
new file mode 100644
index 0000000..394d728
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: flex;
+      justify-content: space-between;
+    }
+    .header {
+      align-items: center;
+      border-top: 1px solid var(--border-color);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    .highlighted {
+      animation: 3s fadeOut;
+    }
+    @keyframes fadeOut {
+      0% {
+        background-color: var(--emphasis-color);
+      }
+      100% {
+        background-color: var(--view-background-color);
+      }
+    }
+    .container {
+      align-items: center;
+      display: flex;
+    }
+    gr-message:not(:last-of-type) {
+      border-bottom: 1px solid var(--border-color);
+    }
+    gr-message:nth-child(2n) {
+      background-color: var(--background-color-secondary);
+    }
+    gr-message:nth-child(2n + 1) {
+      background-color: var(--background-color-tertiary);
+    }
+  </style>
+  <div class="header">
+    <span
+      id="automatedMessageToggleContainer"
+      class="container"
+      hidden$="[[!_hasAutomatedMessages(messages)]]"
+    >
+      <paper-toggle-button
+        id="automatedMessageToggle"
+        checked="{{_hideAutomated}}"
+        on-tap="_onTapAutomatedMessageToggle"
+      ></paper-toggle-button
+      >Only comments
+      <span class="transparent separator"></span>
+    </span>
+    <gr-button
+      id="collapse-messages"
+      link=""
+      title="[[_expandAllTitle]]"
+      on-click="_handleExpandCollapseTap"
+    >
+      [[_expandAllState]]
+    </gr-button>
+  </div>
+  <template
+    id="messageRepeat"
+    is="dom-repeat"
+    items="[[_combinedMessages]]"
+    as="message"
+    filter="_isMessageVisible"
+  >
+    <gr-message
+      change-num="[[changeNum]]"
+      message="[[message]]"
+      comments="[[_computeCommentsForMessage(changeComments, message)]]"
+      project-name="[[projectName]]"
+      show-reply-button="[[showReplyButtons]]"
+      on-message-anchor-tap="_handleAnchorClick"
+      label-extremes="[[_labelExtremes]]"
+      data-message-id$="[[message.id]]"
+    ></gr-message>
+  </template>
+  <gr-reporting id="reporting" category="message-list"></gr-reporting>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
new file mode 100644
index 0000000..9c22ab5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
@@ -0,0 +1,453 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2020 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
+<title>gr-messages-list-experimental</title>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+
+<dom-module id="comment-api-mock">
+  <template>
+    <gr-messages-list-experimental
+        id="messagesList"
+        change-comments="[[_changeComments]]"></gr-messages-list-experimental>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+  </template>
+  </dom-module>
+
+<test-fixture id="basic">
+  <template>
+    <comment-api-mock>
+      <gr-messages-list-experimental></gr-messages-list-experimental>
+    </comment-api-mock>
+  </template>
+</test-fixture>
+
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import './gr-messages-list-experimental.js';
+import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+const randomMessage = function(opt_params) {
+  const params = opt_params || {};
+  const author1 = {
+    _account_id: 1115495,
+    name: 'Andrew Bonventre',
+    email: 'andybons@chromium.org',
+  };
+  return {
+    id: params.id || Math.random().toString(),
+    date: params.date || '2016-01-12 20:28:33.038000',
+    message: params.message || Math.random().toString(),
+    _revision_number: params._revision_number || 1,
+    author: params.author || author1,
+  };
+};
+
+const randomAutomated = function(opt_params) {
+  return Object.assign({tag: 'autogenerated:gerrit:replace'},
+      randomMessage(opt_params));
+};
+
+suite('gr-messages-list-experimental tests', () => {
+  let element;
+  let messages;
+  let sandbox;
+  let commentApiWrapper;
+
+  const getMessages = function() {
+    return dom(element.root).querySelectorAll('gr-message');
+  };
+
+  const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_1 = '8c19ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_2 = 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5';
+
+  const author = {
+    _account_id: 42,
+    name: 'Marvin the Paranoid Android',
+    email: 'marvin@sirius.org',
+  };
+
+  const comments = {
+    file1: [
+      {
+        message: 'message text',
+        change_message_id: MESSAGE_ID_0,
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: '6505d749_f0bec0aa',
+        line: 62,
+        id: '6505d749_10ed44b2',
+        patch_set: 2,
+        author: {
+          email: 'some@email.com',
+          _account_id: 123,
+        },
+      },
+      {
+        message: 'message text',
+        change_message_id: MESSAGE_ID_1,
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: 'c5912363_6b820105',
+        line: 42,
+        id: '450a935e_0f1c05db',
+        patch_set: 2,
+        author,
+      },
+      {
+        message: 'message text',
+        change_message_id: MESSAGE_ID_1,
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: '6505d749_f0bec0aa',
+        line: 62,
+        id: '6505d749_10ed44b2',
+        patch_set: 2,
+        author,
+      },
+      {
+        message: 'message text',
+        change_message_id: MESSAGE_ID_2,
+        updated: '2016-09-27 00:18:03.000000000',
+        line: 64,
+        id: '34ed05d749_10ed44b2',
+        patch_set: 2,
+        author,
+      },
+    ],
+    file2: [
+      {
+        message: 'message text',
+        change_message_id: MESSAGE_ID_1,
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: 'c5912363_4b7d450a',
+        line: 132,
+        id: '450a935e_4f260d25',
+        patch_set: 2,
+        author,
+      },
+    ],
+  };
+
+  suite('basic tests', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve(comments); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      sandbox = sinon.sandbox.create();
+      messages = _.times(3, randomMessage);
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.messagesList;
+      element.messages = messages;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('expand/collapse all', () => {
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message._expanded = false;
+      }
+      MockInteractions.tap(allMessageEls[1]);
+      assert.isTrue(allMessageEls[1]._expanded);
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
+      }
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
+      }
+    });
+
+    test('expand/collapse from external keypress', () => {
+      // Start with one expanded message. -> not all collapsed
+      element.scrollToMessage(messages[1].id);
+      assert.isFalse([...getMessages()].filter(m => m._expanded).length == 0);
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
+
+      // Press 'x' -> all expanded
+      element.handleExpandCollapse(true);
+      assert.isTrue([...getMessages()].filter(m => !m._expanded).length == 0);
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
+    });
+
+    test('hide messages does not appear when no automated messages', () => {
+      assert.isOk(element.shadowRoot
+          .querySelector('#automatedMessageToggleContainer[hidden]'));
+    });
+
+    test('scroll to message', () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message.set('message.expanded', false);
+      }
+
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
+
+      element.scrollToMessage('invalid');
+
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded,
+            'expected gr-message to not be expanded');
+      }
+
+      const messageID = messages[1].id;
+      element.scrollToMessage(messageID);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
+
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+    });
+
+    test('scroll to message offscreen', () => {
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
+      element.messages = _.times(25, randomMessage);
+      flushAsynchronousOperations();
+      assert.isFalse(scrollToStub.called);
+      assert.isFalse(highlightStub.called);
+
+      const messageID = element.messages[1].id;
+      element.scrollToMessage(messageID);
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
+    });
+
+    test('messages', () => {
+      const messages = [].concat(
+          randomMessage(),
+          {
+            _index: 5,
+            _revision_number: 4,
+            message: 'Uploaded patch set 4.',
+            date: '2016-09-28 13:36:33.000000000',
+            author,
+            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+          },
+          {
+            _index: 6,
+            _revision_number: 4,
+            message: 'Patch Set 4:\n\n(6 comments)',
+            date: '2016-09-28 13:36:33.000000000',
+            author,
+            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
+          }
+      );
+      element.messages = messages;
+      const isAuthor = function(author, comment) {
+        return comment.author._account_id === author._account_id;
+      };
+      const isMarvin = isAuthor.bind(null, author);
+      flushAsynchronousOperations();
+      const messageElements = getMessages();
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+      assert.deepEqual(messageElements[1].comments.file1,
+          comments.file1.filter(isMarvin).filter(
+              c => c.change_message_id === messages[1].id));
+      assert.deepEqual(messageElements[1].comments.file2,
+          comments.file2.filter(isMarvin).filter(
+              c => c.change_message_id === messages[1].id));
+      assert.deepEqual(messageElements[2].comments.file1,
+          comments.file1.filter(isMarvin).filter(
+              c => c.change_message_id === messages[2].id));
+      assert.isUndefined(messageElements[2].comments.file2);
+    });
+
+    test('messages without author do not throw', () => {
+      const messages = [{
+        _index: 5,
+        _revision_number: 4,
+        message: 'Uploaded patch set 4.',
+        date: '2016-09-28 13:36:33.000000000',
+        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+      }];
+      element.messages = messages;
+      flushAsynchronousOperations();
+      const messageEls = getMessages();
+      assert.equal(messageEls.length, 1);
+      assert.equal(messageEls[0].message.message, messages[0].message);
+    });
+  });
+
+  suite('gr-messages-list-experimental automate tests', () => {
+    let element;
+    let messages;
+    let sandbox;
+    let commentApiWrapper;
+
+    const getMessages = function() {
+      return dom(element.root).querySelectorAll('gr-message');
+    };
+    const getHiddenMessages = function() {
+      return dom(element.root).querySelectorAll('gr-message[hidden]');
+    };
+
+    const randomMessageReviewer = {
+      reviewer: {},
+      date: '2016-01-13 20:30:33.038000',
+    };
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+
+      sandbox = sinon.sandbox.create();
+      messages = _.times(2, randomAutomated);
+      messages.push(randomMessageReviewer);
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.messagesList;
+      sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.messages = messages;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('hide autogenerated button is not hidden', () => {
+      assert.isNotOk(element.shadowRoot
+          .querySelector('#automatedMessageToggle[hidden]'));
+    });
+
+    test('autogenerated messages are not hidden initially', () => {
+      const allHiddenMessageEls = getHiddenMessages();
+
+      // There are no hidden messages.
+      assert.isFalse(!!allHiddenMessageEls.length);
+    });
+
+    test('autogenerated messages hidden after comments only toggle', () => {
+      let allHiddenMessageEls = getHiddenMessages();
+
+      element._hideAutomated = false;
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      flushAsynchronousOperations();
+      const allMessageEls = getMessages();
+      allHiddenMessageEls = getHiddenMessages();
+
+      // Autogenerated messages are now hidden.
+      assert.equal(allHiddenMessageEls.length, allMessageEls.length);
+    });
+
+    test('autogenerated messages not hidden after comments only toggle',
+        () => {
+          let allHiddenMessageEls = getHiddenMessages();
+
+          element._hideAutomated = true;
+          MockInteractions.tap(element.$.automatedMessageToggle);
+          allHiddenMessageEls = getHiddenMessages();
+
+          // Autogenerated messages are now hidden.
+          assert.isFalse(!!allHiddenMessageEls.length);
+        });
+
+    test('_computeLabelExtremes', () => {
+      const computeSpy = sandbox.spy(element, '_computeLabelExtremes');
+
+      element.labels = null;
+      assert.isTrue(computeSpy.calledOnce);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {};
+      assert.isTrue(computeSpy.calledTwice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {}};
+      assert.isTrue(computeSpy.calledThrice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {values: {}}};
+      assert.equal(computeSpy.callCount, 4);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {values: {'-12': {}}}};
+      assert.equal(computeSpy.callCount, 5);
+      assert.deepEqual(computeSpy.lastCall.returnValue,
+          {'my-label': {min: -12, max: -12}});
+
+      element.labels = {
+        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
+      };
+      assert.equal(computeSpy.callCount, 6);
+      assert.deepEqual(computeSpy.lastCall.returnValue,
+          {'my-label': {min: -2, max: 2}});
+
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
+      };
+      assert.equal(computeSpy.callCount, 7);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {
+        'my-label': {min: -12, max: -12},
+        'other-label': {min: -1, max: 1},
+      });
+    });
+  });
+});
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
deleted file mode 100644
index d71beb4..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ /dev/null
@@ -1,119 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../gr-message/gr-message.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-messages-list">
-  <template>
-    <style include="shared-styles">
-      :host,
-      .messageListControls {
-        display: flex;
-        justify-content: space-between;
-      }
-      .header {
-        align-items: center;
-        background-color: var(--table-header-background-color);
-        border-bottom: 1px solid var(--border-color);
-        border-top: 1px solid var(--border-color);
-        display: flex;
-        justify-content: space-between;
-        min-height: 3.2em;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      #messageControlsContainer {
-        padding: 0 var(--spacing-l);
-      }
-      .highlighted {
-        animation: 3s fadeOut;
-      }
-      @keyframes fadeOut {
-        0% { background-color: var(--emphasis-color); }
-        100% { background-color: var(--view-background-color); }
-      }
-      #messageControlsContainer {
-        align-items: center;
-        border-bottom: 1px solid var(--border-color);
-        display: flex;
-        height: 2.25em;
-        justify-content: center;
-      }
-      #messageControlsContainer gr-button {
-        padding: var(--spacing-s) 0;
-      }
-      .container {
-        align-items: center;
-        display: flex;
-      }
-    </style>
-    <div class="header">
-        <span
-            id="automatedMessageToggleContainer"
-            class="container"
-            hidden$="[[!_hasAutomatedMessages(messages)]]">
-          <paper-toggle-button
-              id="automatedMessageToggle"
-              checked="{{_hideAutomated}}"></paper-toggle-button>Only comments
-          <span class="transparent separator"></span>
-        </span>
-        <gr-button
-            id="collapse-messages"
-            link
-            on-click="_handleExpandCollapseTap">
-          [[_computeExpandCollapseMessage(_expanded)]]
-        </gr-button>
-      </div>
-    <span
-        id="messageControlsContainer"
-        hidden$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
-      <gr-button id="oldMessagesBtn" link on-click="_handleShowAllTap">
-          [[_computeNumMessagesText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
-      </gr-button>
-      <span
-          class="container"
-          hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
-        <span class="transparent separator"></span>
-        <gr-button id="incrementMessagesBtn" link
-            on-click="_handleIncrementShownMessages">
-          [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
-        </gr-button>
-      </span>
-    </span>
-    <template
-        is="dom-repeat"
-        items="[[_visibleMessages]]"
-        as="message">
-      <gr-message
-          change-num="[[changeNum]]"
-          message="[[message]]"
-          comments="[[_computeCommentsForMessage(changeComments, message)]]"
-          hide-automated="[[_hideAutomated]]"
-          project-name="[[projectName]]"
-          show-reply-button="[[showReplyButtons]]"
-          on-message-anchor-tap="_handleAnchorClick"
-          label-extremes="[[_labelExtremes]]"
-          data-message-id$="[[message.id]]"></gr-message>
-    </template>
-    <gr-reporting id="reporting" category="message-list"></gr-reporting>
-  </template>
-  <script src="gr-messages-list.js"></script>
-</dom-module>
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 5652eca..3cd23d5 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,21 +14,54 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_INITIAL_SHOWN_MESSAGES = 20;
-  const MESSAGES_INCREMENT = 5;
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../gr-message/gr-message.js';
+import '../../../styles/shared-styles.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-messages-list_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {util} from '../../../scripts/util.js';
 
-  const ReportingEvent = {
-    SHOW_ALL: 'show-all-messages',
-    SHOW_MORE: 'show-more-messages',
-  };
+const MAX_INITIAL_SHOWN_MESSAGES = 20;
+const MESSAGES_INCREMENT = 5;
 
-  Polymer({
-    is: 'gr-messages-list',
+const ReportingEvent = {
+  SHOW_ALL: 'show-all-messages',
+  SHOW_MORE: 'show-more-messages',
+};
 
-    properties: {
+/**
+ * The content of the enum is also used in the UI for the button text.
+ *
+ * @enum {string}
+ */
+const ExpandAllState = {
+  EXPAND_ALL: 'Expand All',
+  COLLAPSE_ALL: 'Collapse All',
+};
+
+/**
+ * @extends Polymer.Element
+ */
+class GrMessagesList extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-messages-list'; }
+
+  static get properties() {
+    return {
       changeNum: Number,
       messages: {
         type: Array,
@@ -46,11 +79,22 @@
       },
       labels: Object,
 
-      _expanded: {
-        type: Boolean,
-        value: false,
-        observer: '_expandedChanged',
+      /**
+       * Keeps track of the state of the "Expand All" toggle button. Note that
+       * you can individually expand/collapse some messages without affecting
+       * the toggle button's state.
+       *
+       * @type {ExpandAllState}
+       */
+      _expandAllState: {
+        type: String,
+        value: ExpandAllState.EXPAND_ALL,
       },
+      _expandAllTitle: {
+        type: String,
+        computed: '_computeExpandAllTitle(_expandAllState)',
+      },
+
       _hideAutomated: {
         type: Boolean,
         value: false,
@@ -75,326 +119,355 @@
         type: Object,
         computed: '_computeLabelExtremes(labels.*)',
       },
-    },
+    };
+  }
 
-    scrollToMessage(messageID) {
-      let el = this.$$('[data-message-id="' + messageID + '"]');
-      // If the message is hidden, expand the hidden messages back to that
-      // point.
-      if (!el) {
-        let index;
-        for (index = 0; index < this._processedMessages.length; index++) {
-          if (this._processedMessages[index].id === messageID) {
-            break;
-          }
-        }
-        if (index === this._processedMessages.length) { return; }
-
-        const newMessages = this._processedMessages.slice(index,
-            -this._visibleMessages.length);
-        // Add newMessages to the beginning of _visibleMessages.
-        this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
-        // Allow the dom-repeat to stamp.
-        Polymer.dom.flush();
-        el = this.$$('[data-message-id="' + messageID + '"]');
-      }
-
-      el.set('message.expanded', true);
-      let top = el.offsetTop;
-      for (let offsetParent = el.offsetParent;
-        offsetParent;
-        offsetParent = offsetParent.offsetParent) {
-        top += offsetParent.offsetTop;
-      }
-      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
-      if ([messages, reviewerUpdates].some(arg => arg === undefined)) {
-        return [];
-      }
-
-      messages = messages || [];
-      reviewerUpdates = reviewerUpdates || [];
-      let mi = 0;
-      let ri = 0;
-      let result = [];
-      let mDate;
-      let rDate;
-      for (let i = 0; i < messages.length; i++) {
-        messages[i]._index = i;
-      }
-
-      while (mi < messages.length || ri < reviewerUpdates.length) {
-        if (mi >= messages.length) {
-          result = result.concat(reviewerUpdates.slice(ri));
+  scrollToMessage(messageID) {
+    let el = this.shadowRoot
+        .querySelector('[data-message-id="' + messageID + '"]');
+    // If the message is hidden, expand the hidden messages back to that
+    // point.
+    if (!el) {
+      let index;
+      for (index = 0; index < this._processedMessages.length; index++) {
+        if (this._processedMessages[index].id === messageID) {
           break;
         }
-        if (ri >= reviewerUpdates.length) {
-          result = result.concat(messages.slice(mi));
+      }
+      if (index === this._processedMessages.length) { return; }
+
+      const newMessages = this._processedMessages.slice(index,
+          -this._visibleMessages.length);
+      // Add newMessages to the beginning of _visibleMessages.
+      this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
+      // Allow the dom-repeat to stamp.
+      flush();
+      el = this.shadowRoot
+          .querySelector('[data-message-id="' + messageID + '"]');
+    }
+
+    el.set('message.expanded', true);
+    let top = el.offsetTop;
+    for (let offsetParent = el.offsetParent;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent) {
+      top += offsetParent.offsetTop;
+    }
+    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
+    if ([messages, reviewerUpdates].some(arg => arg === undefined)) {
+      return [];
+    }
+
+    messages = messages || [];
+    reviewerUpdates = reviewerUpdates || [];
+    let mi = 0;
+    let ri = 0;
+    let result = [];
+    let mDate;
+    let rDate;
+    for (let i = 0; i < messages.length; i++) {
+      messages[i]._index = i;
+    }
+
+    while (mi < messages.length || ri < reviewerUpdates.length) {
+      if (mi >= messages.length) {
+        result = result.concat(reviewerUpdates.slice(ri));
+        break;
+      }
+      if (ri >= reviewerUpdates.length) {
+        result = result.concat(messages.slice(mi));
+        break;
+      }
+      mDate = mDate || util.parseDate(messages[mi].date);
+      rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
+      if (rDate < mDate) {
+        result.push(reviewerUpdates[ri++]);
+        rDate = null;
+      } else {
+        result.push(messages[mi++]);
+        mDate = null;
+      }
+    }
+    result.forEach(m => {
+      if (m.expanded === undefined) {
+        m.expanded = false;
+      }
+    });
+    return result;
+  }
+
+  _updateExpandedStateOfAllMessages(expanded) {
+    if (this._processedMessages) {
+      for (let i = 0; i < this._processedMessages.length; i++) {
+        this._processedMessages[i].expanded = expanded;
+      }
+    }
+    // _visibleMessages is a subarray of _processedMessages
+    // _processedMessages contains all items from _visibleMessages
+    // At this point all _visibleMessages.expanded values are set,
+    // and notifyPath must be used to notify Polymer about changes.
+    if (this._visibleMessages) {
+      for (let i = 0; i < this._visibleMessages.length; i++) {
+        this.notifyPath(`_visibleMessages.${i}.expanded`);
+      }
+    }
+  }
+
+  _computeExpandAllTitle(_expandAllState) {
+    if (_expandAllState === ExpandAllState.COLLAPSED_ALL) {
+      return this.createTitle(
+          this.Shortcut.COLLAPSE_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+    }
+    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+      return this.createTitle(
+          this.Shortcut.EXPAND_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+    }
+    return '';
+  }
+
+  _highlightEl(el) {
+    const highlightedEls =
+        dom(this.root).querySelectorAll('.highlighted');
+    for (const highlighedEl of highlightedEls) {
+      highlighedEl.classList.remove('highlighted');
+    }
+    function handleAnimationEnd() {
+      el.removeEventListener('animationend', handleAnimationEnd);
+      el.classList.remove('highlighted');
+    }
+    el.addEventListener('animationend', handleAnimationEnd);
+    el.classList.add('highlighted');
+  }
+
+  /**
+   * @param {boolean} expand
+   */
+  handleExpandCollapse(expand) {
+    this._expandAllState = expand ? ExpandAllState.COLLAPSE_ALL
+      : ExpandAllState.EXPAND_ALL;
+    this._updateExpandedStateOfAllMessages(expand);
+  }
+
+  _handleExpandCollapseTap(e) {
+    e.preventDefault();
+    this.handleExpandCollapse(
+        this._expandAllState === ExpandAllState.EXPAND_ALL);
+  }
+
+  _handleAnchorClick(e) {
+    this.scrollToMessage(e.detail.id);
+  }
+
+  _hasAutomatedMessages(messages) {
+    if (!messages) { return false; }
+    for (const message of messages) {
+      if (this._isAutomated(message)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Computes message author's file comments for change's message.
+   * Method uses this.messages to find next message and relies on messages
+   * to be sorted by date field descending.
+   *
+   * @param {!Object} changeComments changeComment object, which includes
+   *     a method to get all published comments (including robot comments),
+   *     which returns a Hash of arrays of comments, filename as key.
+   * @param {!Object} message
+   * @return {!Object} Hash of arrays of comments, filename as key.
+   */
+  _computeCommentsForMessage(changeComments, message) {
+    if ([changeComments, message].some(arg => arg === undefined)) {
+      return {};
+    }
+    const comments = changeComments.getAllPublishedComments();
+    if (message._index === undefined || !comments || !this.messages) {
+      return {};
+    }
+    const messages = this.messages || [];
+    const index = message._index;
+    const authorId = message.author && message.author._account_id;
+    const mDate = util.parseDate(message.date).getTime();
+    // NB: Messages array has oldest messages first.
+    let nextMDate;
+    if (index > 0) {
+      for (let i = index - 1; i >= 0; i--) {
+        if (messages[i] && messages[i].author &&
+            messages[i].author._account_id === authorId) {
+          nextMDate = util.parseDate(messages[i].date).getTime();
           break;
         }
-        mDate = mDate || util.parseDate(messages[mi].date);
-        rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
-        if (rDate < mDate) {
-          result.push(reviewerUpdates[ri++]);
-          rDate = null;
-        } else {
-          result.push(messages[mi++]);
-          mDate = null;
+      }
+    }
+    const msgComments = {};
+    for (const file in comments) {
+      if (!comments.hasOwnProperty(file)) { continue; }
+      const fileComments = comments[file];
+      for (let i = 0; i < fileComments.length; i++) {
+        if (fileComments[i].author &&
+            fileComments[i].author._account_id !== authorId) {
+          continue;
         }
-      }
-      result.forEach(m => {
-        if (m.expanded === undefined) {
-          m.expanded = false;
-        }
-      });
-      return result;
-    },
-
-    _expandedChanged(exp) {
-      if (this._processedMessages) {
-        for (let i = 0; i < this._processedMessages.length; i++) {
-          this._processedMessages[i].expanded = exp;
-        }
-      }
-      // _visibleMessages is a subarray of _processedMessages
-      // _processedMessages contains all items from _visibleMessages
-      // At this point all _visibleMessages.expanded values are set,
-      // and notifyPath must be used to notify Polymer about changes.
-      if (this._visibleMessages) {
-        for (let i = 0; i < this._visibleMessages.length; i++) {
-          this.notifyPath(`_visibleMessages.${i}.expanded`);
-        }
-      }
-    },
-
-    _highlightEl(el) {
-      const highlightedEls =
-          Polymer.dom(this.root).querySelectorAll('.highlighted');
-      for (const highlighedEl of highlightedEls) {
-        highlighedEl.classList.remove('highlighted');
-      }
-      function handleAnimationEnd() {
-        el.removeEventListener('animationend', handleAnimationEnd);
-        el.classList.remove('highlighted');
-      }
-      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; }
-      for (const message of messages) {
-        if (this._isAutomated(message)) {
-          return true;
-        }
-      }
-      return false;
-    },
-
-    _computeExpandCollapseMessage(expanded) {
-      return expanded ? 'Collapse all' : 'Expand all';
-    },
-
-    /**
-     * Computes message author's file comments for change's message.
-     * Method uses this.messages to find next message and relies on messages
-     * to be sorted by date field descending.
-     *
-     * @param {!Object} changeComments changeComment object, which includes
-     *     a method to get all published comments (including robot comments),
-     *     which returns a Hash of arrays of comments, filename as key.
-     * @param {!Object} message
-     * @return {!Object} Hash of arrays of comments, filename as key.
-     */
-    _computeCommentsForMessage(changeComments, message) {
-      if ([changeComments, message].some(arg => arg === undefined)) {
-        return [];
-      }
-      const comments = changeComments.getAllPublishedComments();
-      if (message._index === undefined || !comments || !this.messages) {
-        return [];
-      }
-      const messages = this.messages || [];
-      const index = message._index;
-      const authorId = message.author && message.author._account_id;
-      const mDate = util.parseDate(message.date).getTime();
-      // NB: Messages array has oldest messages first.
-      let nextMDate;
-      if (index > 0) {
-        for (let i = index - 1; i >= 0; i--) {
-          if (messages[i] && messages[i].author &&
-              messages[i].author._account_id === authorId) {
-            nextMDate = util.parseDate(messages[i].date).getTime();
-            break;
-          }
-        }
-      }
-      const msgComments = {};
-      for (const file in comments) {
-        if (!comments.hasOwnProperty(file)) { continue; }
-        const fileComments = comments[file];
-        for (let i = 0; i < fileComments.length; i++) {
-          if (fileComments[i].author &&
-              fileComments[i].author._account_id !== authorId) {
+        const cDate = util.parseDate(fileComments[i].updated).getTime();
+        if (cDate <= mDate) {
+          if (nextMDate && cDate <= nextMDate) {
             continue;
           }
-          const cDate = util.parseDate(fileComments[i].updated).getTime();
-          if (cDate <= mDate) {
-            if (nextMDate && cDate <= nextMDate) {
-              continue;
-            }
-            msgComments[file] = msgComments[file] || [];
-            msgComments[file].push(fileComments[i]);
-          }
+          msgComments[file] = msgComments[file] || [];
+          msgComments[file].push(fileComments[i]);
         }
       }
-      return msgComments;
-    },
+    }
+    return msgComments;
+  }
 
-    /**
-     * Returns the number of messages to splice to the beginning of
-     * _visibleMessages. This is the minimum of the total number of messages
-     * remaining in the list and the number of messages needed to display five
-     * more visible messages in the list.
-     */
-    _getDelta(visibleMessages, messages, hideAutomated) {
-      if ([visibleMessages, messages].some(arg => arg === undefined)) {
-        return 0;
+  /**
+   * Returns the number of messages to splice to the beginning of
+   * _visibleMessages. This is the minimum of the total number of messages
+   * remaining in the list and the number of messages needed to display five
+   * more visible messages in the list.
+   */
+  _getDelta(visibleMessages, messages, hideAutomated) {
+    if ([visibleMessages, messages].some(arg => arg === undefined)) {
+      return 0;
+    }
+
+    let delta = MESSAGES_INCREMENT;
+    const msgsRemaining = messages.length - visibleMessages.length;
+
+    if (hideAutomated) {
+      let counter = 0;
+      let i;
+      for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
+        if (!this._isAutomated(messages[i - 1])) { counter++; }
       }
+      delta = msgsRemaining - i;
+    }
+    return Math.min(msgsRemaining, delta);
+  }
 
-      let delta = MESSAGES_INCREMENT;
-      const msgsRemaining = messages.length - visibleMessages.length;
+  /**
+   * Gets the number of messages that would be visible, but do not currently
+   * exist in _visibleMessages.
+   */
+  _numRemaining(visibleMessages, messages, hideAutomated) {
+    if ([visibleMessages, messages].some(arg => arg === undefined)) {
+      return 0;
+    }
 
-      if (hideAutomated) {
-        let counter = 0;
-        let i;
-        for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
-          if (!this._isAutomated(messages[i - 1])) { counter++; }
-        }
-        delta = msgsRemaining - i;
-      }
-      return Math.min(msgsRemaining, delta);
-    },
+    if (hideAutomated) {
+      return this._getHumanMessages(messages).length -
+          this._getHumanMessages(visibleMessages).length;
+    }
+    return messages.length - visibleMessages.length;
+  }
 
-    /**
-     * Gets the number of messages that would be visible, but do not currently
-     * exist in _visibleMessages.
-     */
-    _numRemaining(visibleMessages, messages, hideAutomated) {
-      if ([visibleMessages, messages].some(arg => arg === undefined)) {
-        return 0;
-      }
+  _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';
+  }
 
-      if (hideAutomated) {
-        return this._getHumanMessages(messages).length -
-            this._getHumanMessages(visibleMessages).length;
-      }
-      return messages.length - visibleMessages.length;
-    },
+  _getHumanMessages(messages) {
+    return messages.filter(msg => !this._isAutomated(msg));
+  }
 
-    _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';
-    },
+  _computeShowHideTextHidden(visibleMessages, messages,
+      hideAutomated) {
+    if ([visibleMessages, messages].some(arg => arg === undefined)) {
+      return 0;
+    }
 
-    _getHumanMessages(messages) {
-      return messages.filter(msg => {
-        return !this._isAutomated(msg);
-      });
-    },
+    if (hideAutomated) {
+      messages = this._getHumanMessages(messages);
+      visibleMessages = this._getHumanMessages(visibleMessages);
+    }
+    return visibleMessages.length >= messages.length;
+  }
 
-    _computeShowHideTextHidden(visibleMessages, messages,
-        hideAutomated) {
-      if ([visibleMessages, messages].some(arg => arg === undefined)) {
-        return 0;
-      }
+  _handleShowAllTap() {
+    this._visibleMessages = this._processedMessages;
+    this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
+  }
 
-      if (hideAutomated) {
-        messages = this._getHumanMessages(messages);
-        visibleMessages = this._getHumanMessages(visibleMessages);
-      }
-      return visibleMessages.length >= messages.length;
-    },
+  _handleIncrementShownMessages() {
+    const delta = this._getDelta(this._visibleMessages,
+        this._processedMessages, this._hideAutomated);
+    const len = this._visibleMessages.length;
+    const newMessages = this._processedMessages.slice(-(len + delta), -len);
+    // Add newMessages to the beginning of _visibleMessages
+    this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
+    this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
+  }
 
-    _handleShowAllTap() {
-      this._visibleMessages = this._processedMessages;
-      this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
-    },
+  _processedMessagesChanged(messages) {
+    if (messages) {
+      this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
 
-    _handleIncrementShownMessages() {
-      const delta = this._getDelta(this._visibleMessages,
-          this._processedMessages, this._hideAutomated);
-      const len = this._visibleMessages.length;
-      const newMessages = this._processedMessages.slice(-(len + delta), -len);
-      // Add newMessages to the beginning of _visibleMessages
-      this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
-      this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
-    },
+      if (messages.length === 0) return;
+      const tags = messages.map(message => message.tag || message.type ||
+          (message.comments ? 'comments' : 'none'));
+      const tagsCounted = tags.reduce((acc, val) => {
+        acc[val] = (acc[val] || 0) + 1;
+        return acc;
+      }, {all: messages.length});
+      this.$.reporting.reportInteraction('messages-count', tagsCounted);
+    }
+  }
 
-    _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';
+  }
 
-    _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);
+  }
 
-    _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
+   * maximum possible values for that label.
+   */
+  _computeLabelExtremes(labelRecord) {
+    const extremes = {};
+    const labels = labelRecord.base;
+    if (!labels) { return extremes; }
+    for (const key of Object.keys(labels)) {
+      if (!labels[key] || !labels[key].values) { continue; }
+      const values = Object.keys(labels[key].values)
+          .map(v => parseInt(v, 10));
+      values.sort((a, b) => a - b);
+      if (!values.length) { continue; }
+      extremes[key] = {min: values[0], max: values[values.length - 1]};
+    }
+    return extremes;
+  }
 
-    /**
-     * Compute a mapping from label name to objects representing the minimum and
-     * maximum possible values for that label.
-     */
-    _computeLabelExtremes(labelRecord) {
-      const extremes = {};
-      const labels = labelRecord.base;
-      if (!labels) { return extremes; }
-      for (const key of Object.keys(labels)) {
-        if (!labels[key] || !labels[key].values) { continue; }
-        const values = Object.keys(labels[key].values)
-            .map(v => parseInt(v, 10));
-        values.sort((a, b) => a - b);
-        if (!values.length) { continue; }
-        extremes[key] = {min: values[0], max: values[values.length - 1]};
-      }
-      return extremes;
-    },
-  });
-})();
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapHideAutomated(e) {
+    e.preventDefault();
+  }
+}
+
+customElements.define(GrMessagesList.is, GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
new file mode 100644
index 0000000..e47af55
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host,
+    .messageListControls {
+      display: flex;
+      justify-content: space-between;
+    }
+    .header {
+      align-items: center;
+      border-top: 1px solid var(--border-color);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    #messageControlsContainer {
+      padding: 0 var(--spacing-l);
+    }
+    .highlighted {
+      animation: 3s fadeOut;
+    }
+    @keyframes fadeOut {
+      0% {
+        background-color: var(--emphasis-color);
+      }
+      100% {
+        background-color: var(--view-background-color);
+      }
+    }
+    #messageControlsContainer {
+      align-items: center;
+      background-color: var(--background-color-secondary);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      height: 2.25em;
+      justify-content: center;
+    }
+    #messageControlsContainer gr-button {
+      padding: var(--spacing-s) 0;
+    }
+    .container {
+      align-items: center;
+      display: flex;
+    }
+    gr-message:not(:last-of-type) {
+      border-bottom: 1px solid var(--border-color);
+    }
+    gr-message:nth-child(2n) {
+      background-color: var(--background-color-secondary);
+    }
+    gr-message:nth-child(2n + 1) {
+      background-color: var(--background-color-tertiary);
+    }
+  </style>
+  <div class="header">
+    <span
+      id="automatedMessageToggleContainer"
+      class="container"
+      hidden$="[[!_hasAutomatedMessages(messages)]]"
+    >
+      <paper-toggle-button
+        id="automatedMessageToggle"
+        checked="{{_hideAutomated}}"
+        on-tap="_onTapHideAutomated"
+      ></paper-toggle-button>
+      Only comments
+      <span class="transparent separator"></span>
+    </span>
+    <gr-button
+      id="collapse-messages"
+      link=""
+      title="[[_expandAllTitle]]"
+      on-click="_handleExpandCollapseTap"
+    >
+      [[_expandAllState]]
+    </gr-button>
+  </div>
+  <span
+    id="messageControlsContainer"
+    hidden$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]"
+  >
+    <gr-button id="oldMessagesBtn" link="" on-click="_handleShowAllTap">
+      [[_computeNumMessagesText(_visibleMessages, _processedMessages,
+      _hideAutomated, _visibleMessages.length)]]
+    </gr-button>
+    <span
+      class="container"
+      hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]"
+    >
+      <span class="transparent separator"></span>
+      <gr-button
+        id="incrementMessagesBtn"
+        link=""
+        on-click="_handleIncrementShownMessages"
+      >
+        [[_computeIncrementText(_visibleMessages, _processedMessages,
+        _hideAutomated, _visibleMessages.length)]]
+      </gr-button>
+    </span>
+  </span>
+  <template is="dom-repeat" items="[[_visibleMessages]]" as="message">
+    <gr-message
+      change-num="[[changeNum]]"
+      message="[[message]]"
+      comments="[[_computeCommentsForMessage(changeComments, message)]]"
+      hide-automated="[[_hideAutomated]]"
+      project-name="[[projectName]]"
+      show-reply-button="[[showReplyButtons]]"
+      on-message-anchor-tap="_handleAnchorClick"
+      label-extremes="[[_labelExtremes]]"
+      data-message-id$="[[message.id]]"
+    ></gr-message>
+  </template>
+  <gr-reporting id="reporting" category="message-list"></gr-reporting>
+`;
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..80896aa 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-messages-list.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <dom-module id="comment-api-mock">
   <template>
@@ -37,8 +32,7 @@
         change-comments="[[_changeComments]]"></gr-messages-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
-  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
-</dom-module>
+  </dom-module>
 
 <test-fixture id="basic">
   <template>
@@ -48,91 +42,96 @@
   </template>
 </test-fixture>
 
-<script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import './gr-messages-list.js';
+import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+const randomMessage = function(opt_params) {
+  const params = opt_params || {};
+  const author1 = {
+    _account_id: 1115495,
+    name: 'Andrew Bonventre',
+    email: 'andybons@chromium.org',
+  };
+  return {
+    id: params.id || Math.random().toString(),
+    date: params.date || '2016-01-12 20:28:33.038000',
+    message: params.message || Math.random().toString(),
+    _revision_number: params._revision_number || 1,
+    author: params.author || author1,
+  };
+};
 
-  const randomMessage = function(opt_params) {
-    const params = opt_params || {};
-    const author1 = {
-      _account_id: 1115495,
-      name: 'Andrew Bonventre',
-      email: 'andybons@chromium.org',
-    };
-    return {
-      id: params.id || Math.random().toString(),
-      date: params.date || '2016-01-12 20:28:33.038000',
-      message: params.message || Math.random().toString(),
-      _revision_number: params._revision_number || 1,
-      author: params.author || author1,
-    };
+const randomAutomated = function(opt_params) {
+  return Object.assign({tag: 'autogenerated:gerrit:replace'},
+      randomMessage(opt_params));
+};
+
+suite('gr-messages-list tests', () => {
+  let element;
+  let messages;
+  let sandbox;
+  let commentApiWrapper;
+
+  const getMessages = function() {
+    return dom(element.root).querySelectorAll('gr-message');
   };
 
-  const randomAutomated = function(opt_params) {
-    return Object.assign({tag: 'autogenerated:gerrit:replace'},
-        randomMessage(opt_params));
+  const author = {
+    _account_id: 42,
+    name: 'Marvin the Paranoid Android',
+    email: 'marvin@sirius.org',
   };
 
-  suite('gr-messages-list tests', () => {
-    let element;
-    let messages;
-    let sandbox;
-    let commentApiWrapper;
-
-    const getMessages = function() {
-      return Polymer.dom(element.root).querySelectorAll('gr-message');
-    };
-
-    const author = {
-      _account_id: 42,
-      name: 'Marvin the Paranoid Android',
-      email: 'marvin@sirius.org',
-    };
-
-    const comments = {
-      file1: [
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: '6505d749_f0bec0aa',
-          line: 62,
-          id: '6505d749_10ed44b2',
-          patch_set: 2,
-          author: {
-            email: 'some@email.com',
-            _account_id: 123,
-          },
+  const comments = {
+    file1: [
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: '6505d749_f0bec0aa',
+        line: 62,
+        id: '6505d749_10ed44b2',
+        patch_set: 2,
+        author: {
+          email: 'some@email.com',
+          _account_id: 123,
         },
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: 'c5912363_6b820105',
-          line: 42,
-          id: '450a935e_0f1c05db',
-          patch_set: 2,
-          author,
-        },
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: '6505d749_f0bec0aa',
-          line: 62,
-          id: '6505d749_10ed44b2',
-          patch_set: 2,
-          author,
-        },
-      ],
-      file2: [
-        {
-          message: 'message text',
-          updated: '2016-09-27 00:18:03.000000000',
-          in_reply_to: 'c5912363_4b7d450a',
-          line: 132,
-          id: '450a935e_4f260d25',
-          patch_set: 2,
-          author,
-        },
-      ],
-    };
+      },
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: 'c5912363_6b820105',
+        line: 42,
+        id: '450a935e_0f1c05db',
+        patch_set: 2,
+        author,
+      },
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: '6505d749_f0bec0aa',
+        line: 62,
+        id: '6505d749_10ed44b2',
+        patch_set: 2,
+        author,
+      },
+    ],
+    file2: [
+      {
+        message: 'message text',
+        updated: '2016-09-27 00:18:03.000000000',
+        in_reply_to: 'c5912363_4b7d450a',
+        line: 132,
+        id: '450a935e_4f260d25',
+        patch_set: 2,
+        author,
+      },
+    ],
+  };
 
+  suite('basic tests', () => {
     setup(() => {
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
@@ -147,7 +146,6 @@
       // comment API.
       commentApiWrapper = fixture('basic');
       element = commentApiWrapper.$.messagesList;
-      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.messages = messages;
 
       // Stub methods on the changeComments object after changeComments has
@@ -232,7 +230,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 +255,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 +285,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 +301,26 @@
     });
 
     test('expand/collapse from external keypress', () => {
-      MockInteractions.tap(element.$$('#collapse-messages'));
-      let allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isTrue(message._expanded);
-      }
+      // Start with one expanded message. -> not all collapsed
+      element.scrollToMessage(messages[1].id);
+      assert.isFalse([...getMessages()].filter(m => m._expanded).length == 0);
 
-      // Expand/collapse all text also changes.
-      assert.equal(element.$$('#collapse-messages').textContent.trim(),
-          'Collapse all');
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
 
-      MockInteractions.tap(element.$$('#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');
+      // Press 'x' -> all expanded
+      element.handleExpandCollapse(true);
+      assert.isTrue([...getMessages()].filter(m => !m._expanded).length == 0);
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
     });
 
     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', () => {
@@ -341,7 +342,9 @@
       const messageID = messages[1].id;
       element.scrollToMessage(messageID);
       assert.isTrue(
-          element.$$('[data-message-id="' + messageID + '"]')._expanded);
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
 
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
@@ -361,7 +364,9 @@
       assert.isTrue(highlightStub.calledOnce);
       assert.equal(element._visibleMessages.length, 24);
       assert.isTrue(
-          element.$$('[data-message-id="' + messageID + '"]')._expanded);
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
     });
 
     test('messages', () => {
@@ -436,10 +441,10 @@
     let commentApiWrapper;
 
     const getMessages = function() {
-      return Polymer.dom(element.root).querySelectorAll('gr-message');
+      return dom(element.root).querySelectorAll('gr-message');
     };
     const getHiddenMessages = function() {
-      return Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
+      return dom(element.root).querySelectorAll('gr-message[hidden]');
     };
 
     const randomMessageReviewer = {
@@ -464,7 +469,7 @@
       // comment API.
       commentApiWrapper = fixture('basic');
       element = commentApiWrapper.$.messagesList;
-      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.messages = messages;
 
       // Stub methods on the changeComments object after changeComments has
@@ -477,7 +482,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', () => {
@@ -493,7 +499,7 @@
       element._hideAutomated = false;
       MockInteractions.tap(element.$.automatedMessageToggle);
       flushAsynchronousOperations();
-      allMessageEls = getMessages();
+      const allMessageEls = getMessages();
       allHiddenMessageEls = getHiddenMessages();
 
       // Autogenerated messages are now hidden.
@@ -517,7 +523,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);
@@ -544,6 +549,23 @@
       assert.isFalse(element._hasAutomatedMessages(messages));
     });
 
+    test('initially show only 20 messages', () => {
+      sandbox.stub(element.$.reporting, 'reportInteraction',
+          (eventName, details) => {
+            assert.equal(typeof(eventName), 'string');
+            if (details) {
+              assert.equal(typeof(details), 'object');
+            }
+          });
+      const messages = Array.from(Array(23).keys())
+          .map(() => {
+            return {};
+          });
+      element._processedMessagesChanged(messages);
+
+      assert.equal(element._visibleMessages.length, 20);
+    });
+
     test('_computeLabelExtremes', () => {
       const computeSpy = sandbox.spy(element, '_computeLabelExtremes');
 
@@ -586,4 +608,5 @@
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
deleted file mode 100644
index 696ffdf..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ /dev/null
@@ -1,189 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-related-changes-list">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      h3 {
-        margin: var(--spacing-m) 0 0;
-      }
-      section {
-        margin-bottom: 1.4em; /* Same as line height for collapse purposes */
-      }
-      a {
-        display: block;
-      }
-      .changeContainer,
-      a {
-        max-width: 100%;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      .changeContainer {
-        display: flex;
-      }
-      .changeContainer.thisChange:before {
-        content: '➔';
-        width: 1.2em;
-      }
-      h4,
-      section div {
-        display: flex;
-      }
-      h4:before,
-      section div:before {
-        content: ' ';
-        flex-shrink: 0;
-        width: 1.2em;
-      }
-      .note {
-        color: var(--error-text-color);
-      }
-      .relatedChanges a {
-        display: inline-block;
-      }
-      .strikethrough {
-        color: var(--deemphasized-text-color);
-        text-decoration: line-through;
-      }
-      .status {
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-bold);
-        margin-left: var(--spacing-xs);
-      }
-      .notCurrent {
-        color: #e65100;
-      }
-      .indirectAncestor {
-        color: #33691e;
-      }
-      .submittable {
-        color: #1b5e20;
-      }
-      .submittableCheck {
-        color: var(--vote-text-color-recommended);
-        display: none;
-      }
-      .submittableCheck.submittable {
-        display: inline;
-      }
-      .hidden,
-      .mobile {
-        display: none;
-      }
-       @media screen and (max-width: 60em) {
-        .mobile {
-          display: block;
-        }
-      }
-    </style>
-    <div>
-      <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
-        <h4>Relation chain</h4>
-        <template
-            is="dom-repeat"
-            items="[[_relatedResponse.changes]]"
-            as="related">
-          <div class$="rightIndent [[_computeChangeContainerClass(change, related)]]">
-            <a href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
-                class$="[[_computeLinkClass(related)]]"
-                title$="[[related.commit.subject]]">
-              [[related.commit.subject]]
-            </a>
-            <span class$="[[_computeChangeStatusClass(related)]]">
-              ([[_computeChangeStatus(related)]])
-            </span>
-          </div>
-        </template>
-      </section>
-      <section
-          id="submittedTogether"
-          class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]">
-        <h4>Submitted together</h4>
-        <template is="dom-repeat" items="[[_submittedTogether.changes]]" as="related">
-          <div class$="[[_computeChangeContainerClass(change, related)]]">
-            <a href$="[[_computeChangeURL(related._number, related.project)]]"
-                class$="[[_computeLinkClass(related)]]"
-                title$="[[related.project]]: [[related.branch]]: [[related.subject]]">
-              [[related.project]]: [[related.branch]]: [[related.subject]]
-            </a>
-            <span
-                tabindex="-1"
-                title="Submittable"
-                class$="submittableCheck [[_computeLinkClass(related)]]">✓</span>
-          </div>
-        </template>
-        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
-          <div class="note">
-            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_sameTopic.length]]" hidden>
-        <h4>Same topic</h4>
-        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
-          <div>
-            <a href$="[[_computeChangeURL(change._number, change.project)]]"
-                class$="[[_computeLinkClass(change)]]"
-                title$="[[change.project]]: [[change.branch]]: [[change.subject]]">
-              [[change.project]]: [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_conflicts.length]]" hidden>
-        <h4>Merge conflicts</h4>
-        <template is="dom-repeat" items="[[_conflicts]]" as="change">
-          <div>
-            <a href$="[[_computeChangeURL(change._number, change.project)]]"
-                class$="[[_computeLinkClass(change)]]"
-                title$="[[change.subject]]">
-              [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_cherryPicks.length]]" hidden>
-        <h4>Cherry picks</h4>
-        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
-          <div>
-            <a href$="[[_computeChangeURL(change._number, change.project)]]"
-                class$="[[_computeLinkClass(change)]]"
-                title$="[[change.branch]]: [[change.subject]]">
-              [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-    </div>
-    <div hidden$="[[!loading]]">Loading...</div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-related-changes-list.js"></script>
-</dom-module>
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..2183dde 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
@@ -14,21 +14,46 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-related-changes-list',
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../plugins/gr-endpoint-slot/gr-endpoint-slot.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-related-changes-list_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 
-    /**
-     * 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
-     * loading.
-     *
-     * @event new-section-loaded
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrRelatedChangesList extends mixinBehaviors( [
+  PatchSetBehavior,
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  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
+   * loading.
+   *
+   * @event new-section-loaded
+   */
+
+  static get properties() {
+    return {
       change: Object,
       hasParent: {
         type: Boolean,
@@ -50,7 +75,7 @@
       _connectedRevisions: {
         type: Array,
         computed: '_computeConnectedRevisions(change, patchNum, ' +
-            '_relatedResponse.changes)',
+          '_relatedResponse.changes)',
       },
       /** @type {?} */
       _relatedResponse: {
@@ -74,313 +99,346 @@
         type: Array,
         value() { return []; },
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_resultsChanged(_relatedResponse, _submittedTogether, ' +
-          '_conflicts, _cherryPicks, _sameTopic)',
-    ],
+        '_conflicts, _cherryPicks, _sameTopic)',
+    ];
+  }
 
-    clear() {
-      this.loading = true;
-      this.hidden = true;
+  clear() {
+    this.loading = true;
+    this.hidden = true;
 
-      this._relatedResponse = {changes: []};
-      this._submittedTogether = {changes: []};
-      this._conflicts = [];
-      this._cherryPicks = [];
-      this._sameTopic = [];
-    },
+    this._relatedResponse = {changes: []};
+    this._submittedTogether = {changes: []};
+    this._conflicts = [];
+    this._cherryPicks = [];
+    this._sameTopic = [];
+  }
 
-    reload() {
-      if (!this.change || !this.patchNum) {
-        return Promise.resolve();
-      }
-      this.loading = true;
-      const promises = [
-        this._getRelatedChanges().then(response => {
-          this._relatedResponse = response;
-          this._fireReloadEvent();
-          this.hasParent = this._calculateHasParent(this.change.change_id,
-              response.changes);
-        }),
-        this._getSubmittedTogether().then(response => {
-          this._submittedTogether = response;
-          this._fireReloadEvent();
-        }),
-        this._getCherryPicks().then(response => {
-          this._cherryPicks = response;
-          this._fireReloadEvent();
-        }),
-      ];
+  reload() {
+    if (!this.change || !this.patchNum) {
+      return Promise.resolve();
+    }
+    this.loading = true;
+    const promises = [
+      this._getRelatedChanges().then(response => {
+        this._relatedResponse = response;
+        this._fireReloadEvent();
+        this.hasParent = this._calculateHasParent(this.change.change_id,
+            response.changes);
+      }),
+      this._getSubmittedTogether().then(response => {
+        this._submittedTogether = response;
+        this._fireReloadEvent();
+      }),
+      this._getCherryPicks().then(response => {
+        this._cherryPicks = response;
+        this._fireReloadEvent();
+      }),
+    ];
 
-      // Get conflicts if change is open and is mergeable.
-      if (this.changeIsOpen(this.change) && this.mergeable) {
-        promises.push(this._getConflicts().then(response => {
-          // Because the server doesn't always return a response and the
-          // template expects an array, always return an array.
-          this._conflicts = response ? response : [];
-          this._fireReloadEvent();
-        }));
-      }
-
-      promises.push(this._getServerConfig().then(config => {
-        if (this.change.topic && !config.change.submit_whole_topic) {
-          return this._getChangesWithSameTopic().then(response => {
-            this._sameTopic = response;
-          });
-        } else {
-          this._sameTopic = [];
-        }
-        return this._sameTopic;
+    // Get conflicts if change is open and is mergeable.
+    if (this.changeIsOpen(this.change) && this.mergeable) {
+      promises.push(this._getConflicts().then(response => {
+        // Because the server doesn't always return a response and the
+        // template expects an array, always return an array.
+        this._conflicts = response ? response : [];
+        this._fireReloadEvent();
       }));
+    }
 
-      return Promise.all(promises).then(() => {
-        this.loading = false;
-      });
-    },
-
-    _fireReloadEvent() {
-      // The listener on the change computes height of the related changes
-      // section, so they have to be rendered first, and inside a dom-repeat,
-      // 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
-     * is a relation chain, and the change id is not the last item of the
-     * relation chain, there is a parent.
-     *
-     * @param  {number} currentChangeId
-     * @param  {!Array} relatedChanges
-     * @return {boolean}
-     */
-    _calculateHasParent(currentChangeId, relatedChanges) {
-      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
-     * @param {string} project
-     * @param {number=} opt_patchNum
-     * @return {string}
-     */
-    _computeChangeURL(changeNum, project, opt_patchNum) {
-      return Gerrit.Nav.getUrlForChangeById(changeNum, project, opt_patchNum);
-    },
-
-    _computeChangeContainerClass(currentChange, relatedChange) {
-      const classes = ['changeContainer'];
-      if ([relatedChange, currentChange].some(arg => arg === undefined)) {
-        return classes;
+    promises.push(this._getServerConfig().then(config => {
+      if (this.change.topic && !config.change.submit_whole_topic) {
+        return this._getChangesWithSameTopic().then(response => {
+          this._sameTopic = response;
+        });
+      } else {
+        this._sameTopic = [];
       }
-      if (this._changesEqual(relatedChange, currentChange)) {
-        classes.push('thisChange');
-      }
-      return classes.join(' ');
-    },
+      return this._sameTopic;
+    }));
 
-    /**
-     * Do the given objects describe the same change? Compares the changes by
-     * their numbers.
-     *
-     * @see /Documentation/rest-api-changes.html#change-info
-     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
-     * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
-     * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
-     * @return {boolean}
-     */
-    _changesEqual(a, b) {
-      const aNum = this._getChangeNumber(a);
-      const bNum = this._getChangeNumber(b);
-      return aNum === bNum;
-    },
+    return Promise.all(promises).then(() => {
+      this.loading = false;
+    });
+  }
 
-    /**
-     * Get the change number from either a ChangeInfo (such as those included in
-     * SubmittedTogetherInfo responses) or get the change number from a
-     * RelatedChangeAndCommitInfo (such as those included in a
-     * RelatedChangesInfo response).
-     *
-     * @see /Documentation/rest-api-changes.html#change-info
-     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
-     *
-     * @param {!Object} change Either a ChangeInfo or a
-     *     RelatedChangeAndCommitInfo object.
-     * @return {number}
-     */
-    _getChangeNumber(change) {
-      // Default to 0 if change property is not defined.
-      if (!change) return 0;
+  _fireReloadEvent() {
+    // The listener on the change computes height of the related changes
+    // section, so they have to be rendered first, and inside a dom-repeat,
+    // that requires a flush.
+    flush();
+    this.dispatchEvent(new CustomEvent('new-section-loaded'));
+  }
 
-      if (change.hasOwnProperty('_change_number')) {
-        return change._change_number;
-      }
-      return change._number;
-    },
+  /**
+   * Determines whether or not the given change has a parent change. If there
+   * is a relation chain, and the change id is not the last item of the
+   * relation chain, there is a parent.
+   *
+   * @param  {number} currentChangeId
+   * @param  {!Array} relatedChanges
+   * @return {boolean}
+   */
+  _calculateHasParent(currentChangeId, relatedChanges) {
+    return relatedChanges.length > 0 &&
+        relatedChanges[relatedChanges.length - 1].change_id !==
+        currentChangeId;
+  }
 
-    _computeLinkClass(change) {
-      const statuses = [];
-      if (change.status == this.ChangeStatus.ABANDONED) {
-        statuses.push('strikethrough');
-      }
-      if (change.submittable) {
-        statuses.push('submittable');
-      }
-      return statuses.join(' ');
-    },
+  _getRelatedChanges() {
+    return this.$.restAPI.getRelatedChanges(this.change._number,
+        this.patchNum);
+  }
 
-    _computeChangeStatusClass(change) {
-      const classes = ['status'];
-      if (change._revision_number != change._current_revision_number) {
-        classes.push('notCurrent');
-      } else if (this._isIndirectAncestor(change)) {
-        classes.push('indirectAncestor');
-      } else if (change.submittable) {
-        classes.push('submittable');
-      } else if (change.status == this.ChangeStatus.NEW) {
-        classes.push('hidden');
-      }
-      return classes.join(' ');
-    },
+  _getSubmittedTogether() {
+    return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
+  }
 
-    _computeChangeStatus(change) {
-      switch (change.status) {
-        case this.ChangeStatus.MERGED:
-          return 'Merged';
-        case this.ChangeStatus.ABANDONED:
-          return 'Abandoned';
-      }
-      if (change._revision_number != change._current_revision_number) {
-        return 'Not current';
-      } else if (this._isIndirectAncestor(change)) {
-        return 'Indirect ancestor';
-      } else if (change.submittable) {
-        return 'Submittable';
-      }
-      return '';
-    },
+  _getServerConfig() {
+    return this.$.restAPI.getConfig();
+  }
 
-    _resultsChanged(related, submittedTogether, conflicts,
-        cherryPicks, sameTopic) {
-      // Polymer 2: check for undefined
-      if ([
-        related,
-        submittedTogether,
-        conflicts,
-        cherryPicks,
-        sameTopic,
-      ].some(arg => arg === undefined)) {
+  _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
+   * @param {string} project
+   * @param {number=} opt_patchNum
+   * @return {string}
+   */
+  _computeChangeURL(changeNum, project, opt_patchNum) {
+    return GerritNav.getUrlForChangeById(changeNum, project, opt_patchNum);
+  }
+
+  _computeChangeContainerClass(currentChange, relatedChange) {
+    const classes = ['changeContainer'];
+    if ([relatedChange, currentChange].some(arg => arg === undefined)) {
+      return classes;
+    }
+    if (this._changesEqual(relatedChange, currentChange)) {
+      classes.push('thisChange');
+    }
+    return classes.join(' ');
+  }
+
+  /**
+   * Do the given objects describe the same change? Compares the changes by
+   * their numbers.
+   *
+   * @see /Documentation/rest-api-changes.html#change-info
+   * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+   * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
+   * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
+   * @return {boolean}
+   */
+  _changesEqual(a, b) {
+    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
+   * SubmittedTogetherInfo responses) or get the change number from a
+   * RelatedChangeAndCommitInfo (such as those included in a
+   * RelatedChangesInfo response).
+   *
+   * @see /Documentation/rest-api-changes.html#change-info
+   * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+   *
+   * @param {!Object} change Either a ChangeInfo or a
+   *     RelatedChangeAndCommitInfo object.
+   * @return {number}
+   */
+  _getChangeNumber(change) {
+    // Default to 0 if change property is not defined.
+    if (!change) return 0;
+
+    if (change.hasOwnProperty('_change_number')) {
+      return change._change_number;
+    }
+    return change._number;
+  }
+
+  _computeLinkClass(change) {
+    const statuses = [];
+    if (change.status == this.ChangeStatus.ABANDONED) {
+      statuses.push('strikethrough');
+    }
+    if (change.submittable) {
+      statuses.push('submittable');
+    }
+    return statuses.join(' ');
+  }
+
+  _computeChangeStatusClass(change) {
+    const classes = ['status'];
+    if (change._revision_number != change._current_revision_number) {
+      classes.push('notCurrent');
+    } else if (this._isIndirectAncestor(change)) {
+      classes.push('indirectAncestor');
+    } else if (change.submittable) {
+      classes.push('submittable');
+    } else if (change.status == this.ChangeStatus.NEW) {
+      classes.push('hidden');
+    }
+    return classes.join(' ');
+  }
+
+  _computeChangeStatus(change) {
+    switch (change.status) {
+      case this.ChangeStatus.MERGED:
+        return 'Merged';
+      case this.ChangeStatus.ABANDONED:
+        return 'Abandoned';
+    }
+    if (change._revision_number != change._current_revision_number) {
+      return 'Not current';
+    } else if (this._isIndirectAncestor(change)) {
+      return 'Indirect ancestor';
+    } else if (change.submittable) {
+      return 'Submittable';
+    }
+    return '';
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    // We listen to `new-section-loaded` events to allow plugins to trigger
+    // visibility computations, if their content or visibility changed.
+    this.addEventListener('new-section-loaded',
+        () => this._handleNewSectionLoaded());
+  }
+
+  _handleNewSectionLoaded() {
+    // A plugin sent a `new-section-loaded` event, so its visibility likely
+    // changed. Hence, we update our visibility if needed.
+    this._resultsChanged(this._relatedResponse, this._submittedTogether,
+        this._conflicts, this._cherryPicks, this._sameTopic);
+  }
+
+  _resultsChanged(related, submittedTogether, conflicts,
+      cherryPicks, sameTopic) {
+    // Polymer 2: check for undefined
+    if ([
+      related,
+      submittedTogether,
+      conflicts,
+      cherryPicks,
+      sameTopic,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    const results = [
+      related && related.changes,
+      // If there are either visible or non-visible changes, we need a
+      // non-empty list to fire the event and set visibility.
+      submittedTogether && ((submittedTogether.changes || [])
+          + (submittedTogether.non_visible_changes ? [{}] : [])),
+      conflicts,
+      cherryPicks,
+      sameTopic,
+    ];
+    for (let i = 0; i < results.length; i++) {
+      if (results[i] && results[i].length > 0) {
+        this.hidden = false;
+        this.dispatchEvent(new CustomEvent('update', {
+          composed: true, bubbles: false,
+        }));
         return;
       }
+    }
 
-      const results = [
-        related && related.changes,
-        submittedTogether && submittedTogether.changes,
-        conflicts,
-        cherryPicks,
-        sameTopic,
-      ];
-      for (let i = 0; i < results.length; i++) {
-        if (results[i] && results[i].length > 0) {
-          this.hidden = false;
-          this.fire('update', null, {bubbles: false});
-          return;
-        }
+    this._computeHidden();
+  }
+
+  _computeHidden() {
+    // None of the built-in change lists had elements. So all of them are
+    // hidden. But since plugins might have injected visible content, we need
+    // to check for that and stay visible if we find any such visible content.
+    // (We consider plugins visible except if it's main element has the hidden
+    // attribute set to true.)
+    const plugins = pluginEndpoints.getDetails('related-changes-section');
+    this.hidden = !(plugins.some(plugin => (
+      (!plugin.domHook)
+        || plugin.domHook.getAllAttached().some(
+            instance => !instance.hidden))));
+  }
+
+  _isIndirectAncestor(change) {
+    return !this._connectedRevisions.includes(change.commit.commit);
+  }
+
+  _computeConnectedRevisions(change, patchNum, relatedChanges) {
+    // Polymer 2: check for undefined
+    if ([change, patchNum, relatedChanges].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const connected = [];
+    let changeRevision;
+    if (!change) { return []; }
+    for (const rev in change.revisions) {
+      if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
+        changeRevision = rev;
       }
-      this.hidden = true;
-    },
+    }
+    const commits = relatedChanges.map(c => c.commit);
+    let pos = commits.length - 1;
 
-    _isIndirectAncestor(change) {
-      return !this._connectedRevisions.includes(change.commit.commit);
-    },
-
-    _computeConnectedRevisions(change, patchNum, relatedChanges) {
-      // Polymer 2: check for undefined
-      if ([change, patchNum, relatedChanges].some(arg => arg === undefined)) {
-        return undefined;
+    while (pos >= 0) {
+      const commit = commits[pos].commit;
+      connected.push(commit);
+      if (commit == changeRevision) {
+        break;
       }
-
-      const connected = [];
-      let changeRevision;
-      if (!change) { return []; }
-      for (const rev in change.revisions) {
-        if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
-          changeRevision = rev;
-        }
-      }
-      const commits = relatedChanges.map(c => { return c.commit; });
-      let pos = commits.length - 1;
-
-      while (pos >= 0) {
-        const commit = commits[pos].commit;
-        connected.push(commit);
-        if (commit == changeRevision) {
+      pos--;
+    }
+    while (pos >= 0) {
+      for (let i = 0; i < commits[pos].parents.length; i++) {
+        if (connected.includes(commits[pos].parents[i].commit)) {
+          connected.push(commits[pos].commit);
           break;
         }
-        pos--;
       }
-      while (pos >= 0) {
-        for (let i = 0; i < commits[pos].parents.length; i++) {
-          if (connected.includes(commits[pos].parents[i].commit)) {
-            connected.push(commits[pos].commit);
-            break;
-          }
-        }
-        --pos;
-      }
-      return connected;
-    },
+      --pos;
+    }
+    return connected;
+  }
 
-    _computeSubmittedTogetherClass(submittedTogether) {
-      if (!submittedTogether || (
-        submittedTogether.changes.length === 0 &&
-          !submittedTogether.non_visible_changes)) {
-        return 'hidden';
-      }
-      return '';
-    },
+  _computeSubmittedTogetherClass(submittedTogether) {
+    if (!submittedTogether || (
+      submittedTogether.changes.length === 0 &&
+        !submittedTogether.non_visible_changes)) {
+      return 'hidden';
+    }
+    return '';
+  }
 
-    _computeNonVisibleChangesNote(n) {
-      const noun = n === 1 ? 'change' : 'changes';
-      return `(+ ${n} non-visible ${noun})`;
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
new file mode 100644
index 0000000..8241165
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
@@ -0,0 +1,208 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    h3 {
+      margin: var(--spacing-m) 0 0;
+    }
+    section {
+      margin-bottom: 1.4em; /* Same as line height for collapse purposes */
+    }
+    a {
+      display: block;
+    }
+    .changeContainer,
+    a {
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .changeContainer {
+      display: flex;
+    }
+    .changeContainer.thisChange:before {
+      content: '➔';
+      width: 1.2em;
+    }
+    h4,
+    section div {
+      display: flex;
+    }
+    h4:before,
+    section div:before {
+      content: ' ';
+      flex-shrink: 0;
+      width: 1.2em;
+    }
+    .note {
+      color: var(--error-text-color);
+    }
+    .relatedChanges a {
+      display: inline-block;
+    }
+    .strikethrough {
+      color: var(--deemphasized-text-color);
+      text-decoration: line-through;
+    }
+    .status {
+      color: var(--deemphasized-text-color);
+      font-weight: var(--font-weight-bold);
+      margin-left: var(--spacing-xs);
+    }
+    .notCurrent {
+      color: #e65100;
+    }
+    .indirectAncestor {
+      color: #33691e;
+    }
+    .submittable {
+      color: #1b5e20;
+    }
+    .submittableCheck {
+      color: var(--vote-text-color-recommended);
+      display: none;
+    }
+    .submittableCheck.submittable {
+      display: inline;
+    }
+    .hidden,
+    .mobile {
+      display: none;
+    }
+    @media screen and (max-width: 60em) {
+      .mobile {
+        display: block;
+      }
+    }
+  </style>
+  <div>
+    <gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      <section
+        class="relatedChanges"
+        hidden$="[[!_relatedResponse.changes.length]]"
+        hidden=""
+      >
+        <h4>Relation chain</h4>
+        <template
+          is="dom-repeat"
+          items="[[_relatedResponse.changes]]"
+          as="related"
+        >
+          <div
+            class$="rightIndent [[_computeChangeContainerClass(change, related)]]"
+          >
+            <a
+              href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
+              class$="[[_computeLinkClass(related)]]"
+              title$="[[related.commit.subject]]"
+            >
+              [[related.commit.subject]]
+            </a>
+            <span class$="[[_computeChangeStatusClass(related)]]">
+              ([[_computeChangeStatus(related)]])
+            </span>
+          </div>
+        </template>
+      </section>
+      <section
+        id="submittedTogether"
+        class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]"
+      >
+        <h4>Submitted together</h4>
+        <template
+          is="dom-repeat"
+          items="[[_submittedTogether.changes]]"
+          as="related"
+        >
+          <div class$="[[_computeChangeContainerClass(change, related)]]">
+            <a
+              href$="[[_computeChangeURL(related._number, related.project)]]"
+              class$="[[_computeLinkClass(related)]]"
+              title$="[[related.project]]: [[related.branch]]: [[related.subject]]"
+            >
+              [[related.project]]: [[related.branch]]: [[related.subject]]
+            </a>
+            <span
+              tabindex="-1"
+              title="Submittable"
+              class$="submittableCheck [[_computeLinkClass(related)]]"
+              >✓</span
+            >
+          </div>
+        </template>
+        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
+          <div class="note">
+            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_sameTopic.length]]" hidden="">
+        <h4>Same topic</h4>
+        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
+          <div>
+            <a
+              href$="[[_computeChangeURL(change._number, change.project)]]"
+              class$="[[_computeLinkClass(change)]]"
+              title$="[[change.project]]: [[change.branch]]: [[change.subject]]"
+            >
+              [[change.project]]: [[change.branch]]: [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_conflicts.length]]" hidden="">
+        <h4>Merge conflicts</h4>
+        <template is="dom-repeat" items="[[_conflicts]]" as="change">
+          <div>
+            <a
+              href$="[[_computeChangeURL(change._number, change.project)]]"
+              class$="[[_computeLinkClass(change)]]"
+              title$="[[change.subject]]"
+            >
+              [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_cherryPicks.length]]" hidden="">
+        <h4>Cherry picks</h4>
+        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
+          <div>
+            <a
+              href$="[[_computeChangeURL(change._number, change.project)]]"
+              class$="[[_computeLinkClass(change)]]"
+              title$="[[change.branch]]: [[change.subject]]"
+            >
+              [[change.branch]]: [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>
+  </div>
+  <div hidden$="[[!loading]]">Loading...</div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..9054aa3 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-related-changes-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,237 +31,269 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-related-changes-list tests', () => {
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-related-changes-list.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-related-changes-list tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('connected revisions', () => {
+    const change = {
+      revisions: {
+        'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
+          _number: 1,
+        },
+        '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
+          _number: 2,
+        },
+        'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
+          _number: 7,
+        },
+        'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
+          _number: 5,
+        },
+        'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
+          _number: 6,
+        },
+        'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
+          _number: 3,
+        },
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
+          _number: 4,
+        },
+      },
+    };
+    let patchNum = 7;
+    let relatedChanges = [
+      {
+        commit: {
+          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+          parents: [
+            {
+              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+          parents: [
+            {
+              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+          parents: [
+            {
+              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
+            },
+          ],
+        },
+      },
+    ];
+
+    let connectedChanges =
+        element._computeConnectedRevisions(change, patchNum, relatedChanges);
+    assert.deepEqual(connectedChanges, [
+      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+    ]);
+
+    patchNum = 4;
+    relatedChanges = [
+      {
+        commit: {
+          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+          parents: [
+            {
+              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+          parents: [
+            {
+              commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+          parents: [
+            {
+              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
+            },
+          ],
+        },
+      },
+    ];
+
+    connectedChanges =
+        element._computeConnectedRevisions(change, patchNum, relatedChanges);
+    assert.deepEqual(connectedChanges, [
+      'af815dac54318826b7f1fa468acc76349ffc588e',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+    ]);
+  });
+
+  test('_computeChangeContainerClass', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _change_number: 1};
+    const change3 = {change_id: 123, _number: 2};
+
+    assert.notEqual(element._computeChangeContainerClass(
+        change1, change1).indexOf('thisChange'), -1);
+    assert.equal(element._computeChangeContainerClass(
+        change1, change2).indexOf('thisChange'), -1);
+    assert.equal(element._computeChangeContainerClass(
+        change1, change3).indexOf('thisChange'), -1);
+  });
+
+  test('_changesEqual', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _number: 1};
+    const change3 = {change_id: 123, _number: 2};
+    const change4 = {change_id: 123, _change_number: 1};
+
+    assert.isTrue(element._changesEqual(change1, change1));
+    assert.isFalse(element._changesEqual(change1, change2));
+    assert.isFalse(element._changesEqual(change1, change3));
+    assert.isTrue(element._changesEqual(change2, change4));
+  });
+
+  test('_getChangeNumber', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _change_number: 1};
+    assert.equal(element._getChangeNumber(change1), 0);
+    assert.equal(element._getChangeNumber(change2), 1);
+  });
+
+  test('event for section loaded fires for each section ', () => {
+    const loadedStub = sandbox.stub();
+    element.patchNum = 7;
+    element.change = {
+      change_id: 123,
+      status: 'NEW',
+    };
+    element.mergeable = true;
+    element.addEventListener('new-section-loaded', loadedStub);
+    sandbox.stub(element, '_getRelatedChanges')
+        .returns(Promise.resolve({changes: []}));
+    sandbox.stub(element, '_getSubmittedTogether')
+        .returns(Promise.resolve());
+    sandbox.stub(element, '_getCherryPicks')
+        .returns(Promise.resolve());
+    sandbox.stub(element, '_getConflicts')
+        .returns(Promise.resolve());
+
+    return element.reload().then(() => {
+      assert.equal(loadedStub.callCount, 4);
+    });
+  });
+
+  suite('_getConflicts resolves undefined', () => {
     let element;
-    let sandbox;
 
     setup(() => {
       element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('connected revisions', () => {
-      const change = {
-        revisions: {
-          'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
-            _number: 1,
-          },
-          '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
-            _number: 2,
-          },
-          'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
-            _number: 7,
-          },
-          'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
-            _number: 5,
-          },
-          'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
-            _number: 6,
-          },
-          'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
-            _number: 3,
-          },
-          '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
-            _number: 4,
-          },
-        },
-      };
-      let patchNum = 7;
-      let relatedChanges = [
-        {
-          commit: {
-            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-            parents: [
-              {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            parents: [
-              {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            parents: [
-              {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            parents: [
-              {
-                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-            parents: [
-              {
-                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-            parents: [
-              {
-                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
-              },
-            ],
-          },
-        },
-      ];
-
-      let connectedChanges =
-          element._computeConnectedRevisions(change, patchNum, relatedChanges);
-      assert.deepEqual(connectedChanges, [
-        '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-        'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-        'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-        '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-        '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-        '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-      ]);
-
-      patchNum = 4;
-      relatedChanges = [
-        {
-          commit: {
-            commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-            parents: [
-              {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            parents: [
-              {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            parents: [
-              {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-            parents: [
-              {
-                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-            parents: [
-              {
-                commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-              },
-            ],
-          },
-        },
-        {
-          commit: {
-            commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-            parents: [
-              {
-                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
-              },
-            ],
-          },
-        },
-      ];
-
-      connectedChanges =
-          element._computeConnectedRevisions(change, patchNum, relatedChanges);
-      assert.deepEqual(connectedChanges, [
-        'af815dac54318826b7f1fa468acc76349ffc588e',
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-        'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-      ]);
-    });
-
-    test('_computeChangeContainerClass', () => {
-      const change1 = {change_id: 123, _number: 0};
-      const change2 = {change_id: 456, _change_number: 1};
-      const change3 = {change_id: 123, _number: 2};
-
-      assert.notEqual(element._computeChangeContainerClass(
-          change1, change1).indexOf('thisChange'), -1);
-      assert.equal(element._computeChangeContainerClass(
-          change1, change2).indexOf('thisChange'), -1);
-      assert.equal(element._computeChangeContainerClass(
-          change1, change3).indexOf('thisChange'), -1);
-    });
-
-    test('_changesEqual', () => {
-      const change1 = {change_id: 123, _number: 0};
-      const change2 = {change_id: 456, _number: 1};
-      const change3 = {change_id: 123, _number: 2};
-      const change4 = {change_id: 123, _change_number: 1};
-
-      assert.isTrue(element._changesEqual(change1, change1));
-      assert.isFalse(element._changesEqual(change1, change2));
-      assert.isFalse(element._changesEqual(change1, change3));
-      assert.isTrue(element._changesEqual(change2, change4));
-    });
-
-    test('_getChangeNumber', () => {
-      const change1 = {change_id: 123, _number: 0};
-      const change2 = {change_id: 456, _change_number: 1};
-      assert.equal(element._getChangeNumber(change1), 0);
-      assert.equal(element._getChangeNumber(change2), 1);
-    });
-
-    test('event for section loaded fires for each section ', () => {
-      const loadedStub = sandbox.stub();
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.addEventListener('new-section-loaded', loadedStub);
       sandbox.stub(element, '_getRelatedChanges')
           .returns(Promise.resolve({changes: []}));
       sandbox.stub(element, '_getSubmittedTogether')
@@ -273,278 +302,381 @@
           .returns(Promise.resolve());
       sandbox.stub(element, '_getConflicts')
           .returns(Promise.resolve());
-
-      return element.reload().then(() => {
-        assert.equal(loadedStub.callCount, 4);
-      });
     });
 
-    suite('_getConflicts resolves undefined', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-
-        sandbox.stub(element, '_getRelatedChanges')
-            .returns(Promise.resolve({changes: []}));
-        sandbox.stub(element, '_getSubmittedTogether')
-            .returns(Promise.resolve());
-        sandbox.stub(element, '_getCherryPicks')
-            .returns(Promise.resolve());
-        sandbox.stub(element, '_getConflicts')
-            .returns(Promise.resolve());
-      });
-
-      test('_conflicts are an empty array', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'NEW',
-        };
-        element.mergeable = true;
-        element.reload();
-        assert.equal(element._conflicts.length, 0);
-      });
-    });
-
-    suite('get conflicts tests', () => {
-      let element;
-      let conflictsStub;
-
-      setup(() => {
-        element = fixture('basic');
-
-        sandbox.stub(element, '_getRelatedChanges')
-            .returns(Promise.resolve({changes: []}));
-        sandbox.stub(element, '_getSubmittedTogether')
-            .returns(Promise.resolve());
-        sandbox.stub(element, '_getCherryPicks')
-            .returns(Promise.resolve());
-        conflictsStub = sandbox.stub(element, '_getConflicts')
-            .returns(Promise.resolve());
-      });
-
-      test('request conflicts if open and mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'NEW',
-        };
-        element.mergeable = true;
-        element.reload();
-        assert.isTrue(conflictsStub.called);
-      });
-
-      test('does not request conflicts if closed and mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'MERGED',
-        };
-        element.reload();
-        assert.isFalse(conflictsStub.called);
-      });
-
-      test('does not request conflicts if open and not mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'NEW',
-        };
-        element.mergeable = false;
-        element.reload();
-        assert.isFalse(conflictsStub.called);
-      });
-
-      test('doesnt request conflicts if closed and not mergeable', () => {
-        element.patchNum = 7;
-        element.change = {
-          change_id: 123,
-          status: 'MERGED',
-        };
-        element.mergeable = false;
-        element.reload();
-        assert.isFalse(conflictsStub.called);
-      });
-    });
-
-    test('_calculateHasParent', () => {
-      const changeId = 123;
-      const relatedChanges = [];
-
-      assert.equal(element._calculateHasParent(changeId, relatedChanges),
-          false);
-
-      relatedChanges.push({change_id: 123});
-      assert.equal(element._calculateHasParent(changeId, relatedChanges),
-          false);
-
-      relatedChanges.push({change_id: 234});
-      assert.equal(element._calculateHasParent(changeId, relatedChanges),
-          true);
-    });
-
-    suite('hidden attribute and update event', () => {
-      const changes = [{
-        project: 'foo/bar',
-        change_id: 'Ideadbeef',
-        commit: {
-          commit: 'deadbeef',
-          parents: [{commit: 'abc123'}],
-          author: {},
-          subject: 'do that thing',
-        },
-        _change_number: 12345,
-        _revision_number: 1,
-        _current_revision_number: 1,
-        status: 'NEW',
-      }];
-
-      test('clear and empties', () => {
-        element._relatedResponse = {changes};
-        element._submittedTogether = {changes};
-        element._conflicts = changes;
-        element._cherryPicks = changes;
-        element._sameTopic = changes;
-
-        element.hidden = false;
-        element.clear();
-        assert.isTrue(element.hidden);
-        assert.equal(element._relatedResponse.changes.length, 0);
-        assert.equal(element._submittedTogether.changes.length, 0);
-        assert.equal(element._conflicts.length, 0);
-        assert.equal(element._cherryPicks.length, 0);
-        assert.equal(element._sameTopic.length, 0);
-      });
-
-      test('update fires', () => {
-        const updateHandler = sandbox.stub();
-        element.addEventListener('update', updateHandler);
-
-        element._resultsChanged({}, {}, [], [], []);
-        assert.isTrue(element.hidden);
-        assert.isFalse(updateHandler.called);
-
-        element._resultsChanged({}, {}, [], [], ['test']);
-        assert.isFalse(element.hidden);
-        assert.isTrue(updateHandler.called);
-      });
-
-      suite('hiding and unhiding', () => {
-        test('related response', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({changes}, {}, [], [], []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('submitted together', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {changes}, [], [], []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('conflicts', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {}, changes, [], []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('cherrypicks', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {}, [], changes, []);
-          assert.isFalse(element.hidden);
-        });
-
-        test('same topic', () => {
-          assert.isTrue(element.hidden);
-          element._resultsChanged({}, {}, [], [], changes);
-          assert.isFalse(element.hidden);
-        });
-      });
-    });
-
-    test('_computeChangeURL uses Gerrit.Nav', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChangeById');
-      element._computeChangeURL(123, 'abc/def', 12);
-      assert.isTrue(getUrlStub.called);
-    });
-
-    suite('submitted together changes', () => {
-      const change = {
-        project: 'foo/bar',
-        change_id: 'Ideadbeef',
-        commit: {
-          commit: 'deadbeef',
-          parents: [{commit: 'abc123'}],
-          author: {},
-          subject: 'do that thing',
-        },
-        _change_number: 12345,
-        _revision_number: 1,
-        _current_revision_number: 1,
+    test('_conflicts are an empty array', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
         status: 'NEW',
       };
+      element.mergeable = true;
+      element.reload();
+      assert.equal(element._conflicts.length, 0);
+    });
+  });
 
-      test('_computeSubmittedTogetherClass', () => {
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass(undefined),
-            'hidden');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({changes: []}),
-            'hidden');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({changes: [{}]}),
-            '');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({
-              changes: [],
-              non_visible_changes: 0,
-            }),
-            'hidden');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({
-              changes: [],
-              non_visible_changes: 1,
-            }),
-            '');
-        assert.strictEqual(
-            element._computeSubmittedTogetherClass({
-              changes: [{}],
-              non_visible_changes: 1,
-            }),
-            '');
+  suite('get conflicts tests', () => {
+    let element;
+    let conflictsStub;
+
+    setup(() => {
+      element = fixture('basic');
+
+      sandbox.stub(element, '_getRelatedChanges')
+          .returns(Promise.resolve({changes: []}));
+      sandbox.stub(element, '_getSubmittedTogether')
+          .returns(Promise.resolve());
+      sandbox.stub(element, '_getCherryPicks')
+          .returns(Promise.resolve());
+      conflictsStub = sandbox.stub(element, '_getConflicts')
+          .returns(Promise.resolve());
+    });
+
+    test('request conflicts if open and mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.isTrue(conflictsStub.called);
+    });
+
+    test('does not request conflicts if closed and mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'MERGED',
+      };
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('does not request conflicts if open and not mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('doesnt request conflicts if closed and not mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'MERGED',
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+  });
+
+  test('_calculateHasParent', () => {
+    const changeId = 123;
+    const relatedChanges = [];
+
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        false);
+
+    relatedChanges.push({change_id: 123});
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        false);
+
+    relatedChanges.push({change_id: 234});
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        true);
+  });
+
+  suite('hidden attribute and update event', () => {
+    const changes = [{
+      project: 'foo/bar',
+      change_id: 'Ideadbeef',
+      commit: {
+        commit: 'deadbeef',
+        parents: [{commit: 'abc123'}],
+        author: {},
+        subject: 'do that thing',
+      },
+      _change_number: 12345,
+      _revision_number: 1,
+      _current_revision_number: 1,
+      status: 'NEW',
+    }];
+
+    test('clear and empties', () => {
+      element._relatedResponse = {changes};
+      element._submittedTogether = {changes};
+      element._conflicts = changes;
+      element._cherryPicks = changes;
+      element._sameTopic = changes;
+
+      element.hidden = false;
+      element.clear();
+      assert.isTrue(element.hidden);
+      assert.equal(element._relatedResponse.changes.length, 0);
+      assert.equal(element._submittedTogether.changes.length, 0);
+      assert.equal(element._conflicts.length, 0);
+      assert.equal(element._cherryPicks.length, 0);
+      assert.equal(element._sameTopic.length, 0);
+    });
+
+    test('update fires', () => {
+      const updateHandler = sandbox.stub();
+      element.addEventListener('update', updateHandler);
+
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged({}, {}, [], [], ['test']);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+          {}, {changes: [], non_visible_changes: 0}, [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged(
+          {}, {changes: ['test'], non_visible_changes: 0}, [], [], []);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+          {}, {changes: [], non_visible_changes: 1}, [], [], []);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+    });
+
+    suite('hiding and unhiding', () => {
+      test('related response', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({changes}, {}, [], [], []);
+        assert.isFalse(element.hidden);
       });
 
-      test('no submitted together changes', () => {
-        flushAsynchronousOperations();
-        assert.include(element.$.submittedTogether.className, 'hidden');
+      test('submitted together', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {changes}, [], [], []);
+        assert.isFalse(element.hidden);
       });
 
-      test('no non-visible submitted together changes', () => {
-        element._submittedTogether = {changes: [change]};
-        flushAsynchronousOperations();
-        assert.notInclude(element.$.submittedTogether.className, 'hidden');
-        assert.isNull(element.$$('.note'));
+      test('conflicts', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, changes, [], []);
+        assert.isFalse(element.hidden);
       });
 
-      test('no visible submitted together changes', () => {
-        // Technically this should never happen, but worth asserting the logic.
-        element._submittedTogether = {changes: [], non_visible_changes: 1};
-        flushAsynchronousOperations();
-        assert.notInclude(element.$.submittedTogether.className, 'hidden');
-        assert.isNotNull(element.$$('.note'));
-        assert.strictEqual(
-            element.$$('.note').innerText, '(+ 1 non-visible change)');
+      test('cherrypicks', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, [], changes, []);
+        assert.isFalse(element.hidden);
       });
 
-      test('visible and non-visible submitted together changes', () => {
-        element._submittedTogether = {changes: [change], non_visible_changes: 2};
-        flushAsynchronousOperations();
-        assert.notInclude(element.$.submittedTogether.className, 'hidden');
-        assert.isNotNull(element.$$('.note'));
-        assert.strictEqual(
-            element.$$('.note').innerText, '(+ 2 non-visible changes)');
+      test('same topic', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, [], [], changes);
+        assert.isFalse(element.hidden);
       });
     });
   });
+
+  test('_computeChangeURL uses GerritNav', () => {
+    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForChangeById');
+    element._computeChangeURL(123, 'abc/def', 12);
+    assert.isTrue(getUrlStub.called);
+  });
+
+  suite('submitted together changes', () => {
+    const change = {
+      project: 'foo/bar',
+      change_id: 'Ideadbeef',
+      commit: {
+        commit: 'deadbeef',
+        parents: [{commit: 'abc123'}],
+        author: {},
+        subject: 'do that thing',
+      },
+      _change_number: 12345,
+      _revision_number: 1,
+      _current_revision_number: 1,
+      status: 'NEW',
+    };
+
+    test('_computeSubmittedTogetherClass', () => {
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass(undefined),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({changes: []}),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({changes: [{}]}),
+          '');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [],
+            non_visible_changes: 0,
+          }),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [],
+            non_visible_changes: 1,
+          }),
+          '');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [{}],
+            non_visible_changes: 1,
+          }),
+          '');
+    });
+
+    test('no submitted together changes', () => {
+      flushAsynchronousOperations();
+      assert.include(element.$.submittedTogether.className, 'hidden');
+    });
+
+    test('no non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change]};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNull(element.shadowRoot
+          .querySelector('.note'));
+    });
+
+    test('no visible submitted together changes', () => {
+      // Technically this should never happen, but worth asserting the logic.
+      element._submittedTogether = {changes: [], non_visible_changes: 1};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText, '(+ 1 non-visible change)');
+    });
+
+    test('visible and non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 2};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText, '(+ 2 non-visible changes)');
+    });
+  });
+});
+
+suite('gr-related-changes-list plugin tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    resetPlugins();
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    resetPlugins();
+  });
+
+  test('endpoint params', done => {
+    element.change = {labels: {}};
+    let hookEl;
+    let plugin;
+    pluginApi.install(
+        p => {
+          plugin = p;
+          plugin.hook('related-changes-section').getLastAttached()
+              .then(el => hookEl = el);
+        },
+        '0.1',
+        'http://some/plugins/url1.html');
+    pluginLoader.loadPlugins([]);
+    flush(() => {
+      assert.strictEqual(hookEl.plugin, plugin);
+      assert.strictEqual(hookEl.change, element.change);
+      done();
+    });
+  });
+
+  test('hiding and unhiding', done => {
+    element.change = {labels: {}};
+    let hookEl;
+    let plugin;
+
+    // No changes, and no plugin. The element is still hidden.
+    element._resultsChanged({}, {}, [], [], []);
+    assert.isTrue(element.hidden);
+    pluginApi.install(
+        p => {
+          plugin = p;
+          plugin.hook('related-changes-section').getLastAttached()
+              .then(el => hookEl = el);
+        },
+        '0.1',
+        'http://some/plugins/url2.html');
+    pluginLoader.loadPlugins([]);
+    flush(() => {
+      // No changes, and plugin without hidden attribute. So it's visible.
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isFalse(element.hidden);
+
+      // No changes, but plugin with true hidden attribute. So it's invisible.
+      hookEl.hidden = true;
+
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isTrue(element.hidden);
+
+      // No changes, and plugin with false hidden attribute. So it's visible.
+      hookEl.hidden = false;
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isFalse(element.hidden);
+
+      // Hiding triggered by plugin itself
+      hookEl.hidden = true;
+      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+        composed: true, bubbles: true,
+      }));
+      assert.isTrue(element.hidden);
+
+      // Unhiding triggered by plugin itself
+      hookEl.hidden = false;
+      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+        composed: true, bubbles: true,
+      }));
+      assert.isFalse(element.hidden);
+
+      // Hiding plugin keeps list visible, if there are changes
+      hookEl.hidden = false;
+      element._sameTopic = ['test'];
+      element._resultsChanged({}, {}, [], [], ['test']);
+      assert.isFalse(element.hidden);
+      hookEl.hidden = true;
+      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+        composed: true, bubbles: true,
+      }));
+      assert.isFalse(element.hidden);
+
+      done();
+    });
+  });
+});
 </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..d3232e9 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="gr-reply-dialog.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -42,126 +37,139 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-reply-dialog tests', () => {
-    let element;
-    let changeNum;
-    let patchNum;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../plugins/gr-plugin-host/gr-plugin-host.js';
+import './gr-reply-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    let sandbox;
+_testOnly_initGerritPluginApi();
 
-    const setupElement = element => {
-      element.change = {
-        _number: changeNum,
-        labels: {
-          'Verified': {
-            values: {
-              '-1': 'Fails',
-              ' 0': 'No score',
-              '+1': 'Verified',
-            },
-            default_value: 0,
+suite('gr-reply-dialog tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
+
+  let sandbox;
+
+  const setupElement = element => {
+    element.change = {
+      _number: changeNum,
+      labels: {
+        'Verified': {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
           },
-          'Code-Review': {
-            values: {
-              '-2': 'Do not submit',
-              '-1': 'I would prefer that you didn\'t submit this',
-              ' 0': 'No score',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
-            },
-            all: [{_account_id: 42, value: 0}],
-            default_value: 0,
-          },
+          default_value: 0,
         },
-      };
-      element.patchNum = patchNum;
-      element.permittedLabels = {
-        'Code-Review': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
-      sandbox.stub(element, 'fetchChangeUpdates')
-          .returns(Promise.resolve({isLatest: true}));
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          all: [{_account_id: 42, value: 0}],
+          default_value: 0,
+        },
+      },
     };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    sandbox.stub(element, 'fetchChangeUpdates')
+        .returns(Promise.resolve({isLatest: true}));
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      changeNum = 42;
-      patchNum = 1;
+    changeNum = 42;
+    patchNum = 1;
 
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getAccount() { return Promise.resolve({_account_id: 42}); },
-      });
-
-      element = fixture('basic');
-      setupElement(element);
-
-      // Allow the elements created by dom-repeat to be stamped.
-      flushAsynchronousOperations();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({_account_id: 42}); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element = fixture('basic');
+    setupElement(element);
 
-    test('_submit blocked when invalid email is supplied to ccs', () => {
-      const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
-      // Stub the below function to avoid side effects from the send promise
-      // resolving.
-      sandbox.stub(element, '_purgeReviewersPendingRemove');
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
 
-      element.$.ccs.$.entry.setText('test');
-      MockInteractions.tap(element.$$('gr-button.send'));
-      assert.isFalse(sendStub.called);
-      flushAsynchronousOperations();
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      element.$.ccs.$.entry.setText('test@test.test');
-      MockInteractions.tap(element.$$('gr-button.send'));
-      assert.isTrue(sendStub.called);
-    });
+  test('_submit blocked when invalid email is supplied to ccs', () => {
+    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sandbox.stub(element, '_purgeReviewersPendingRemove');
 
-    test('lgtm plugin', done => {
-      Gerrit._testOnly_resetPlugins();
-      const pluginHost = fixture('plugin-host');
-      pluginHost.config = {
-        plugin: {
-          js_resource_paths: [],
-          html_resource_paths: [
-            new URL('test/plugin.html?' + Math.random(),
-                window.location.href).toString(),
-          ],
-        },
-      };
-      element = fixture('basic');
-      setupElement(element);
-      const importSpy =
-          sandbox.spy(element.$$('gr-endpoint-decorator'), '_import');
-      Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues).then(() => {
-          flush(() => {
-            const textarea = element.$.textarea.getNativeTextarea();
-            textarea.value = 'LGTM';
-            textarea.dispatchEvent(new CustomEvent(
-                'input', {bubbles: true, composed: true}));
-            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');
-            assert.isOk(selectedBtn);
-            done();
-          });
+    element.$.ccs.$.entry.setText('test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+    flushAsynchronousOperations();
+
+    element.$.ccs.$.entry.setText('test@test.test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('lgtm plugin', done => {
+    resetPlugins();
+    const pluginHost = fixture('plugin-host');
+    pluginHost.config = {
+      plugin: {
+        js_resource_paths: [],
+        html_resource_paths: [
+          new URL('test/plugin.html?' + Math.random(),
+              window.location.href).toString(),
+        ],
+      },
+    };
+    element = fixture('basic');
+    setupElement(element);
+    const importSpy =
+        sandbox.spy(element.shadowRoot
+            .querySelector('gr-endpoint-decorator'), '_import');
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      Promise.all(importSpy.returnValues).then(() => {
+        flush(() => {
+          const textarea = element.$.textarea.getNativeTextarea();
+          textarea.value = 'LGTM';
+          textarea.dispatchEvent(new CustomEvent(
+              'input', {bubbles: true, composed: true}));
+          const labelScoreRows = dom(element.$.labelScores.root)
+              .querySelector('gr-label-score-row[name="Code-Review"]');
+          const selectedBtn = dom(labelScoreRows.root)
+              .querySelector('gr-button[data-value="+1"].iron-selected');
+          assert.isOk(selectedBtn);
+          done();
         });
       });
     });
   });
+});
 </script>
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
deleted file mode 100644
index e836ccc..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ /dev/null
@@ -1,318 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.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="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../../shared/gr-account-list/gr-account-list.html">
-<link rel="import" href="../gr-label-scores/gr-label-scores.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../change/gr-comment-list/gr-comment-list.html">
-<script src="../../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js"></script>
-
-<dom-module id="gr-reply-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--dialog-background-color);
-        display: block;
-        max-height: 100%;
-      }
-      :host([disabled]) {
-        pointer-events: none;
-      }
-      :host([disabled]) .container {
-        opacity: .5;
-      }
-      .container {
-        display: flex;
-        flex-direction: column;
-        max-height: 100%;
-      }
-      section {
-        border-top: 1px solid var(--border-color);
-        flex-shrink: 0;
-        padding: var(--spacing-m) var(--spacing-xl);
-        width: 100%;
-      }
-      .actions {
-        background-color: var(--dialog-background-color);
-        bottom: 0;
-        display: flex;
-        justify-content: space-between;
-        position: sticky;
-        /* @see Issue 8602 */
-        z-index: 1;
-      }
-      .actions .right gr-button {
-        margin-left: var(--spacing-l);
-      }
-      .peopleContainer,
-      .labelsContainer {
-        flex-shrink: 0;
-      }
-      .peopleContainer {
-        border-top: none;
-        display: table;
-      }
-      .peopleList {
-        display: flex;
-        padding-top: var(--spacing-xxs);
-      }
-      .peopleListLabel {
-        color: var(--deemphasized-text-color);
-        margin-top: var(--spacing-xs);
-        min-width: 7em;
-        padding-right: var(--spacing-m);
-      }
-      gr-account-list {
-        display: flex;
-        flex-wrap: wrap;
-        flex: 1;
-        min-height: 1.8em;
-      }
-      #reviewerConfirmationOverlay {
-        padding: var(--spacing-l);
-        text-align: center;
-      }
-      .reviewerConfirmationButtons {
-        margin-top: var(--spacing-l);
-      }
-      .groupName {
-        font-weight: var(--font-weight-bold);
-      }
-      .groupSize {
-        font-style: italic;
-      }
-      .textareaContainer {
-        min-height: 12em;
-        position: relative;
-      }
-      .textareaContainer,
-      #textarea,
-      gr-endpoint-decorator {
-        display: flex;
-        width: 100%;
-      }
-      gr-endpoint-decorator[name="reply-label-scores"] {
-        display: block;
-      }
-      .previewContainer gr-formatted-text {
-        background: var(--table-header-background-color);
-        padding: var(--spacing-l);
-      }
-      .draftsContainer h3 {
-        margin-top: var(--spacing-xs);
-      }
-      #checkingStatusLabel,
-      #notLatestLabel {
-        margin-left: var(--spacing-l);
-      }
-      #checkingStatusLabel {
-        color: var(--deemphasized-text-color);
-        font-style: italic;
-      }
-      #notLatestLabel,
-      #savingLabel {
-        color: var(--error-text-color);
-      }
-      #savingLabel {
-        display: none;
-      }
-      #savingLabel.saving {
-        display: inline;
-      }
-      #pluginMessage {
-        color: var(--deemphasized-text-color);
-        margin-left: var(--spacing-l);
-        margin-bottom: var(--spacing-m);
-      }
-      #pluginMessage:empty {
-        display: none;
-      }
-    </style>
-    <div class="container" tabindex="-1">
-      <section class="peopleContainer">
-        <div class="peopleList">
-          <div class="peopleListLabel">Reviewers</div>
-          <gr-account-list
-              id="reviewers"
-              accounts="{{_reviewers}}"
-              removable-values="[[change.removable_reviewers]]"
-              filter="[[filterReviewerSuggestion]]"
-              pending-confirmation="{{_reviewerPendingConfirmation}}"
-              placeholder="Add reviewer..."
-              on-account-text-changed="_handleAccountTextEntry"
-              suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
-          </gr-account-list>
-        </div>
-        <div class="peopleList">
-          <div class="peopleListLabel">CC</div>
-          <gr-account-list
-              id="ccs"
-              accounts="{{_ccs}}"
-              filter="[[filterCCSuggestion]]"
-              pending-confirmation="{{_ccPendingConfirmation}}"
-              allow-any-input
-              placeholder="Add CC..."
-              on-account-text-changed="_handleAccountTextEntry"
-              suggestions-provider="[[_getCcSuggestionsProvider(change)]]">
-          </gr-account-list>
-        </div>
-        <gr-overlay
-            id="reviewerConfirmationOverlay"
-            on-iron-overlay-canceled="_cancelPendingReviewer">
-          <div class="reviewerConfirmation">
-            Group
-            <span class="groupName">
-              [[_pendingConfirmationDetails.group.name]]
-            </span>
-            has
-            <span class="groupSize">
-              [[_pendingConfirmationDetails.count]]
-            </span>
-            members.
-            <br>
-            Are you sure you want to add them all?
-          </div>
-          <div class="reviewerConfirmationButtons">
-            <gr-button on-click="_confirmPendingReviewer">Yes</gr-button>
-            <gr-button on-click="_cancelPendingReviewer">No</gr-button>
-          </div>
-        </gr-overlay>
-      </section>
-      <section class="textareaContainer">
-        <gr-endpoint-decorator name="reply-text">
-          <gr-textarea
-              id="textarea"
-              class="message"
-              autocomplete="on"
-              placeholder=[[_messagePlaceholder]]
-              fixed-position-dropdown
-              hide-border="true"
-              monospace="true"
-              disabled="{{disabled}}"
-              rows="4"
-              text="{{draft}}"
-              on-bind-value-changed="_handleHeightChanged">
-          </gr-textarea>
-        </gr-endpoint-decorator>
-      </section>
-      <section class="previewContainer">
-        <label>
-          <input type="checkbox" checked="{{_previewFormatting::change}}">
-          Preview formatting
-        </label>
-        <gr-formatted-text
-            content="[[draft]]"
-            hidden$="[[!_previewFormatting]]"
-            config="[[projectConfig.commentlinks]]"></gr-formatted-text>
-      </section>
-      <section class="labelsContainer">
-        <gr-endpoint-decorator name="reply-label-scores">
-          <gr-label-scores
-              id="labelScores"
-              account="[[_account]]"
-              change="[[change]]"
-              on-labels-changed="_handleLabelsChanged"
-              permitted-labels=[[permittedLabels]]></gr-label-scores>
-        </gr-endpoint-decorator>
-        <div id="pluginMessage">[[_pluginMessage]]</div>
-      </section>
-      <section class="draftsContainer" hidden$="[[_computeHideDraftList(diffDrafts)]]">
-        <div class="includeComments">
-          <input type="checkbox" id="includeComments"
-              checked="{{_includeComments::change}}">
-          <label for="includeComments">Publish [[_computeDraftsTitle(diffDrafts)]]</label>
-        </div>
-        <gr-comment-list
-            id="commentList"
-            comments="[[diffDrafts]]"
-            change-num="[[change._number]]"
-            project-config="[[projectConfig]]"
-            patch-num="[[patchNum]]"
-            hidden$="[[!_includeComments]]"></gr-comment-list>
-        <span
-            id="savingLabel"
-            class$="[[_computeSavingLabelClass(_savingComments)]]">
-          Saving comments...
-        </span>
-      </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')]]">
-            Checking whether patch [[patchNum]] is latest...
-          </span>
-          <span
-              id="notLatestLabel"
-              hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
-            [[_computePatchSetWarning(patchNum, _labelsChanged)]]
-            <gr-button link on-click="_reload">Reload</gr-button>
-          </span>
-        </div>
-        <div class="right">
-          <gr-button
-              link
-              id="cancelButton"
-              class="action cancel"
-              on-click="_cancelTapHandler">Cancel</gr-button>
-          <gr-button
-              id="sendButton"
-              link
-              primary
-              disabled="[[_sendDisabled]]"
-              class="action send"
-              has-tooltip
-              title$="[[_computeSendButtonTooltip(canBeStarted)]]"
-              on-click="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
-        </div>
-      </section>
-    </div>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-reply-dialog.js"></script>
-</dom-module>
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 da19e62..749f0ba 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,89 +14,133 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-textarea/gr-textarea.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-formatted-text/gr-formatted-text.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-storage/gr-storage.js';
+import '../../shared/gr-account-list/gr-account-list.js';
+import '../gr-label-scores/gr-label-scores.js';
+import '../gr-thread-list/gr-thread-list.js';
+import '../../../styles/shared-styles.js';
+import '../gr-comment-list/gr-comment-list.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-reply-dialog_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  const FocusTarget = {
-    ANY: 'any',
-    BODY: 'body',
-    CCS: 'cc',
-    REVIEWERS: 'reviewers',
-  };
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-  const ReviewerTypes = {
-    REVIEWER: 'REVIEWER',
-    CC: 'CC',
-  };
+const FocusTarget = {
+  ANY: 'any',
+  BODY: 'body',
+  CCS: 'cc',
+  REVIEWERS: 'reviewers',
+};
 
-  const LatestPatchState = {
-    LATEST: 'latest',
-    CHECKING: 'checking',
-    NOT_LATEST: 'not-latest',
-  };
+const ReviewerTypes = {
+  REVIEWER: 'REVIEWER',
+  CC: 'CC',
+};
 
-  const ButtonLabels = {
-    START_REVIEW: 'Start review',
-    SEND: 'Send',
-  };
+const LatestPatchState = {
+  LATEST: 'latest',
+  CHECKING: 'checking',
+  NOT_LATEST: 'not-latest',
+};
 
-  const ButtonTooltips = {
-    SAVE: 'Save reply but do not send notification',
-    START_REVIEW: 'Mark as ready for review and send reply',
-    SEND: 'Send reply',
-  };
+const ButtonLabels = {
+  START_REVIEW: 'Start review',
+  SEND: 'Send',
+};
 
-  const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
+const ButtonTooltips = {
+  SAVE: 'Save but do not send notification or change review state',
+  START_REVIEW: 'Mark as ready for review and send reply',
+  SEND: 'Send reply',
+};
 
-  const SEND_REPLY_TIMING_LABEL = 'SendReply';
+const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-  Polymer({
-    is: 'gr-reply-dialog',
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
 
+/**
+ * @extends Polymer.Element
+ */
+class GrReplyDialog extends mixinBehaviors( [
+  BaseUrlBehavior,
+  KeyboardShortcutBehavior,
+  PatchSetBehavior,
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-reply-dialog'; }
+  /**
+   * Fired when a reply is successfully sent.
+   *
+   * @event send
+   */
+
+  /**
+   * Fired when the user presses the cancel button.
+   *
+   * @event cancel
+   */
+
+  /**
+   * Fired when the main textarea's value changes, which may have triggered
+   * a change in size for the dialog.
+   *
+   * @event autogrow
+   */
+
+  /**
+   * Fires to show an alert when a send is attempted on the non-latest patch.
+   *
+   * @event show-alert
+   */
+
+  /**
+   * Fires when the reply dialog believes that the server side diff drafts
+   * have been updated and need to be refreshed.
+   *
+   * @event comment-refresh
+   */
+
+  /**
+   * Fires when the state of the send button (enabled/disabled) changes.
+   *
+   * @event send-disabled-changed
+   */
+
+  constructor() {
+    super();
+    this.FocusTarget = FocusTarget;
+  }
+
+  static get properties() {
+    return {
     /**
-     * Fired when a reply is successfully sent.
-     *
-     * @event send
+     * @type {{ _number: number, removable_reviewers: Array }}
      */
-
-    /**
-     * Fired when the user presses the cancel button.
-     *
-     * @event cancel
-     */
-
-    /**
-     * Fired when the main textarea's value changes, which may have triggered
-     * a change in size for the dialog.
-     *
-     * @event autogrow
-     */
-
-    /**
-     * Fires to show an alert when a send is attempted on the non-latest patch.
-     *
-     * @event show-alert
-     */
-
-    /**
-     * Fires when the reply dialog believes that the server side diff drafts
-     * have been updated and need to be refreshed.
-     *
-     * @event comment-refresh
-     */
-
-    /**
-     * Fires when the state of the send button (enabled/disabled) changes.
-     *
-     * @event send-disabled-changed
-     */
-
-    properties: {
-      /**
-       * @type {{ _number: number, removable_reviewers: Array }}
-       */
       change: Object,
       patchNum: String,
       canBeStarted: {
@@ -117,10 +161,6 @@
         type: String,
         value: '',
       },
-      diffDrafts: {
-        type: Object,
-        observer: '_handleHeightChanged',
-      },
       /** @type {!Function} */
       filterReviewerSuggestion: {
         type: Function,
@@ -204,665 +244,708 @@
         type: String,
         value: '',
       },
+      _commentEditing: {
+        type: Boolean,
+        value: false,
+      },
       _sendDisabled: {
         type: Boolean,
-        computed: '_computeSendButtonDisabled(_sendButtonLabel, diffDrafts, ' +
-            'draft, _reviewersMutated, _labelsChanged, _includeComments, ' +
-            'disabled)',
+        computed: '_computeSendButtonDisabled(canBeStarted, ' +
+          'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
+          '_includeComments, disabled, _commentEditing)',
         observer: '_sendDisabledChanged',
       },
-    },
+      draftCommentThreads: {
+        type: Array,
+        observer: '_handleHeightChanged',
+      },
+    };
+  }
 
-    FocusTarget,
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
-
-    keyBindings: {
+  get keyBindings() {
+    return {
       'esc': '_handleEscKey',
       'ctrl+enter meta+enter': '_handleEnterKey',
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_changeUpdated(change.reviewers.*, change.owner)',
       '_ccsChanged(_ccs.splices)',
       '_reviewersChanged(_reviewers.splices)',
-    ],
+    ];
+  }
 
-    attached() {
-      this._getAccount().then(account => {
-        this._account = account || {};
-      });
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this._getAccount().then(account => {
+      this._account = account || {};
+    });
 
-    ready() {
-      this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
-    },
+    this.addEventListener('comment-editing-changed', e => {
+      this._commentEditing = e.detail;
+    });
+  }
 
-    open(opt_focusTarget) {
-      this.knownLatestState = LatestPatchState.CHECKING;
-      this.fetchChangeUpdates(this.change, this.$.restAPI)
-          .then(result => {
-            this.knownLatestState = result.isLatest ?
-              LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
-          });
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
+  }
 
-      this._focusOn(opt_focusTarget);
-      if (this.quote && this.quote.length) {
-        // If a reply quote has been provided, use it and clear the property.
-        this.draft = this.quote;
-        this.quote = '';
-      } else {
-        // Otherwise, check for an unsaved draft in localstorage.
-        this.draft = this._loadStoredDraft();
-      }
-      if (this.$.restAPI.hasPendingDiffDrafts()) {
-        this._savingComments = true;
-        this.$.restAPI.awaitPendingDiffDrafts().then(() => {
-          this.fire('comment-refresh');
-          this._savingComments = false;
+  open(opt_focusTarget) {
+    this.knownLatestState = LatestPatchState.CHECKING;
+    this.fetchChangeUpdates(this.change, this.$.restAPI)
+        .then(result => {
+          this.knownLatestState = result.isLatest ?
+            LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
         });
-      }
-    },
 
-    focus() {
-      this._focusOn(FocusTarget.ANY);
-    },
-
-    getFocusStops() {
-      const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
-      return {
-        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 =
-          this.$.labelScores.$$(`gr-label-score-row[name="${label}"]`);
-      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) {
-        this._reviewersMutated = true;
-        this._processReviewerChange(splices.indexSplices,
-            reviewerType);
-        let key;
-        let index;
-        let account;
-        // Remove any accounts that already exist as a CC for reviewer
-        // or vice versa.
-        const isReviewer = ReviewerTypes.REVIEWER === reviewerType;
-        for (const splice of splices.indexSplices) {
-          for (let i = 0; i < splice.addedCount; i++) {
-            account = splice.object[splice.index + i];
-            key = this._accountOrGroupKey(account);
-            const array = isReviewer ? this._ccs : this._reviewers;
-            index = array.findIndex(
-                account => this._accountOrGroupKey(account) === key);
-            if (index >= 0) {
-              this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
-              const moveFrom = isReviewer ? 'CC' : 'reviewer';
-              const moveTo = isReviewer ? 'reviewer' : 'CC';
-              const message = (account.name || account.email || key) +
-                  ` moved from ${moveFrom} to ${moveTo}.`;
-              this.fire('show-alert', {message});
-            }
-          }
-        }
-      }
-    },
-
-    _processReviewerChange(indexSplices, type) {
-      for (const splice of indexSplices) {
-        for (const account of splice.removed) {
-          if (!this._reviewersPendingRemove[type]) {
-            console.err('Invalid type ' + type + ' for reviewer.');
-            return;
-          }
-          this._reviewersPendingRemove[type].push(account);
-        }
-      }
-    },
-
-    /**
-     * Resets the state of the _reviewersPendingRemove object, and removes
-     * accounts if necessary.
-     *
-     * @param {boolean} isCancel true if the action is a cancel.
-     * @param {Object=} opt_accountIdsTransferred map of account IDs that must
-     *     not be removed, because they have been readded in another state.
-     */
-    _purgeReviewersPendingRemove(isCancel, opt_accountIdsTransferred) {
-      let reviewerArr;
-      const keep = opt_accountIdsTransferred || {};
-      for (const type in this._reviewersPendingRemove) {
-        if (this._reviewersPendingRemove.hasOwnProperty(type)) {
-          if (!isCancel) {
-            reviewerArr = this._reviewersPendingRemove[type];
-            for (let i = 0; i < reviewerArr.length; i++) {
-              if (!keep[reviewerArr[i]._account_id]) {
-                this._removeAccount(reviewerArr[i], type);
-              }
-            }
-          }
-          this._reviewersPendingRemove[type] = [];
-        }
-      }
-    },
-
-    /**
-     * Removes an account from the change, both on the backend and the client.
-     * Does nothing if the account is a pending addition.
-     *
-     * @param {!Object} account
-     * @param {string} type
-     */
-    _removeAccount(account, type) {
-      if (account._pendingAdd) { return; }
-
-      return this.$.restAPI.removeChangeReviewer(this.change._number,
-          account._account_id).then(response => {
-        if (!response.ok) { return response; }
-
-        const reviewers = this.change.reviewers[type] || [];
-        for (let i = 0; i < reviewers.length; i++) {
-          if (reviewers[i]._account_id == account._account_id) {
-            this.splice(`change.reviewers.${type}`, i, 1);
-            break;
-          }
-        }
+    this._focusOn(opt_focusTarget);
+    if (this.quote && this.quote.length) {
+      // If a reply quote has been provided, use it and clear the property.
+      this.draft = this.quote;
+      this.quote = '';
+    } else {
+      // Otherwise, check for an unsaved draft in localstorage.
+      this.draft = this._loadStoredDraft();
+    }
+    if (this.$.restAPI.hasPendingDiffDrafts()) {
+      this._savingComments = true;
+      this.$.restAPI.awaitPendingDiffDrafts().then(() => {
+        this.dispatchEvent(new CustomEvent('comment-refresh', {
+          composed: true, bubbles: true,
+        }));
+        this._savingComments = false;
       });
-    },
+    }
+  }
 
-    _mapReviewer(reviewer) {
-      let reviewerId;
-      let confirmed;
+  focus() {
+    this._focusOn(FocusTarget.ANY);
+  }
+
+  getFocusStops() {
+    const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
+    return {
+      start: this.$.reviewers.focusStart,
+      end,
+    };
+  }
+
+  setLabelValue(label, value) {
+    const selectorEl =
+        this.$.labelScores.shadowRoot
+            .querySelector(`gr-label-score-row[name="${label}"]`);
+    if (!selectorEl) { return; }
+    selectorEl.setSelectedValue(value);
+  }
+
+  getLabelValue(label) {
+    const selectorEl =
+        this.$.labelScores.shadowRoot
+            .querySelector(`gr-label-score-row[name="${label}"]`);
+    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) {
+      this._reviewersMutated = true;
+      this._processReviewerChange(splices.indexSplices,
+          reviewerType);
+      let key;
+      let index;
+      let account;
+      // Remove any accounts that already exist as a CC for reviewer
+      // or vice versa.
+      const isReviewer = ReviewerTypes.REVIEWER === reviewerType;
+      for (const splice of splices.indexSplices) {
+        for (let i = 0; i < splice.addedCount; i++) {
+          account = splice.object[splice.index + i];
+          key = this._accountOrGroupKey(account);
+          const array = isReviewer ? this._ccs : this._reviewers;
+          index = array.findIndex(
+              account => this._accountOrGroupKey(account) === key);
+          if (index >= 0) {
+            this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
+            const moveFrom = isReviewer ? 'CC' : 'reviewer';
+            const moveTo = isReviewer ? 'reviewer' : 'CC';
+            const message = (account.name || account.email || key) +
+                ` moved from ${moveFrom} to ${moveTo}.`;
+            this.dispatchEvent(new CustomEvent('show-alert', {
+              detail: {message},
+              composed: true, bubbles: true,
+            }));
+          }
+        }
+      }
+    }
+  }
+
+  _processReviewerChange(indexSplices, type) {
+    for (const splice of indexSplices) {
+      for (const account of splice.removed) {
+        if (!this._reviewersPendingRemove[type]) {
+          console.err('Invalid type ' + type + ' for reviewer.');
+          return;
+        }
+        this._reviewersPendingRemove[type].push(account);
+      }
+    }
+  }
+
+  /**
+   * Resets the state of the _reviewersPendingRemove object, and removes
+   * accounts if necessary.
+   *
+   * @param {boolean} isCancel true if the action is a cancel.
+   * @param {Object=} opt_accountIdsTransferred map of account IDs that must
+   *     not be removed, because they have been readded in another state.
+   */
+  _purgeReviewersPendingRemove(isCancel, opt_accountIdsTransferred) {
+    let reviewerArr;
+    const keep = opt_accountIdsTransferred || {};
+    for (const type in this._reviewersPendingRemove) {
+      if (this._reviewersPendingRemove.hasOwnProperty(type)) {
+        if (!isCancel) {
+          reviewerArr = this._reviewersPendingRemove[type];
+          for (let i = 0; i < reviewerArr.length; i++) {
+            if (!keep[reviewerArr[i]._account_id]) {
+              this._removeAccount(reviewerArr[i], type);
+            }
+          }
+        }
+        this._reviewersPendingRemove[type] = [];
+      }
+    }
+  }
+
+  /**
+   * Removes an account from the change, both on the backend and the client.
+   * Does nothing if the account is a pending addition.
+   *
+   * @param {!Object} account
+   * @param {string} type
+   */
+  _removeAccount(account, type) {
+    if (account._pendingAdd) { return; }
+
+    return this.$.restAPI.removeChangeReviewer(this.change._number,
+        account._account_id).then(response => {
+      if (!response.ok) { return response; }
+
+      const reviewers = this.change.reviewers[type] || [];
+      for (let i = 0; i < reviewers.length; i++) {
+        if (reviewers[i]._account_id == account._account_id) {
+          this.splice(`change.reviewers.${type}`, i, 1);
+          break;
+        }
+      }
+    });
+  }
+
+  _mapReviewer(reviewer) {
+    let reviewerId;
+    let confirmed;
+    if (reviewer.account) {
+      reviewerId = reviewer.account._account_id || reviewer.account.email;
+    } else if (reviewer.group) {
+      reviewerId = decodeURIComponent(reviewer.group.id);
+      confirmed = reviewer.group.confirmed;
+    }
+    return {reviewer: reviewerId, confirmed};
+  }
+
+  send(includeComments, startReview) {
+    this.$.reporting.time(SEND_REPLY_TIMING_LABEL);
+    const labels = this.$.labelScores.getLabelValues();
+
+    const obj = {
+      drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
+      labels,
+    };
+
+    if (startReview) {
+      obj.ready = true;
+    }
+
+    if (this.draft != null) {
+      obj.message = this.draft;
+    }
+
+    const accountAdditions = {};
+    obj.reviewers = this.$.reviewers.additions().map(reviewer => {
       if (reviewer.account) {
-        reviewerId = reviewer.account._account_id || reviewer.account.email;
-      } else if (reviewer.group) {
-        reviewerId = decodeURIComponent(reviewer.group.id);
-        confirmed = reviewer.group.confirmed;
+        accountAdditions[reviewer.account._account_id] = true;
       }
-      return {reviewer: reviewerId, confirmed};
-    },
-
-    send(includeComments, startReview) {
-      this.$.reporting.time(SEND_REPLY_TIMING_LABEL);
-      const labels = this.$.labelScores.getLabelValues();
-
-      const obj = {
-        drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
-        labels,
-      };
-
-      if (startReview) {
-        obj.ready = true;
-      }
-
-      if (this.draft != null) {
-        obj.message = this.draft;
-      }
-
-      const accountAdditions = {};
-      obj.reviewers = this.$.reviewers.additions().map(reviewer => {
+      return this._mapReviewer(reviewer);
+    });
+    const ccsEl = this.$.ccs;
+    if (ccsEl) {
+      for (let reviewer of ccsEl.additions()) {
         if (reviewer.account) {
           accountAdditions[reviewer.account._account_id] = true;
         }
-        return this._mapReviewer(reviewer);
-      });
-      const ccsEl = this.$.ccs;
-      if (ccsEl) {
-        for (let reviewer of ccsEl.additions()) {
-          if (reviewer.account) {
-            accountAdditions[reviewer.account._account_id] = true;
+        reviewer = this._mapReviewer(reviewer);
+        reviewer.state = 'CC';
+        obj.reviewers.push(reviewer);
+      }
+    }
+
+    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 {};
           }
-          reviewer = this._mapReviewer(reviewer);
-          reviewer.state = 'CC';
-          obj.reviewers.push(reviewer);
-        }
-      }
+          if (!response.ok) {
+            this.dispatchEvent(new CustomEvent('server-error', {
+              detail: {response},
+              composed: true, bubbles: true,
+            }));
+            return {};
+          }
 
-      this.disabled = true;
+          this.draft = '';
+          this._includeComments = true;
+          this.dispatchEvent(new CustomEvent('send', {
+            composed: true, bubbles: false,
+          }));
+          return accountAdditions;
+        })
+        .then(result => {
+          this.disabled = false;
+          return result;
+        })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
+        });
+  }
 
-      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 {};
-        }
+  _focusOn(section) {
+    // Safeguard- always want to focus on something.
+    if (!section || section === FocusTarget.ANY) {
+      section = this._chooseFocusTarget();
+    }
+    if (section === FocusTarget.BODY) {
+      const textarea = this.$.textarea;
+      textarea.async(textarea.getNativeTextarea()
+          .focus.bind(textarea.getNativeTextarea()));
+    } else if (section === FocusTarget.REVIEWERS) {
+      const reviewerEntry = this.$.reviewers.focusStart;
+      reviewerEntry.async(reviewerEntry.focus);
+    } else if (section === FocusTarget.CCS) {
+      const ccEntry = this.$.ccs.focusStart;
+      ccEntry.async(ccEntry.focus);
+    }
+  }
 
-        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;
-      });
-    },
+  _chooseFocusTarget() {
+    // If we are the owner and the reviewers field is empty, focus on that.
+    if (this._account && this.change && this.change.owner &&
+        this._account._account_id === this.change.owner._account_id &&
+        (!this._reviewers || this._reviewers.length === 0)) {
+      return FocusTarget.REVIEWERS;
+    }
 
-    _focusOn(section) {
-      // Safeguard- always want to focus on something.
-      if (!section || section === FocusTarget.ANY) {
-        section = this._chooseFocusTarget();
-      }
-      if (section === FocusTarget.BODY) {
-        const textarea = this.$.textarea;
-        textarea.async(textarea.getNativeTextarea()
-            .focus.bind(textarea.getNativeTextarea()));
-      } else if (section === FocusTarget.REVIEWERS) {
-        const reviewerEntry = this.$.reviewers.focusStart;
-        reviewerEntry.async(reviewerEntry.focus);
-      } else if (section === FocusTarget.CCS) {
-        const ccEntry = this.$.ccs.focusStart;
-        ccEntry.async(ccEntry.focus);
-      }
-    },
+    // Default to BODY.
+    return FocusTarget.BODY;
+  }
 
-    _chooseFocusTarget() {
-      // If we are the owner and the reviewers field is empty, focus on that.
-      if (this._account && this.change && this.change.owner &&
-          this._account._account_id === this.change.owner._account_id &&
-          (!this._reviewers || this._reviewers.length === 0)) {
-        return FocusTarget.REVIEWERS;
-      }
+  _handle400Error(response) {
+    // A call to _saveReview could fail with a server error if erroneous
+    // reviewers were requested. This is signalled with a 400 Bad Request
+    // status. The default gr-rest-api-interface error handling would
+    // result in a large JSON response body being displayed to the user in
+    // the gr-error-manager toast.
+    //
+    // We can modify the error handling behavior by passing this function
+    // through to restAPI as a custom error handling function. Since we're
+    // short-circuiting restAPI we can do our own response parsing and fire
+    // the server-error ourselves.
+    //
+    this.disabled = false;
 
-      // Default to BODY.
-      return FocusTarget.BODY;
-    },
-
-    _handle400Error(response) {
-      // A call to _saveReview could fail with a server error if erroneous
-      // reviewers were requested. This is signalled with a 400 Bad Request
-      // status. The default gr-rest-api-interface error handling would
-      // result in a large JSON response body being displayed to the user in
-      // the gr-error-manager toast.
-      //
-      // We can modify the error handling behavior by passing this function
-      // through to restAPI as a custom error handling function. Since we're
-      // short-circuiting restAPI we can do our own response parsing and fire
-      // the server-error ourselves.
-      //
-      this.disabled = false;
-
-      // Using response.clone() here, because getResponseObject() and
-      // potentially the generic error handler will want to call text() on the
-      // response object, which can only be done once per object.
-      const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
-      return jsonPromise.then(result => {
-        // Only perform custom error handling for 400s and a parseable
-        // ReviewResult response.
-        if (response.status === 400 && result) {
-          const errors = [];
-          for (const state of ['reviewers', 'ccs']) {
-            if (!result.hasOwnProperty(state)) { continue; }
-            for (const reviewer of Object.values(result[state])) {
-              if (reviewer.error) {
-                errors.push(reviewer.error);
-              }
+    // Using response.clone() here, because getResponseObject() and
+    // potentially the generic error handler will want to call text() on the
+    // response object, which can only be done once per object.
+    const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
+    return jsonPromise.then(result => {
+      // Only perform custom error handling for 400s and a parseable
+      // ReviewResult response.
+      if (response.status === 400 && result) {
+        const errors = [];
+        for (const state of ['reviewers', 'ccs']) {
+          if (!result.hasOwnProperty(state)) { continue; }
+          for (const reviewer of Object.values(result[state])) {
+            if (reviewer.error) {
+              errors.push(reviewer.error);
             }
           }
-          response = {
-            ok: false,
-            status: response.status,
-            text() { return Promise.resolve(errors.join(', ')); },
-          };
         }
-        this.fire('server-error', {response});
-        return null; // Means that the error has been handled.
-      });
-    },
+        response = {
+          ok: false,
+          status: response.status,
+          text() { return Promise.resolve(errors.join(', ')); },
+        };
+      }
+      this.dispatchEvent(new CustomEvent('server-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+      return null; // Means that the error has been handled.
+    });
+  }
 
-    _computeHideDraftList(drafts) {
-      return Object.keys(drafts || {}).length == 0;
-    },
+  _computeHideDraftList(draftCommentThreads) {
+    return draftCommentThreads.length === 0;
+  }
 
-    _computeDraftsTitle(drafts) {
-      let total = 0;
-      for (const file in drafts) {
-        if (drafts.hasOwnProperty(file)) {
-          total += drafts[file].length;
+  _computeDraftsTitle(draftCommentThreads) {
+    const total = draftCommentThreads.length;
+    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
+    if ([changeRecord, owner].some(arg => arg === undefined)) {
+      return;
+    }
+
+    this._rebuildReviewerArrays(changeRecord.base, owner);
+  }
+
+  _rebuildReviewerArrays(change, owner) {
+    this._owner = owner;
+
+    const reviewers = [];
+    const ccs = [];
+
+    for (const key in change) {
+      if (change.hasOwnProperty(key)) {
+        if (key !== 'REVIEWER' && key !== 'CC') {
+          console.warn('unexpected reviewer state:', key);
+          continue;
         }
-      }
-      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
-      if ([changeRecord, owner].some(arg => arg === undefined)) {
-        return;
-      }
-
-      this._rebuildReviewerArrays(changeRecord.base, owner);
-    },
-
-    _rebuildReviewerArrays(change, owner) {
-      this._owner = owner;
-
-      const reviewers = [];
-      const ccs = [];
-
-      for (const key in change) {
-        if (change.hasOwnProperty(key)) {
-          if (key !== 'REVIEWER' && key !== 'CC') {
-            console.warn('unexpected reviewer state:', key);
+        for (const entry of change[key]) {
+          if (entry._account_id === owner._account_id) {
             continue;
           }
-          for (const entry of change[key]) {
-            if (entry._account_id === owner._account_id) {
-              continue;
-            }
-            switch (key) {
-              case 'REVIEWER':
-                reviewers.push(entry);
-                break;
-              case 'CC':
-                ccs.push(entry);
-                break;
-            }
+          switch (key) {
+            case 'REVIEWER':
+              reviewers.push(entry);
+              break;
+            case 'CC':
+              ccs.push(entry);
+              break;
           }
         }
       }
+    }
 
-      this._ccs = ccs;
-      this._reviewers = reviewers;
-    },
+    this._ccs = ccs;
+    this._reviewers = reviewers;
+  }
 
-    _accountOrGroupKey(entry) {
-      return entry.id || entry._account_id;
-    },
+  _accountOrGroupKey(entry) {
+    return entry.id || entry._account_id;
+  }
 
-    /**
-     * Generates a function to filter out reviewer/CC entries. When isCCs is
-     * truthy, the function filters out entries that already exist in this._ccs.
-     * When falsy, the function filters entries that exist in this._reviewers.
-     *
-     * @param {boolean} isCCs
-     * @return {!Function}
-     */
-    _filterReviewerSuggestionGenerator(isCCs) {
-      return suggestion => {
-        let entry;
-        if (suggestion.account) {
-          entry = suggestion.account;
-        } else if (suggestion.group) {
-          entry = suggestion.group;
-        } else {
-          console.warn(
-              'received suggestion that was neither account nor group:',
-              suggestion);
-        }
-        if (entry._account_id === this._owner._account_id) {
-          return false;
-        }
-
-        const key = this._accountOrGroupKey(entry);
-        const finder = entry => this._accountOrGroupKey(entry) === key;
-        if (isCCs) {
-          return this._ccs.find(finder) === undefined;
-        }
-        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) {
-      e.preventDefault();
-      if (!this.$.ccs.submitEntryText()) {
-        // Do not proceed with the save if there is an invalid email entry in
-        // the text field of the CC entry.
-        return;
-      }
-      this.send(this._includeComments, false).then(keepReviewers => {
-        this._purgeReviewersPendingRemove(false, keepReviewers);
-      });
-    },
-
-    _sendTapHandler(e) {
-      e.preventDefault();
-      this._submit();
-    },
-
-    _submit() {
-      if (!this.$.ccs.submitEntryText()) {
-        // Do not proceed with the send if there is an invalid email entry in
-        // the text field of the CC entry.
-        return;
-      }
-      if (this._sendDisabled) {
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          bubbles: true,
-          composed: true,
-          detail: {message: EMPTY_REPLY_MESSAGE},
-        }));
-        return;
-      }
-      return this.send(this._includeComments, this.canBeStarted)
-          .then(keepReviewers => {
-            this._purgeReviewersPendingRemove(false, keepReviewers);
-          })
-          .catch(err => {
-            this.dispatchEvent(new CustomEvent('show-error', {
-              bubbles: true,
-              composed: true,
-              detail: {message: `Error submitting review ${err}`},
-            }));
-          });
-    },
-
-    _saveReview(review, opt_errFn) {
-      return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
-          review, opt_errFn);
-    },
-
-    _reviewerPendingConfirmationUpdated(reviewer) {
-      if (reviewer === null) {
-        this.$.reviewerConfirmationOverlay.close();
+  /**
+   * Generates a function to filter out reviewer/CC entries. When isCCs is
+   * truthy, the function filters out entries that already exist in this._ccs.
+   * When falsy, the function filters entries that exist in this._reviewers.
+   *
+   * @param {boolean} isCCs
+   * @return {!Function}
+   */
+  _filterReviewerSuggestionGenerator(isCCs) {
+    return suggestion => {
+      let entry;
+      if (suggestion.account) {
+        entry = suggestion.account;
+      } else if (suggestion.group) {
+        entry = suggestion.group;
       } else {
-        this._pendingConfirmationDetails =
-            this._ccPendingConfirmation || this._reviewerPendingConfirmation;
-        this.$.reviewerConfirmationOverlay.open();
+        console.warn(
+            'received suggestion that was neither account nor group:',
+            suggestion);
       }
-    },
-
-    _confirmPendingReviewer() {
-      if (this._ccPendingConfirmation) {
-        this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
-        this._focusOn(FocusTarget.CCS);
-      } else {
-        this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
-        this._focusOn(FocusTarget.REVIEWERS);
-      }
-    },
-
-    _cancelPendingReviewer() {
-      this._ccPendingConfirmation = null;
-      this._reviewerPendingConfirmation = null;
-
-      const target =
-          this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
-      this._focusOn(target);
-    },
-
-    _getStorageLocation() {
-      // Tests trigger this method without setting change.
-      if (!this.change) { return {}; }
-      return {
-        changeNum: this.change._number,
-        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,
-      // it should trigger the save button to enable/
-      //
-      // Note: if the text is removed, the save button will not get disabled.
-      this._reviewersMutated = true;
-    },
-
-    _draftChanged(newDraft, oldDraft) {
-      this.debounce('store', () => {
-        if (!newDraft.length && oldDraft) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.$.storage.eraseDraftComment(this._getStorageLocation());
-        } else if (newDraft.length) {
-          this.$.storage.setDraftComment(this._getStorageLocation(),
-              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.
-      Gerrit.Nav.navigateToChange(this.change);
-      this.cancel();
-    },
-
-    _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) {
-      // Polymer 2: check for undefined
-      if ([
-        buttonLabel,
-        drafts,
-        text,
-        reviewersMutated,
-        labelsChanged,
-        includeComments,
-        disabled,
-      ].some(arg => arg === undefined)) {
-        return undefined;
+      if (entry._account_id === this._owner._account_id) {
+        return false;
       }
 
-      if (disabled) { return true; }
-      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.`;
-      if (labelsChanged) {
-        str += ' Voting on a non-latest patch will have no effect.';
+      const key = this._accountOrGroupKey(entry);
+      const finder = entry => this._accountOrGroupKey(entry) === key;
+      if (isCCs) {
+        return this._ccs.find(finder) === undefined;
       }
-      return str;
-    },
+      return this._reviewers.find(finder) === undefined;
+    };
+  }
 
-    setPluginMessage(message) {
-      this._pluginMessage = message;
-    },
+  _getAccount() {
+    return this.$.restAPI.getAccount();
+  }
 
-    _sendDisabledChanged(sendDisabled) {
-      this.dispatchEvent(new CustomEvent('send-disabled-changed'));
-    },
+  _cancelTapHandler(e) {
+    e.preventDefault();
+    this.cancel();
+  }
 
-    _getReviewerSuggestionsProvider(change) {
-      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      provider.init();
-      return provider;
-    },
+  cancel() {
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, bubbles: false,
+    }));
+    this.$.textarea.closeDropdown();
+    this._purgeReviewersPendingRemove(true);
+    this._rebuildReviewerArrays(this.change.reviewers, this._owner);
+  }
 
-    _getCcSuggestionsProvider(change) {
-      const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-          change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC);
-      provider.init();
-      return provider;
-    },
-  });
-})();
+  _saveClickHandler(e) {
+    e.preventDefault();
+    if (!this.$.ccs.submitEntryText()) {
+      // Do not proceed with the save if there is an invalid email entry in
+      // the text field of the CC entry.
+      return;
+    }
+    this.send(this._includeComments, false).then(keepReviewers => {
+      this._purgeReviewersPendingRemove(false, keepReviewers);
+    });
+  }
+
+  _sendTapHandler(e) {
+    e.preventDefault();
+    this._submit();
+  }
+
+  _submit() {
+    if (!this.$.ccs.submitEntryText()) {
+      // Do not proceed with the send if there is an invalid email entry in
+      // the text field of the CC entry.
+      return;
+    }
+    if (this._sendDisabled) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        bubbles: true,
+        composed: true,
+        detail: {message: EMPTY_REPLY_MESSAGE},
+      }));
+      return;
+    }
+    return this.send(this._includeComments, this.canBeStarted)
+        .then(keepReviewers => {
+          this._purgeReviewersPendingRemove(false, keepReviewers);
+        })
+        .catch(err => {
+          this.dispatchEvent(new CustomEvent('show-error', {
+            bubbles: true,
+            composed: true,
+            detail: {message: `Error submitting review ${err}`},
+          }));
+        });
+  }
+
+  _saveReview(review, opt_errFn) {
+    return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
+        review, opt_errFn);
+  }
+
+  _reviewerPendingConfirmationUpdated(reviewer) {
+    if (reviewer === null) {
+      this.$.reviewerConfirmationOverlay.close();
+    } else {
+      this._pendingConfirmationDetails =
+          this._ccPendingConfirmation || this._reviewerPendingConfirmation;
+      this.$.reviewerConfirmationOverlay.open();
+    }
+  }
+
+  _confirmPendingReviewer() {
+    if (this._ccPendingConfirmation) {
+      this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
+      this._focusOn(FocusTarget.CCS);
+    } else {
+      this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
+      this._focusOn(FocusTarget.REVIEWERS);
+    }
+  }
+
+  _cancelPendingReviewer() {
+    this._ccPendingConfirmation = null;
+    this._reviewerPendingConfirmation = null;
+
+    const target =
+        this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
+    this._focusOn(target);
+  }
+
+  _getStorageLocation() {
+    // Tests trigger this method without setting change.
+    if (!this.change) { return {}; }
+    return {
+      changeNum: this.change._number,
+      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,
+    // it should trigger the save button to enable/
+    //
+    // Note: if the text is removed, the save button will not get disabled.
+    this._reviewersMutated = true;
+  }
+
+  _draftChanged(newDraft, oldDraft) {
+    this.debounce('store', () => {
+      if (!newDraft.length && oldDraft) {
+        // If the draft has been modified to be empty, then erase the storage
+        // entry.
+        this.$.storage.eraseDraftComment(this._getStorageLocation());
+      } else if (newDraft.length) {
+        this.$.storage.setDraftComment(this._getStorageLocation(),
+            this.draft);
+      }
+    }, STORAGE_DEBOUNCE_INTERVAL_MS);
+  }
+
+  _handleHeightChanged(e) {
+    this.dispatchEvent(new CustomEvent('autogrow', {
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _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.
+    GerritNav.navigateToChange(this.change);
+    this.cancel();
+  }
+
+  _computeSendButtonLabel(canBeStarted) {
+    return canBeStarted ? ButtonLabels.SEND + ' and ' +
+        ButtonLabels.START_REVIEW : ButtonLabels.SEND;
+  }
+
+  _computeSendButtonTooltip(canBeStarted) {
+    return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
+  }
+
+  _computeSavingLabelClass(savingComments) {
+    return savingComments ? 'saving' : '';
+  }
+
+  _computeSendButtonDisabled(
+      canBeStarted, draftCommentThreads, text, reviewersMutated,
+      labelsChanged, includeComments, disabled, commentEditing) {
+    // Polymer 2: check for undefined
+    if ([
+      canBeStarted,
+      draftCommentThreads,
+      text,
+      reviewersMutated,
+      labelsChanged,
+      includeComments,
+      disabled,
+      commentEditing,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (commentEditing || disabled) { return true; }
+    if (canBeStarted === true) { return false; }
+    const hasDrafts = includeComments && draftCommentThreads.length;
+    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
+  }
+
+  _computePatchSetWarning(patchNum, labelsChanged) {
+    let str = `Patch ${patchNum} is not latest.`;
+    if (labelsChanged) {
+      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, SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
+    provider.init();
+    return provider;
+  }
+
+  _getCcSuggestionsProvider(change) {
+    const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
+        change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.CC);
+    provider.init();
+    return provider;
+  }
+
+  _onThreadListModified() {
+    // TODO(taoalpha): this won't propogate the changes to the files
+    // should consider replacing this with either top level events
+    // or gerrit level events
+
+    // emit the event so change-view can also get updated with latest changes
+    this.dispatchEvent(new CustomEvent('comment-refresh', {
+      composed: true, bubbles: true,
+    }));
+  }
+}
+
+customElements.define(GrReplyDialog.is, GrReplyDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
new file mode 100644
index 0000000..54fd47a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
@@ -0,0 +1,322 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--dialog-background-color);
+      display: block;
+      max-height: 90vh;
+    }
+    :host([disabled]) {
+      pointer-events: none;
+    }
+    :host([disabled]) .container {
+      opacity: 0.5;
+    }
+    .container {
+      display: flex;
+      flex-direction: column;
+      max-height: 100%;
+    }
+    section {
+      border-top: 1px solid var(--border-color);
+      flex-shrink: 0;
+      padding: var(--spacing-m) var(--spacing-xl);
+      width: 100%;
+    }
+    section.labelsContainer {
+      /* We want the :hover highlight to extend to the border of the dialog. */
+      padding: var(--spacing-m) 0;
+    }
+    .actions {
+      background-color: var(--dialog-background-color);
+      bottom: 0;
+      display: flex;
+      justify-content: space-between;
+      position: sticky;
+      /* @see Issue 8602 */
+      z-index: 1;
+    }
+    .actions .right gr-button {
+      margin-left: var(--spacing-l);
+    }
+    .peopleContainer,
+    .labelsContainer {
+      flex-shrink: 0;
+    }
+    .peopleContainer {
+      border-top: none;
+      display: table;
+    }
+    .peopleList {
+      display: flex;
+    }
+    .peopleListLabel {
+      color: var(--deemphasized-text-color);
+      margin-top: var(--spacing-xs);
+      min-width: 6em;
+      padding-right: var(--spacing-m);
+    }
+    gr-account-list {
+      display: flex;
+      flex-wrap: wrap;
+      flex: 1;
+    }
+    #reviewerConfirmationOverlay {
+      padding: var(--spacing-l);
+      text-align: center;
+    }
+    .reviewerConfirmationButtons {
+      margin-top: var(--spacing-l);
+    }
+    .groupName {
+      font-weight: var(--font-weight-bold);
+    }
+    .groupSize {
+      font-style: italic;
+    }
+    .textareaContainer {
+      min-height: 12em;
+      position: relative;
+    }
+    .textareaContainer,
+    #textarea,
+    gr-endpoint-decorator {
+      display: flex;
+      width: 100%;
+    }
+    gr-endpoint-decorator[name='reply-label-scores'] {
+      display: block;
+    }
+    .previewContainer gr-formatted-text {
+      background: var(--table-header-background-color);
+      padding: var(--spacing-l);
+    }
+    .draftsContainer h3 {
+      margin-top: var(--spacing-xs);
+    }
+    #checkingStatusLabel,
+    #notLatestLabel {
+      margin-left: var(--spacing-l);
+    }
+    #checkingStatusLabel {
+      color: var(--deemphasized-text-color);
+      font-style: italic;
+    }
+    #notLatestLabel,
+    #savingLabel {
+      color: var(--error-text-color);
+    }
+    #savingLabel {
+      display: none;
+    }
+    #savingLabel.saving {
+      display: inline;
+    }
+    #pluginMessage {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-l);
+      margin-bottom: var(--spacing-m);
+    }
+    #pluginMessage:empty {
+      display: none;
+    }
+  </style>
+  <div class="container" tabindex="-1">
+    <section class="peopleContainer">
+      <div class="peopleList">
+        <div class="peopleListLabel">Reviewers</div>
+        <gr-account-list
+          id="reviewers"
+          accounts="{{_reviewers}}"
+          removable-values="[[change.removable_reviewers]]"
+          filter="[[filterReviewerSuggestion]]"
+          pending-confirmation="{{_reviewerPendingConfirmation}}"
+          placeholder="Add reviewer..."
+          on-account-text-changed="_handleAccountTextEntry"
+          suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
+        >
+        </gr-account-list>
+      </div>
+      <div class="peopleList">
+        <div class="peopleListLabel">CC</div>
+        <gr-account-list
+          id="ccs"
+          accounts="{{_ccs}}"
+          filter="[[filterCCSuggestion]]"
+          pending-confirmation="{{_ccPendingConfirmation}}"
+          allow-any-input=""
+          placeholder="Add CC..."
+          on-account-text-changed="_handleAccountTextEntry"
+          suggestions-provider="[[_getCcSuggestionsProvider(change)]]"
+        >
+        </gr-account-list>
+      </div>
+      <gr-overlay
+        id="reviewerConfirmationOverlay"
+        on-iron-overlay-canceled="_cancelPendingReviewer"
+      >
+        <div class="reviewerConfirmation">
+          Group
+          <span class="groupName">
+            [[_pendingConfirmationDetails.group.name]]
+          </span>
+          has
+          <span class="groupSize">
+            [[_pendingConfirmationDetails.count]]
+          </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="reviewerConfirmationButtons">
+          <gr-button on-click="_confirmPendingReviewer">Yes</gr-button>
+          <gr-button on-click="_cancelPendingReviewer">No</gr-button>
+        </div>
+      </gr-overlay>
+    </section>
+    <section class="textareaContainer">
+      <gr-endpoint-decorator name="reply-text">
+        <gr-textarea
+          id="textarea"
+          class="message"
+          autocomplete="on"
+          placeholder="[[_messagePlaceholder]]"
+          fixed-position-dropdown=""
+          hide-border="true"
+          monospace="true"
+          disabled="{{disabled}}"
+          rows="4"
+          text="{{draft}}"
+          on-bind-value-changed="_handleHeightChanged"
+        >
+        </gr-textarea>
+      </gr-endpoint-decorator>
+    </section>
+    <section class="previewContainer">
+      <label>
+        <input type="checkbox" checked="{{_previewFormatting::change}}" />
+        Preview formatting
+      </label>
+      <gr-formatted-text
+        content="[[draft]]"
+        hidden$="[[!_previewFormatting]]"
+        config="[[projectConfig.commentlinks]]"
+      ></gr-formatted-text>
+    </section>
+    <section class="labelsContainer">
+      <gr-endpoint-decorator name="reply-label-scores">
+        <gr-label-scores
+          id="labelScores"
+          account="[[_account]]"
+          change="[[change]]"
+          on-labels-changed="_handleLabelsChanged"
+          permitted-labels="[[permittedLabels]]"
+        ></gr-label-scores>
+      </gr-endpoint-decorator>
+      <div id="pluginMessage">[[_pluginMessage]]</div>
+    </section>
+    <section
+      class="draftsContainer"
+      hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
+    >
+      <div class="includeComments">
+        <input
+          type="checkbox"
+          id="includeComments"
+          checked="{{_includeComments::change}}"
+        />
+        <label for="includeComments"
+          >Publish [[_computeDraftsTitle(draftCommentThreads)]]</label
+        >
+      </div>
+      <gr-thread-list
+        id="commentList"
+        hidden$="[[!_includeComments]]"
+        threads="[[draftCommentThreads]]"
+        change="[[change]]"
+        change-num="[[change._number]]"
+        logged-in="true"
+        hide-toggle-buttons=""
+        on-thread-list-modified="_onThreadListModified"
+      >
+      </gr-thread-list>
+      <span
+        id="savingLabel"
+        class$="[[_computeSavingLabelClass(_savingComments)]]"
+      >
+        Saving comments...
+      </span>
+    </section>
+    <section class="actions">
+      <div class="left">
+        <span
+          id="checkingStatusLabel"
+          hidden$="[[!_isState(knownLatestState, 'checking')]]"
+        >
+          Checking whether patch [[patchNum]] is latest...
+        </span>
+        <span
+          id="notLatestLabel"
+          hidden$="[[!_isState(knownLatestState, 'not-latest')]]"
+        >
+          [[_computePatchSetWarning(patchNum, _labelsChanged)]]
+          <gr-button link="" on-click="_reload">Reload</gr-button>
+        </span>
+      </div>
+      <div class="right">
+        <gr-button
+          link=""
+          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"
+            >Save</gr-button
+          >
+        </template>
+        <gr-button
+          id="sendButton"
+          primary=""
+          disabled="[[_sendDisabled]]"
+          class="action send"
+          has-tooltip=""
+          title$="[[_computeSendButtonTooltip(canBeStarted)]]"
+          on-click="_sendTapHandler"
+          >[[_sendButtonLabel]]</gr-button
+        >
+      </div>
+    </section>
+  </div>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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..5a61864 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-reply-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,114 +31,120 @@
   </template>
 </test-fixture>
 
-<script>
-  function cloneableResponse(status, text) {
-    return {
-      ok: false,
-      status,
-      text() {
-        return Promise.resolve(text);
-      },
-      clone() {
-        return {
-          ok: false,
-          status,
-          text() {
-            return Promise.resolve(text);
-          },
-        };
-      },
-    };
-  }
-
-  suite('gr-reply-dialog tests', () => {
-    let element;
-    let changeNum;
-    let patchNum;
-
-    let sandbox;
-    let getDraftCommentStub;
-    let setDraftCommentStub;
-    let eraseDraftCommentStub;
-
-    let lastId = 0;
-    const makeAccount = function() { return {_account_id: lastId++}; };
-    const makeGroup = function() { return {id: lastId++}; };
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      changeNum = 42;
-      patchNum = 1;
-
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getAccount() { return Promise.resolve({}); },
-        getChange() { return Promise.resolve({}); },
-        getChangeSuggestedReviewers() { return Promise.resolve([]); },
-      });
-
-      element = fixture('basic');
-      element.change = {
-        _number: changeNum,
-        labels: {
-          'Verified': {
-            values: {
-              '-1': 'Fails',
-              ' 0': 'No score',
-              '+1': 'Verified',
-            },
-            default_value: 0,
-          },
-          'Code-Review': {
-            values: {
-              '-2': 'Do not submit',
-              '-1': 'I would prefer that you didn\'t submit this',
-              ' 0': 'No score',
-              '+1': 'Looks good to me, but someone else must approve',
-              '+2': 'Looks good to me, approved',
-            },
-            default_value: 0,
-          },
+<script type="module">
+import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
+import '../../../test/common-test-setup.js';
+import './gr-reply-dialog.js';
+import {mockPromise} from '../../../test/test-utils.js';
+function cloneableResponse(status, text) {
+  return {
+    ok: false,
+    status,
+    text() {
+      return Promise.resolve(text);
+    },
+    clone() {
+      return {
+        ok: false,
+        status,
+        text() {
+          return Promise.resolve(text);
         },
       };
-      element.patchNum = patchNum;
-      element.permittedLabels = {
-        'Code-Review': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-        'Verified': [
-          '-1',
-          ' 0',
-          '+1',
-        ],
-      };
+    },
+  };
+}
 
-      getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      eraseDraftCommentStub = sandbox.stub(element.$.storage,
-          'eraseDraftComment');
+suite('gr-reply-dialog tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
 
-      sandbox.stub(element, 'fetchChangeUpdates')
-          .returns(Promise.resolve({isLatest: true}));
+  let sandbox;
+  let getDraftCommentStub;
+  let setDraftCommentStub;
+  let eraseDraftCommentStub;
 
-      // Allow the elements created by dom-repeat to be stamped.
-      flushAsynchronousOperations();
+  let lastId = 0;
+  const makeAccount = function() { return {_account_id: lastId++}; };
+  const makeGroup = function() { return {id: lastId++}; };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+
+    changeNum = 42;
+    patchNum = 1;
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({}); },
+      getChange() { return Promise.resolve({}); },
+      getChangeSuggestedReviewers() { return Promise.resolve([]); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element = fixture('basic');
+    element.change = {
+      _number: changeNum,
+      labels: {
+        'Verified': {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
 
-    function stubSaveReview(jsonResponseProducer) {
-      return sandbox.stub(element, '_saveReview', review => {
-        return new Promise((resolve, reject) => {
+    getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
+    setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
+    eraseDraftCommentStub = sandbox.stub(element.$.storage,
+        'eraseDraftComment');
+
+    sandbox.stub(element, 'fetchChangeUpdates')
+        .returns(Promise.resolve({isLatest: true}));
+
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  function stubSaveReview(jsonResponseProducer) {
+    return sandbox.stub(
+        element,
+        '_saveReview',
+        review => new Promise((resolve, reject) => {
           try {
             const result = jsonResponseProducer(review) || {};
             const resultStr =
-                element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+            element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
             resolve({
               ok: true,
               text() {
@@ -151,1042 +154,1152 @@
           } catch (err) {
             reject(err);
           }
-        });
-      });
-    }
+        }));
+  }
 
-    test('default to publishing drafts with reply', done => {
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+  test('default to publishing draft comments with reply', done => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    flush(() => {
       flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'PUBLISH_ALL_REVISIONS',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            message: 'I wholeheartedly disapprove',
+            reviewers: [],
+          });
+          assert.isFalse(element.$.commentList.hidden);
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
         flush(() => {
-          element.draft = 'I wholeheartedly disapprove';
-
-          stubSaveReview(review => {
-            assert.deepEqual(review, {
-              drafts: 'PUBLISH_ALL_REVISIONS',
-              labels: {
-                'Code-Review': 0,
-                'Verified': 0,
-              },
-              message: 'I wholeheartedly disapprove',
-              reviewers: [],
-            });
-            assert.isFalse(element.$.commentList.hidden);
-            done();
-          });
-
-          // This is needed on non-Blink engines most likely due to the ways in
-          // which the dom-repeat elements are stamped.
-          flush(() => {
-            MockInteractions.tap(element.$$('.send'));
-          });
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
         });
       });
     });
+  });
 
-    test('keep drafts with reply', done => {
-      MockInteractions.tap(element.$$('#includeComments'));
-      assert.equal(element._includeComments, false);
+  test('keep draft comments with reply', done => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
+    assert.equal(element._includeComments, false);
 
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    flush(() => {
       flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'KEEP',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            message: 'I wholeheartedly disapprove',
+            reviewers: [],
+          });
+          assert.isTrue(element.$.commentList.hidden);
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
         flush(() => {
-          element.draft = 'I wholeheartedly disapprove';
-
-          stubSaveReview(review => {
-            assert.deepEqual(review, {
-              drafts: 'KEEP',
-              labels: {
-                'Code-Review': 0,
-                'Verified': 0,
-              },
-              message: 'I wholeheartedly disapprove',
-              reviewers: [],
-            });
-            assert.isTrue(element.$.commentList.hidden);
-            done();
-          });
-
-          // This is needed on non-Blink engines most likely due to the ways in
-          // which the dom-repeat elements are stamped.
-          flush(() => {
-            MockInteractions.tap(element.$$('.send'));
-          });
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
         });
       });
     });
+  });
 
-    test('label picker', done => {
-      element.draft = 'I wholeheartedly disapprove';
-      stubSaveReview(review => {
-        assert.deepEqual(review, {
-          drafts: 'PUBLISH_ALL_REVISIONS',
-          labels: {
-            'Code-Review': -1,
-            'Verified': -1,
-          },
-          message: 'I wholeheartedly disapprove',
-          reviewers: [],
-        });
-      });
-
-      sandbox.stub(element.$.labelScores, 'getLabelValues', () => {
-        return {
+  test('label picker', done => {
+    element.draft = 'I wholeheartedly disapprove';
+    stubSaveReview(review => {
+      assert.deepEqual(review, {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {
           'Code-Review': -1,
           'Verified': -1,
-        };
+        },
+        message: 'I wholeheartedly disapprove',
+        reviewers: [],
+      });
+    });
+
+    sandbox.stub(element.$.labelScores, 'getLabelValues', () => {
+      return {
+        'Code-Review': -1,
+        'Verified': -1,
+      };
+    });
+
+    element.addEventListener('send', () => {
+      // Flush to ensure properties are updated.
+      flush(() => {
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done sending reply.');
+        assert.equal(element.draft.length, 0);
+        done();
+      });
+    });
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    flush(() => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+      assert.isTrue(element.disabled);
+    });
+  });
+
+  test('getlabelValue returns value', done => {
+    flush(() => {
+      element.shadowRoot
+          .querySelector('gr-label-scores')
+          .shadowRoot
+          .querySelector(`gr-label-score-row[name="Verified"]`)
+          .setSelectedValue(-1);
+      assert.equal('-1', element.getLabelValue('Verified'));
+      done();
+    });
+  });
+
+  test('getlabelValue when no score is selected', done => {
+    flush(() => {
+      element.shadowRoot
+          .querySelector('gr-label-scores')
+          .shadowRoot
+          .querySelector(`gr-label-score-row[name="Code-Review"]`)
+          .setSelectedValue(-1);
+      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
+      done();
+    });
+  });
+
+  test('setlabelValue', done => {
+    element._account = {_account_id: 1};
+    flush(() => {
+      const label = 'Verified';
+      const value = '+1';
+      element.setLabelValue(label, value);
+
+      const labels = element.$.labelScores.getLabelValues();
+      assert.deepEqual(labels, {
+        'Code-Review': 0,
+        'Verified': 1,
+      });
+      done();
+    });
+  });
+
+  function getActiveElement() {
+    return IronOverlayManager.deepActiveElement;
+  }
+
+  function isVisible(el) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') != 'none';
+  }
+
+  function overlayObserver(mode) {
+    return new Promise(resolve => {
+      function listener() {
+        element.removeEventListener('iron-overlay-' + mode, listener);
+        resolve();
+      }
+      element.addEventListener('iron-overlay-' + mode, listener);
+    });
+  }
+
+  function isFocusInsideElement(element) {
+    // In Polymer 2 focused element either <paper-input> or nested
+    // native input <input> element depending on the current focus
+    // in browser window.
+    // For example, the focus is changed if the developer console
+    // get a focus.
+    let activeElement = getActiveElement();
+    while (activeElement) {
+      if (activeElement === element) {
+        return true;
+      }
+      if (activeElement.parentElement) {
+        activeElement = activeElement.parentElement;
+      } else {
+        activeElement = activeElement.getRootNode().host;
+      }
+    }
+    return false;
+  }
+
+  function testConfirmationDialog(done, cc) {
+    const yesButton = element
+        .shadowRoot
+        .querySelector('.reviewerConfirmationButtons gr-button:first-child');
+    const noButton = element
+        .shadowRoot
+        .querySelector('.reviewerConfirmationButtons gr-button:last-child');
+
+    element._ccPendingConfirmation = null;
+    element._reviewerPendingConfirmation = null;
+    flushAsynchronousOperations();
+    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+
+    // Cause the confirmation dialog to display.
+    let observer = overlayObserver('opened');
+    const group = {
+      id: 'id',
+      name: 'name',
+    };
+    if (cc) {
+      element._ccPendingConfirmation = {
+        group,
+        count: 10,
+      };
+    } else {
+      element._reviewerPendingConfirmation = {
+        group,
+        count: 10,
+      };
+    }
+    flushAsynchronousOperations();
+
+    if (cc) {
+      assert.deepEqual(
+          element._ccPendingConfirmation,
+          element._pendingConfirmationDetails);
+    } else {
+      assert.deepEqual(
+          element._reviewerPendingConfirmation,
+          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));
+
+          // We should be focused on account entry input.
+          assert.isTrue(
+              isFocusInsideElement(
+                  element.$.reviewers.$.entry.$.input.$.input
+              )
+          );
+
+          // No reviewer/CC should have been added.
+          assert.equal(element.$.ccs.additions().length, 0);
+          assert.equal(element.$.reviewers.additions().length, 0);
+
+          // Reopen confirmation dialog.
+          observer = overlayObserver('opened');
+          if (cc) {
+            element._ccPendingConfirmation = {
+              group,
+              count: 10,
+            };
+          } else {
+            element._reviewerPendingConfirmation = {
+              group,
+              count: 10,
+            };
+          }
+          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);
+  }
+
+  test('cc confirmation', done => {
+    testConfirmationDialog(done, true);
+  });
+
+  test('reviewer confirmation', done => {
+    testConfirmationDialog(done, false);
+  });
+
+  test('_getStorageLocation', () => {
+    const actual = element._getStorageLocation();
+    assert.equal(actual.changeNum, changeNum);
+    assert.equal(actual.patchNum, '@change');
+    assert.equal(actual.path, '@change');
+  });
+
+  test('_reviewersMutated when account-text-change is fired from ccs', () => {
+    flushAsynchronousOperations();
+    assert.isFalse(element._reviewersMutated);
+    assert.isTrue(element.$.ccs.allowAnyInput);
+    assert.isFalse(element.shadowRoot
+        .querySelector('#reviewers').allowAnyInput);
+    element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
+        {bubbles: true, composed: true}));
+    assert.isTrue(element._reviewersMutated);
+  });
+
+  test('gets draft from storage on open', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('gets draft from storage even when text is already present', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('blank if no stored draft', () => {
+    getDraftCommentStub.returns(null);
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, '');
+  });
+
+  test('does not check stored draft when quote is present', () => {
+    const storedDraft = 'hello world';
+    const quote = '> foo bar';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.quote = quote;
+    element.open();
+    assert.isFalse(getDraftCommentStub.called);
+    assert.equal(element.draft, quote);
+    assert.isNotOk(element.quote);
+  });
+
+  test('updates stored draft on edits', () => {
+    const firstEdit = 'hello';
+    const location = element._getStorageLocation();
+
+    element.draft = firstEdit;
+    element.flushDebouncer('store');
+
+    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
+
+    element.draft = '';
+    element.flushDebouncer('store');
+
+    assert.isTrue(eraseDraftCommentStub.calledWith(location));
+  });
+
+  test('400 converts to human-readable server-error', done => {
+    sandbox.stub(window, 'fetch', () => {
+      const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
+        '"ccs":{"id2":{"error":"second error"}}}';
+      return Promise.resolve(cloneableResponse(400, text));
+    });
+
+    element.addEventListener('server-error', event => {
+      if (event.target !== element) {
+        return;
+      }
+      event.detail.response.text().then(body => {
+        assert.equal(body, 'first error, second error');
+        done();
+      });
+    });
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    flush(() => { element.send(); });
+  });
+
+  test('non-json 400 is treated as a normal server-error', done => {
+    sandbox.stub(window, 'fetch', () => {
+      const text = 'Comment validation error!';
+      return Promise.resolve(cloneableResponse(400, text));
+    });
+
+    element.addEventListener('server-error', event => {
+      if (event.target !== element) {
+        return;
+      }
+      event.detail.response.text().then(body => {
+        assert.equal(body, 'Comment validation error!');
+        done();
+      });
+    });
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    flush(() => { element.send(); });
+  });
+
+  test('filterReviewerSuggestion', () => {
+    const owner = makeAccount();
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeGroup();
+    const cc1 = makeAccount();
+    const cc2 = makeGroup();
+    let filter = element._filterReviewerSuggestionGenerator(false);
+
+    element._owner = owner;
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2];
+
+    assert.isTrue(filter({account: makeAccount()}));
+    assert.isTrue(filter({group: makeGroup()}));
+
+    // Owner should be excluded.
+    assert.isFalse(filter({account: owner}));
+
+    // Existing and pending reviewers should be excluded when isCC = false.
+    assert.isFalse(filter({account: reviewer1}));
+    assert.isFalse(filter({group: reviewer2}));
+
+    filter = element._filterReviewerSuggestionGenerator(true);
+
+    // Existing and pending CCs should be excluded when isCC = true;.
+    assert.isFalse(filter({account: cc1}));
+    assert.isFalse(filter({group: cc2}));
+  });
+
+  test('_focusOn', () => {
+    sandbox.spy(element, '_chooseFocusTarget');
+    flushAsynchronousOperations();
+    const textareaStub = sandbox.stub(element.$.textarea, 'async');
+    const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
+        'async');
+    const ccStub = sandbox.stub(element.$.ccs.focusStart, 'async');
+    element._focusOn();
+    assert.equal(element._chooseFocusTarget.callCount, 1);
+    assert.deepEqual(textareaStub.callCount, 1);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.ANY);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 2);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.BODY);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.REVIEWERS);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 1);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.CCS);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 1);
+    assert.deepEqual(ccStub.callCount, 1);
+  });
+
+  test('_chooseFocusTarget', () => {
+    element._account = null;
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element._account = {_account_id: 1};
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change.owner = {_account_id: 2};
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change.owner._account_id = 1;
+    element.change._reviewers = null;
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+    element._reviewers = [];
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+    element._reviewers.push({});
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+  });
+
+  test('only send labels that have changed', done => {
+    flush(() => {
+      stubSaveReview(review => {
+        assert.deepEqual(review.labels, {
+          'Code-Review': 0,
+          'Verified': -1,
+        });
       });
 
       element.addEventListener('send', () => {
-        // Flush to ensure properties are updated.
-        flush(() => {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done sending reply.');
-          assert.equal(element.draft.length, 0);
-          done();
-        });
-      });
-
-      // This is needed on non-Blink engines most likely due to the ways in
-      // which the dom-repeat elements are stamped.
-      flush(() => {
-        MockInteractions.tap(element.$$('.send'));
-        assert.isTrue(element.disabled);
-      });
-    });
-
-    test('getlabelValue returns value', done => {
-      flush(() => {
-        element.$$('gr-label-scores').$$(`gr-label-score-row[name="Verified"]`)
-            .setSelectedValue(-1);
-        assert.equal('-1', element.getLabelValue('Verified'));
         done();
       });
+      // Without wrapping this test in flush(), the below two calls to
+      // MockInteractions.tap() cause a race in some situations in shadow DOM.
+      // The send button can be tapped before the others, causing the test to
+      // fail.
+
+      element.shadowRoot
+          .querySelector('gr-label-scores').shadowRoot
+          .querySelector(
+              'gr-label-score-row[name="Verified"]')
+          .setSelectedValue(-1);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
     });
+  });
 
-    test('getlabelValue when no score is selected', done => {
-      flush(() => {
-        element.$$('gr-label-scores')
-            .$$(`gr-label-score-row[name="Code-Review"]`).setSelectedValue(-1);
-        assert.strictEqual(element.getLabelValue('Verified'), ' 0');
-        done();
-      });
-    });
+  test('_processReviewerChange', () => {
+    const mockIndexSplices = function(toRemove) {
+      return [{
+        removed: [toRemove],
+      }];
+    };
 
-    test('setlabelValue', done => {
-      element._account = {_account_id: 1};
-      flush(() => {
-        const label = 'Verified';
-        const value = '+1';
-        element.setLabelValue(label, value);
+    element._processReviewerChange(
+        mockIndexSplices(makeAccount()), 'REVIEWER');
+    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
+  });
 
-        const labels = element.$.labelScores.getLabelValues();
-        assert.deepEqual(labels, {
-          'Code-Review': 0,
-          'Verified': 1,
-        });
-        done();
-      });
-    });
-
-    function getActiveElement() {
-      return Polymer.IronOverlayManager.deepActiveElement;
-    }
-
-    function isVisible(el) {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') != 'none';
-    }
-
-    function overlayObserver(mode) {
-      return new Promise(resolve => {
-        function listener() {
-          element.removeEventListener('iron-overlay-' + mode, listener);
-          resolve();
-        }
-        element.addEventListener('iron-overlay-' + mode, listener);
-      });
-    }
-
-    function isFocusInsideElement(element) {
-      // In Polymer 2 focused element either <paper-input> or nested
-      // native input <input> element depending on the current focus
-      // in browser window.
-      // For example, the focus is changed if the developer console
-      // get a focus.
-      let activeElement = getActiveElement();
-      while (activeElement) {
-        if (activeElement === element) {
-          return true;
-        }
-        if (activeElement.parentElement) {
-          activeElement = activeElement.parentElement;
-        } else {
-          activeElement = activeElement.getRootNode().host;
-        }
-      }
-      return false;
-    }
-
-    function testConfirmationDialog(done, cc) {
-      const yesButton =
-          element.$$('.reviewerConfirmationButtons gr-button:first-child');
-      const noButton =
-          element.$$('.reviewerConfirmationButtons gr-button:last-child');
-
-      element._ccPendingConfirmation = null;
-      element._reviewerPendingConfirmation = null;
-      flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-
-      // Cause the confirmation dialog to display.
-      let observer = overlayObserver('opened');
-      const group = {
-        id: 'id',
-        name: 'name',
-      };
-      if (cc) {
-        element._ccPendingConfirmation = {
-          group,
-          count: 10,
-        };
-      } else {
-        element._reviewerPendingConfirmation = {
-          group,
-          count: 10,
-        };
-      }
-      flushAsynchronousOperations();
-
-      if (cc) {
-        assert.deepEqual(
-            element._ccPendingConfirmation,
-            element._pendingConfirmationDetails);
-      } else {
-        assert.deepEqual(
-            element._reviewerPendingConfirmation,
-            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));
-
-        // We should be focused on account entry input.
-        assert.isTrue(
-            isFocusInsideElement(element.$.reviewers.$.entry.$.input.$.input));
-
-        // No reviewer/CC should have been added.
-        assert.equal(element.$.ccs.additions().length, 0);
-        assert.equal(element.$.reviewers.additions().length, 0);
-
-        // Reopen confirmation dialog.
-        observer = overlayObserver('opened');
-        if (cc) {
-          element._ccPendingConfirmation = {
-            group,
-            count: 10,
-          };
-        } else {
-          element._reviewerPendingConfirmation = {
-            group,
-            count: 10,
-          };
-        }
-        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);
-    }
-
-    test('cc confirmation', done => {
-      testConfirmationDialog(done, true);
-    });
-
-    test('reviewer confirmation', done => {
-      testConfirmationDialog(done, false);
-    });
-
-    test('_getStorageLocation', () => {
-      const actual = element._getStorageLocation();
-      assert.equal(actual.changeNum, changeNum);
-      assert.equal(actual.patchNum, '@change');
-      assert.equal(actual.path, '@change');
-    });
-
-    test('_reviewersMutated when account-text-change is fired from ccs', () => {
-      flushAsynchronousOperations();
-      assert.isFalse(element._reviewersMutated);
-      assert.isTrue(element.$.ccs.allowAnyInput);
-      assert.isFalse(element.$$('#reviewers').allowAnyInput);
-      element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
-          {bubbles: true, composed: true}));
-      assert.isTrue(element._reviewersMutated);
-    });
-
-    test('gets draft from storage on open', () => {
-      const storedDraft = 'hello world';
-      getDraftCommentStub.returns({message: storedDraft});
-      element.open();
-      assert.isTrue(getDraftCommentStub.called);
-      assert.equal(element.draft, storedDraft);
-    });
-
-    test('gets draft from storage even when text is already present', () => {
-      const storedDraft = 'hello world';
-      getDraftCommentStub.returns({message: storedDraft});
-      element.draft = 'foo bar';
-      element.open();
-      assert.isTrue(getDraftCommentStub.called);
-      assert.equal(element.draft, storedDraft);
-    });
-
-    test('blank if no stored draft', () => {
-      getDraftCommentStub.returns(null);
-      element.draft = 'foo bar';
-      element.open();
-      assert.isTrue(getDraftCommentStub.called);
-      assert.equal(element.draft, '');
-    });
-
-    test('does not check stored draft when quote is present', () => {
-      const storedDraft = 'hello world';
-      const quote = '> foo bar';
-      getDraftCommentStub.returns({message: storedDraft});
-      element.quote = quote;
-      element.open();
-      assert.isFalse(getDraftCommentStub.called);
-      assert.equal(element.draft, quote);
-      assert.isNotOk(element.quote);
-    });
-
-    test('updates stored draft on edits', () => {
-      const firstEdit = 'hello';
-      const location = element._getStorageLocation();
-
-      element.draft = firstEdit;
-      element.flushDebouncer('store');
-
-      assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
-
-      element.draft = '';
-      element.flushDebouncer('store');
-
-      assert.isTrue(eraseDraftCommentStub.calledWith(location));
-    });
-
-    test('400 converts to human-readable server-error', done => {
-      sandbox.stub(window, 'fetch', () => {
-        const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
-          '"ccs":{"id2":{"error":"second error"}}}';
-        return Promise.resolve(cloneableResponse(400, text));
-      });
-
-      element.addEventListener('server-error', event => {
-        if (event.target !== element) {
-          return;
-        }
-        event.detail.response.text().then(body => {
-          assert.equal(body, 'first error, second error');
-          done();
-        });
-      });
-
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      flush(() => { element.send(); });
-    });
-
-    test('non-json 400 is treated as a normal server-error', done => {
-      sandbox.stub(window, 'fetch', () => {
-        const text = 'Comment validation error!';
-        return Promise.resolve(cloneableResponse(400, text));
-      });
-
-      element.addEventListener('server-error', event => {
-        if (event.target !== element) {
-          return;
-        }
-        event.detail.response.text().then(body => {
-          assert.equal(body, 'Comment validation error!');
-          done();
-        });
-      });
-
-      // Async tick is needed because iron-selector content is distributed and
-      // distributed content requires an observer to be set up.
-      flush(() => { element.send(); });
-    });
-
-    test('filterReviewerSuggestion', () => {
-      const owner = makeAccount();
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeGroup();
-      const cc1 = makeAccount();
-      const cc2 = makeGroup();
-      let filter = element._filterReviewerSuggestionGenerator(false);
-
-      element._owner = owner;
-      element._reviewers = [reviewer1, reviewer2];
-      element._ccs = [cc1, cc2];
-
-      assert.isTrue(filter({account: makeAccount()}));
-      assert.isTrue(filter({group: makeGroup()}));
-
-      // Owner should be excluded.
-      assert.isFalse(filter({account: owner}));
-
-      // Existing and pending reviewers should be excluded when isCC = false.
-      assert.isFalse(filter({account: reviewer1}));
-      assert.isFalse(filter({group: reviewer2}));
-
-      filter = element._filterReviewerSuggestionGenerator(true);
-
-      // Existing and pending CCs should be excluded when isCC = true;.
-      assert.isFalse(filter({account: cc1}));
-      assert.isFalse(filter({group: cc2}));
-    });
-
-    test('_focusOn', () => {
-      sandbox.spy(element, '_chooseFocusTarget');
-      flushAsynchronousOperations();
-      const textareaStub = sandbox.stub(element.$.textarea, 'async');
-      const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
-          'async');
-      const ccStub = sandbox.stub(element.$.ccs.focusStart, 'async');
-      element._focusOn();
-      assert.equal(element._chooseFocusTarget.callCount, 1);
-      assert.deepEqual(textareaStub.callCount, 1);
-      assert.deepEqual(reviewerEntryStub.callCount, 0);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.ANY);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 2);
-      assert.deepEqual(reviewerEntryStub.callCount, 0);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.BODY);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 3);
-      assert.deepEqual(reviewerEntryStub.callCount, 0);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.REVIEWERS);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 3);
-      assert.deepEqual(reviewerEntryStub.callCount, 1);
-      assert.deepEqual(ccStub.callCount, 0);
-
-      element._focusOn(element.FocusTarget.CCS);
-      assert.equal(element._chooseFocusTarget.callCount, 2);
-      assert.deepEqual(textareaStub.callCount, 3);
-      assert.deepEqual(reviewerEntryStub.callCount, 1);
-      assert.deepEqual(ccStub.callCount, 1);
-    });
-
-    test('_chooseFocusTarget', () => {
-      element._account = null;
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-      element._account = {_account_id: 1};
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-      element.change.owner = {_account_id: 2};
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-      element.change.owner._account_id = 1;
-      element.change._reviewers = null;
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-      element._reviewers = [];
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-      element._reviewers.push({});
-      assert.strictEqual(
-          element._chooseFocusTarget(), element.FocusTarget.BODY);
-    });
-
-    test('only send labels that have changed', done => {
-      flush(() => {
-        stubSaveReview(review => {
-          assert.deepEqual(review.labels, {
-            'Code-Review': 0,
-            'Verified': -1,
-          });
-        });
-
-        element.addEventListener('send', () => {
-          done();
-        });
-        // Without wrapping this test in flush(), the below two calls to
-        // MockInteractions.tap() cause a race in some situations in shadow DOM.
-        // The send button can be tapped before the others, causing the test to
-        // fail.
-
-        element.$$('gr-label-scores').$$(
-            'gr-label-score-row[name="Verified"]').setSelectedValue(-1);
-        MockInteractions.tap(element.$$('.send'));
-      });
-    });
-
-    test('_processReviewerChange', () => {
-      const mockIndexSplices = function(toRemove) {
-        return [{
-          removed: [toRemove],
-        }];
-      };
-
-      element._processReviewerChange(
-          mockIndexSplices(makeAccount()), 'REVIEWER');
-      assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
-    });
-
-    test('_purgeReviewersPendingRemove', () => {
-      const removeStub = sandbox.stub(element, '_removeAccount');
-      const mock = function() {
-        element._reviewersPendingRemove = {
-          test: [makeAccount()],
-          test2: [makeAccount(), makeAccount()],
-        };
-      };
-      const checkObjEmpty = function(obj) {
-        for (const prop in obj) {
-          if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
-        }
-        return true;
-      };
-      mock();
-      element._purgeReviewersPendingRemove(true); // Cancel
-      assert.isFalse(removeStub.called);
-      assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-
-      mock();
-      element._purgeReviewersPendingRemove(false); // Submit
-      assert.isTrue(removeStub.called);
-      assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-    });
-
-    test('_removeAccount', done => {
-      sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
-          .returns(Promise.resolve({ok: true}));
-      const arr = [makeAccount(), makeAccount()];
-      element.change.reviewers = {
-        REVIEWER: arr.slice(),
-      };
-
-      element._removeAccount(arr[1], 'REVIEWER').then(() => {
-        assert.equal(element.change.reviewers.REVIEWER.length, 1);
-        assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
-        done();
-      });
-    });
-
-    test('moving from cc to reviewer', () => {
+  test('_purgeReviewersPendingRemove', () => {
+    const removeStub = sandbox.stub(element, '_removeAccount');
+    const mock = function() {
       element._reviewersPendingRemove = {
-        CC: [],
-        REVIEWER: [],
+        test: [makeAccount()],
+        test2: [makeAccount(), makeAccount()],
       };
-      flushAsynchronousOperations();
+    };
+    const checkObjEmpty = function(obj) {
+      for (const prop in obj) {
+        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+      }
+      return true;
+    };
+    mock();
+    element._purgeReviewersPendingRemove(true); // Cancel
+    assert.isFalse(removeStub.called);
+    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
 
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeAccount();
-      const reviewer3 = makeAccount();
-      const cc1 = makeAccount();
-      const cc2 = makeAccount();
-      const cc3 = makeAccount();
-      const cc4 = makeAccount();
-      element._reviewers = [reviewer1, reviewer2, reviewer3];
-      element._ccs = [cc1, cc2, cc3, cc4];
-      element.push('_reviewers', cc1);
-      flushAsynchronousOperations();
+    mock();
+    element._purgeReviewersPendingRemove(false); // Submit
+    assert.isTrue(removeStub.called);
+    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
+  });
 
-      assert.deepEqual(element._reviewers,
-          [reviewer1, reviewer2, reviewer3, cc1]);
-      assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
-      assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
+  test('_removeAccount', done => {
+    sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
+        .returns(Promise.resolve({ok: true}));
+    const arr = [makeAccount(), makeAccount()];
+    element.change.reviewers = {
+      REVIEWER: arr.slice(),
+    };
 
-      element.push('_reviewers', cc4, cc3);
-      flushAsynchronousOperations();
+    element._removeAccount(arr[1], 'REVIEWER').then(() => {
+      assert.equal(element.change.reviewers.REVIEWER.length, 1);
+      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
+      done();
+    });
+  });
 
-      assert.deepEqual(element._reviewers,
-          [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
-      assert.deepEqual(element._ccs, [cc2]);
-      assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
+  test('moving from cc to reviewer', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_reviewers', cc1);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1]);
+    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
+
+    element.push('_reviewers', cc4, cc3);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
+    assert.deepEqual(element._ccs, [cc2]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
+  });
+
+  test('moving from reviewer to cc', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_ccs', reviewer1);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer2, reviewer3]);
+    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
+
+    element.push('_ccs', reviewer3, reviewer2);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers, []);
+    assert.deepEqual(element._ccs,
+        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
+        [reviewer1, reviewer3, reviewer2]);
+  });
+
+  test('migrate reviewers between states', done => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+    const reviewers = element.$.reviewers;
+    const ccs = element.$.ccs;
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2, cc3];
+
+    const mutations = [];
+
+    stubSaveReview(review => mutations.push(...review.reviewers));
+
+    sandbox.stub(element, '_removeAccount', (account, type) => {
+      mutations.push({state: 'REMOVED', account});
+      return Promise.resolve();
     });
 
-    test('moving from reviewer to cc', () => {
-      element._reviewersPendingRemove = {
-        CC: [],
-        REVIEWER: [],
-      };
-      flushAsynchronousOperations();
+    // Remove and add to other field.
+    reviewers.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: reviewer1},
+          composed: true, bubbles: true,
+        }));
+    ccs.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: reviewer1}},
+          composed: true, bubbles: true,
+        }));
+    ccs.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: cc1},
+          composed: true, bubbles: true,
+        }));
+    ccs.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: cc3},
+          composed: true, bubbles: true,
+        }));
+    reviewers.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: cc1}},
+          composed: true, bubbles: true,
+        }));
 
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeAccount();
-      const reviewer3 = makeAccount();
-      const cc1 = makeAccount();
-      const cc2 = makeAccount();
-      const cc3 = makeAccount();
-      const cc4 = makeAccount();
-      element._reviewers = [reviewer1, reviewer2, reviewer3];
-      element._ccs = [cc1, cc2, cc3, cc4];
-      element.push('_ccs', reviewer1);
-      flushAsynchronousOperations();
+    // Add to other field without removing from former field.
+    // (Currently not possible in UI, but this is a good consistency check).
+    reviewers.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: cc2}},
+          composed: true, bubbles: true,
+        }));
+    ccs.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: reviewer2}},
+          composed: true, bubbles: true,
+        }));
+    const mapReviewer = function(reviewer, opt_state) {
+      const result = {reviewer: reviewer._account_id, confirmed: undefined};
+      if (opt_state) {
+        result.state = opt_state;
+      }
+      return result;
+    };
 
-      assert.deepEqual(element._reviewers,
-          [reviewer2, reviewer3]);
-      assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
-      assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
-
-      element.push('_ccs', reviewer3, reviewer2);
-      flushAsynchronousOperations();
-
-      assert.deepEqual(element._reviewers, []);
-      assert.deepEqual(element._ccs,
-          [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
-      assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
-          [reviewer1, reviewer3, reviewer2]);
-    });
-
-    test('migrate reviewers between states', done => {
-      element._reviewersPendingRemove = {
-        CC: [],
-        REVIEWER: [],
-      };
-      flushAsynchronousOperations();
-      const reviewers = element.$.reviewers;
-      const ccs = element.$.ccs;
-      const reviewer1 = makeAccount();
-      const reviewer2 = makeAccount();
-      const cc1 = makeAccount();
-      const cc2 = makeAccount();
-      const cc3 = makeAccount();
-      element._reviewers = [reviewer1, reviewer2];
-      element._ccs = [cc1, cc2, cc3];
-
-      const mutations = [];
-
-      stubSaveReview(review => mutations.push(...review.reviewers));
-
-      sandbox.stub(element, '_removeAccount', (account, type) => {
-        mutations.push({state: 'REMOVED', account});
-        return Promise.resolve();
-      });
-
-      // Remove and add to other field.
-      reviewers.fire('remove', {account: reviewer1});
-      ccs.$.entry.fire('add', {value: {account: reviewer1}});
-      ccs.fire('remove', {account: cc1});
-      ccs.fire('remove', {account: cc3});
-      reviewers.$.entry.fire('add', {value: {account: cc1}});
-
-      // Add to other field without removing from former field.
-      // (Currently not possible in UI, but this is a good consistency check).
-      reviewers.$.entry.fire('add', {value: {account: cc2}});
-      ccs.$.entry.fire('add', {value: {account: reviewer2}});
-      const mapReviewer = function(reviewer, opt_state) {
-        const result = {reviewer: reviewer._account_id, confirmed: undefined};
-        if (opt_state) {
-          result.state = opt_state;
-        }
-        return result;
-      };
-
-      // Send and purge and verify moves, delete cc3.
-      element.send()
-          .then(keepReviewers =>
-            element._purgeReviewersPendingRemove(false, keepReviewers))
-          .then(() => {
-            assert.deepEqual(
-                mutations, [
-                  mapReviewer(cc1),
-                  mapReviewer(cc2),
-                  mapReviewer(reviewer1, 'CC'),
-                  mapReviewer(reviewer2, 'CC'),
-                  {account: cc3, state: 'REMOVED'},
-                ]);
-            done();
-          });
-    });
-
-    test('emits cancel on esc key', () => {
-      const cancelHandler = sandbox.spy();
-      element.addEventListener('cancel', cancelHandler);
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-      flushAsynchronousOperations();
-
-      assert.isTrue(cancelHandler.called);
-    });
-
-    test('should not send on enter key', () => {
-      stubSaveReview(() => undefined);
-      element.addEventListener('send', () => assert.fail('wrongly called'));
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      flushAsynchronousOperations();
-    });
-
-    test('emit send on ctrl+enter key', done => {
-      stubSaveReview(() => undefined);
-      element.addEventListener('send', () => done());
-      MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
-      flushAsynchronousOperations();
-    });
-
-    test('_computeMessagePlaceholder', () => {
-      assert.equal(
-          element._computeMessagePlaceholder(false),
-          'Say something nice...');
-      assert.equal(
-          element._computeMessagePlaceholder(true),
-          'Add a note for your reviewers...');
-    });
-
-    test('_computeSendButtonLabel', () => {
-      assert.equal(
-          element._computeSendButtonLabel(false),
-          'Send');
-      assert.equal(
-          element._computeSendButtonLabel(true),
-          'Start review');
-    });
-
-    test('_handle400Error reviewrs and CCs', done => {
-      const error1 = 'error 1';
-      const error2 = 'error 2';
-      const error3 = 'error 3';
-      const text = ')]}\'' + JSON.stringify({
-        reviewers: {
-          username1: {
-            input: 'user 1',
-            error: error1,
-          },
-          username2: {
-            input: 'user 2',
-            error: error2,
-          },
-        },
-        ccs: {
-          username3: {
-            input: 'user 3',
-            error: error3,
-          },
-        },
-      });
-      element.addEventListener('server-error', e => {
-        e.detail.response.text().then(text => {
-          assert.equal(text, [error1, error2, error3].join(', '));
+    // Send and purge and verify moves, delete cc3.
+    element.send()
+        .then(keepReviewers =>
+          element._purgeReviewersPendingRemove(false, keepReviewers))
+        .then(() => {
+          assert.deepEqual(
+              mutations, [
+                mapReviewer(cc1),
+                mapReviewer(cc2),
+                mapReviewer(reviewer1, 'CC'),
+                mapReviewer(reviewer2, 'CC'),
+                {account: cc3, state: 'REMOVED'},
+              ]);
           done();
         });
-      });
-      element._handle400Error(cloneableResponse(400, text));
-    });
+  });
 
-    test('_handle400Error CCs only', done => {
-      const error1 = 'error 1';
-      const text = ')]}\'' + JSON.stringify({
-        ccs: {
-          username1: {
-            input: 'user 1',
-            error: error1,
-          },
+  test('emits cancel on esc key', () => {
+    const cancelHandler = sandbox.spy();
+    element.addEventListener('cancel', cancelHandler);
+    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+    flushAsynchronousOperations();
+
+    assert.isTrue(cancelHandler.called);
+  });
+
+  test('should not send on enter key', () => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => assert.fail('wrongly called'));
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+    flushAsynchronousOperations();
+  });
+
+  test('emit send on ctrl+enter key', done => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => done());
+    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+    flushAsynchronousOperations();
+  });
+
+  test('_computeMessagePlaceholder', () => {
+    assert.equal(
+        element._computeMessagePlaceholder(false),
+        'Say something nice...');
+    assert.equal(
+        element._computeMessagePlaceholder(true),
+        'Add a note for your reviewers...');
+  });
+
+  test('_computeSendButtonLabel', () => {
+    assert.equal(
+        element._computeSendButtonLabel(false),
+        'Send');
+    assert.equal(
+        element._computeSendButtonLabel(true),
+        'Send and Start review');
+  });
+
+  test('_handle400Error reviewrs and CCs', done => {
+    const error1 = 'error 1';
+    const error2 = 'error 2';
+    const error3 = 'error 3';
+    const text = ')]}\'' + JSON.stringify({
+      reviewers: {
+        username1: {
+          input: 'user 1',
+          error: error1,
         },
-      });
-      element.addEventListener('server-error', e => {
-        e.detail.response.text().then(text => {
-          assert.equal(text, error1);
-          done();
-        });
-      });
-      element._handle400Error(cloneableResponse(400, text));
+        username2: {
+          input: 'user 2',
+          error: error2,
+        },
+      },
+      ccs: {
+        username3: {
+          input: 'user 3',
+          error: error3,
+        },
+      },
     });
+    element.addEventListener('server-error', e => {
+      e.detail.response.text().then(text => {
+        assert.equal(text, [error1, error2, error3].join(', '));
+        done();
+      });
+    });
+    element._handle400Error(cloneableResponse(400, text));
+  });
 
-    test('fires height change when the drafts load', done => {
-      // Flush DOM operations before binding to the autogrow event so we don't
-      // catch the events fired from the initial layout.
+  test('_handle400Error CCs only', done => {
+    const error1 = 'error 1';
+    const text = ')]}\'' + JSON.stringify({
+      ccs: {
+        username1: {
+          input: 'user 1',
+          error: error1,
+        },
+      },
+    });
+    element.addEventListener('server-error', e => {
+      e.detail.response.text().then(text => {
+        assert.equal(text, error1);
+        done();
+      });
+    });
+    element._handle400Error(cloneableResponse(400, text));
+  });
+
+  test('fires height change when the drafts comments load', done => {
+    // Flush DOM operations before binding to the autogrow event so we don't
+    // catch the events fired from the initial layout.
+    flush(() => {
+      const autoGrowHandler = sinon.stub();
+      element.addEventListener('autogrow', autoGrowHandler);
+      element.draftCommentThreads = [];
       flush(() => {
-        const autoGrowHandler = sinon.stub();
-        element.addEventListener('autogrow', autoGrowHandler);
-        element.diffDrafts = {};
-        flush(() => {
-          assert.isTrue(autoGrowHandler.called);
-          done();
-        });
+        assert.isTrue(autoGrowHandler.called);
+        done();
       });
     });
+  });
 
-    suite('post review API', () => {
-      let startReviewStub;
+  suite('post review API', () => {
+    let startReviewStub;
 
-      setup(() => {
-        startReviewStub = sandbox.stub(element.$.restAPI, 'startReview', () => {
-          return Promise.resolve();
-        });
-      });
-
-      test('ready property in review input on start review', () => {
-        stubSaveReview(review => {
-          assert.isTrue(review.ready);
-          return {ready: true};
-        });
-        return element.send(true, true).then(() => {
-          assert.isFalse(startReviewStub.called);
-        });
-      });
-
-      test('no ready property in review input on save review', () => {
-        stubSaveReview(review => {
-          assert.isUndefined(review.ready);
-        });
-        return element.send(true, false).then(() => {
-          assert.isFalse(startReviewStub.called);
-        });
-      });
+    setup(() => {
+      startReviewStub = sandbox.stub(
+          element.$.restAPI,
+          'startReview',
+          () => Promise.resolve());
     });
 
-    suite('start review and save buttons', () => {
-      let sendStub;
-
-      setup(() => {
-        sendStub = sandbox.stub(element, 'send', () => Promise.resolve());
-        element.canBeStarted = true;
-        // Flush to make both Start/Save buttons appear in DOM.
-        flushAsynchronousOperations();
-      });
-
-      test('start review sets ready', () => {
-        MockInteractions.tap(element.$$('.send'));
-        flushAsynchronousOperations();
-        assert.isTrue(sendStub.calledWith(true, true));
-      });
-
-      test('save review doesn\'t set ready', () => {
-        MockInteractions.tap(element.$$('.save'));
-        flushAsynchronousOperations();
-        assert.isTrue(sendStub.calledWith(true, false));
-      });
-    });
-
-    test('buttons disabled until all API calls are resolved', () => {
+    test('ready property in review input on start review', () => {
       stubSaveReview(review => {
+        assert.isTrue(review.ready);
         return {ready: true};
       });
       return element.send(true, true).then(() => {
-        assert.isFalse(element.disabled);
+        assert.isFalse(startReviewStub.called);
       });
     });
 
-    suite('error handling', () => {
-      const expectedDraft = 'draft';
-      const expectedError = new Error('test');
-
-      setup(() => {
-        element.draft = expectedDraft;
+    test('no ready property in review input on save review', () => {
+      stubSaveReview(review => {
+        assert.isUndefined(review.ready);
       });
-
-      function assertDialogOpenAndEnabled() {
-        assert.strictEqual(expectedDraft, element.draft);
-        assert.isFalse(element.disabled);
-      }
-
-      test('error occurs in _saveReview', () => {
-        stubSaveReview(review => {
-          throw expectedError;
-        });
-        return element.send(true, true).catch(err => {
-          assert.strictEqual(expectedError, err);
-          assertDialogOpenAndEnabled();
-        });
+      return element.send(true, false).then(() => {
+        assert.isFalse(startReviewStub.called);
       });
-
-      suite('pending diff drafts?', () => {
-        test('yes', () => {
-          const promise = mockPromise();
-          const refreshHandler = sandbox.stub();
-
-          element.addEventListener('comment-refresh', refreshHandler);
-          sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
-          element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
-          element.open();
-
-          assert.isFalse(refreshHandler.called);
-          assert.isTrue(element._savingComments);
-
-          promise.resolve();
-
-          return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
-            assert.isTrue(refreshHandler.called);
-            assert.isFalse(element._savingComments);
-          });
-        });
-
-        test('no', () => {
-          sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
-          element.open();
-          assert.notOk(element._savingComments);
-        });
-      });
-    });
-
-    test('_computeSendButtonDisabled', () => {
-      const fn = element._computeSendButtonDisabled.bind(element);
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Start review',
-          /* drafts= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      assert.isTrue(fn(
-          /* buttonLabel= */ 'Send',
-          /* drafts= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock nonempty comment draft array, with seding comments.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* drafts= */ {file: ['draft']},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ true,
-          /* disabled= */ false
-      ));
-      // Mock nonempty comment draft array, without seding comments.
-      assert.isTrue(fn(
-          /* buttonLabel= */ 'Send',
-          /* drafts= */ {file: ['draft']},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock nonempty change message.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* drafts= */ {},
-          /* text= */ 'test',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock reviewers mutated.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* drafts= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ true,
-          /* labelsChanged= */ false,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Mock labels changed.
-      assert.isFalse(fn(
-          /* buttonLabel= */ 'Send',
-          /* drafts= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ true,
-          /* includeComments= */ false,
-          /* disabled= */ false
-      ));
-      // Whole dialog is disabled.
-      assert.isTrue(fn(
-          /* buttonLabel= */ 'Send',
-          /* drafts= */ {},
-          /* text= */ '',
-          /* reviewersMutated= */ false,
-          /* labelsChanged= */ true,
-          /* includeComments= */ false,
-          /* disabled= */ true
-      ));
-    });
-
-    test('_submit blocked when no mutations exist', () => {
-      const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
-      // Stub the below function to avoid side effects from the send promise
-      // resolving.
-      sandbox.stub(element, '_purgeReviewersPendingRemove');
-      element.diffDrafts = {};
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.$$('gr-button.send'));
-      assert.isFalse(sendStub.called);
-
-      element.diffDrafts = {test: [{val: true}]};
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.$$('gr-button.send'));
-      assert.isTrue(sendStub.called);
-    });
-
-    test('getFocusStops', () => {
-      // Setting diffDrafts to an empty object causes _sendDisabled to be
-      // computed to false.
-      element.diffDrafts = {};
-      assert.equal(element.getFocusStops().end, element.$.cancelButton);
-      element.diffDrafts = {test: [{val: true}]};
-      assert.equal(element.getFocusStops().end, element.$.sendButton);
-    });
-
-    test('setPluginMessage', () => {
-      element.setPluginMessage('foo');
-      assert.equal(element.$.pluginMessage.textContent, 'foo');
     });
   });
+
+  suite('start review and save buttons', () => {
+    let sendStub;
+
+    setup(() => {
+      sendStub = sandbox.stub(element, 'send', () => Promise.resolve());
+      element.canBeStarted = true;
+      // Flush to make both Start/Save buttons appear in DOM.
+      flushAsynchronousOperations();
+    });
+
+    test('start review sets ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+      flushAsynchronousOperations();
+      assert.isTrue(sendStub.calledWith(true, true));
+    });
+
+    test('save review doesn\'t set ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      flushAsynchronousOperations();
+      assert.isTrue(sendStub.calledWith(true, false));
+    });
+  });
+
+  test('buttons disabled until all API calls are resolved', () => {
+    stubSaveReview(review => {
+      return {ready: true};
+    });
+    return element.send(true, true).then(() => {
+      assert.isFalse(element.disabled);
+    });
+  });
+
+  suite('error handling', () => {
+    const expectedDraft = 'draft';
+    const expectedError = new Error('test');
+
+    setup(() => {
+      element.draft = expectedDraft;
+    });
+
+    function assertDialogOpenAndEnabled() {
+      assert.strictEqual(expectedDraft, element.draft);
+      assert.isFalse(element.disabled);
+    }
+
+    test('error occurs in _saveReview', () => {
+      stubSaveReview(review => {
+        throw expectedError;
+      });
+      return element.send(true, true).catch(err => {
+        assert.strictEqual(expectedError, err);
+        assertDialogOpenAndEnabled();
+      });
+    });
+
+    suite('pending diff drafts?', () => {
+      test('yes', () => {
+        const promise = mockPromise();
+        const refreshHandler = sandbox.stub();
+
+        element.addEventListener('comment-refresh', refreshHandler);
+        sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
+        element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
+        element.open();
+
+        assert.isFalse(refreshHandler.called);
+        assert.isTrue(element._savingComments);
+
+        promise.resolve();
+
+        return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
+          assert.isTrue(refreshHandler.called);
+          assert.isFalse(element._savingComments);
+        });
+      });
+
+      test('no', () => {
+        sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
+        element.open();
+        assert.notOk(element._savingComments);
+      });
+    });
+  });
+
+  test('_computeSendButtonDisabled_canBeStarted', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock canBeStarted
+    assert.isFalse(fn(
+        /* canBeStarted= */ true,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_allFalse', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock everything false
+    assert.isTrue(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsSend', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock nonempty comment draft array, with sending comments.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ true,
+        /* disabled= */ false,
+        /* commentEditing= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock nonempty comment draft array, without sending comments.
+    assert.isTrue(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_changeMessage', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock nonempty change message.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ 'test',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_reviewersChanged', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock reviewers mutated.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ true,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_labelsChanged', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock labels changed.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_dialogDisabled', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Whole dialog is disabled.
+    assert.isTrue(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ true,
+        /* commentEditing= */ false
+    ));
+    assert.isTrue(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ true
+    ));
+  });
+
+  test('_submit blocked when no mutations exist', () => {
+    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sandbox.stub(element, '_purgeReviewersPendingRemove');
+    element.draftCommentThreads = [];
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+
+    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('getFocusStops', () => {
+    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
+    // computed to false.
+    element.draftCommentThreads = [];
+    assert.equal(element.getFocusStops().end, element.$.cancelButton);
+    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    assert.equal(element.getFocusStops().end, element.$.sendButton);
+  });
+
+  test('setPluginMessage', () => {
+    element.setPluginMessage('foo');
+    assert.equal(element.$.pluginMessage.textContent, 'foo');
+  });
+});
 </script>
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
deleted file mode 100644
index a5875ab1..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ /dev/null
@@ -1,68 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<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-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-reviewer-list">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: .8;
-        pointer-events: none;
-      }
-      .container > :not(:first-child) {
-        margin-top: var(--spacing-s);
-      }
-      gr-button {
-        --gr-button: {
-          padding: 0px 0px;
-        }
-      }
-    </style>
-    <div class="container">
-      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-        <gr-account-chip class="reviewer" account="[[reviewer]]"
-            on-remove="_handleRemove"
-            additional-text="[[_computeReviewerTooltip(reviewer, change)]]"
-            removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
-        </gr-account-chip>
-      </template>
-      <gr-button
-          class="hiddenReviewers"
-          link
-          hidden$="[[!_hiddenReviewerCount]]"
-          on-click="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
-      <div class="controlsContainer" hidden$="[[!mutable]]">
-        <gr-button
-            link
-            id="addReviewer"
-            class="addReviewer"
-            on-click="_handleAddTap">[[_addLabel]]</gr-button>
-      </div>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-reviewer-list.js"></script>
-</dom-module>
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..c933c7c 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,20 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-reviewer-list',
+import '../../shared/gr-account-chip/gr-account-chip.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-reviewer-list_html.js';
 
-    /**
-     * Fired when the "Add reviewer..." button is tapped.
-     *
-     * @event show-reply-dialog
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrReviewerList extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-reviewer-list'; }
+  /**
+   * Fired when the "Add reviewer..." button is tapped.
+   *
+   * @event show-reply-dialog
+   */
+
+  static get properties() {
+    return {
       change: Object,
+      serverConfig: Object,
       disabled: {
         type: Boolean,
         value: false,
@@ -45,7 +61,6 @@
         type: Boolean,
         value: false,
       },
-      maxReviewersDisplayed: Number,
 
       _displayedReviewers: {
         type: Array,
@@ -68,210 +83,213 @@
         computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
       },
 
-
       // Used for testing.
       _lastAutocompleteRequest: Object,
       _xhrPromise: Object,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  static get observers() {
+    return [
+      '_reviewersChanged(change.reviewers.*, change.owner, serverConfig)',
+    ];
+  }
 
-    observers: [
-      '_reviewersChanged(change.reviewers.*, change.owner)',
-    ],
-
-    /**
-     * Converts change.permitted_labels to an array of hashes of label keys to
-     * numeric scores.
-     * Example:
-     * [{
-     *   'Code-Review': ['-1', ' 0', '+1']
-     * }]
-     * will be converted to
-     * [{
-     *   label: 'Code-Review',
-     *   scores: [-1, 0, 1]
-     * }]
-     */
-    _permittedLabelsToNumericScores(labels) {
-      if (!labels) return [];
-      return Object.keys(labels).map(label => ({
+  /**
+   * Converts change.permitted_labels to an array of hashes of label keys to
+   * numeric scores.
+   * Example:
+   * [{
+   *   'Code-Review': ['-1', ' 0', '+1']
+   * }]
+   * will be converted to
+   * [{
+   *   label: 'Code-Review',
+   *   scores: [-1, 0, 1]
+   * }]
+   */
+  _permittedLabelsToNumericScores(labels) {
+    if (!labels) return [];
+    return Object.keys(labels).map(label => {
+      return {
         label,
         scores: labels[label].map(v => parseInt(v, 10)),
-      }));
-    },
+      };
+    });
+  }
 
-    /**
-     * Returns hash of labels to max permitted score.
-     *
-     * @param {!Object} change
-     * @returns {!Object} labels to max permitted scores hash
-     */
-    _getMaxPermittedScores(change) {
-      return this._permittedLabelsToNumericScores(change.permitted_labels)
-          .map(({label, scores}) => ({
+  /**
+   * Returns hash of labels to max permitted score.
+   *
+   * @param {!Object} change
+   * @returns {!Object} labels to max permitted scores hash
+   */
+  _getMaxPermittedScores(change) {
+    return this._permittedLabelsToNumericScores(change.permitted_labels)
+        .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), {});
-    },
+                .reduce((a, b) => Math.max(a, b))};
+        })
+        .reduce((acc, i) => Object.assign(acc, i), {});
+  }
 
-    /**
-     * Returns max permitted score for reviewer.
-     *
-     * @param {!Object} reviewer
-     * @param {!Object} change
-     * @param {string} label
-     * @return {number}
-     */
-    _getReviewerPermittedScore(reviewer, change, label) {
-      // Note (issue 7874): sometimes the "all" list is not included in change
-      // detail responses, even when DETAILED_LABELS is included in options.
-      if (!change.labels[label].all) { return NaN; }
-      const detailed = change.labels[label].all.filter(
-          ({_account_id}) => reviewer._account_id === _account_id).pop();
-      if (!detailed) {
-        return NaN;
-      }
-      if (detailed.hasOwnProperty('permitted_voting_range')) {
-        return detailed.permitted_voting_range.max;
-      } else if (detailed.hasOwnProperty('value')) {
-        // If preset, user can vote on the label.
-        return 0;
-      }
+  /**
+   * Returns max permitted score for reviewer.
+   *
+   * @param {!Object} reviewer
+   * @param {!Object} change
+   * @param {string} label
+   * @return {number}
+   */
+  _getReviewerPermittedScore(reviewer, change, label) {
+    // Note (issue 7874): sometimes the "all" list is not included in change
+    // detail responses, even when DETAILED_LABELS is included in options.
+    if (!change.labels[label].all) { return NaN; }
+    const detailed = change.labels[label].all.filter(
+        ({_account_id}) => reviewer._account_id === _account_id).pop();
+    if (!detailed) {
       return NaN;
-    },
+    }
+    if (detailed.hasOwnProperty('permitted_voting_range')) {
+      return detailed.permitted_voting_range.max;
+    } else if (detailed.hasOwnProperty('value')) {
+      // If preset, user can vote on the label.
+      return 0;
+    }
+    return NaN;
+  }
 
-    _computeReviewerTooltip(reviewer, change) {
-      if (!change || !change.labels) { return ''; }
-      const maxScores = [];
-      const maxPermitted = this._getMaxPermittedScores(change);
-      for (const label of Object.keys(change.labels)) {
-        const maxScore =
-              this._getReviewerPermittedScore(reviewer, change, label);
-        if (isNaN(maxScore) || maxScore < 0) { continue; }
-        if (maxScore > 0 && maxScore === maxPermitted[label]) {
-          maxScores.push(`${label}: +${maxScore}`);
-        } else {
-          maxScores.push(`${label}`);
-        }
-      }
-      if (maxScores.length) {
-        return 'Votable: ' + maxScores.join(', ');
+  _computeVoteableText(reviewer, change) {
+    if (!change || !change.labels) { return ''; }
+    const maxScores = [];
+    const maxPermitted = this._getMaxPermittedScores(change);
+    for (const label of Object.keys(change.labels)) {
+      const maxScore =
+            this._getReviewerPermittedScore(reviewer, change, label);
+      if (isNaN(maxScore) || maxScore < 0) { continue; }
+      if (maxScore > 0 && maxScore === maxPermitted[label]) {
+        maxScores.push(`${label}: +${maxScore}`);
       } else {
-        return '';
+        maxScores.push(`${label}`);
       }
-    },
+    }
+    return maxScores.join(', ');
+  }
 
-    _reviewersChanged(changeRecord, owner) {
-      // Polymer 2: check for undefined
-      if ([changeRecord, owner].some(arg => arg === undefined)) {
-        return;
+  _reviewersChanged(changeRecord, owner, serverConfig) {
+    // Polymer 2: check for undefined
+    if ([changeRecord, owner, serverConfig].some(arg => arg === undefined)) {
+      return;
+    }
+
+    let result = [];
+    const reviewers = changeRecord.base;
+    for (const key in reviewers) {
+      if (this.reviewersOnly && key !== 'REVIEWER') {
+        continue;
       }
-
-      let result = [];
-      const reviewers = changeRecord.base;
-      for (const key in reviewers) {
-        if (this.reviewersOnly && key !== 'REVIEWER') {
-          continue;
-        }
-        if (this.ccsOnly && key !== 'CC') {
-          continue;
-        }
-        if (key === 'REVIEWER' || key === 'CC') {
-          result = result.concat(reviewers[key]);
-        }
+      if (this.ccsOnly && key !== 'CC') {
+        continue;
       }
-      this._reviewers = result.filter(reviewer => {
-        return 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.
-      if (this.maxReviewersDisplayed &&
-          this._reviewers.length > this.maxReviewersDisplayed + 2) {
-        this._displayedReviewers =
-          this._reviewers.slice(0, this.maxReviewersDisplayed);
-      } else {
-        this._displayedReviewers = this._reviewers;
+      if (key === 'REVIEWER' || key === 'CC') {
+        result = result.concat(reviewers[key]);
       }
-    },
+    }
+    this._reviewers = result
+        .filter(reviewer => reviewer._account_id != owner._account_id);
 
-    _computeHiddenCount(reviewers, displayedReviewers) {
-      // Polymer 2: check for undefined
-      if ([reviewers, displayedReviewers].some(arg => arg === undefined)) {
-        return undefined;
+    const isFirstNameConfigured = serverConfig.accounts
+        && serverConfig.accounts.default_display_name === 'FIRST_NAME';
+    const maxReviewers = isFirstNameConfigured ? 6 : 3;
+    // 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.
+    if (this._reviewers.length > maxReviewers + 2) {
+      this._displayedReviewers = this._reviewers.slice(0, maxReviewers);
+    } else {
+      this._displayedReviewers = this._reviewers;
+    }
+  }
+
+  _computeHiddenCount(reviewers, displayedReviewers) {
+    // Polymer 2: check for undefined
+    if ([reviewers, displayedReviewers].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return reviewers.length - displayedReviewers.length;
+  }
+
+  _computeCanRemoveReviewer(reviewer, mutable) {
+    if (!mutable) { return false; }
+
+    let current;
+    for (let i = 0; i < this.change.removable_reviewers.length; i++) {
+      current = this.change.removable_reviewers[i];
+      if (current._account_id === reviewer._account_id ||
+          (!reviewer._account_id && current.email === reviewer.email)) {
+        return true;
       }
+    }
+    return false;
+  }
 
-      return reviewers.length - displayedReviewers.length;
-    },
+  _handleRemove(e) {
+    e.preventDefault();
+    const target = dom(e).rootTarget;
+    if (!target.account) { return; }
+    const accountID = target.account._account_id || target.account.email;
+    this.disabled = true;
+    this._xhrPromise = this._removeReviewer(accountID).then(response => {
+      this.disabled = false;
+      if (!response.ok) { return response; }
 
-    _computeCanRemoveReviewer(reviewer, mutable) {
-      if (!mutable) { return false; }
+      const reviewers = this.change.reviewers;
 
-      let current;
-      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
-        current = this.change.removable_reviewers[i];
-        if (current._account_id === reviewer._account_id ||
-            (!reviewer._account_id && current.email === reviewer.email)) {
-          return true;
-        }
-      }
-      return false;
-    },
-
-    _handleRemove(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      if (!target.account) { return; }
-      const accountID = target.account._account_id || target.account.email;
-      this.disabled = true;
-      this._xhrPromise = this._removeReviewer(accountID).then(response => {
-        this.disabled = false;
-        if (!response.ok) { return response; }
-
-        const reviewers = this.change.reviewers;
-
-        for (const type of ['REVIEWER', 'CC']) {
-          reviewers[type] = reviewers[type] || [];
-          for (let i = 0; i < reviewers[type].length; i++) {
-            if (reviewers[type][i]._account_id == accountID ||
-            reviewers[type][i].email == accountID) {
-              this.splice('change.reviewers.' + type, i, 1);
-              break;
-            }
+      for (const type of ['REVIEWER', 'CC']) {
+        reviewers[type] = reviewers[type] || [];
+        for (let i = 0; i < reviewers[type].length; i++) {
+          if (reviewers[type][i]._account_id == accountID ||
+          reviewers[type][i].email == accountID) {
+            this.splice('change.reviewers.' + type, i, 1);
+            break;
           }
         }
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-    },
-
-    _handleAddTap(e) {
-      e.preventDefault();
-      const value = {};
-      if (this.reviewersOnly) {
-        value.reviewersOnly = true;
       }
-      if (this.ccsOnly) {
-        value.ccsOnly = true;
-      }
-      this.fire('show-reply-dialog', {value});
-    },
+    })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
+        });
+  }
 
-    _handleViewAll(e) {
-      this._displayedReviewers = this._reviewers;
-    },
+  _handleAddTap(e) {
+    e.preventDefault();
+    const value = {};
+    if (this.reviewersOnly) {
+      value.reviewersOnly = true;
+    }
+    if (this.ccsOnly) {
+      value.ccsOnly = true;
+    }
+    this.dispatchEvent(new CustomEvent('show-reply-dialog', {
+      detail: {value},
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _removeReviewer(id) {
-      return this.$.restAPI.removeChangeReviewer(this.change._number, id);
-    },
+  _handleViewAll(e) {
+    this._displayedReviewers = this._reviewers;
+  }
 
-    _computeAddLabel(ccsOnly) {
-      return ccsOnly ? 'Add CC' : 'Add reviewer';
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
new file mode 100644
index 0000000..93926cf
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.8;
+      pointer-events: none;
+    }
+    .container {
+      display: block;
+    }
+    gr-button {
+      --gr-button: {
+        padding: 0px 0px;
+      }
+    }
+    gr-account-chip {
+      display: inline-block;
+    }
+  </style>
+  <div class="container">
+    <div>
+      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
+        <gr-account-chip
+          class="reviewer"
+          account="[[reviewer]]"
+          on-remove="_handleRemove"
+          voteable-text="[[_computeVoteableText(reviewer, change)]]"
+          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
+        >
+        </gr-account-chip>
+      </template>
+    </div>
+    <gr-button
+      class="hiddenReviewers"
+      link=""
+      hidden$="[[!_hiddenReviewerCount]]"
+      on-click="_handleViewAll"
+      >and [[_hiddenReviewerCount]] more</gr-button
+    >
+    <div class="controlsContainer" hidden$="[[!mutable]]">
+      <gr-button
+        link=""
+        id="addReviewer"
+        class="addReviewer"
+        on-click="_handleAddTap"
+        >[[_addLabel]]</gr-button
+      >
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..6949afc 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-reviewer-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,74 +31,66 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-reviewer-list tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-reviewer-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-reviewer-list tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        removeChangeReviewer() {
-          return Promise.resolve({ok: true});
-        },
-      });
+  setup(() => {
+    element = fixture('basic');
+    element.serverConfig = {};
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      removeChangeReviewer() {
+        return Promise.resolve({ok: true});
+      },
     });
+  });
 
-    teardown(() => {
-      sandbox.restore();
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('controls hidden on immutable element', () => {
+    element.mutable = false;
+    assert.isTrue(element.shadowRoot
+        .querySelector('.controlsContainer').hasAttribute('hidden'));
+    element.mutable = true;
+    assert.isFalse(element.shadowRoot
+        .querySelector('.controlsContainer').hasAttribute('hidden'));
+  });
+
+  test('add reviewer button opens reply dialog', done => {
+    element.addEventListener('show-reply-dialog', () => {
+      done();
     });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.addReviewer'));
+  });
 
-    test('controls hidden on immutable element', () => {
-      element.mutable = false;
-      assert.isTrue(element.$$('.controlsContainer').hasAttribute('hidden'));
-      element.mutable = true;
-      assert.isFalse(element.$$('.controlsContainer').hasAttribute('hidden'));
-    });
-
-    test('add reviewer button opens reply dialog', done => {
-      element.addEventListener('show-reply-dialog', () => {
-        done();
-      });
-      MockInteractions.tap(element.$$('.addReviewer'));
-    });
-
-    test('only show remove for removable reviewers', () => {
-      element.mutable = true;
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          REVIEWER: [
-            {
-              _account_id: 2,
-              name: 'Bojack Horseman',
-              email: 'SecretariatRulez96@hotmail.com',
-            },
-            {
-              _account_id: 3,
-              name: 'Pinky Penguin',
-            },
-          ],
-          CC: [
-            {
-              _account_id: 4,
-              name: 'Diane Nguyen',
-              email: 'macarthurfellow2B@juno.com',
-            },
-            {
-              email: 'test@e.mail',
-            },
-          ],
-        },
-        removable_reviewers: [
+  test('only show remove for removable reviewers', () => {
+    element.mutable = true;
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        REVIEWER: [
+          {
+            _account_id: 2,
+            name: 'Bojack Horseman',
+            email: 'SecretariatRulez96@hotmail.com',
+          },
           {
             _account_id: 3,
             name: 'Pinky Penguin',
           },
+        ],
+        CC: [
           {
             _account_id: 4,
             name: 'Diane Nguyen',
@@ -111,224 +100,224 @@
             email: 'test@e.mail',
           },
         ],
-      };
-      flushAsynchronousOperations();
-      const chips =
-          Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-      assert.equal(chips.length, 4);
+      },
+      removable_reviewers: [
+        {
+          _account_id: 3,
+          name: 'Pinky Penguin',
+        },
+        {
+          _account_id: 4,
+          name: 'Diane Nguyen',
+          email: 'macarthurfellow2B@juno.com',
+        },
+        {
+          email: 'test@e.mail',
+        },
+      ],
+    };
+    flushAsynchronousOperations();
+    const chips =
+        dom(element.root).querySelectorAll('gr-account-chip');
+    assert.equal(chips.length, 4);
 
-      for (const el of Array.from(chips)) {
-        const accountID = el.account._account_id || el.account.email;
-        assert.ok(accountID);
+    for (const el of Array.from(chips)) {
+      const accountID = el.account._account_id || el.account.email;
+      assert.ok(accountID);
 
-        const buttonEl = el.$$('gr-button');
-        assert.isNotNull(buttonEl);
-        if (accountID == 2) {
-          assert.isTrue(buttonEl.hasAttribute('hidden'));
-        } else {
-          assert.isFalse(buttonEl.hasAttribute('hidden'));
-        }
+      const buttonEl = el.shadowRoot
+          .querySelector('gr-button');
+      assert.isNotNull(buttonEl);
+      if (accountID == 2) {
+        assert.isTrue(buttonEl.hasAttribute('hidden'));
+      } else {
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
       }
-    });
-
-    test('tracking reviewers and ccs', () => {
-      let counter = 0;
-      function makeAccount() {
-        return {_account_id: counter++};
-      }
-
-      const owner = makeAccount();
-      const reviewer = makeAccount();
-      const cc = makeAccount();
-      const reviewers = {
-        REMOVED: [makeAccount()],
-        REVIEWER: [owner, reviewer],
-        CC: [owner, cc],
-      };
-
-      element.ccsOnly = false;
-      element.reviewersOnly = false;
-      element.change = {
-        owner,
-        reviewers,
-      };
-      assert.deepEqual(element._reviewers, [reviewer, cc]);
-
-      element.reviewersOnly = true;
-      element.change = {
-        owner,
-        reviewers,
-      };
-      assert.deepEqual(element._reviewers, [reviewer]);
-
-      element.ccsOnly = true;
-      element.reviewersOnly = false;
-      element.change = {
-        owner,
-        reviewers,
-      };
-      assert.deepEqual(element._reviewers, [cc]);
-    });
-
-    test('_handleAddTap passes mode with event', () => {
-      const fireStub = sandbox.stub(element, 'fire');
-      const e = {preventDefault() {}};
-
-      element.ccsOnly = false;
-      element.reviewersOnly = false;
-      element._handleAddTap(e);
-      assert.isTrue(fireStub.calledWith('show-reply-dialog', {value: {}}));
-
-      element.reviewersOnly = true;
-      element._handleAddTap(e);
-      assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
-          {value: {reviewersOnly: true}}));
-
-      element.ccsOnly = true;
-      element.reviewersOnly = false;
-      element._handleAddTap(e);
-      assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
-          {value: {ccsOnly: true}}));
-    });
-
-    test('no show all reviewers button with 6 reviewers', () => {
-      const reviewers = [];
-      element.maxReviewersDisplayed = 5;
-      for (let i = 0; i < 6; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 0);
-      assert.equal(element._displayedReviewers.length, 6);
-      assert.equal(element._reviewers.length, 6);
-      assert.isTrue(element.$$('.hiddenReviewers').hidden);
-    });
-
-    test('show all reviewers button with 8 reviewers', () => {
-      const reviewers = [];
-      element.maxReviewersDisplayed = 5;
-      for (let i = 0; i < 8; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 3);
-      assert.equal(element._displayedReviewers.length, 5);
-      assert.equal(element._reviewers.length, 8);
-      assert.isFalse(element.$$('.hiddenReviewers').hidden);
-    });
-
-
-    test('no maxReviewersDisplayed', () => {
-      const reviewers = [];
-      for (let i = 0; i < 7; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 0);
-      assert.equal(element._displayedReviewers.length, 7);
-      assert.equal(element._reviewers.length, 7);
-      assert.isTrue(element.$$('.hiddenReviewers').hidden);
-    });
-
-    test('show all reviewers button', () => {
-      const reviewers = [];
-      element.maxReviewersDisplayed = 5;
-      for (let i = 0; i < 100; i++) {
-        reviewers.push(
-            {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-      }
-      element.ccsOnly = true;
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          CC: reviewers,
-        },
-      };
-      assert.equal(element._hiddenReviewerCount, 95);
-      assert.equal(element._displayedReviewers.length, 5);
-      assert.equal(element._reviewers.length, 100);
-      assert.isFalse(element.$$('.hiddenReviewers').hidden);
-
-      MockInteractions.tap(element.$$('.hiddenReviewers'));
-
-      assert.equal(element._hiddenReviewerCount, 0);
-      assert.equal(element._displayedReviewers.length, 100);
-      assert.equal(element._reviewers.length, 100);
-      assert.isTrue(element.$$('.hiddenReviewers').hidden);
-    });
-
-    test('votable labels', () => {
-      const change = {
-        labels: {
-          Foo: {
-            all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
-          },
-          Bar: {
-            all: [{_account_id: 1, permitted_voting_range: {max: 1}},
-              {_account_id: 7, permitted_voting_range: {max: 1}}],
-          },
-          FooBar: {
-            all: [{_account_id: 7, value: 0}],
-          },
-        },
-        permitted_labels: {
-          Foo: ['-1', ' 0', '+1', '+2'],
-          FooBar: ['-1', ' 0'],
-        },
-      };
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 1}, change),
-          'Votable: Bar');
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 7}, change),
-          'Votable: Foo: +2, Bar, FooBar');
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 2}, change),
-          '');
-    });
-
-    test('fails gracefully when all is not included', () => {
-      const change = {
-        labels: {Foo: {}},
-        permitted_labels: {
-          Foo: ['-1', ' 0', '+1', '+2'],
-        },
-      };
-      assert.strictEqual(
-          element._computeReviewerTooltip({_account_id: 1}, change), '');
-    });
+    }
   });
+
+  test('tracking reviewers and ccs', () => {
+    let counter = 0;
+    function makeAccount() {
+      return {_account_id: counter++};
+    }
+
+    const owner = makeAccount();
+    const reviewer = makeAccount();
+    const cc = makeAccount();
+    const reviewers = {
+      REMOVED: [makeAccount()],
+      REVIEWER: [owner, reviewer],
+      CC: [owner, cc],
+    };
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+    element.reviewersOnly = true;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer]);
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [cc]);
+  });
+
+  test('_handleAddTap passes mode with event', () => {
+    const fireStub = sandbox.stub(element, 'dispatchEvent');
+    const e = {preventDefault() {}};
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {}});
+
+    element.reviewersOnly = true;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(
+        fireStub.lastCall.args[0].detail,
+        {value: {reviewersOnly: true}});
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(fireStub.lastCall.args[0].detail,
+        {value: {ccsOnly: true}});
+  });
+
+  test('dont show all reviewers button with 4 reviewers', () => {
+    const reviewers = [];
+    element.maxReviewersDisplayed = 3;
+    for (let i = 0; i < 4; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 4);
+    assert.equal(element._reviewers.length, 4);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button with 6 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 6; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 3);
+    assert.equal(element._displayedReviewers.length, 3);
+    assert.equal(element._reviewers.length, 6);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button', () => {
+    const reviewers = [];
+    for (let i = 0; i < 100; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 97);
+    assert.equal(element._displayedReviewers.length, 3);
+    assert.equal(element._reviewers.length, 100);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.hiddenReviewers'));
+
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 100);
+    assert.equal(element._reviewers.length, 100);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('votable labels', () => {
+    const change = {
+      labels: {
+        Foo: {
+          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
+        },
+        Bar: {
+          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
+            {_account_id: 7, permitted_voting_range: {max: 1}}],
+        },
+        FooBar: {
+          all: [{_account_id: 7, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 1}, change),
+        'Bar');
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 7}, change),
+        'Foo: +2, Bar, FooBar');
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 2}, change),
+        '');
+  });
+
+  test('fails gracefully when all is not included', () => {
+    const change = {
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 1}, change), '');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
deleted file mode 100644
index 4c82d2a..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ /dev/null
@@ -1,102 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
-
-<dom-module id="gr-thread-list">
-  <template>
-    <style include="shared-styles">
-      #threads {
-        display: block;
-        min-height: 20rem;
-        padding: var(--spacing-l);
-      }
-      gr-comment-thread {
-        display: block;
-        margin-bottom: var(--spacing-m);
-        max-width: 80ch;
-      }
-      .header {
-        align-items: center;
-        background-color: var(--table-header-background-color);
-        border-bottom: 1px solid var(--border-color);
-        border-top: 1px solid var(--border-color);
-        display: flex;
-        justify-content: left;
-        min-height: 3.2em;
-        padding: var(--spacing-m) var(--spacing-l);
-      }
-      .toggleItem.draftToggle {
-        display: none;
-      }
-      .toggleItem.draftToggle.show {
-        display: flex;
-      }
-      .toggleItem {
-        align-items: center;
-        display: flex;
-        margin-right: var(--spacing-l);
-      }
-      .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
-      .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
-      .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
-        display: block
-      }
-    </style>
-    <div class="header">
-      <div class="toggleItem">
-        <paper-toggle-button
-            id="unresolvedToggle"
-            checked="{{_unresolvedOnly}}"></paper-toggle-button>
-          Only unresolved threads</div>
-      <div class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]">
-        <paper-toggle-button
-            id="draftToggle"
-            checked="{{_draftsOnly}}"></paper-toggle-button>
-          Only threads with drafts</div>
-    </div>
-    <div id="threads">
-      <template is="dom-if" if="[[!threads.length]]">
-        There are no inline comment threads on any diff for this change.
-      </template>
-      <template
-          is="dom-repeat"
-          items="[[_filteredThreads]]"
-          as="thread"
-          initial-count="5"
-          target-framerate="60">
-        <gr-comment-thread
-            show-file-path
-            change-num="[[changeNum]]"
-            comments="[[thread.comments]]"
-            comment-side="[[thread.commentSide]]"
-            project-name="[[change.project]]"
-            is-on-parent="[[_isOnParent(thread.commentSide)]]"
-            line-num="[[thread.line]]"
-            patch-num="[[thread.patchNum]]"
-            path="[[thread.path]]"
-            root-id="{{thread.rootId}}"
-            on-thread-changed="_handleCommentsChanged"
-            on-thread-discard="_handleThreadDiscard"></gr-comment-thread>
-      </template>
-    </div>
-  </template>
-  <script src="gr-thread-list.js"></script>
-</dom-module>
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..44ec9b0 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
@@ -14,19 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * Fired when a comment is saved or deleted
-   *
-   * @event thread-list-modified
-   */
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-comment-thread/gr-comment-thread.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-thread-list_html.js';
+import {util} from '../../../scripts/util.js';
 
-  Polymer({
-    is: 'gr-thread-list',
+import {NO_THREADS_MSG} from '../../../constants/messages.js';
 
-    properties: {
+/**
+ * Fired when a comment is saved or deleted
+ *
+ * @event thread-list-modified
+ * @extends Polymer.Element
+ */
+class GrThreadList extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-thread-list'; }
+
+  static get properties() {
+    return {
       /** @type {?} */
       change: Object,
       threads: Array,
@@ -37,8 +53,9 @@
       },
       _filteredThreads: {
         type: Array,
-        computed: '_computeFilteredThreads(_sortedThreads, _unresolvedOnly, ' +
-            '_draftsOnly)',
+        computed: '_computeFilteredThreads(_sortedThreads, ' +
+          '_unresolvedOnly, _draftsOnly,' +
+          'onlyShowRobotCommentsWithHumanReply)',
       },
       _unresolvedOnly: {
         type: Boolean,
@@ -48,130 +65,176 @@
         type: Boolean,
         value: false,
       },
-    },
+      /* Boolean properties used must default to false if passed as attribute
+      by the parent */
+      onlyShowRobotCommentsWithHumanReply: {
+        type: Boolean,
+        value: false,
+      },
+      hideToggleButtons: {
+        type: Boolean,
+        value: false,
+      },
+      emptyThreadMsg: {
+        type: String,
+        value: NO_THREADS_MSG,
+      },
+    };
+  }
 
-    observers: ['_computeSortedThreads(threads.*)'],
+  static get observers() { return ['_computeSortedThreads(threads.*)']; }
 
-    _computeShowDraftToggle(loggedIn) {
-      return loggedIn ? 'show' : '';
-    },
+  _computeShowDraftToggle(loggedIn) {
+    return loggedIn ? 'show' : '';
+  }
 
-    /**
-     * Order as follows:
-     *  - Unresolved threads with drafts (reverse chronological)
-     *  - Unresolved threads without drafts (reverse chronological)
-     *  - Resolved threads with drafts (reverse chronological)
-     *  - Resolved threads without drafts (reverse chronological)
-     *
-     * @param {!Object} changeRecord
-     */
-    _computeSortedThreads(changeRecord) {
-      const threads = changeRecord.base;
-      if (!threads) { return []; }
-      this._updateSortedThreads(threads);
-    },
+  /**
+   * Order as follows:
+   *  - Unresolved threads with drafts (reverse chronological)
+   *  - Unresolved threads without drafts (reverse chronological)
+   *  - Resolved threads with drafts (reverse chronological)
+   *  - Resolved threads without drafts (reverse chronological)
+   *
+   * @param {!Object} changeRecord
+   */
+  _computeSortedThreads(changeRecord) {
+    const threads = changeRecord.base;
+    if (!threads) { return []; }
+    this._updateSortedThreads(threads);
+  }
 
-    _updateSortedThreads(threads) {
-      this._sortedThreads =
-          threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
-            const c1Date = c1.__date || util.parseDate(c1.updated);
-            const c2Date = c2.__date || util.parseDate(c2.updated);
-            const dateCompare = c2Date - c1Date;
-            if (c2.unresolved || c1.unresolved) {
-              if (!c1.unresolved) { return 1; }
-              if (!c2.unresolved) { return -1; }
-            }
-            if (c2.hasDraft || c1.hasDraft) {
-              if (!c1.hasDraft) { return 1; }
-              if (!c2.hasDraft) { return -1; }
-            }
-
-            if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
-              return 0;
-            }
-            return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-          });
-    },
-
-    _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly) {
-      // Polymer 2: check for undefined
-      if ([
-        sortedThreads,
-        unresolvedOnly,
-        draftsOnly,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return sortedThreads.filter(c => {
-        if (draftsOnly) {
-          return c.hasDraft;
-        } else if (unresolvedOnly) {
-          return c.unresolved;
-        } else {
-          const comments = c && c.thread && c.thread.comments;
-          let robotComment = false;
-          let humanReplyToRobotComment = false;
-          comments.forEach(comment => {
-            if (comment.robot_id) {
-              robotComment = true;
-            } else if (robotComment) {
-              // Robot comment exists and human comment exists after it
-              humanReplyToRobotComment = true;
-            }
-          });
-          if (robotComment) {
-            return humanReplyToRobotComment ? c : false;
+  // TODO(taoalpha): should allow only sort once during initialization
+  // to avoid flickering
+  _updateSortedThreads(threads) {
+    this._sortedThreads =
+        threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
+          // threads will be sorted by:
+          // - unresolved first
+          // - with drafts
+          // - file path
+          // - line
+          // - updated time
+          if (c2.unresolved || c1.unresolved) {
+            if (!c1.unresolved) { return 1; }
+            if (!c2.unresolved) { return -1; }
           }
-          return c;
+
+          if (c2.hasDraft || c1.hasDraft) {
+            if (!c1.hasDraft) { return 1; }
+            if (!c2.hasDraft) { return -1; }
+          }
+
+          // TODO: Update here once we introduce patchset level comments
+          // they may not have or have a special line or path attribute
+
+          if (c1.thread.path !== c2.thread.path) {
+            return c1.thread.path.localeCompare(c2.thread.path);
+          }
+
+          // File level comments (no `line` property)
+          // should always show before any lines
+          if ([c1, c2].some(c => c.thread.line === undefined)) {
+            if (!c1.thread.line) { return -1; }
+            if (!c2.thread.line) { return 1; }
+          } else if (c1.thread.line !== c2.thread.line) {
+            return c1.thread.line - c2.thread.line;
+          }
+
+          const c1Date = c1.__date || util.parseDate(c1.updated);
+          const c2Date = c2.__date || util.parseDate(c2.updated);
+          const dateCompare = c2Date - c1Date;
+          if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
+            return 0;
+          }
+          return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+        });
+  }
+
+  _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly,
+      onlyShowRobotCommentsWithHumanReply) {
+    // Polymer 2: check for undefined
+    if ([
+      sortedThreads,
+      unresolvedOnly,
+      draftsOnly,
+      onlyShowRobotCommentsWithHumanReply,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return sortedThreads.filter(c => {
+      if (draftsOnly) {
+        return c.hasDraft;
+      } else if (unresolvedOnly) {
+        return c.unresolved;
+      } else {
+        const comments = c && c.thread && c.thread.comments;
+        let robotComment = false;
+        let humanReplyToRobotComment = false;
+        comments.forEach(comment => {
+          if (comment.robot_id) {
+            robotComment = true;
+          } else if (robotComment) {
+            // Robot comment exists and human comment exists after it
+            humanReplyToRobotComment = true;
+          }
+        });
+        if (robotComment && onlyShowRobotCommentsWithHumanReply) {
+          return humanReplyToRobotComment;
         }
-      }).map(threadInfo => threadInfo.thread);
-    },
-
-    _getThreadWithSortInfo(thread) {
-      const lastComment = thread.comments[thread.comments.length - 1] || {};
-
-      const lastNonDraftComment =
-          (lastComment.__draft && thread.comments.length > 1) ?
-            thread.comments[thread.comments.length - 2] :
-            lastComment;
-
-      return {
-        thread,
-        // Use the unresolved bit for the last non draft comment. This is what
-        // anybody other than the current user would see.
-        unresolved: !!lastNonDraftComment.unresolved,
-        hasDraft: !!lastComment.__draft,
-        updated: lastComment.updated,
-      };
-    },
-
-    removeThread(rootId) {
-      for (let i = 0; i < this.threads.length; i++) {
-        if (this.threads[i].rootId === rootId) {
-          this.splice('threads', i, 1);
-          // Needed to ensure threads get re-rendered in the correct order.
-          Polymer.dom.flush();
-          return;
-        }
+        return c;
       }
-    },
+    }).map(threadInfo => threadInfo.thread);
+  }
 
-    _handleThreadDiscard(e) {
-      this.removeThread(e.detail.rootId);
-    },
+  _getThreadWithSortInfo(thread) {
+    const lastComment = thread.comments[thread.comments.length - 1] || {};
 
-    _handleCommentsChanged(e) {
-      // Reset threads so thread computations occur on deep array changes to
-      // threads comments that are not observed naturally.
-      this._updateSortedThreads(this.threads);
+    const lastNonDraftComment =
+        (lastComment.__draft && thread.comments.length > 1) ?
+          thread.comments[thread.comments.length - 2] :
+          lastComment;
 
-      this.dispatchEvent(new CustomEvent('thread-list-modified',
-          {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
-    },
+    return {
+      thread,
+      // Use the unresolved bit for the last non draft comment. This is what
+      // anybody other than the current user would see.
+      unresolved: !!lastNonDraftComment.unresolved,
+      hasDraft: !!lastComment.__draft,
+      updated: lastComment.updated || lastComment.__date,
+    };
+  }
 
-    _isOnParent(side) {
-      return !!side;
-    },
-  });
-})();
+  removeThread(rootId) {
+    for (let i = 0; i < this.threads.length; i++) {
+      if (this.threads[i].rootId === rootId) {
+        this.splice('threads', i, 1);
+        // Needed to ensure threads get re-rendered in the correct order.
+        flush();
+        return;
+      }
+    }
+  }
+
+  _handleThreadDiscard(e) {
+    this.removeThread(e.detail.rootId);
+  }
+
+  _handleCommentsChanged(e) {
+    this.dispatchEvent(new CustomEvent('thread-list-modified',
+        {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
+  }
+
+  _isOnParent(side) {
+    return !!side;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapUnresolvedToggle(e) {
+    e.preventDefault();
+  }
+}
+
+customElements.define(GrThreadList.is, GrThreadList);
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
new file mode 100644
index 0000000..e48fe97
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #threads {
+      display: block;
+      padding: var(--spacing-l);
+    }
+    gr-comment-thread {
+      display: block;
+      margin-bottom: var(--spacing-m);
+      max-width: 80ch;
+    }
+    .header {
+      align-items: center;
+      background-color: var(--table-header-background-color);
+      border-bottom: 1px solid var(--border-color);
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      justify-content: left;
+      min-height: 3.2em;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    .toggleItem.draftToggle {
+      display: none;
+    }
+    .toggleItem.draftToggle.show {
+      display: flex;
+    }
+    .toggleItem {
+      align-items: center;
+      display: flex;
+      margin-right: var(--spacing-l);
+    }
+    .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+    .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+    .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
+      display: block;
+    }
+  </style>
+  <template is="dom-if" if="[[!hideToggleButtons]]">
+    <div class="header">
+      <div class="toggleItem">
+        <paper-toggle-button
+          id="unresolvedToggle"
+          checked="{{_unresolvedOnly}}"
+          on-tap="_onTapUnresolvedToggle"
+        ></paper-toggle-button>
+        Only unresolved threads
+      </div>
+      <div
+        class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
+      >
+        <paper-toggle-button
+          id="draftToggle"
+          checked="{{_draftsOnly}}"
+          on-tap="_onTapUnresolvedToggle"
+        ></paper-toggle-button>
+        Only threads with drafts
+      </div>
+    </div>
+  </template>
+  <div id="threads">
+    <template is="dom-if" if="[[!threads.length]]">
+      [[emptyThreadMsg]]
+    </template>
+    <template
+      is="dom-repeat"
+      items="[[_filteredThreads]]"
+      as="thread"
+      initial-count="5"
+      target-framerate="60"
+    >
+      <gr-comment-thread
+        show-file-path=""
+        change-num="[[changeNum]]"
+        comments="[[thread.comments]]"
+        comment-side="[[thread.commentSide]]"
+        project-name="[[change.project]]"
+        is-on-parent="[[_isOnParent(thread.commentSide)]]"
+        line-num="[[thread.line]]"
+        patch-num="[[thread.patchNum]]"
+        path="[[thread.path]]"
+        root-id="{{thread.rootId}}"
+        on-thread-changed="_handleCommentsChanged"
+        on-thread-discard="_handleThreadDiscard"
+      ></gr-comment-thread>
+    </template>
+  </div>
+`;
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..4b00d5a 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-thread-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,320 +31,374 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-thread-list tests', () => {
-    let element;
-    let sandbox;
-    let threadElements;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-thread-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {NO_THREADS_MSG} from '../../../constants/messages.js';
+suite('gr-thread-list tests', () => {
+  let element;
+  let sandbox;
+  let threadElements;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.threads = [
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'ecf0b9fa_fe1a5f62',
-              line: 5,
-              updated: '2018-02-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.onlyShowRobotCommentsWithHumanReply = true;
+    element.threads = [
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-            {
-              id: '503008e2_0ab203ee',
-              path: '/COMMIT_MSG',
-              line: 5,
-              in_reply_to: 'ecf0b9fa_fe1a5f62',
-              updated: '2018-02-13 22:48:48.018000000',
-              message: 'draft',
-              unresolved: false,
-              __draft: true,
-              __draftID: '0.m683trwff68',
-              __editing: false,
-              patch_set: '2',
+            patch_set: 4,
+            id: 'ecf0b9fa_fe1a5f62',
+            line: 5,
+            updated: '2018-02-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+          },
+          {
+            id: '503008e2_0ab203ee',
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62',
+            updated: '2018-02-13 22:48:48.018000000',
+            message: 'draft',
+            unresolved: false,
+            __draft: true,
+            __draftID: '0.m683trwff68',
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'ecf0b9fa_fe1a5f62',
+        start_datetime: '2018-02-08 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: 'test.txt',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 5,
-          rootId: 'ecf0b9fa_fe1a5f62',
-          start_datetime: '2018-02-08 18:49:18.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: 'test.txt',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 3,
-              id: '09a9fb0a_1484e6cf',
-              side: 'PARENT',
-              updated: '2018-02-13 22:47:19.000000000',
-              message: 'Some comment on another patchset.',
-              unresolved: false,
+            patch_set: 3,
+            id: '09a9fb0a_1484e6cf',
+            side: 'PARENT',
+            updated: '2018-02-13 22:47:19.000000000',
+            message: 'Some comment on another patchset.',
+            unresolved: false,
+          },
+        ],
+        patchNum: 3,
+        path: 'test.txt',
+        rootId: '09a9fb0a_1484e6cf',
+        start_datetime: '2018-02-13 22:47:19.000000000',
+        commentSide: 'PARENT',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 3,
-          path: 'test.txt',
-          rootId: '09a9fb0a_1484e6cf',
-          start_datetime: '2018-02-13 22:47:19.000000000',
-          commentSide: 'PARENT',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 2,
-              id: '8caddf38_44770ec1',
-              line: 4,
-              updated: '2018-02-13 22:48:40.000000000',
-              message: 'Another unresolved comment',
-              unresolved: true,
+            patch_set: 2,
+            id: '8caddf38_44770ec1',
+            updated: '2018-02-13 22:48:40.000000000',
+            message: 'Another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2,
+        path: '/COMMIT_MSG',
+        rootId: '8caddf38_44770ec1',
+        start_datetime: '2018-02-13 22:48:40.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 2,
-          path: '/COMMIT_MSG',
-          line: 4,
-          rootId: '8caddf38_44770ec1',
-          start_datetime: '2018-02-13 22:48:40.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 2,
-              id: 'scaddf38_44770ec1',
-              line: 4,
-              updated: '2018-02-14 22:48:40.000000000',
-              message: 'Yet another unresolved comment',
-              unresolved: true,
+            patch_set: 2,
+            id: 'scaddf38_44770ec1',
+            line: 4,
+            updated: '2018-02-14 22:48:40.000000000',
+            message: 'Yet another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: 'scaddf38_44770ec1',
+        start_datetime: '2018-02-14 22:48:40.000000000',
+      },
+      {
+        comments: [
+          {
+            id: 'zcf0b9fa_fe1a5f62',
+            path: '/COMMIT_MSG',
+            line: 6,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'resolved draft',
+            unresolved: false,
+            __draft: true,
+            __draftID: '0.m683trwff68',
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 6,
+        rootId: 'zcf0b9fa_fe1a5f62',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 2,
-          path: '/COMMIT_MSG',
-          line: 4,
-          rootId: 'scaddf38_44770ec1',
-          start_datetime: '2018-02-14 22:48:40.000000000',
-        },
-        {
-          comments: [
-            {
-              id: 'zcf0b9fa_fe1a5f62',
-              path: '/COMMIT_MSG',
-              line: 6,
-              updated: '2018-02-15 22:48:48.018000000',
-              message: 'resolved draft',
-              unresolved: false,
-              __draft: true,
-              __draftID: '0.m683trwff68',
-              __editing: false,
-              patch_set: '2',
+            patch_set: 4,
+            id: 'rc1',
+            line: 5,
+            updated: '2019-02-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc1',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc1',
+        start_datetime: '2019-02-08 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 6,
-          rootId: 'zcf0b9fa_fe1a5f62',
-          start_datetime: '2018-02-09 18:49:18.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'rc1',
-              line: 5,
-              updated: '2019-02-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
-              robot_id: 'rc1',
+            patch_set: 4,
+            id: 'rc2',
+            line: 7,
+            updated: '2019-03-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc2',
+          },
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
             },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 5,
-          rootId: 'rc1',
-          start_datetime: '2019-02-08 18:49:18.000000000',
-        },
-        {
-          comments: [
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'rc2',
-              line: 5,
-              updated: '2019-03-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
-              robot_id: 'rc2',
-            },
-            {
-              __path: '/COMMIT_MSG',
-              author: {
-                _account_id: 1000000,
-                name: 'user',
-                username: 'user',
-              },
-              patch_set: 4,
-              id: 'c2_1',
-              line: 5,
-              updated: '2019-03-08 18:49:18.000000000',
-              message: 'test',
-              unresolved: true,
-            },
-          ],
-          patchNum: 4,
-          path: '/COMMIT_MSG',
-          line: 5,
-          rootId: 'rc2',
-          start_datetime: '2019-03-08 18:49:18.000000000',
-        },
-      ];
-      flushAsynchronousOperations();
-      threadElements = Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread');
+            patch_set: 4,
+            id: 'c2_1',
+            line: 5,
+            updated: '2019-03-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 7,
+        rootId: 'rc2',
+        start_datetime: '2019-03-08 18:49:18.000000000',
+      },
+    ];
+    flushAsynchronousOperations();
+    threadElements = dom(element.root)
+        .querySelectorAll('gr-comment-thread');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('draft toggle only appears when logged in', () => {
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.draftToggle')).display,
+    'none');
+    element.loggedIn = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.draftToggle')).display,
+    'none');
+  });
+
+  test('there are five threads by default', () => {
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 5);
+  });
+
+  test('_computeSortedThreads', () => {
+    assert.equal(element._sortedThreads.length, 7);
+    // Draft and unresolved for commit-msg at line 5
+    assert.equal(element._sortedThreads[0].thread.rootId,
+        'ecf0b9fa_fe1a5f62');
+    // /COMMIT_MSG
+    // unresolved no draft and file level
+    assert.equal(element._sortedThreads[1].thread.rootId,
+        '8caddf38_44770ec1');
+    // unresolved no draft at line 4
+    assert.equal(element._sortedThreads[2].thread.rootId,
+        'scaddf38_44770ec1');
+    // unresolved no draft at line 5
+    assert.equal(element._sortedThreads[3].thread.rootId,
+        'rc1');
+    // Unresolved no draft at line 7
+    assert.equal(element._sortedThreads[4].thread.rootId,
+        'rc2');
+    // resolved and draft on COMMIT_MSG
+    assert.equal(element._sortedThreads[5].thread.rootId,
+        'zcf0b9fa_fe1a5f62');
+    // resolved and on file test.txt
+    assert.equal(element._sortedThreads[6].thread.rootId,
+        '09a9fb0a_1484e6cf');
+  });
+
+  test('filtered threads do not contain robot comments without reply', () => {
+    const thread = element.threads.find(thread => thread.rootId === 'rc1');
+    assert.equal(element._filteredThreads.includes(thread), false);
+  });
+
+  test('filtered threads contains robot comments with reply', () => {
+    const thread = element.threads.find(thread => thread.rootId === 'rc2');
+    assert.equal(element._filteredThreads.includes(thread), true);
+  });
+
+  test('thread removal', () => {
+    threadElements[1].dispatchEvent(
+        new CustomEvent('thread-discard', {
+          detail: {rootId: 'rc2'},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    assert.equal(element._sortedThreads.length, 6);
+    assert.equal(element._sortedThreads[0].thread.rootId,
+        'ecf0b9fa_fe1a5f62');
+    // /COMMIT_MSG
+    // unresolved no draft and file level
+    assert.equal(element._sortedThreads[1].thread.rootId,
+        '8caddf38_44770ec1');
+    // unresolved no draft at line 4
+    assert.equal(element._sortedThreads[2].thread.rootId,
+        'scaddf38_44770ec1');
+    // unresolved no draft at line 5
+    assert.equal(element._sortedThreads[3].thread.rootId,
+        'rc1');
+    // resolved and draft
+    assert.equal(element._sortedThreads[4].thread.rootId,
+        'zcf0b9fa_fe1a5f62');
+    // resolved and on file test.txt
+    assert.equal(element._sortedThreads[5].thread.rootId,
+        '09a9fb0a_1484e6cf');
+  });
+
+  test('toggle unresolved only shows unresolved comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 5);
+  });
+
+  test('toggle drafts only shows threads with draft comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 2);
+  });
+
+  test('toggle drafts and unresolved only shows threads with drafts and ' +
+      'publicly unresolved ', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, 2);
+  });
+
+  test('modification events are consumed and displatched', () => {
+    sandbox.spy(element, '_handleCommentsChanged');
+    const dispatchSpy = sandbox.stub();
+    element.addEventListener('thread-list-modified', dispatchSpy);
+    threadElements[0].dispatchEvent(
+        new CustomEvent('thread-changed', {
+          detail: {
+            rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'},
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(element._handleCommentsChanged.called);
+    assert.isTrue(dispatchSpy.called);
+    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
+        'ecf0b9fa_fe1a5f62');
+    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
+  });
+
+  suite('hideToggleButtons', () => {
+    setup(done => {
+      element.hideToggleButtons = true;
+      flush(() => {
+        done();
+      });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('draft toggle only appears when logged in', () => {
-      assert.equal(getComputedStyle(element.$$('.draftToggle')).display,
+    test('toggle buttons are hidden', () => {
+      assert.equal(element.shadowRoot.querySelector('.header').style.display,
           'none');
-      element.loggedIn = true;
-      assert.notEqual(getComputedStyle(element.$$('.draftToggle')).display,
-          'none');
-    });
-
-    test('there are five threads by default', () => {
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 5);
-    });
-
-    test('_computeSortedThreads', () => {
-      assert.equal(element._sortedThreads.length, 7);
-      // Draft and unresolved
-      assert.equal(element._sortedThreads[0].thread.rootId,
-          'ecf0b9fa_fe1a5f62');
-      // Unresolved robot comment
-      assert.equal(element._sortedThreads[1].thread.rootId,
-          'rc2');
-      // Unresolved robot comment
-      assert.equal(element._sortedThreads[2].thread.rootId,
-          'rc1');
-      // unresolved
-      assert.equal(element._sortedThreads[3].thread.rootId,
-          'scaddf38_44770ec1');
-      // unresolved
-      assert.equal(element._sortedThreads[4].thread.rootId,
-          '8caddf38_44770ec1');
-      // resolved and draft
-      assert.equal(element._sortedThreads[5].thread.rootId,
-          'zcf0b9fa_fe1a5f62');
-      // resolved
-      assert.equal(element._sortedThreads[6].thread.rootId,
-          '09a9fb0a_1484e6cf');
-    });
-
-    test('filtered threads do not contain robot comments without reply', () => {
-      const thread = element.threads.find(thread => thread.rootId === 'rc1');
-      assert.equal(element._filteredThreads.includes(thread), false);
-    });
-
-    test('filtered threads contains robot comments with reply', () => {
-      const thread = element.threads.find(thread => thread.rootId === 'rc2');
-      assert.equal(element._filteredThreads.includes(thread), true);
-    });
-
-
-    test('thread removal', () => {
-      threadElements[1].fire('thread-discard', {rootId: 'rc2'});
-      flushAsynchronousOperations();
-      assert.equal(element._sortedThreads.length, 6);
-      assert.equal(element._sortedThreads[0].thread.rootId,
-          'ecf0b9fa_fe1a5f62');
-      // Unresolved robot comment
-      assert.equal(element._sortedThreads[1].thread.rootId,
-          'rc1');
-      // unresolved
-      assert.equal(element._sortedThreads[2].thread.rootId,
-          'scaddf38_44770ec1');
-      // unresolved
-      assert.equal(element._sortedThreads[3].thread.rootId,
-          '8caddf38_44770ec1');
-      // resolved and draft
-      assert.equal(element._sortedThreads[4].thread.rootId,
-          'zcf0b9fa_fe1a5f62');
-      // resolved
-      assert.equal(element._sortedThreads[5].thread.rootId,
-          '09a9fb0a_1484e6cf');
-    });
-
-    test('toggle unresolved only shows unresolved comments', () => {
-      MockInteractions.tap(element.$.unresolvedToggle);
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 5);
-    });
-
-    test('toggle drafts only shows threads with draft comments', () => {
-      MockInteractions.tap(element.$.draftToggle);
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 2);
-    });
-
-    test('toggle drafts and unresolved only shows threads with drafts and ' +
-        'publicly unresolved ', () => {
-      MockInteractions.tap(element.$.draftToggle);
-      MockInteractions.tap(element.$.unresolvedToggle);
-      flushAsynchronousOperations();
-      assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-comment-thread').length, 2);
-    });
-
-    test('modification events are consumed and displatched', () => {
-      sandbox.spy(element, '_handleCommentsChanged');
-      const dispatchSpy = sandbox.stub();
-      element.addEventListener('thread-list-modified', dispatchSpy);
-      threadElements[0].fire('thread-changed', {
-        rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'});
-      assert.isTrue(element._handleCommentsChanged.called);
-      assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
-          'ecf0b9fa_fe1a5f62');
-      assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
     });
   });
+
+  suite('empty thread', () => {
+    setup(done => {
+      element.threads = [];
+      flush(() => {
+        done();
+      });
+    });
+
+    test('default empty message should show', () => {
+      assert.equal(
+          element.shadowRoot.querySelector('#threads').textContent.trim(),
+          NO_THREADS_MSG
+      );
+    });
+
+    test('can override empty message', () => {
+      element.emptyThreadMsg = 'test';
+      assert.equal(
+          element.shadowRoot.querySelector('#threads').textContent.trim(),
+          'test'
+      );
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
deleted file mode 100644
index e3cee56..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
+++ /dev/null
@@ -1,82 +0,0 @@
-<!--
-@license
-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.
--->
-
-<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="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-upload-help-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--dialog-background-color);
-        display: block;
-      }
-      .main {
-        width: 100%;
-      }
-      ol {
-        margin-left: var(--spacing-l);
-        list-style: decimal;
-      }
-      p {
-        margin-bottom: var(--spacing-m);
-      }
-    </style>
-    <gr-dialog
-        confirm-label="Done"
-        cancel-label=""
-        on-confirm="_handleCloseTap">
-      <div class="header" slot="header">How to update this change:</div>
-      <div class="main" slot="main">
-        <ol>
-          <li>
-            <p>
-              Checkout this change locally and make your desired modifications
-              to the files.
-            </p>
-            <template is="dom-if" if="[[_fetchCommand]]">
-              <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
-            </template>
-          </li>
-          <li>
-            <p>
-              Update the local commit with your modifications using the following
-              command.
-            </p>
-            <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
-            <p>
-              Leave the "Change-Id:" line of the commit message as is.
-            </p>
-          </li>
-          <li>
-            <p>Push the updated commit to Gerrit.</p>
-            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
-          </li>
-          <li>
-            <p>Refresh this page to view the the update.</p>
-          </li>
-        </ol>
-      </div>
-    </gr-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-upload-help-dialog.js"></script>
-</dom-module>
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..9171908 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
@@ -14,29 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
-  const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-shell-command/gr-shell-command.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-upload-help-dialog_html.js';
 
-  // Command names correspond to download plugin definitions.
-  const PREFERRED_FETCH_COMMAND_ORDER = [
-    'checkout',
-    'cherry pick',
-    'pull',
-  ];
+const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
+const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
 
-  Polymer({
-    is: 'gr-upload-help-dialog',
+// Command names correspond to download plugin definitions.
+const PREFERRED_FETCH_COMMAND_ORDER = [
+  'checkout',
+  'cherry pick',
+  'pull',
+];
 
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrUploadHelpDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-upload-help-dialog'; }
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  static get properties() {
+    return {
       revision: Object,
       targetBranch: String,
       _commitCommand: {
@@ -47,7 +61,7 @@
       _fetchCommand: {
         type: String,
         computed: '_computeFetchCommand(revision, ' +
-            '_preferredDownloadCommand, _preferredDownloadScheme)',
+          '_preferredDownloadCommand, _preferredDownloadScheme)',
       },
       _preferredDownloadCommand: String,
       _preferredDownloadScheme: String,
@@ -55,81 +69,85 @@
         type: String,
         computed: '_computePushCommand(targetBranch)',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  /** @override */
+  attached() {
+    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;
+          }
+        });
+  }
 
-    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.dispatchEvent(new CustomEvent('close', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleCloseTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('close', null, {bubbles: false});
-    },
+  _computeFetchCommand(revision, preferredDownloadCommand,
+      preferredDownloadScheme) {
+    // Polymer 2: check for undefined
+    if ([
+      revision,
+      preferredDownloadCommand,
+      preferredDownloadScheme,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-    _computeFetchCommand(revision, preferredDownloadCommand,
-        preferredDownloadScheme) {
-      // Polymer 2: check for undefined
-      if ([
-        revision,
-        preferredDownloadCommand,
-        preferredDownloadScheme,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
+    if (!revision) { return; }
+    if (!revision || !revision.fetch) { return; }
 
-      if (!revision) { return; }
-      if (!revision || !revision.fetch) { return; }
-
-      let scheme = preferredDownloadScheme;
-      if (!scheme) {
-        const keys = Object.keys(revision.fetch).sort();
-        if (keys.length === 0) {
-          return;
-        }
-        scheme = keys[0];
-      }
-
-      if (!revision.fetch[scheme] || !revision.fetch[scheme].commands) {
+    let scheme = preferredDownloadScheme;
+    if (!scheme) {
+      const keys = Object.keys(revision.fetch).sort();
+      if (keys.length === 0) {
         return;
       }
+      scheme = keys[0];
+    }
 
-      const cmds = {};
-      Object.entries(revision.fetch[scheme].commands).forEach(([key, cmd]) => {
-        cmds[key.toLowerCase()] = cmd;
-      });
+    if (!revision.fetch[scheme] || !revision.fetch[scheme].commands) {
+      return;
+    }
 
-      if (preferredDownloadCommand &&
-          cmds[preferredDownloadCommand.toLowerCase()]) {
-        return cmds[preferredDownloadCommand.toLowerCase()];
+    const cmds = {};
+    Object.entries(revision.fetch[scheme].commands).forEach(([key, cmd]) => {
+      cmds[key.toLowerCase()] = cmd;
+    });
+
+    if (preferredDownloadCommand &&
+        cmds[preferredDownloadCommand.toLowerCase()]) {
+      return cmds[preferredDownloadCommand.toLowerCase()];
+    }
+
+    // If no supported command preference is given, look for known commands
+    // from the downloads plugin in order of preference.
+    for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
+      if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
+        return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
       }
+    }
 
-      // If no supported command preference is given, look for known commands
-      // from the downloads plugin in order of preference.
-      for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
-        if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
-          return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
-        }
-      }
+    return undefined;
+  }
 
-      return undefined;
-    },
+  _computePushCommand(targetBranch) {
+    return PUSH_COMMAND_PREFIX + targetBranch;
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
new file mode 100644
index 0000000..ec010a1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--dialog-background-color);
+      display: block;
+    }
+    .main {
+      width: 100%;
+    }
+    ol {
+      margin-left: var(--spacing-xl);
+      list-style: decimal;
+    }
+    p {
+      margin-bottom: var(--spacing-m);
+    }
+  </style>
+  <gr-dialog confirm-label="Done" cancel-label="" on-confirm="_handleCloseTap">
+    <div class="header" slot="header">How to update this change:</div>
+    <div class="main" slot="main">
+      <ol>
+        <li>
+          <p>
+            Checkout this change locally and make your desired modifications to
+            the files.
+          </p>
+          <template is="dom-if" if="[[_fetchCommand]]">
+            <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
+          </template>
+        </li>
+        <li>
+          <p>
+            Update the local commit with your modifications using the following
+            command.
+          </p>
+          <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
+          <p>
+            Leave the "Change-Id:" line of the commit message as is.
+          </p>
+        </li>
+        <li>
+          <p>Push the updated commit to Gerrit.</p>
+          <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+        </li>
+        <li>
+          <p>Refresh this page to view the the update.</p>
+        </li>
+      </ol>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..164c483 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-upload-help-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,92 +31,94 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-upload-help-dialog tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-upload-help-dialog.js';
+suite('gr-upload-help-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
+  setup(() => {
+    element = fixture('basic');
+  });
 
-    test('constructs push command from branch', () => {
-      element.targetBranch = 'foo';
-      assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
+  test('constructs push command from branch', () => {
+    element.targetBranch = 'foo';
+    assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
 
-      element.targetBranch = 'master';
-      assert.equal(element._pushCommand,
-          'git push origin HEAD:refs/for/master');
-    });
+    element.targetBranch = 'master';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/master');
+  });
 
-    suite('fetch command', () => {
-      const testRev = {
-        fetch: {
-          http: {
-            commands: {
-              Checkout: 'http checkout',
-              Pull: 'http pull',
-            },
-          },
-          ssh: {
-            commands: {
-              Pull: 'ssh pull',
-            },
+  suite('fetch command', () => {
+    const testRev = {
+      fetch: {
+        http: {
+          commands: {
+            Checkout: 'http checkout',
+            Pull: 'http pull',
           },
         },
-      };
+        ssh: {
+          commands: {
+            Pull: 'ssh pull',
+          },
+        },
+      },
+    };
 
-      test('null cases', () => {
-        assert.isUndefined(element._computeFetchCommand());
-        assert.isUndefined(element._computeFetchCommand({}));
-        assert.isUndefined(element._computeFetchCommand({fetch: null}));
-        assert.isUndefined(element._computeFetchCommand({fetch: {}}));
-      });
+    test('null cases', () => {
+      assert.isUndefined(element._computeFetchCommand());
+      assert.isUndefined(element._computeFetchCommand({}));
+      assert.isUndefined(element._computeFetchCommand({fetch: null}));
+      assert.isUndefined(element._computeFetchCommand({fetch: {}}));
+    });
 
-      test('not all defined', () => {
-        assert.isUndefined(
-            element._computeFetchCommand(testRev, undefined, ''));
-        assert.isUndefined(
-            element._computeFetchCommand(testRev, '', undefined));
-        assert.isUndefined(
-            element._computeFetchCommand(undefined, '', ''));
-      });
+    test('not all defined', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, undefined, ''));
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, '', undefined));
+      assert.isUndefined(
+          element._computeFetchCommand(undefined, '', ''));
+    });
 
-      test('insufficiently defined scheme', () => {
-        assert.isUndefined(
-            element._computeFetchCommand(testRev, '', 'badscheme'));
+    test('insufficiently defined scheme', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, '', 'badscheme'));
 
-        const rev = Object.assign({}, testRev);
-        rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
-        assert.isUndefined(
-            element._computeFetchCommand(rev, '', 'nocmds'));
+      const rev = Object.assign({}, testRev);
+      rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
+      assert.isUndefined(
+          element._computeFetchCommand(rev, '', 'nocmds'));
 
-        rev.fetch.nocmds.commands.unsupported = 'unsupported';
-        assert.isUndefined(
-            element._computeFetchCommand(rev, '', 'nocmds'));
-      });
+      rev.fetch.nocmds.commands.unsupported = 'unsupported';
+      assert.isUndefined(
+          element._computeFetchCommand(rev, '', 'nocmds'));
+    });
 
-      test('default scheme and command', () => {
-        const cmd = element._computeFetchCommand(testRev, '', '');
-        assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
-      });
+    test('default scheme and command', () => {
+      const cmd = element._computeFetchCommand(testRev, '', '');
+      assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
+    });
 
-      test('default command', () => {
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, '', 'http'),
-            'http checkout');
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, '', 'ssh'),
-            'ssh pull');
-      });
+    test('default command', () => {
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, '', 'http'),
+          'http checkout');
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, '', 'ssh'),
+          'ssh pull');
+    });
 
-      test('user preferred scheme and command', () => {
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, 'PULL', 'http'),
-            'http pull');
-        assert.strictEqual(
-            element._computeFetchCommand(testRev, 'badcmd', 'http'),
-            'http checkout');
-      });
+    test('user preferred scheme and command', () => {
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'PULL', 'http'),
+          'http pull');
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'badcmd', 'http'),
+          'http checkout');
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
deleted file mode 100644
index 5152ef9..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ /dev/null
@@ -1,56 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
-
-<dom-module id="gr-account-dropdown">
-  <template>
-    <style include="shared-styles">
-      gr-dropdown {
-        padding: 0 var(--spacing-m);
-        --gr-button: {
-          color: var(--header-text-color);
-        }
-        --gr-dropdown-item: {
-          color: var(--primary-text-color);
-        }
-      }
-      gr-avatar {
-        height: 2em;
-        width: 2em;
-        vertical-align: middle;
-      }
-    </style>
-    <gr-dropdown
-        link
-        items=[[links]]
-        top-content=[[topContent]]
-        horizontal-align="right">
-        <span hidden$="[[_hasAvatars]]" hidden>[[_accountName(account)]]</span>
-        <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
-            image-size="56" aria-label="Account avatar"></gr-avatar>
-    </gr-dropdown>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-account-dropdown.js"></script>
-</dom-module>
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..2645c63 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,15 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-avatar/gr-avatar.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-dropdown_html.js';
+import {DisplayNameBehavior} from '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
 
-  const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
+const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
 
-  Polymer({
-    is: 'gr-account-dropdown',
+/**
+ * @extends Polymer.Element
+ */
+class GrAccountDropdown extends mixinBehaviors( [
+  DisplayNameBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-account-dropdown'; }
+
+  static get properties() {
+    return {
       account: Object,
       config: Object,
       links: {
@@ -39,69 +59,71 @@
       },
       _hasAvatars: Boolean,
       _switchAccountUrl: String,
-    },
+    };
+  }
 
-    attached() {
-      this._handleLocationChange();
-      this.listen(window, 'location-change', '_handleLocationChange');
-      this.$.restAPI.getConfig().then(cfg => {
-        this.config = cfg;
+  /** @override */
+  attached() {
+    super.attached();
+    this._handleLocationChange();
+    this.listen(window, 'location-change', '_handleLocationChange');
+    this.$.restAPI.getConfig().then(cfg => {
+      this.config = cfg;
 
-        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
-          this._switchAccountUrl = cfg.auth.switch_account_url;
-        } else {
-          this._switchAccountUrl = '';
-        }
-        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-      });
-    },
-
-    behaviors: [
-      Gerrit.DisplayNameBehavior,
-    ],
-
-    detached() {
-      this.unlisten(window, 'location-change', '_handleLocationChange');
-    },
-
-    _getLinks(switchAccountUrl, path) {
-      // Polymer 2: check for undefined
-      if ([switchAccountUrl, path].some(arg => arg === undefined)) {
-        return undefined;
+      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+        this._switchAccountUrl = cfg.auth.switch_account_url;
+      } else {
+        this._switchAccountUrl = '';
       }
+      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+    });
+  }
 
-      const links = [{name: 'Settings', url: '/settings/'}];
-      if (switchAccountUrl) {
-        const replacements = {path};
-        const url = this._interpolateUrl(switchAccountUrl, replacements);
-        links.push({name: 'Switch account', url, external: true});
-      }
-      links.push({name: 'Sign out', url: '/logout'});
-      return links;
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
 
-    _getTopContent(account) {
-      return [
-        {text: this._accountName(account), bold: true},
-        {text: account.email ? account.email : ''},
-      ];
-    },
+  _getLinks(switchAccountUrl, path) {
+    // Polymer 2: check for undefined
+    if ([switchAccountUrl, path].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-    _handleLocationChange() {
-      this._path =
-          window.location.pathname +
-          window.location.search +
-          window.location.hash;
-    },
+    const links = [{name: 'Settings', url: '/settings/'}];
+    if (switchAccountUrl) {
+      const replacements = {path};
+      const url = this._interpolateUrl(switchAccountUrl, replacements);
+      links.push({name: 'Switch account', url, external: true});
+    }
+    links.push({name: 'Sign out', url: '/logout'});
+    return links;
+  }
 
-    _interpolateUrl(url, replacements) {
-      return url.replace(INTERPOLATE_URL_PATTERN, (match, p1) => {
-        return replacements[p1] || '';
-      });
-    },
+  _getTopContent(account) {
+    return [
+      {text: this._accountName(account), bold: true},
+      {text: account.email ? account.email : ''},
+    ];
+  }
 
-    _accountName(account) {
-      return this.getUserName(this.config, account, true);
-    },
-  });
-})();
+  _handleLocationChange() {
+    this._path =
+        window.location.pathname +
+        window.location.search +
+        window.location.hash;
+  }
+
+  _interpolateUrl(url, replacements) {
+    return url.replace(
+        INTERPOLATE_URL_PATTERN,
+        (match, p1) => replacements[p1] || '');
+  }
+
+  _accountName(account) {
+    return this.getUserName(this.config, account);
+  }
+}
+
+customElements.define(GrAccountDropdown.is, GrAccountDropdown);
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
new file mode 100644
index 0000000..b47894e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-dropdown {
+      padding: 0 var(--spacing-m);
+      --gr-button: {
+        color: var(--header-text-color);
+      }
+      --gr-dropdown-item: {
+        color: var(--primary-text-color);
+      }
+    }
+    gr-avatar {
+      height: 2em;
+      width: 2em;
+      vertical-align: middle;
+    }
+  </style>
+  <gr-dropdown
+    link=""
+    items="[[links]]"
+    top-content="[[topContent]]"
+    horizontal-align="right"
+  >
+    <span hidden$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
+    <gr-avatar
+      account="[[account]]"
+      hidden$="[[!_hasAvatars]]"
+      hidden=""
+      image-size="56"
+      aria-label="Account avatar"
+    ></gr-avatar>
+  </gr-dropdown>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..6c8ed68 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-dropdown.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,92 +31,94 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-dropdown tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-account-dropdown.js';
+suite('gr-account-dropdown tests', () => {
+  let element;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+  });
+
+  test('account information', () => {
+    element.account = {name: 'John Doe', email: 'john@doe.com'};
+    assert.deepEqual(element.topContent,
+        [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
+  });
+
+  test('test for account without a name', () => {
+    element.account = {id: '0001'};
+    assert.deepEqual(element.topContent,
+        [{text: 'Anonymous', bold: true}, {text: ''}]);
+  });
+
+  test('test for account without a name but using config', () => {
+    element.config = {
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {id: '0001'};
+    assert.deepEqual(element.topContent,
+        [{text: 'WikiGerrit', bold: true}, {text: ''}]);
+  });
+
+  test('test for account name as an email', () => {
+    element.config = {
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {email: 'john@doe.com'};
+    assert.deepEqual(element.topContent,
+        [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
+  });
+
+  test('switch account', () => {
+    // Missing params.
+    assert.isUndefined(element._getLinks());
+    assert.isUndefined(element._getLinks(null));
+
+    // No switch account link.
+    assert.equal(element._getLinks(null, '').length, 2);
+
+    // Unparameterized switch account link.
+    let links = element._getLinks('/switch-account', '');
+    assert.equal(links.length, 3);
+    assert.deepEqual(links[1], {
+      name: 'Switch account',
+      url: '/switch-account',
+      external: true,
     });
 
-    test('account information', () => {
-      element.account = {name: 'John Doe', email: 'john@doe.com'};
-      assert.deepEqual(element.topContent,
-          [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
-    });
-
-    test('test for account without a name', () => {
-      element.account = {id: '0001'};
-      assert.deepEqual(element.topContent,
-          [{text: 'Anonymous', bold: true}, {text: ''}]);
-    });
-
-    test('test for account without a name but using config', () => {
-      element.config = {
-        user: {
-          anonymous_coward_name: 'WikiGerrit',
-        },
-      };
-      element.account = {id: '0001'};
-      assert.deepEqual(element.topContent,
-          [{text: 'WikiGerrit', bold: true}, {text: ''}]);
-    });
-
-    test('test for account name as an email', () => {
-      element.config = {
-        user: {
-          anonymous_coward_name: 'WikiGerrit',
-        },
-      };
-      element.account = {email: 'john@doe.com'};
-      assert.deepEqual(element.topContent,
-          [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
-    });
-
-    test('switch account', () => {
-      // Missing params.
-      assert.isUndefined(element._getLinks());
-      assert.isUndefined(element._getLinks(null));
-
-      // No switch account link.
-      assert.equal(element._getLinks(null, '').length, 2);
-
-      // Unparameterized switch account link.
-      let links = element._getLinks('/switch-account', '');
-      assert.equal(links.length, 3);
-      assert.deepEqual(links[1], {
-        name: 'Switch account',
-        url: '/switch-account',
-        external: true,
-      });
-
-      // Parameterized switch account link.
-      links = element._getLinks('/switch-account${path}', '/c/123');
-      assert.equal(links.length, 3);
-      assert.deepEqual(links[1], {
-        name: 'Switch account',
-        url: '/switch-account/c/123',
-        external: true,
-      });
-    });
-
-    test('_interpolateUrl', () => {
-      const replacements = {
-        foo: 'bar',
-        test: 'TEST',
-      };
-      const interpolate = function(url) {
-        return element._interpolateUrl(url, replacements);
-      };
-
-      assert.equal(interpolate('test'), 'test');
-      assert.equal(interpolate('${test}'), 'TEST');
-      assert.equal(
-          interpolate('${}, ${test}, ${TEST}, ${foo}'),
-          '${}, TEST, , bar');
+    // Parameterized switch account link.
+    links = element._getLinks('/switch-account${path}', '/c/123');
+    assert.equal(links.length, 3);
+    assert.deepEqual(links[1], {
+      name: 'Switch account',
+      url: '/switch-account/c/123',
+      external: true,
     });
   });
+
+  test('_interpolateUrl', () => {
+    const replacements = {
+      foo: 'bar',
+      test: 'TEST',
+    };
+    const interpolate = function(url) {
+      return element._interpolateUrl(url, replacements);
+    };
+
+    assert.equal(interpolate('test'), 'test');
+    assert.equal(interpolate('${test}'), 'TEST');
+    assert.equal(
+        interpolate('${}, ${test}, ${TEST}, ${foo}'),
+        '${}, TEST, , bar');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
deleted file mode 100644
index 09b928e..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-error-dialog">
-  <template>
-    <style include="shared-styles">
-      .main {
-        max-height: 40em;
-        max-width: 60em;
-        overflow-y: auto;
-        white-space: pre-wrap;
-      }
-      @media screen and (max-width: 50em) {
-        .main {
-          max-height: none;
-          max-width: 50em;
-        }
-      }
-    </style>
-    <gr-dialog
-        id="dialog"
-        cancel-label=""
-        on-confirm="_handleConfirm"
-        confirm-label="Dismiss"
-        confirm-on-enter>
-      <div class="header" slot="header">An error occurred</div>
-      <div class="main" slot="main">[[text]]</div>
-    </gr-dialog>
-  </template>
-  <script src="gr-error-dialog.js"></script>
-</dom-module>
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..6814d89 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
@@ -14,24 +14,51 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-error-dialog',
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-error-dialog_html.js';
 
-    /**
-     * Fired when the dismiss button is pressed.
-     *
-     * @event dismiss
-     */
+/** @extends Polymer.Element */
+class GrErrorDialog extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-error-dialog'; }
+  /**
+   * Fired when the dismiss button is pressed.
+   *
+   * @event dismiss
+   */
+
+  static get properties() {
+    return {
       text: String,
-    },
+      /**
+       * loginUrl to open on "sign in" button click
+       */
+      loginUrl: {
+        type: String,
+        value: '/login',
+      },
+      /**
+       * Show/hide "Sign In" button in dialog
+       */
+      showSignInButton: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    _handleConfirm() {
-      this.dispatchEvent(new CustomEvent('dismiss'));
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
new file mode 100644
index 0000000..39d4f2d
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .main {
+      max-height: 40em;
+      max-width: 60em;
+      overflow-y: auto;
+      white-space: pre-wrap;
+    }
+    @media screen and (max-width: 50em) {
+      .main {
+        max-height: none;
+        max-width: 50em;
+      }
+    }
+    .signInLink {
+      text-decoration: none;
+    }
+  </style>
+  <gr-dialog
+    id="dialog"
+    cancel-label=""
+    on-confirm="_handleConfirm"
+    confirm-label="Dismiss"
+    confirm-on-enter=""
+  >
+    <div class="header" slot="header">An error occurred</div>
+    <div class="main" slot="main">[[text]]</div>
+    <gr-button
+      id="signIn"
+      class$="signInLink"
+      hidden$="[[!showSignInButton]]"
+      link=""
+      slot="footer"
+    >
+      <a href$="[[loginUrl]]" class="signInLink">Sign in</a>
+    </gr-button>
+  </gr-dialog>
+`;
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..bd4991f 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-error-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,17 +31,19 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-error-dialog tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-error-dialog.js';
+suite('gr-error-dialog tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('dismiss tap fires event', done => {
-      element.addEventListener('dismiss', () => { done(); });
-      MockInteractions.tap(element.$.dialog.$.confirm);
-    });
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('dismiss tap fires event', done => {
+    element.addEventListener('dismiss', () => { done(); });
+    MockInteractions.tap(element.$.dialog.$.confirm);
+  });
+});
 </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
deleted file mode 100644
index 048d392..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ /dev/null
@@ -1,40 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../core/gr-error-dialog/gr-error-dialog.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<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">
-
-<dom-module id="gr-error-manager">
-  <template>
-    <gr-overlay with-backdrop id="errorOverlay">
-      <gr-error-dialog
-          id="errorDialog"
-          on-dismiss="_handleDismissErrorDialog"
-          confirm-label="Dismiss"
-          confirm-on-enter></gr-error-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-error-manager.js"></script>
-</dom-module>
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..4b5969a 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
@@ -14,30 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+/* Import to get Gerrit interface */
+/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
 
-  const HIDE_ALERT_TIMEOUT_MS = 5000;
-  const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
-  const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
-  const SIGN_IN_WIDTH_PX = 690;
-  const SIGN_IN_HEIGHT_PX = 500;
-  const TOO_MANY_FILES = 'too many files to find conflicts';
-  const AUTHENTICATION_REQUIRED = 'Authentication required\n';
+import '../../../scripts/bundled-polymer.js';
+import '../gr-error-dialog/gr-error-dialog.js';
+import '../gr-reporting/gr-reporting.js';
+import '../../shared/gr-alert/gr-alert.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-error-manager_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {authService} from '../../shared/gr-rest-api-interface/gr-auth.js';
+import {gerritEventEmitter} from '../../shared/gr-event-emitter/gr-event-emitter.js';
 
-  Polymer({
-    is: 'gr-error-manager',
+const HIDE_ALERT_TIMEOUT_MS = 5000;
+const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
+const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
+const SIGN_IN_WIDTH_PX = 690;
+const SIGN_IN_HEIGHT_PX = 500;
+const TOO_MANY_FILES = 'too many files to find conflicts';
+const AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-    ],
+/**
+ * @extends Polymer.Element
+ */
+class GrErrorManager extends mixinBehaviors( [
+  BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /**
-       * The ID of the account that was logged in when the app was launched. If
-       * not set, then there was no account at launch.
-       */
+  static get is() { return 'gr-error-manager'; }
+
+  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,
 
       /** @type {?Object} */
@@ -56,179 +82,272 @@
         type: Number,
         value() { return Date.now(); },
       },
-    },
 
-    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');
-    },
+      loginUrl: {
+        type: String,
+        value: '/login',
+      },
+    };
+  }
 
-    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');
-    },
+  constructor() {
+    super();
 
-    _shouldSuppressError(msg) {
-      return msg.includes(TOO_MANY_FILES);
-    },
+    /** @type {!Auth} */
+    this._authService = authService;
 
-    _handleAuthRequired() {
-      this._showAuthErrorAlert(
-          'Log in is required to perform that action.', 'Log in.');
-    },
+    /** @type {?Function} */
+    this._authErrorHandlerDeregistrationHook;
+  }
 
-    _handleAuthError() {
-      this._showAuthErrorAlert('Auth error', 'Refresh credentials.');
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(document, 'server-error', '_handleServerError');
+    this.listen(document, 'network-error', '_handleNetworkError');
+    this.listen(document, 'show-alert', '_handleShowAlert');
+    this.listen(document, 'show-error', '_handleShowErrorDialog');
+    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+    this.listen(document, 'show-auth-required', '_handleAuthRequired');
 
-    _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);
+    this._authErrorHandlerDeregistrationHook =
+      gerritEventEmitter.on('auth-error',
+          event => {
+            this._handleAuthError(event.message, event.action);
           });
-    },
+  }
 
-    _constructServerErrorMsg({errorText, status, statusText, url}) {
-      let err = `Error ${status}`;
-      if (statusText) { err += ` (${statusText})`; }
-      if (errorText || url) { err += ': '; }
-      if (errorText) { err += errorText; }
-      if (url) { err += `\nEndpoint: ${url}`; }
-      return err;
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this._clearHideAlertHandle();
+    this.unlisten(document, 'server-error', '_handleServerError');
+    this.unlisten(document, 'network-error', '_handleNetworkError');
+    this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.unlisten(document, 'show-error', '_handleShowErrorDialog');
 
-    _handleShowAlert(e) {
-      this._showAlert(e.detail.message, e.detail.action, e.detail.callback,
-          e.detail.dismissOnNavigation);
-    },
+    this._authErrorHandlerDeregistrationHook();
+  }
 
-    _handleNetworkError(e) {
-      this._showAlert('Server unavailable');
-      console.error(e.detail.error.message);
-    },
+  _shouldSuppressError(msg) {
+    return msg.includes(TOO_MANY_FILES);
+  }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
+  _handleAuthRequired() {
+    this._showAuthErrorAlert(
+        'Log in is required to perform that action.', 'Log in.');
+  }
 
-    /**
-     * @param {string} text
-     * @param {?string=} opt_actionText
-     * @param {?Function=} opt_actionCallback
-     * @param {?boolean=} opt_dismissOnNavigation
-     */
-    _showAlert(text, opt_actionText, opt_actionCallback,
-        opt_dismissOnNavigation) {
-      if (this._alertElement) {
-        this._hideAlert();
+  _handleAuthError(msg, action) {
+    this.$.noInteractionOverlay.open().then(() => {
+      this._showAuthErrorAlert(msg, action);
+    });
+  }
+
+  _handleServerError(e) {
+    const {request, response} = e.detail;
+    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)) {
+        const trace =
+            response.headers && response.headers.get('X-Gerrit-Trace');
+        if (response.status === 404) {
+          this._showNotFoundMessageWithTip({
+            status,
+            statusText,
+            errorText,
+            url,
+            trace,
+          });
+        } else {
+          this._showErrorDialog(this._constructServerErrorMsg({
+            status,
+            statusText,
+            errorText,
+            url,
+            trace,
+          }));
+        }
       }
+      console.log(`server error: ${errorText}`);
+    });
+  }
 
-      this._clearHideAlertHandle();
-      if (opt_dismissOnNavigation) {
-        // Persist alert until navigation.
-        this.listen(document, 'location-change', '_hideAlert');
-      } else {
-        this._hideAlertHandle =
-          this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
-      }
-      const el = this._createToastAlert();
-      el.show(text, opt_actionText, opt_actionCallback);
-      this._alertElement = el;
-    },
+  _showNotFoundMessageWithTip({status, statusText, errorText, url, trace}) {
+    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+      const tip = isLoggedIn ?
+        'You might have not enough privileges.' :
+        'You might have not enough privileges. Sign in and try again.';
+      this._showErrorDialog(this._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+        trace,
+        tip,
+      }), {
+        showSignInButton: !isLoggedIn,
+      });
+    });
+    return;
+  }
 
-    _hideAlert() {
-      if (!this._alertElement) { return; }
+  _constructServerErrorMsg({errorText, status, statusText, url, trace, tip}) {
+    let err = '';
+    if (tip) {
+      err += `${tip}\n\n`;
+    }
+    err += `Error ${status}`;
+    if (statusText) { err += ` (${statusText})`; }
+    if (errorText || url) { err += ': '; }
+    if (errorText) { err += errorText; }
+    if (url) { err += `\nEndpoint: ${url}`; }
+    if (trace) { err += `\nTrace Id: ${trace}`; }
+    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);
+  }
+
+  /**
+   * @param {string} text
+   * @param {?string=} opt_actionText
+   * @param {?Function=} opt_actionCallback
+   * @param {?boolean=} opt_dismissOnNavigation
+   */
+  _showAlert(text, opt_actionText, opt_actionCallback,
+      opt_dismissOnNavigation) {
+    if (this._alertElement) {
+      // do not override auth alerts
+      if (this._alertElement.type === 'AUTH') return;
+      this._hideAlert();
+    }
+
+    this._clearHideAlertHandle();
+    if (opt_dismissOnNavigation) {
+      // Persist alert until navigation.
+      this.listen(document, 'location-change', '_hideAlert');
+    } else {
+      this._hideAlertHandle =
+        this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
+    }
+    const el = this._createToastAlert();
+    el.show(text, opt_actionText, opt_actionCallback);
+    this._alertElement = el;
+  }
+
+  _hideAlert() {
+    if (!this._alertElement) { return; }
+
+    this._alertElement.hide();
+    this._alertElement = null;
+
+    // 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) {
+    // hide any existing alert like `reload`
+    // as auth error should have the highest priority
+    if (this._alertElement) {
       this._alertElement.hide();
-      this._alertElement = null;
+    }
 
-      // Remove listener for page navigation, if it exists.
-      this.unlisten(document, 'location-change', '_hideAlert');
-    },
+    this._alertElement = this._createToastAlert();
+    this._alertElement.type = 'AUTH';
+    this._alertElement.show(errorText, actionText,
+        this._createLoginPopup.bind(this));
 
-    _clearHideAlertHandle() {
-      if (this._hideAlertHandle != null) {
-        this.cancelAsync(this._hideAlertHandle);
-        this._hideAlertHandle = null;
-      }
-    },
+    this._refreshingCredentials = true;
+    this._requestCheckLoggedIn();
+    if (!document.hidden) {
+      this._handleVisibilityChange();
+    }
+  }
 
-    _showAuthErrorAlert(errorText, actionText) {
-      // TODO(viktard): close alert if it's not for auth error.
-      if (this._alertElement) { return; }
+  _createToastAlert() {
+    const el = document.createElement('gr-alert');
+    el.toast = true;
+    return el;
+  }
 
-      this._alertElement = this._createToastAlert();
-      this._alertElement.show(errorText, actionText,
-          this._createLoginPopup.bind(this));
+  _handleVisibilityChange() {
+    // Ignore when the page is transitioning to hidden (or hidden is
+    // undefined).
+    if (document.hidden !== false) { return; }
 
-      this._refreshingCredentials = true;
-      this._requestCheckLoggedIn();
-      if (!document.hidden) {
-        this._handleVisibilityChange();
-      }
-    },
+    // If not currently refreshing credentials and the credentials are old,
+    // request them to confirm their validity or (display an auth toast if it
+    // fails).
+    const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+    if (!this._refreshingCredentials &&
+        this.knownAccountId !== undefined &&
+        timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
+      this._lastCredentialCheck = Date.now();
 
-    _createToastAlert() {
-      const el = document.createElement('gr-alert');
-      el.toast = true;
-      return el;
-    },
+      // check auth status in case:
+      // - user signed out
+      // - user switched account
+      this._checkSignedIn();
+    }
+  }
 
-    _handleVisibilityChange() {
-      // Ignore when the page is transitioning to hidden (or hidden is
-      // undefined).
-      if (document.hidden !== false) { return; }
+  _requestCheckLoggedIn() {
+    this.debounce(
+        'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
+  }
 
-      // If not currently refreshing credentials and the credentials are old,
-      // request them to confirm their validity or (display an auth toast if it
-      // fails).
-      const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
-      if (!this._refreshingCredentials &&
-          this.knownAccountId !== undefined &&
-          timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
-        this._lastCredentialCheck = Date.now();
-        this.$.restAPI.checkCredentials();
-      }
-    },
+  _checkSignedIn() {
+    this._lastCredentialCheck = Date.now();
 
-    _requestCheckLoggedIn() {
-      this.debounce(
-          'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
-    },
+    // force to refetch account info
+    this.$.restAPI.invalidateAccountsCache();
+    this._authService.clearCache();
 
-    _checkSignedIn() {
-      this.$.restAPI.checkCredentials().then(account => {
-        const isLoggedIn = !!account;
-        this._lastCredentialCheck = Date.now();
-        if (this._refreshingCredentials) {
-          if (isLoggedIn) {
+    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) {
@@ -237,56 +356,62 @@
             }
 
             this._handleCredentialRefreshed();
-          } else {
-            this._requestCheckLoggedIn();
           }
-        }
-      });
-    },
+        });
+      }
+    });
+  }
 
-    _reloadPage() {
-      window.location.reload();
-    },
+  _reloadPage() {
+    window.location.reload();
+  }
 
-    _createLoginPopup() {
-      const left = window.screenLeft +
-          (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
-      const top = window.screenTop +
-          (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
-      const options = [
-        'width=' + SIGN_IN_WIDTH_PX,
-        'height=' + SIGN_IN_HEIGHT_PX,
-        'left=' + left,
-        'top=' + top,
-      ];
-      window.open(this.getBaseUrl() +
-          '/login/%3FcloseAfterLogin', '_blank', options.join(','));
-      this.listen(window, 'focus', '_handleWindowFocus');
-    },
+  _createLoginPopup() {
+    const left = window.screenLeft +
+        (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
+    const top = window.screenTop +
+        (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
+    const options = [
+      'width=' + SIGN_IN_WIDTH_PX,
+      'height=' + SIGN_IN_HEIGHT_PX,
+      'left=' + left,
+      'top=' + top,
+    ];
+    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.');
-    },
+  _handleCredentialRefreshed() {
+    this.unlisten(window, 'focus', '_handleWindowFocus');
+    this._refreshingCredentials = false;
+    this._hideAlert();
+    this._showAlert('Credentials refreshed.');
+    this.$.noInteractionOverlay.close();
 
-    _handleWindowFocus() {
-      this.flushDebouncer('checkLoggedIn');
-    },
+    // Clear the cache for auth
+    this._authService.clearCache();
+  }
 
-    _handleShowErrorDialog(e) {
-      this._showErrorDialog(e.detail.message);
-    },
+  _handleWindowFocus() {
+    this.flushDebouncer('checkLoggedIn');
+  }
 
-    _handleDismissErrorDialog() {
-      this.$.errorOverlay.close();
-    },
+  _handleShowErrorDialog(e) {
+    this._showErrorDialog(e.detail.message);
+  }
 
-    _showErrorDialog(message) {
-      this.$.reporting.reportErrorDialog(message);
-      this.$.errorDialog.text = message;
-      this.$.errorOverlay.open();
-    },
-  });
-})();
+  _handleDismissErrorDialog() {
+    this.$.errorOverlay.close();
+  }
+
+  _showErrorDialog(message, opt_options) {
+    this.$.reporting.reportErrorDialog(message);
+    this.$.errorDialog.text = message;
+    this.$.errorDialog.showSignInButton =
+        opt_options && opt_options.showSignInButton;
+    this.$.errorOverlay.open();
+  }
+}
+
+customElements.define(GrErrorManager.is, GrErrorManager);
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
new file mode 100644
index 0000000..4d32f24
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-overlay with-backdrop="" id="errorOverlay">
+    <gr-error-dialog
+      id="errorDialog"
+      on-dismiss="_handleDismissErrorDialog"
+      confirm-label="Dismiss"
+      confirm-on-enter=""
+      login-url="[[loginUrl]]"
+    ></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>
+`;
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..8272c6e 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
@@ -17,16 +17,18 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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="gr-error-manager.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-error-manager.js';
+void (0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -34,70 +36,108 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-error-manager tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-error-manager.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
+_testOnly_initGerritPluginApi();
+
+suite('gr-error-manager tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('when authed', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
+      sandbox.stub(window, 'fetch')
+          .returns(Promise.resolve({ok: true, status: 204}));
       element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
+      element._authService.clearCache();
     });
 
     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(() => {
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
         assert.isFalse(showAuthErrorStub.calledOnce);
         done();
       });
     });
 
-    test('shows auth error on 403 and Authentication required', done => {
-      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+    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.dispatchEvent(
+              new CustomEvent('server-error', {
+                detail:
+              {response: {status: 403, text() { return responseText; }}},
+                composed: true, bubbles: true,
+              }));
+          flush(() => {
+            assert.isTrue(showAuthErrorStub.calledOnce);
+            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');
-      element.fire('server-error',
-          {response: {status: 403, text() { return responseText; }}}
-      );
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        responseText,
-      ]).then(() => {
-        assert.isTrue(showAuthErrorStub.calledOnce);
+      sinon.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(true));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
         done();
       });
     });
 
     test('show logged in error', () => {
       sandbox.stub(element, '_showAuthErrorAlert');
-      element.fire('show-auth-required');
+      element.dispatchEvent(
+          new CustomEvent('show-auth-required', {
+            composed: true, bubbles: true,
+          }));
       assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
           'Log in is required to perform that action.', 'Log in.'));
     });
 
     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}});
+      const textSpy = sandbox.spy(() => Promise.resolve('ZOMG'));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {response: {status: 500, text: textSpy}},
+            composed: true, bubbles: true,
+          }));
 
       assert.isTrue(textSpy.called);
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        textSpy.lastCall.returnValue,
-      ]).then(() => {
+      flush(() => {
         assert.isTrue(showErrorStub.calledOnce);
         assert.isTrue(showErrorStub.lastCall.calledWithExactly(
             'Error 500: ZOMG'));
@@ -115,29 +155,65 @@
           '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, 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');
+      '\nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+        trace: 'xxxxx',
+      }), 'Error 409 (Conflict): change conflicts' +
+      '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
+    });
+
+    test('extract trace id from headers if exists', done => {
+      const textSpy = sandbox.spy(
+          () => Promise.resolve('500')
+      );
+      const headers = new Headers();
+      headers.set('X-Gerrit-Trace', 'xxxx');
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {
+              response: {
+                headers,
+                status: 500,
+                text: textSpy,
+              },
+            },
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
+        assert.equal(
+            element.$.errorDialog.text,
+            'Error 500: 500\nTrace Id: xxxx'
+        );
+        done();
+      });
     });
 
     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');
-      });
-      element.fire('server-error', {response: {status: 500, text: textSpy}});
+      const textSpy = sandbox.spy(
+          () => Promise.resolve('too many files to find conflicts')
+      );
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {response: {status: 500, text: textSpy}},
+            composed: true, bubbles: true,
+          }));
 
       assert.isTrue(textSpy.called);
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        textSpy.lastCall.returnValue,
-      ]).then(() => {
+      flush(() => {
         assert.isFalse(showAlertStub.called);
         done();
       });
@@ -146,7 +222,11 @@
     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')});
+      element.dispatchEvent(
+          new CustomEvent('network-error', {
+            detail: {error: new Error('ZOMG')},
+            composed: true, bubbles: true,
+          }));
       flush(() => {
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
@@ -158,57 +238,201 @@
     });
 
     test('show auth refresh toast', done => {
-      const refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
-          () => { return Promise.resolve(true); });
+      // 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');
-      element.fire('server-error',
-          {response: {status: 403, text() { return responseText; }}}
-      );
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        responseText,
-      ]).then(() => {
-        assert.isTrue(toastSpy.called);
-        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.');
-
-        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');
-
-        element._handleWindowFocus();
-        assert.isTrue(refreshStub.called);
-        element.flushDebouncer('checkLoggedIn');
+      // fake failed auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      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(() => {
-          assert.isTrue(refreshStub.called);
-          assert.isTrue(hideToastSpy.called);
+          // auth-error fired
+          assert.isTrue(toastSpy.called);
 
-          assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
-          toast = toastSpy.lastCall.returnValue;
+          // toast
+          let toast = toastSpy.lastCall.returnValue;
           assert.isOk(toast);
           assert.include(
-              Polymer.dom(toast.root).textContent, 'Credentials refreshed');
+              dom(toast.root).textContent, 'Credentials expired.');
+          assert.include(
+              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.shadowRoot
+              .querySelector('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(
+                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.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: 'test reload', action: 'reload'},
+            composed: true, bubbles: true,
+          }));
+      const toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'test reload');
+
+      // fake auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      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(
+              dom(toast.root).textContent, 'Credentials expired.');
+          assert.include(
+              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.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: 'test reload', action: 'reload'},
+            composed: true, bubbles: true,
+          }));
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'test reload');
+
+      // new alert
+      element.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: 'second-test', action: 'reload'},
+            composed: true, bubbles: true,
+          }));
+
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(dom(toast.root).textContent, 'second-test');
+    });
+
+    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');
+
+      // fake auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      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(() => {
+          let toast = toastSpy.lastCall.returnValue;
+          assert.include(
+              dom(toast.root).textContent, 'Credentials expired.');
+          assert.include(
+              dom(toast.root).textContent, 'Refresh credentials');
+
+          // fake an alert
+          element.dispatchEvent(
+              new CustomEvent('show-alert', {
+                detail: {
+                  message: 'test-alert', action: 'reload',
+                },
+                composed: true, bubbles: true,
+              }));
+          flush(() => {
+            toast = toastSpy.lastCall.returnValue;
+            assert.isOk(toast);
+            assert.include(
+                dom(toast.root).textContent, 'Credentials expired.');
+            done();
+          });
+        });
+      });
+    });
+
     test('show alert', () => {
       const alertObj = {message: 'foo'};
       sandbox.stub(element, '_showAlert');
-      element.fire('show-alert', alertObj);
+      element.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: alertObj,
+            composed: true, bubbles: true,
+          }));
       assert.isTrue(element._showAlert.calledOnce);
       assert.equal(element._showAlert.lastCall.args[0], 'foo');
       assert.isNotOk(element._showAlert.lastCall.args[1]);
@@ -216,8 +440,8 @@
     });
 
     test('checks stale credentials on visibility change', () => {
-      const refreshStub = sandbox.stub(element.$.restAPI,
-          'checkCredentials');
+      const refreshStub = sandbox.stub(element,
+          '_checkSignedIn');
       sandbox.stub(Date, 'now').returns(999999);
       element._lastCredentialCheck = 0;
       element._handleVisibilityChange();
@@ -234,29 +458,9 @@
       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();
-      });
-    });
-
     test('refreshes with same credentials', done => {
       const accountPromise = Promise.resolve({_account_id: 1234});
-      sandbox.stub(element.$.restAPI, 'checkCredentials')
+      sandbox.stub(element.$.restAPI, 'getAccount')
           .returns(accountPromise);
       const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
       const handleRefreshStub = sandbox.stub(element,
@@ -267,7 +471,7 @@
       element._refreshingCredentials = true;
       element._checkSignedIn();
 
-      accountPromise.then(() => {
+      flush(() => {
         assert.isFalse(requestCheckStub.called);
         assert.isTrue(handleRefreshStub.called);
         assert.isFalse(reloadStub.called);
@@ -275,27 +479,6 @@
       });
     });
 
-    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');
@@ -306,20 +489,82 @@
     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 reportStub = sandbox.stub(
+          element.$.reporting,
+          'reportErrorDialog'
+      );
 
       const message = 'test message';
-      element.fire('show-error', {message});
+      element.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message},
+            composed: true, bubbles: true,
+          }));
       flushAsynchronousOperations();
 
       assert.isTrue(openStub.called);
       assert.isTrue(reportStub.called);
       assert.equal(element.$.errorDialog.text, message);
 
-      element.$.errorDialog.fire('dismiss');
+      element.$.errorDialog.dispatchEvent(
+          new CustomEvent('dismiss', {
+            composed: true, bubbles: true,
+          }));
       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();
+      });
+    });
   });
+
+  suite('when not authed', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+    });
+
+    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._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isTrue(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
deleted file mode 100644
index a863276..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.html
+++ /dev/null
@@ -1,48 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-key-binding-display">
-  <template>
-    <style include="shared-styles">
-      .key {
-        background-color: var(--chip-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        display: inline-block;
-        font-weight: var(--font-weight-bold);
-        padding: var(--spacing-xxs) var(--spacing-m);
-        text-align: center;
-      }
-    </style>
-    <template is="dom-repeat" items="[[binding]]">
-      <template is="dom-if" if="[[index]]">
-        or
-      </template>
-      <template
-          is="dom-repeat"
-          items="[[_computeModifiers(item)]]"
-          as="modifier">
-        <span class="key modifier">[[modifier]]</span>
-      </template>
-      <span class="key">[[_computeKey(item)]]</span>
-    </template>
-  </template>
-  <script src="gr-key-binding-display.js"></script>
-</dom-module>
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..5d7ec27 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
@@ -14,23 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-key-binding-display',
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-key-binding-display_html.js';
 
-    properties: {
-      /** @type {Array<string>} */
+/** @extends Polymer.Element */
+class GrKeyBindingDisplay extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-key-binding-display'; }
+
+  static get properties() {
+    return {
+    /** @type {Array<string>} */
       binding: Array,
-    },
+    };
+  }
 
-    _computeModifiers(binding) {
-      return binding.slice(0, binding.length - 1);
-    },
+  _computeModifiers(binding) {
+    return binding.slice(0, binding.length - 1);
+  }
 
-    _computeKey(binding) {
-      return binding[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_html.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
new file mode 100644
index 0000000..334a40a
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .key {
+      background-color: var(--chip-background-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      display: inline-block;
+      font-weight: var(--font-weight-bold);
+      padding: var(--spacing-xxs) var(--spacing-m);
+      text-align: center;
+    }
+  </style>
+  <template is="dom-repeat" items="[[binding]]">
+    <template is="dom-if" if="[[index]]">
+      or
+    </template>
+    <template is="dom-repeat" items="[[_computeModifiers(item)]]" as="modifier">
+      <span class="key modifier">[[modifier]]</span>
+    </template>
+    <span class="key">[[_computeKey(item)]]</span>
+  </template>
+`;
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..8ae0f69 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
@@ -16,16 +16,13 @@
 limitations under the License.
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-key-binding-display.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,36 +30,38 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-key-binding-display tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-key-binding-display.js';
+suite('gr-key-binding-display tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  suite('_computeKey', () => {
+    test('unmodified key', () => {
+      assert.strictEqual(element._computeKey(['x']), 'x');
     });
 
-    suite('_computeKey', () => {
-      test('unmodified key', () => {
-        assert.strictEqual(element._computeKey(['x']), 'x');
-      });
-
-      test('key with modifiers', () => {
-        assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
-        assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
-      });
-    });
-
-    suite('_computeModifiers', () => {
-      test('single unmodified key', () => {
-        assert.deepEqual(element._computeModifiers(['x']), []);
-      });
-
-      test('key with modifiers', () => {
-        assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-        assert.deepEqual(
-            element._computeModifiers(['Shift', 'Meta', 'x']),
-            ['Shift', 'Meta']);
-      });
+    test('key with modifiers', () => {
+      assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
+      assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
     });
   });
+
+  suite('_computeModifiers', () => {
+    test('single unmodified key', () => {
+      assert.deepEqual(element._computeModifiers(['x']), []);
+    });
+
+    test('key with modifiers', () => {
+      assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
+      assert.deepEqual(
+          element._computeModifiers(['Shift', 'Meta', 'x']),
+          ['Shift', 'Meta']);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
deleted file mode 100644
index 5494f62..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ /dev/null
@@ -1,111 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../gr-key-binding-display/gr-key-binding-display.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-keyboard-shortcuts-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        max-height: 100vh;
-        overflow-y: auto;
-      }
-      header{
-        padding: var(--spacing-l);
-      }
-      main {
-        display: flex;
-        padding: 0 var(--spacing-xxl) var(--spacing-xxl);
-      }
-      header {
-        align-items: center;
-        border-bottom: 1px solid var(--border-color);
-        display: flex;
-        justify-content: space-between;
-      }
-      table:last-of-type {
-        margin-left: var(--spacing-xxl);
-      }
-      td {
-        padding: var(--spacing-xs) 0;
-      }
-      td:first-child {
-        padding-right: var(--spacing-m);
-        text-align: right;
-      }
-      .header {
-        font-weight: var(--font-weight-bold);
-        padding-top: var(--spacing-l);
-      }
-      .modifier {
-        font-weight: normal;
-      }
-    </style>
-    <header>
-      <h3>Keyboard shortcuts</h3>
-      <gr-button link on-click="_handleCloseTap">Close</gr-button>
-    </header>
-    <main>
-      <table>
-        <tbody>
-          <template is="dom-repeat" items="[[_left]]">
-            <tr>
-              <td></td><td class="header">[[item.section]]</td>
-            </tr>
-            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
-              <tr>
-                <td>
-                  <gr-key-binding-display binding="[[shortcut.binding]]">
-                  </gr-key-binding-display>
-                </td>
-                <td>[[shortcut.text]]</td>
-              </tr>
-            </template>
-          </template>
-        </tbody>
-      </table>
-      <template is="dom-if" if="[[_right]]">
-        <table>
-          <tbody>
-            <template is="dom-repeat" items="[[_right]]">
-              <tr>
-                <td></td><td class="header">[[item.section]]</td>
-              </tr>
-              <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
-                <tr>
-                  <td>
-                    <gr-key-binding-display binding="[[shortcut.binding]]">
-                    </gr-key-binding-display>
-                  </td>
-                  <td>[[shortcut.text]]</td>
-                </tr>
-              </template>
-            </template>
-          </tbody>
-        </table>
-      </template>
-    </main>
-    <footer></footer>
-  </template>
-  <script src="gr-keyboard-shortcuts-dialog.js"></script>
-</dom-module>
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..beb0f7e 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
@@ -14,21 +14,39 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const {ShortcutSection} = window.Gerrit.KeyboardShortcutBinder;
+import '../../shared/gr-button/gr-button.js';
+import '../gr-key-binding-display/gr-key-binding-display.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html.js';
+import {KeyboardShortcutBehavior, KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  Polymer({
-    is: 'gr-keyboard-shortcuts-dialog',
+const {ShortcutSection} = KeyboardShortcutBinder;
 
-    /**
-     * Fired when the user presses the close button.
-     *
-     * @event close
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrKeyboardShortcutsDialog extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-keyboard-shortcuts-dialog'; }
+  /**
+   * Fired when the user presses the close button.
+   *
+   * @event close
+   */
+
+  static get properties() {
+    return {
       _left: Array,
       _right: Array,
 
@@ -47,81 +65,87 @@
           };
         },
       },
-    },
+    };
+  }
 
-    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));
+  }
 
-    attached() {
-      this.addKeyboardShortcutDirectoryListener(
-          this._onDirectoryUpdated.bind(this));
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this.removeKeyboardShortcutDirectoryListener(
+        this._onDirectoryUpdated.bind(this));
+  }
 
-    detached() {
-      this.removeKeyboardShortcutDirectoryListener(
-          this._onDirectoryUpdated.bind(this));
-    },
+  _handleCloseTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('close', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleCloseTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('close', null, {bubbles: false});
-    },
+  _onDirectoryUpdated(directory) {
+    const left = [];
+    const right = [];
 
-    _onDirectoryUpdated(directory) {
-      const left = [];
-      const right = [];
+    if (directory.has(ShortcutSection.EVERYWHERE)) {
+      left.push({
+        section: ShortcutSection.EVERYWHERE,
+        shortcuts: directory.get(ShortcutSection.EVERYWHERE),
+      });
+    }
 
-      if (directory.has(ShortcutSection.EVERYWHERE)) {
-        left.push({
-          section: ShortcutSection.EVERYWHERE,
-          shortcuts: directory.get(ShortcutSection.EVERYWHERE),
-        });
-      }
+    if (directory.has(ShortcutSection.NAVIGATION)) {
+      left.push({
+        section: ShortcutSection.NAVIGATION,
+        shortcuts: directory.get(ShortcutSection.NAVIGATION),
+      });
+    }
 
-      if (directory.has(ShortcutSection.NAVIGATION)) {
-        left.push({
-          section: ShortcutSection.NAVIGATION,
-          shortcuts: directory.get(ShortcutSection.NAVIGATION),
-        });
-      }
+    if (directory.has(ShortcutSection.ACTIONS)) {
+      right.push({
+        section: ShortcutSection.ACTIONS,
+        shortcuts: directory.get(ShortcutSection.ACTIONS),
+      });
+    }
 
-      if (directory.has(ShortcutSection.ACTIONS)) {
-        right.push({
-          section: ShortcutSection.ACTIONS,
-          shortcuts: directory.get(ShortcutSection.ACTIONS),
-        });
-      }
+    if (directory.has(ShortcutSection.REPLY_DIALOG)) {
+      right.push({
+        section: ShortcutSection.REPLY_DIALOG,
+        shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
+      });
+    }
 
-      if (directory.has(ShortcutSection.REPLY_DIALOG)) {
-        right.push({
-          section: ShortcutSection.REPLY_DIALOG,
-          shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
-        });
-      }
+    if (directory.has(ShortcutSection.FILE_LIST)) {
+      right.push({
+        section: ShortcutSection.FILE_LIST,
+        shortcuts: directory.get(ShortcutSection.FILE_LIST),
+      });
+    }
 
-      if (directory.has(ShortcutSection.FILE_LIST)) {
-        right.push({
-          section: ShortcutSection.FILE_LIST,
-          shortcuts: directory.get(ShortcutSection.FILE_LIST),
-        });
-      }
+    if (directory.has(ShortcutSection.DIFFS)) {
+      right.push({
+        section: ShortcutSection.DIFFS,
+        shortcuts: directory.get(ShortcutSection.DIFFS),
+      });
+    }
 
-      if (directory.has(ShortcutSection.DIFFS)) {
-        right.push({
-          section: ShortcutSection.DIFFS,
-          shortcuts: directory.get(ShortcutSection.DIFFS),
-        });
-      }
+    this.set('_left', left);
+    this.set('_right', right);
+  }
+}
 
-      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_html.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
new file mode 100644
index 0000000..78b576e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      max-height: 100vh;
+      overflow-y: auto;
+    }
+    header {
+      padding: var(--spacing-l);
+    }
+    main {
+      display: flex;
+      padding: 0 var(--spacing-xxl) var(--spacing-xxl);
+    }
+    header {
+      align-items: center;
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+    }
+    table:last-of-type {
+      margin-left: var(--spacing-xxl);
+    }
+    td {
+      padding: var(--spacing-xs) 0;
+    }
+    td:first-child {
+      padding-right: var(--spacing-m);
+      text-align: right;
+    }
+    .header {
+      font-weight: var(--font-weight-bold);
+      padding-top: var(--spacing-l);
+    }
+    .modifier {
+      font-weight: var(--font-weight-normal);
+    }
+  </style>
+  <header>
+    <h3>Keyboard shortcuts</h3>
+    <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
+  </header>
+  <main>
+    <table>
+      <tbody>
+        <template is="dom-repeat" items="[[_left]]">
+          <tr>
+            <td></td>
+            <td class="header">[[item.section]]</td>
+          </tr>
+          <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+            <tr>
+              <td>
+                <gr-key-binding-display binding="[[shortcut.binding]]">
+                </gr-key-binding-display>
+              </td>
+              <td>[[shortcut.text]]</td>
+            </tr>
+          </template>
+        </template>
+      </tbody>
+    </table>
+    <template is="dom-if" if="[[_right]]">
+      <table>
+        <tbody>
+          <template is="dom-repeat" items="[[_right]]">
+            <tr>
+              <td></td>
+              <td class="header">[[item.section]]</td>
+            </tr>
+            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+              <tr>
+                <td>
+                  <gr-key-binding-display binding="[[shortcut.binding]]">
+                  </gr-key-binding-display>
+                </td>
+                <td>[[shortcut.text]]</td>
+              </tr>
+            </template>
+          </template>
+        </tbody>
+      </table>
+    </template>
+  </main>
+  <footer></footer>
+`;
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..1b5cd0f 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
@@ -16,16 +16,13 @@
 limitations under the License.
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-keyboard-shortcuts-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,149 +30,152 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-keyboard-shortcuts-dialog tests', () => {
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-keyboard-shortcuts-dialog.js';
+import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+suite('gr-keyboard-shortcuts-dialog tests', () => {
+  const kb = KeyboardShortcutBinder;
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
+  setup(() => {
+    element = fixture('basic');
+  });
+
+  function update(directory) {
+    element._onDirectoryUpdated(directory);
+    flushAsynchronousOperations();
+  }
+
+  suite('_left and _right contents', () => {
+    test('empty dialog', () => {
+      assert.strictEqual(element._left.length, 0);
+      assert.strictEqual(element._right.length, 0);
     });
 
-    function update(directory) {
-      element._onDirectoryUpdated(directory);
-      flushAsynchronousOperations();
-    }
+    test('everywhere goes on left', () => {
+      update(new Map([
+        [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: kb.ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
 
-    suite('_left and _right contents', () => {
-      test('empty dialog', () => {
-        assert.strictEqual(element._left.length, 0);
-        assert.strictEqual(element._right.length, 0);
-      });
+    test('navigation goes on left', () => {
+      update(new Map([
+        [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: kb.ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
 
-      test('everywhere goes on left', () => {
-        update(new Map([
-          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._left,
-            [
-              {
-                section: kb.ShortcutSection.EVERYWHERE,
-                shortcuts: ['everywhere shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._right.length, 0);
-      });
+    test('actions go on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('navigation goes on left', () => {
-        update(new Map([
-          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._left,
-            [
-              {
-                section: kb.ShortcutSection.NAVIGATION,
-                shortcuts: ['navigation shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._right.length, 0);
-      });
+    test('reply dialog goes on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.REPLY_DIALOG,
+              shortcuts: ['reply dialog shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('actions go on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.ACTIONS,
-                shortcuts: ['actions shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
+    test('file list goes on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.FILE_LIST,
+              shortcuts: ['file list shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('reply dialog goes on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.REPLY_DIALOG,
-                shortcuts: ['reply dialog shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
+    test('diffs go on right', () => {
+      update(new Map([
+        [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
 
-      test('file list goes on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.FILE_LIST,
-                shortcuts: ['file list shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
-
-      test('diffs go on right', () => {
-        update(new Map([
-          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.DIFFS,
-                shortcuts: ['diffs shortcuts'],
-              },
-            ]);
-        assert.strictEqual(element._left.length, 0);
-      });
-
-      test('multiple sections on each side', () => {
-        update(new Map([
-          [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
-          [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
-          [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-          [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-        ]));
-        assert.deepEqual(
-            element._left,
-            [
-              {
-                section: kb.ShortcutSection.EVERYWHERE,
-                shortcuts: ['everywhere shortcuts'],
-              },
-              {
-                section: kb.ShortcutSection.NAVIGATION,
-                shortcuts: ['navigation shortcuts'],
-              },
-            ]);
-        assert.deepEqual(
-            element._right,
-            [
-              {
-                section: kb.ShortcutSection.ACTIONS,
-                shortcuts: ['actions shortcuts'],
-              },
-              {
-                section: kb.ShortcutSection.DIFFS,
-                shortcuts: ['diffs shortcuts'],
-              },
-            ]);
-      });
+    test('multiple sections on each side', () => {
+      update(new Map([
+        [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
+        [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
+        [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+        [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: kb.ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+            {
+              section: kb.ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: kb.ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+            {
+              section: kb.ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
     });
   });
+});
 </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
deleted file mode 100644
index d29858e..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ /dev/null
@@ -1,238 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
-<link rel="import" href="../gr-smart-search/gr-smart-search.html">
-
-<dom-module id="gr-main-header">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      nav {
-        align-items: center;
-        display: flex;
-      }
-      .bigTitle {
-        color: var(--header-text-color);
-        font-size: var(--header-title-font-size);
-        text-decoration: none;
-      }
-      .bigTitle:hover {
-        text-decoration: underline;
-      }
-      .titleText::before {
-        background-image: var(--header-icon);
-        background-size: var(--header-icon-size) var(--header-icon-size);
-        background-repeat: no-repeat;
-        content: "";
-        display: inline-block;
-        height: var(--header-icon-size);
-        margin-right: calc(var(--header-icon-size) / 4);
-        vertical-align: text-bottom;
-        width: var(--header-icon-size);
-      }
-      .titleText::after {
-        content: var(--header-title-content);
-      }
-      ul {
-        list-style: none;
-        padding-left: var(--spacing-l);
-      }
-      .links > li {
-        cursor: default;
-        display: inline-block;
-        padding: 0;
-        position: relative;
-      }
-      .linksTitle {
-        display: inline-block;
-        font-weight: var(--font-weight-bold);
-        position: relative;
-        text-transform: uppercase;
-      }
-      .linksTitle:hover {
-        opacity: .75;
-      }
-      .rightItems {
-        align-items: center;
-        display: flex;
-        flex: 1;
-        justify-content: flex-end;
-      }
-      .rightItems gr-endpoint-decorator:not(:empty) {
-        margin-left: var(--spacing-l);
-      }
-      gr-smart-search {
-        flex-grow: 1;
-        margin: 0 var(--spacing-m);
-        max-width: 500px;
-      }
-      gr-dropdown,
-      .browse {
-        padding: var(--spacing-m);
-      }
-      gr-dropdown {
-        --gr-dropdown-item: {
-          color: var(--primary-text-color);
-        }
-      }
-      .settingsButton {
-        margin-left: var(--spacing-m);
-      }
-      .browse {
-        color: var(--header-text-color);
-        /* Same as gr-button */
-        margin: 5px 4px;
-        text-decoration: none;
-      }
-      .invisible,
-      .settingsButton,
-      gr-account-dropdown {
-        display: none;
-      }
-      :host([loading]) .accountContainer,
-      :host([logged-in]) .loginButton,
-      :host([logged-in]) .registerButton {
-        display: none;
-      }
-      :host([logged-in]) .settingsButton,
-      :host([logged-in]) gr-account-dropdown {
-        display: inline;
-      }
-      .accountContainer {
-        align-items: center;
-        display: flex;
-        margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      .loginButton, .registerButton {
-        padding: var(--spacing-m) var(--spacing-l);
-      }
-      .dropdown-trigger {
-        text-decoration: none;
-      }
-      .dropdown-content {
-        background-color: var(--view-background-color);
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
-      }
-      /*
-       * We are not using :host to do this, because :host has a lowest css priority
-       * compared to others. This means that using :host to do this would break styles.
-       */
-      .linksTitle,
-      .bigTitle,
-      .loginButton,
-      .registerButton,
-      iron-icon,
-      gr-account-dropdown {
-        color: var(--header-text-color);
-      }
-      #mobileSearch {
-        display: none;
-      }
-      @media screen and (max-width: 50em) {
-        .bigTitle {
-          font-size: var(--font-size-h3);
-          font-weight: var(--font-weight-bold);
-        }
-        gr-smart-search,
-        .browse,
-        .rightItems .hideOnMobile,
-        .links > li.hideOnMobile {
-          display: none;
-        }
-        #mobileSearch {
-          display: inline-flex;
-        }
-        .accountContainer {
-          margin-left: var(--spacing-m) !important;
-        }
-        gr-dropdown {
-          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
-        }
-      }
-    </style>
-    <nav>
-      <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
-        <gr-endpoint-decorator name="header-title">
-          <span class="titleText"></span>
-        </gr-endpoint-decorator>
-      </a>
-      <ul class="links">
-        <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-          <li class$="[[_computeLinkGroupClass(linkGroup)]]">
-            <gr-dropdown
-                link
-                down-arrow
-                items = [[linkGroup.links]]
-                horizontal-align="left">
-              <span class="linksTitle" id="[[linkGroup.title]]">
-                [[linkGroup.title]]
-              </span>
-            </gr-dropdown>
-          </li>
-        </template>
-      </ul>
-      <div class="rightItems">
-        <gr-endpoint-decorator
-            class="hideOnMobile"
-            name="header-small-banner"></gr-endpoint-decorator>
-        <gr-smart-search
-            id="search"
-            search-query="{{searchQuery}}"></gr-smart-search>
-        <gr-endpoint-decorator
-            class="hideOnMobile"
-            name="header-browse-source"></gr-endpoint-decorator>
-        <div class="accountContainer" id="accountContainer">
-          <iron-icon id="mobileSearch" icon="gr-icons:search" on-tap='_onMobileSearchTap'></iron-icon>
-          <div class$="[[_computeIsInvisible(_registerURL)]]">
-            <a
-                class="registerButton"
-                href$="[[_registerURL]]">
-              [[_registerText]]
-            </a>
-          </div>
-          <a class="loginButton" href$="[[_loginURL]]">Sign in</a>
-          <a
-              class="settingsButton"
-              href$="[[_generateSettingsLink()]]"
-              title="Settings">
-            <iron-icon icon="gr-icons:settings"></iron-icon>
-          </a>
-          <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
-        </div>
-      </div>
-    </nav>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-main-header.js"></script>
-</dom-module>
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 ab9b70e..44537e1 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
@@ -14,69 +14,93 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DEFAULT_LINKS = [{
-    title: 'Changes',
-    links: [
-      {
-        url: '/q/status:open',
-        name: 'Open',
-      },
-      {
-        url: '/q/status:merged',
-        name: 'Merged',
-      },
-      {
-        url: '/q/status:abandoned',
-        name: 'Abandoned',
-      },
-    ],
-  }];
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-account-dropdown/gr-account-dropdown.js';
+import '../gr-smart-search/gr-smart-search.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-main-header_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {DocsUrlBehavior} from '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
+import {AdminNavBehavior} from '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-  const DOCUMENTATION_LINKS = [
+const DEFAULT_LINKS = [{
+  title: 'Changes',
+  links: [
     {
-      url: '/index.html',
-      name: 'Table of Contents',
+      url: '/q/status:open+-is:wip',
+      name: 'Open',
     },
     {
-      url: '/user-search.html',
-      name: 'Searching',
+      url: '/q/status:merged',
+      name: 'Merged',
     },
     {
-      url: '/user-upload.html',
-      name: 'Uploading',
+      url: '/q/status:abandoned',
+      name: 'Abandoned',
     },
-    {
-      url: '/access-control.html',
-      name: 'Access Control',
-    },
-    {
-      url: '/rest-api.html',
-      name: 'REST API',
-    },
-    {
-      url: '/intro-project-owner.html',
-      name: 'Project Owner Guide',
-    },
-  ];
+  ],
+}];
 
-  // Set of authentication methods that can provide custom registration page.
-  const AUTH_TYPES_WITH_REGISTER_URL = new Set([
-    'LDAP',
-    'LDAP_BIND',
-    'CUSTOM_EXTENSION',
-  ]);
+const DOCUMENTATION_LINKS = [
+  {
+    url: '/index.html',
+    name: 'Table of Contents',
+  },
+  {
+    url: '/user-search.html',
+    name: 'Searching',
+  },
+  {
+    url: '/user-upload.html',
+    name: 'Uploading',
+  },
+  {
+    url: '/access-control.html',
+    name: 'Access Control',
+  },
+  {
+    url: '/rest-api.html',
+    name: 'REST API',
+  },
+  {
+    url: '/intro-project-owner.html',
+    name: 'Project Owner Guide',
+  },
+];
 
-  Polymer({
-    is: 'gr-main-header',
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL = new Set([
+  'LDAP',
+  'LDAP_BIND',
+  'CUSTOM_EXTENSION',
+]);
 
-    hostAttributes: {
-      role: 'banner',
-    },
+/**
+ * @extends Polymer.Element
+ */
+class GrMainHeader extends mixinBehaviors( [
+  AdminNavBehavior,
+  BaseUrlBehavior,
+  DocsUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-main-header'; }
+
+  static get properties() {
+    return {
       searchQuery: {
         type: String,
         notify: true,
@@ -109,9 +133,9 @@
       _links: {
         type: Array,
         computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-            '_topMenus, _docBaseUrl)',
+          '_topMenus, _docBaseUrl)',
       },
-      _loginURL: {
+      loginUrl: {
         type: String,
         value: '/login',
       },
@@ -131,214 +155,205 @@
         type: String,
         value: null,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.AdminNavBehavior,
-      Gerrit.BaseUrlBehavior,
-      Gerrit.DocsUrlBehavior,
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_accountLoaded(_account)',
-    ],
+    ];
+  }
 
-    attached() {
-      this._loadAccount();
-      this._loadConfig();
-      this.listen(window, 'location-change', '_handleLocationChange');
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'banner');
+  }
 
-    detached() {
-      this.unlisten(window, 'location-change', '_handleLocationChange');
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadAccount();
+    this._loadConfig();
+  }
 
-    reload() {
-      this._loadAccount();
-    },
+  /** @override */
+  detached() {
+    super.detached();
+  }
 
-    _handleLocationChange(e) {
-      const baseUrl = this.getBaseUrl();
-      if (baseUrl) {
-        // Strip the canonical path from the path since needing canonical in
-        // the path is uneeded and breaks the url.
-        this._loginURL = baseUrl + '/login/' + encodeURIComponent(
-            '/' + window.location.pathname.substring(baseUrl.length) +
-            window.location.search +
-            window.location.hash);
-      } else {
-        this._loginURL = '/login/' + encodeURIComponent(
-            window.location.pathname +
-            window.location.search +
-            window.location.hash);
-      }
-    },
+  reload() {
+    this._loadAccount();
+  }
 
-    _computeRelativeURL(path) {
-      return '//' + window.location.host + this.getBaseUrl() + path;
-    },
+  _computeRelativeURL(path) {
+    return '//' + window.location.host + this.getBaseUrl() + path;
+  }
 
-    _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
-      // Polymer 2: check for undefined
-      if ([
-        defaultLinks,
-        userLinks,
-        adminLinks,
-        topMenus,
-        docBaseUrl,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
+  _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
+    // Polymer 2: check for undefined
+    if ([
+      defaultLinks,
+      userLinks,
+      adminLinks,
+      topMenus,
+      docBaseUrl,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-      const links = defaultLinks.map(menu => {
-        return {
-          title: menu.title,
-          links: menu.links.slice(),
-        };
-      });
-      if (userLinks && userLinks.length > 0) {
-        links.push({
-          title: 'Your',
-          links: userLinks.slice(),
-        });
-      }
-      const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
-      if (docLinks.length) {
-        links.push({
-          title: 'Documentation',
-          links: docLinks,
-          class: 'hideOnMobile',
-        });
-      }
+    const links = defaultLinks.map(menu => {
+      return {
+        title: menu.title,
+        links: menu.links.slice(),
+      };
+    });
+    if (userLinks && userLinks.length > 0) {
       links.push({
-        title: 'Browse',
-        links: adminLinks.slice(),
+        title: 'Your',
+        links: userLinks.slice(),
       });
-      const topMenuLinks = [];
-      links.forEach(link => { topMenuLinks[link.title] = link.links; });
-      for (const m of topMenus) {
-        const items = m.items.map(this._fixCustomMenuItem).filter(link => {
-          // Ignore GWT project links
-          return !link.url.includes('${projectName}');
+    }
+    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    if (docLinks.length) {
+      links.push({
+        title: 'Documentation',
+        links: docLinks,
+        class: 'hideOnMobile',
+      });
+    }
+    links.push({
+      title: 'Browse',
+      links: adminLinks.slice(),
+    });
+    const topMenuLinks = [];
+    links.forEach(link => { topMenuLinks[link.title] = link.links; });
+    for (const m of topMenus) {
+      const items = m.items.map(this._fixCustomMenuItem).filter(link =>
+        // Ignore GWT project links
+        !link.url.includes('${projectName}')
+      );
+      if (m.name in topMenuLinks) {
+        items.forEach(link => { topMenuLinks[m.name].push(link); });
+      } else if (items.length > 0) {
+        links.push({
+          title: m.name,
+          links: topMenuLinks[m.name] = items,
         });
-        if (m.name in topMenuLinks) {
-          items.forEach(link => { topMenuLinks[m.name].push(link); });
-        } else if (items.length > 0) {
-          links.push({
-            title: m.name,
-            links: topMenuLinks[m.name] = items,
+      }
+    }
+    return links;
+  }
+
+  _getDocLinks(docBaseUrl, docLinks) {
+    if (!docBaseUrl || !docLinks) {
+      return [];
+    }
+    return docLinks.map(link => {
+      let url = docBaseUrl;
+      if (url && url[url.length - 1] === '/') {
+        url = url.substring(0, url.length - 1);
+      }
+      return {
+        url: url + link.url,
+        name: link.name,
+        target: '_blank',
+      };
+    });
+  }
+
+  _loadAccount() {
+    this.loading = true;
+    const promises = [
+      this.$.restAPI.getAccount(),
+      this.$.restAPI.getTopMenus(),
+      pluginLoader.awaitPluginsLoaded(),
+    ];
+
+    return Promise.all(promises).then(result => {
+      const account = result[0];
+      this._account = account;
+      this.loggedIn = !!account;
+      this.loading = false;
+      this._topMenus = result[1];
+
+      return this.getAdminLinks(account,
+          this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
+          this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
+          .then(res => {
+            this._adminLinks = res.links;
           });
-        }
+    });
+  }
+
+  _loadConfig() {
+    this.$.restAPI.getConfig()
+        .then(config => {
+          this._retrieveRegisterURL(config);
+          return this.getDocsBaseUrl(config, this.$.restAPI);
+        })
+        .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
+  }
+
+  _accountLoaded(account) {
+    if (!account) { return; }
+
+    this.$.restAPI.getPreferences().then(prefs => {
+      this._userLinks = prefs && prefs.my ?
+        prefs.my.map(this._fixCustomMenuItem) : [];
+    });
+  }
+
+  _retrieveRegisterURL(config) {
+    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+      this._registerURL = config.auth.register_url;
+      if (config.auth.register_text) {
+        this._registerText = config.auth.register_text;
       }
-      return links;
-    },
+    }
+  }
 
-    _getDocLinks(docBaseUrl, docLinks) {
-      if (!docBaseUrl || !docLinks) {
-        return [];
-      }
-      return docLinks.map(link => {
-        let url = docBaseUrl;
-        if (url && url[url.length - 1] === '/') {
-          url = url.substring(0, url.length - 1);
-        }
-        return {
-          url: url + link.url,
-          name: link.name,
-          target: '_blank',
-        };
-      });
-    },
+  _computeIsInvisible(registerURL) {
+    return registerURL ? '' : 'invisible';
+  }
 
-    _loadAccount() {
-      this.loading = true;
-      const promises = [
-        this.$.restAPI.getAccount(),
-        this.$.restAPI.getTopMenus(),
-        Gerrit.awaitPluginsLoaded(),
-      ];
+  _fixCustomMenuItem(linkObj) {
+    // Normalize all urls to PolyGerrit style.
+    if (linkObj.url.startsWith('#')) {
+      linkObj.url = linkObj.url.slice(1);
+    }
 
-      return Promise.all(promises).then(result => {
-        const account = result[0];
-        this._account = account;
-        this.loggedIn = !!account;
-        this.loading = false;
-        this._topMenus = result[1];
+    // Delete target property due to complications of
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+    //
+    // The server tries to guess whether URL is a view within the UI.
+    // If not, it sets target='_blank' on the menu item. The server
+    // makes assumptions that work for the GWT UI, but not PolyGerrit,
+    // so we'll just disable it altogether for now.
+    delete linkObj.target;
 
-        return this.getAdminLinks(account,
-            this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
-            this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
-            .then(res => {
-              this._adminLinks = res.links;
-            });
-      });
-    },
+    return linkObj;
+  }
 
-    _loadConfig() {
-      this.$.restAPI.getConfig()
-          .then(config => {
-            this._retrieveRegisterURL(config);
-            return this.getDocsBaseUrl(config, this.$.restAPI);
-          })
-          .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
-    },
+  _generateSettingsLink() {
+    return this.getBaseUrl() + '/settings/';
+  }
 
-    _accountLoaded(account) {
-      if (!account) { return; }
+  _onMobileSearchTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('mobile-search', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-      this.$.restAPI.getPreferences().then(prefs => {
-        this._userLinks = prefs.my.map(this._fixCustomMenuItem);
-      });
-    },
+  _computeLinkGroupClass(linkGroup) {
+    if (linkGroup && linkGroup.class) {
+      return linkGroup.class;
+    }
 
-    _retrieveRegisterURL(config) {
-      if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-        this._registerURL = config.auth.register_url;
-        if (config.auth.register_text) {
-          this._registerText = config.auth.register_text;
-        }
-      }
-    },
+    return '';
+  }
+}
 
-    _computeIsInvisible(registerURL) {
-      return registerURL ? '' : 'invisible';
-    },
-
-    _fixCustomMenuItem(linkObj) {
-      // Normalize all urls to PolyGerrit style.
-      if (linkObj.url.startsWith('#')) {
-        linkObj.url = linkObj.url.slice(1);
-      }
-
-      // Delete target property due to complications of
-      // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
-      //
-      // The server tries to guess whether URL is a view within the UI.
-      // If not, it sets target='_blank' on the menu item. The server
-      // makes assumptions that work for the GWT UI, but not PolyGerrit,
-      // so we'll just disable it altogether for now.
-      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) {
-        return linkGroup.class;
-      }
-
-      return '';
-    },
-  });
-})();
+customElements.define(GrMainHeader.is, GrMainHeader);
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
new file mode 100644
index 0000000..19e833c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
@@ -0,0 +1,233 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    nav {
+      align-items: center;
+      display: flex;
+    }
+    .bigTitle {
+      color: var(--header-text-color);
+      font-size: var(--header-title-font-size);
+      text-decoration: none;
+    }
+    .bigTitle:hover {
+      text-decoration: underline;
+    }
+    .titleText::before {
+      background-image: var(--header-icon);
+      background-size: var(--header-icon-size) var(--header-icon-size);
+      background-repeat: no-repeat;
+      content: '';
+      display: inline-block;
+      height: var(--header-icon-size);
+      margin-right: calc(var(--header-icon-size) / 4);
+      vertical-align: text-bottom;
+      width: var(--header-icon-size);
+    }
+    .titleText::after {
+      content: var(--header-title-content);
+    }
+    ul {
+      list-style: none;
+      padding-left: var(--spacing-l);
+    }
+    .links > li {
+      cursor: default;
+      display: inline-block;
+      padding: 0;
+      position: relative;
+    }
+    .linksTitle {
+      display: inline-block;
+      font-weight: var(--font-weight-bold);
+      position: relative;
+      text-transform: uppercase;
+    }
+    .linksTitle:hover {
+      opacity: 0.75;
+    }
+    .rightItems {
+      align-items: center;
+      display: flex;
+      flex: 1;
+      justify-content: flex-end;
+    }
+    .rightItems gr-endpoint-decorator:not(:empty) {
+      margin-left: var(--spacing-l);
+    }
+    gr-smart-search {
+      flex-grow: 1;
+      margin: 0 var(--spacing-m);
+      max-width: 500px;
+    }
+    gr-dropdown,
+    .browse {
+      padding: var(--spacing-m);
+    }
+    gr-dropdown {
+      --gr-dropdown-item: {
+        color: var(--primary-text-color);
+      }
+    }
+    .settingsButton {
+      margin-left: var(--spacing-m);
+    }
+    .browse {
+      color: var(--header-text-color);
+      /* Same as gr-button */
+      margin: 5px 4px;
+      text-decoration: none;
+    }
+    .invisible,
+    .settingsButton,
+    gr-account-dropdown {
+      display: none;
+    }
+    :host([loading]) .accountContainer,
+    :host([logged-in]) .loginButton,
+    :host([logged-in]) .registerButton {
+      display: none;
+    }
+    :host([logged-in]) .settingsButton,
+    :host([logged-in]) gr-account-dropdown {
+      display: inline;
+    }
+    .accountContainer {
+      align-items: center;
+      display: flex;
+      margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .loginButton,
+    .registerButton {
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    .dropdown-trigger {
+      text-decoration: none;
+    }
+    .dropdown-content {
+      background-color: var(--view-background-color);
+      box-shadow: var(--elevation-level-2);
+    }
+    /*
+       * We are not using :host to do this, because :host has a lowest css priority
+       * compared to others. This means that using :host to do this would break styles.
+       */
+    .linksTitle,
+    .bigTitle,
+    .loginButton,
+    .registerButton,
+    iron-icon,
+    gr-account-dropdown {
+      color: var(--header-text-color);
+    }
+    #mobileSearch {
+      display: none;
+    }
+    @media screen and (max-width: 50em) {
+      .bigTitle {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      gr-smart-search,
+      .browse,
+      .rightItems .hideOnMobile,
+      .links > li.hideOnMobile {
+        display: none;
+      }
+      #mobileSearch {
+        display: inline-flex;
+      }
+      .accountContainer {
+        margin-left: var(--spacing-m) !important;
+      }
+      gr-dropdown {
+        padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+      }
+    }
+  </style>
+  <nav>
+    <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
+      <gr-endpoint-decorator name="header-title">
+        <span class="titleText"></span>
+      </gr-endpoint-decorator>
+    </a>
+    <ul class="links">
+      <template is="dom-repeat" items="[[_links]]" as="linkGroup">
+        <li class$="[[_computeLinkGroupClass(linkGroup)]]">
+          <gr-dropdown
+            link=""
+            down-arrow=""
+            items="[[linkGroup.links]]"
+            horizontal-align="left"
+          >
+            <span class="linksTitle" id="[[linkGroup.title]]">
+              [[linkGroup.title]]
+            </span>
+          </gr-dropdown>
+        </li>
+      </template>
+    </ul>
+    <div class="rightItems">
+      <gr-endpoint-decorator
+        class="hideOnMobile"
+        name="header-small-banner"
+      ></gr-endpoint-decorator>
+      <gr-smart-search
+        id="search"
+        search-query="{{searchQuery}}"
+      ></gr-smart-search>
+      <gr-endpoint-decorator
+        class="hideOnMobile"
+        name="header-browse-source"
+      ></gr-endpoint-decorator>
+      <div class="accountContainer" id="accountContainer">
+        <iron-icon
+          id="mobileSearch"
+          icon="gr-icons:search"
+          on-tap="_onMobileSearchTap"
+        ></iron-icon>
+        <div class$="[[_computeIsInvisible(_registerURL)]]">
+          <a class="registerButton" href$="[[_registerURL]]">
+            [[_registerText]]
+          </a>
+        </div>
+        <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
+        <a
+          class="settingsButton"
+          href$="[[_generateSettingsLink()]]"
+          title="Settings"
+        >
+          <iron-icon icon="gr-icons:settings"></iron-icon>
+        </a>
+        <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
+      </div>
+    </div>
+  </nav>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..336d873 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-main-header.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,368 +31,380 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-main-header tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-main-header.js';
+suite('gr-main-header tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        probePath(path) { return Promise.resolve(false); },
-      });
-      stub('gr-main-header', {
-        _loadAccount() {},
-      });
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      probePath(path) { return Promise.resolve(false); },
     });
-
-    teardown(() => {
-      sandbox.restore();
+    stub('gr-main-header', {
+      _loadAccount() {},
     });
-
-    test('link visibility', () => {
-      element.loading = true;
-      assert.equal(getComputedStyle(element.$$('.accountContainer')).display,
-          'none');
-      element.loading = false;
-      element.loggedIn = false;
-      assert.notEqual(getComputedStyle(element.$$('.accountContainer')).display,
-          'none');
-      assert.notEqual(getComputedStyle(element.$$('.loginButton')).display,
-          'none');
-      assert.notEqual(getComputedStyle(element.$$('.registerButton')).display,
-          'none');
-      assert.equal(getComputedStyle(element.$$('gr-account-dropdown')).display,
-          'none');
-      assert.equal(getComputedStyle(element.$$('.settingsButton')).display,
-          'none');
-      element.loggedIn = true;
-      assert.equal(getComputedStyle(element.$$('.loginButton')).display,
-          'none');
-      assert.equal(getComputedStyle(element.$$('.registerButton')).display,
-          'none');
-      assert.notEqual(getComputedStyle(element.$$('gr-account-dropdown'))
-          .display,
-      'none');
-      assert.notEqual(getComputedStyle(element.$$('.settingsButton')).display,
-          'none');
-    });
-
-    test('fix my menu item', () => {
-      assert.deepEqual([
-        {url: 'https://awesometown.com/#hashyhash'},
-        {url: 'url', target: '_blank'},
-      ].map(element._fixCustomMenuItem), [
-        {url: 'https://awesometown.com/#hashyhash'},
-        {url: 'url'},
-      ]);
-    });
-
-    test('user links', () => {
-      const defaultLinks = [{
-        title: 'Faves',
-        links: [{
-          name: 'Pinterest',
-          url: 'https://pinterest.com',
-        }],
-      }];
-      const userLinks = [{
-        name: 'Facebook',
-        url: 'https://facebook.com',
-      }];
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-
-      // When no admin links are passed, it should use the default.
-      assert.deepEqual(element._computeLinks(
-          defaultLinks,
-          /* userLinks= */[],
-          adminLinks,
-          /* topMenus= */[],
-          /* docBaseUrl= */ ''
-      ),
-      defaultLinks.concat({
-        title: 'Browse',
-        links: adminLinks,
-      }));
-      assert.deepEqual(element._computeLinks(
-          defaultLinks,
-          userLinks,
-          adminLinks,
-          /* topMenus= */[],
-          /* docBaseUrl= */ ''
-      ),
-      defaultLinks.concat([
-        {
-          title: 'Your',
-          links: userLinks,
-        },
-        {
-          title: 'Browse',
-          links: adminLinks,
-        }])
-      );
-    });
-
-    test('documentation links', () => {
-      const docLinks = [
-        {
-          name: 'Table of Contents',
-          url: '/index.html',
-        },
-      ];
-
-      assert.deepEqual(element._getDocLinks(null, docLinks), []);
-      assert.deepEqual(element._getDocLinks('', docLinks), []);
-      assert.deepEqual(element._getDocLinks('base', null), []);
-      assert.deepEqual(element._getDocLinks('base', []), []);
-
-      assert.deepEqual(element._getDocLinks('base', docLinks), [{
-        name: 'Table of Contents',
-        target: '_blank',
-        url: 'base/index.html',
-      }]);
-
-      assert.deepEqual(element._getDocLinks('base/', docLinks), [{
-        name: 'Table of Contents',
-        target: '_blank',
-        url: 'base/index.html',
-      }]);
-    });
-
-    test('top menus', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Plugins',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks,
-      },
-      {
-        title: 'Plugins',
-        links: [{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }]);
-    });
-
-    test('ignore top project menus', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Projects',
-        items: [{
-          name: 'Project Settings',
-          target: '_blank',
-          url: '/plugins/myplugin/${projectName}',
-        }, {
-          name: 'Project List',
-          target: '_blank',
-          url: '/plugins/myplugin/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks,
-      },
-      {
-        title: 'Projects',
-        links: [{
-          name: 'Project List',
-          url: '/plugins/myplugin/index.html',
-        }],
-      }]);
-    });
-
-    test('merge top menus', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Plugins',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }, {
-        name: 'Plugins',
-        items: [{
-          name: 'Create',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks,
-      }, {
-        title: 'Plugins',
-        links: [{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }, {
-          name: 'Create',
-          url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-        }],
-      }]);
-    });
-
-    test('merge top menus in default links', () => {
-      const defaultLinks = [{
-        title: 'Faves',
-        links: [{
-          name: 'Pinterest',
-          url: 'https://pinterest.com',
-        }],
-      }];
-      const topMenus = [{
-        name: 'Faves',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          defaultLinks,
-          /* userLinks= */ [],
-          /* adminLinks= */ [],
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Faves',
-        links: defaultLinks[0].links.concat([{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }]),
-      }, {
-        title: 'Browse',
-        links: [],
-      }]);
-    });
-
-    test('merge top menus in user links', () => {
-      const userLinks = [{
-        name: 'Facebook',
-        url: 'https://facebook.com',
-      }];
-      const topMenus = [{
-        name: 'Your',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          userLinks,
-          /* adminLinks= */ [],
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Your',
-        links: userLinks.concat([{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }]),
-      }, {
-        title: 'Browse',
-        links: [],
-      }]);
-    });
-
-    test('merge top menus in admin links', () => {
-      const adminLinks = [{
-        name: 'Repos',
-        url: '/repos',
-      }];
-      const topMenus = [{
-        name: 'Browse',
-        items: [{
-          name: 'Manage',
-          target: '_blank',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }],
-      }];
-      assert.deepEqual(element._computeLinks(
-          /* defaultLinks= */ [],
-          /* userLinks= */ [],
-          adminLinks,
-          topMenus,
-          /* baseDocUrl= */ ''
-      ), [{
-        title: 'Browse',
-        links: adminLinks.concat([{
-          name: 'Manage',
-          url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-        }]),
-      }]);
-    });
-
-    test('register URL', () => {
-      const config = {
-        auth: {
-          auth_type: 'LDAP',
-          register_url: 'https//gerrit.example.com/register',
-        },
-      };
-      element._retrieveRegisterURL(config);
-      assert.equal(element._registerURL, config.auth.register_url);
-      assert.equal(element._registerText, 'Sign up');
-
-      config.auth.register_text = 'Create account';
-      element._retrieveRegisterURL(config);
-      assert.equal(element._registerURL, config.auth.register_url);
-      assert.equal(element._registerText, config.auth.register_text);
-    });
-
-    test('register URL ignored for wrong auth type', () => {
-      const config = {
-        auth: {
-          auth_type: 'OPENID',
-          register_url: 'https//gerrit.example.com/register',
-        },
-      };
-      element._retrieveRegisterURL(config);
-      assert.equal(element._registerURL, null);
-      assert.equal(element._registerText, 'Sign up');
-    });
+    element = fixture('basic');
   });
-      </script>
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('link visibility', () => {
+    element.loading = true;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.accountContainer')).display,
+    'none');
+    element.loading = false;
+    element.loggedIn = false;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.accountContainer')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.loginButton')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.registerButton')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('gr-account-dropdown')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.settingsButton')).display,
+    'none');
+    element.loggedIn = true;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.loginButton')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.registerButton')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('gr-account-dropdown'))
+        .display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.settingsButton')).display,
+    'none');
+  });
+
+  test('fix my menu item', () => {
+    assert.deepEqual([
+      {url: 'https://awesometown.com/#hashyhash'},
+      {url: 'url', target: '_blank'},
+    ].map(element._fixCustomMenuItem), [
+      {url: 'https://awesometown.com/#hashyhash'},
+      {url: 'url'},
+    ]);
+  });
+
+  test('user links', () => {
+    const defaultLinks = [{
+      title: 'Faves',
+      links: [{
+        name: 'Pinterest',
+        url: 'https://pinterest.com',
+      }],
+    }];
+    const userLinks = [{
+      name: 'Facebook',
+      url: 'https://facebook.com',
+    }];
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+
+    // When no admin links are passed, it should use the default.
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        /* userLinks= */[],
+        adminLinks,
+        /* topMenus= */[],
+        /* docBaseUrl= */ ''
+    ),
+    defaultLinks.concat({
+      title: 'Browse',
+      links: adminLinks,
+    }));
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        userLinks,
+        adminLinks,
+        /* topMenus= */[],
+        /* docBaseUrl= */ ''
+    ),
+    defaultLinks.concat([
+      {
+        title: 'Your',
+        links: userLinks,
+      },
+      {
+        title: 'Browse',
+        links: adminLinks,
+      }])
+    );
+  });
+
+  test('documentation links', () => {
+    const docLinks = [
+      {
+        name: 'Table of Contents',
+        url: '/index.html',
+      },
+    ];
+
+    assert.deepEqual(element._getDocLinks(null, docLinks), []);
+    assert.deepEqual(element._getDocLinks('', docLinks), []);
+    assert.deepEqual(element._getDocLinks('base', null), []);
+    assert.deepEqual(element._getDocLinks('base', []), []);
+
+    assert.deepEqual(element._getDocLinks('base', docLinks), [{
+      name: 'Table of Contents',
+      target: '_blank',
+      url: 'base/index.html',
+    }]);
+
+    assert.deepEqual(element._getDocLinks('base/', docLinks), [{
+      name: 'Table of Contents',
+      target: '_blank',
+      url: 'base/index.html',
+    }]);
+  });
+
+  test('top menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Plugins',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    },
+    {
+      title: 'Plugins',
+      links: [{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }]);
+  });
+
+  test('ignore top project menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Projects',
+      items: [{
+        name: 'Project Settings',
+        target: '_blank',
+        url: '/plugins/myplugin/${projectName}',
+      }, {
+        name: 'Project List',
+        target: '_blank',
+        url: '/plugins/myplugin/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    },
+    {
+      title: 'Projects',
+      links: [{
+        name: 'Project List',
+        url: '/plugins/myplugin/index.html',
+      }],
+    }]);
+  });
+
+  test('merge top menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Plugins',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }, {
+      name: 'Plugins',
+      items: [{
+        name: 'Create',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    }, {
+      title: 'Plugins',
+      links: [{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }, {
+        name: 'Create',
+        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+      }],
+    }]);
+  });
+
+  test('merge top menus in default links', () => {
+    const defaultLinks = [{
+      title: 'Faves',
+      links: [{
+        name: 'Pinterest',
+        url: 'https://pinterest.com',
+      }],
+    }];
+    const topMenus = [{
+      name: 'Faves',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        /* userLinks= */ [],
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Faves',
+      links: defaultLinks[0].links.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }, {
+      title: 'Browse',
+      links: [],
+    }]);
+  });
+
+  test('merge top menus in user links', () => {
+    const userLinks = [{
+      name: 'Facebook',
+      url: 'https://facebook.com',
+    }];
+    const topMenus = [{
+      name: 'Your',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        userLinks,
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Your',
+      links: userLinks.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }, {
+      title: 'Browse',
+      links: [],
+    }]);
+  });
+
+  test('merge top menus in admin links', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Browse',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }]);
+  });
+
+  test('register URL', () => {
+    const config = {
+      auth: {
+        auth_type: 'LDAP',
+        register_url: 'https//gerrit.example.com/register',
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, 'Sign up');
+
+    config.auth.register_text = 'Create account';
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, config.auth.register_text);
+  });
+
+  test('register URL ignored for wrong auth type', () => {
+    const config = {
+      auth: {
+        auth_type: 'OPENID',
+        register_url: 'https//gerrit.example.com/register',
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, null);
+    assert.equal(element._registerText, 'Sign up');
+  });
+});
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
deleted file mode 100644
index c8bf6c1..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ /dev/null
@@ -1,750 +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>
-  (function(window) {
-    'use strict';
-
-    // Navigation parameters object format:
-    //
-    // Each object has a `view` property with a value from Gerrit.Nav.View. The
-    // remaining properties depend on the value used for view.
-    //
-    //  - Gerrit.Nav.View.CHANGE:
-    //    - `changeNum`, required, String: the numeric ID of the change.
-    //    - `project`, optional, String: the project name.
-    //    - `patchNum`, optional, Number: the patch for the right-hand-side of
-    //        the diff.
-    //    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-    //        of the diff. If `basePatchNum` is provided, then `patchNum` must
-    //        also be provided.
-    //    - `edit`, optional, Boolean: whether or not to load the file list with
-    //        edit controls.
-    //    - `messageHash`, optional, String: the hash of the change message to
-    //        scroll to.
-    //
-    // - Gerrit.Nav.View.SEARCH:
-    //    - `query`, optional, String: the literal search query. If provided,
-    //        the string will be used as the query, and all other params will be
-    //        ignored.
-    //    - `owner`, optional, String: the owner name.
-    //    - `project`, optional, String: the project name.
-    //    - `branch`, optional, String: the branch name.
-    //    - `topic`, optional, String: the topic name.
-    //    - `hashtag`, optional, String: the hashtag name.
-    //    - `statuses`, optional, Array<String>: the list of change statuses to
-    //        search for. If more than one is provided, the search will OR them
-    //        together.
-    //    - `offset`, optional, Number: the offset for the query.
-    //
-    //  - Gerrit.Nav.View.DIFF:
-    //    - `changeNum`, required, String: the numeric ID of the change.
-    //    - `path`, required, String: the filepath of the diff.
-    //    - `patchNum`, required, Number: the patch for the right-hand-side of
-    //        the diff.
-    //    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-    //        of the diff. If `basePatchNum` is provided, then `patchNum` must
-    //        also be provided.
-    //    - `lineNum`, optional, Number: the line number to be selected on load.
-    //    - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
-    //        of true selects the line from base of the patch range. False by
-    //        default.
-    //
-    //  - Gerrit.Nav.View.GROUP:
-    //    - `groupId`, required, String: the ID of the group.
-    //    - `detail`, optional, String: the name of the group detail view.
-    //      Takes any value from Gerrit.Nav.GroupDetailView.
-    //
-    //  - Gerrit.Nav.View.REPO:
-    //    - `repoName`, required, String: the name of the repo
-    //    - `detail`, optional, String: the name of the repo detail view.
-    //      Takes any value from Gerrit.Nav.RepoDetailView.
-    //
-    //  - Gerrit.Nav.View.DASHBOARD
-    //    - `repo`, optional, String.
-    //    - `sections`, optional, Array of objects with `title` and `query`
-    //      strings.
-    //    - `user`, optional, String.
-    //
-    //  - Gerrit.Nav.View.ROOT:
-    //    - no possible parameters.
-
-    window.Gerrit = window.Gerrit || {};
-
-    // Prevent redefinition.
-    if (window.Gerrit.hasOwnProperty('Nav')) { return; }
-
-    const uninitialized = () => {
-      console.warn('Use of uninitialized routing');
-    };
-
-    const EDIT_PATCHNUM = 'edit';
-    const PARENT_PATCHNUM = 'PARENT';
-
-    const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
-
-    // NOTE: These queries are tested in Java. Any changes made to definitions
-    // here require corresponding changes to:
-    // javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
-    const DEFAULT_SECTIONS = [
-      {
-        // Changes with unpublished draft comments. This section is omitted when
-        // viewing other users, so we don't need to filter anything out.
-        name: 'Has draft comments',
-        query: 'has:draft',
-        selfOnly: true,
-        hideIfEmpty: true,
-        suffixForDashboard: 'limit:10',
-      },
-      {
-        // Changes that are assigned to the viewed user.
-        name: 'Assigned reviews',
-        query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
-            'is:open -is:ignored',
-        hideIfEmpty: true,
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // WIP open changes owned by viewing user. This section is omitted when
-        // viewing other users, so we don't need to filter anything out.
-        name: 'Work in progress',
-        query: 'is:open owner:${user} is:wip',
-        selfOnly: true,
-        hideIfEmpty: true,
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // Non-WIP open changes owned by viewed user. Filter out changes ignored
-        // by the viewing user.
-        name: 'Outgoing reviews',
-        query: 'is:open owner:${user} -is:wip -is:ignored',
-        isOutgoing: true,
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // Non-WIP open changes not owned by the viewed user, that the viewed user
-        // is associated with (as either a reviewer or the assignee). Changes
-        // ignored by the viewing user are filtered out.
-        name: 'Incoming reviews',
-        query: 'is:open -owner:${user} -is:wip -is:ignored ' +
-            '(reviewer:${user} OR assignee:${user})',
-        suffixForDashboard: 'limit:25',
-      },
-      {
-        // Non-WIP open changes the viewed user is CCed on. Changes ignored by the
-        // viewing user are filtered out.
-        name: 'CCed on',
-        query: 'is:open -is:wip -is:ignored cc:${user}',
-        suffixForDashboard: 'limit:10',
-      },
-      {
-        name: 'Recently closed',
-        // Closed changes where viewed user is owner, reviewer, or assignee.
-        // Changes ignored by the viewing user are filtered out, and so are WIP
-        // changes not owned by the viewing user (the one instance of
-        // 'owner:self' is intentional and implements this logic).
-        query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
-            '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
-            'OR cc:${user})',
-        suffixForDashboard: '-age:4w limit:10',
-      },
-    ];
-
-    window.Gerrit.Nav = {
-
-      View: {
-        ADMIN: 'admin',
-        AGREEMENTS: 'agreements',
-        CHANGE: 'change',
-        DASHBOARD: 'dashboard',
-        DIFF: 'diff',
-        DOCUMENTATION_SEARCH: 'documentation-search',
-        EDIT: 'edit',
-        GROUP: 'group',
-        PLUGIN_SCREEN: 'plugin-screen',
-        REPO: 'repo',
-        ROOT: 'root',
-        SEARCH: 'search',
-        SETTINGS: 'settings',
-      },
-
-      GroupDetailView: {
-        MEMBERS: 'members',
-        LOG: 'log',
-      },
-
-      RepoDetailView: {
-        ACCESS: 'access',
-        BRANCHES: 'branches',
-        COMMANDS: 'commands',
-        DASHBOARDS: 'dashboards',
-        TAGS: 'tags',
-      },
-
-      WeblinkType: {
-        CHANGE: 'change',
-        FILE: 'file',
-        PATCHSET: 'patchset',
-      },
-
-      /** @type {Function} */
-      _navigate: uninitialized,
-
-      /** @type {Function} */
-      _generateUrl: uninitialized,
-
-      /** @type {Function} */
-      _generateWeblinks: uninitialized,
-
-      /** @type {Function} */
-      mapCommentlinks: uninitialized,
-
-      /**
-       * @param {number=} patchNum
-       * @param {number|string=} basePatchNum
-       */
-      _checkPatchRange(patchNum, basePatchNum) {
-        if (basePatchNum && !patchNum) {
-          throw new Error('Cannot use base patch number without patch number.');
-        }
-      },
-
-      /**
-       * Setup router implementation.
-       *
-       * @param {function(!string)} navigate the router-abstracted equivalent of
-       *     `window.location.href = ...`. Takes a string.
-       * @param {function(!Object): string} generateUrl generates a URL given
-       *     navigation parameters, detailed in the file header.
-       * @param {function(!Object): string} generateWeblinks weblinks generator
-       *     function takes single payload parameter with type property that
-       *  determines which
-       *     part of the UI is the consumer of the weblinks. type property can
-       *     be one of file, change, or patchset.
-       *     - For file type, payload will also contain string properties: repo,
-       *         commit, file.
-       *     - For patchset type, payload will also contain string properties:
-       *         repo, commit.
-       *     - For change type, payload will also contain string properties:
-       *         repo, commit. If server provides weblinks, those will be passed
-       *         as options.weblinks property on the main payload object.
-       * @param {function(!Object): Object} mapCommentlinks provides an escape
-       *     hatch to modify the commentlinks object, e.g. if it contains any
-       *     relative URLs.
-       */
-      setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
-        this._navigate = navigate;
-        this._generateUrl = generateUrl;
-        this._generateWeblinks = generateWeblinks;
-        this.mapCommentlinks = mapCommentlinks;
-      },
-
-      destroy() {
-        this._navigate = uninitialized;
-        this._generateUrl = uninitialized;
-        this._generateWeblinks = uninitialized;
-        this.mapCommentlinks = uninitialized;
-      },
-
-      /**
-       * Generate a URL for the given route parameters.
-       *
-       * @param {Object} params
-       * @return {string}
-       */
-      _getUrlFor(params) {
-        return this._generateUrl(params);
-      },
-
-      getUrlForSearchQuery(query, opt_offset) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          query,
-          offset: opt_offset,
-        });
-      },
-
-      /**
-       * @param {!string} project The name of the project.
-       * @param {boolean=} opt_openOnly When true, only search open changes in
-       *     the project.
-       * @param {string=} opt_host The host in which to search.
-       * @return {string}
-       */
-      getUrlForProjectChanges(project, opt_openOnly, opt_host) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          project,
-          statuses: opt_openOnly ? ['open'] : [],
-          host: opt_host,
-        });
-      },
-
-      /**
-       * @param {string} branch The name of the branch.
-       * @param {string} project The name of the project.
-       * @param {string=} opt_status The status to search.
-       * @param {string=} opt_host The host in which to search.
-       * @return {string}
-       */
-      getUrlForBranch(branch, project, opt_status, opt_host) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          branch,
-          project,
-          statuses: opt_status ? [opt_status] : undefined,
-          host: opt_host,
-        });
-      },
-
-      /**
-       * @param {string} topic The name of the topic.
-       * @param {string=} opt_host The host in which to search.
-       * @return {string}
-       */
-      getUrlForTopic(topic, opt_host) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          topic,
-          statuses: ['open', 'merged'],
-          host: opt_host,
-        });
-      },
-
-      /**
-       * @param {string} hashtag The name of the hashtag.
-       * @return {string}
-       */
-      getUrlForHashtag(hashtag) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          hashtag,
-          statuses: ['open', 'merged'],
-        });
-      },
-
-      /**
-       * Navigate to a search for changes with the given status.
-       *
-       * @param {string} status
-       */
-      navigateToStatusSearch(status) {
-        this._navigate(this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          statuses: [status],
-        }));
-      },
-
-      /**
-       * Navigate to a search query
-       *
-       * @param {string} query
-       * @param {number=} opt_offset
-       */
-      navigateToSearchQuery(query, opt_offset) {
-        return this._navigate(this.getUrlForSearchQuery(query, opt_offset));
-      },
-
-      /**
-       * Navigate to the user's dashboard
-       */
-      navigateToUserDashboard() {
-        return this._navigate(this.getUrlForUserDashboard('self'));
-      },
-
-      /**
-       * @param {!Object} change The change object.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {boolean=} opt_isEdit
-       * @param {string=} opt_messageHash
-       * @return {string}
-       */
-      getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
-          opt_messageHash) {
-        if (opt_basePatchNum === PARENT_PATCHNUM) {
-          opt_basePatchNum = undefined;
-        }
-
-        this._checkPatchRange(opt_patchNum, opt_basePatchNum);
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: change._number,
-          project: change.project,
-          patchNum: opt_patchNum,
-          basePatchNum: opt_basePatchNum,
-          edit: opt_isEdit,
-          host: change.internalHost || undefined,
-          messageHash: opt_messageHash,
-        });
-      },
-
-      /**
-       * @param {number} changeNum
-       * @param {string} project The name of the project.
-       * @param {number=} opt_patchNum
-       * @return {string}
-       */
-      getUrlForChangeById(changeNum, project, opt_patchNum) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum,
-          project,
-          patchNum: opt_patchNum,
-        });
-      },
-
-      /**
-       * @param {!Object} change The change object.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {boolean=} opt_isEdit
-       */
-      navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit) {
-        this._navigate(this.getUrlForChange(change, opt_patchNum,
-            opt_basePatchNum, opt_isEdit));
-      },
-
-      /**
-       * @param {{ _number: number, project: string }} change The change object.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {number|string=} opt_lineNum
-       * @return {string}
-       */
-      getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum, opt_lineNum) {
-        return this.getUrlForDiffById(change._number, change.project, path,
-            opt_patchNum, opt_basePatchNum, opt_lineNum);
-      },
-
-      /**
-       * @param {number} changeNum
-       * @param {string} project The name of the project.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       * @param {number=} opt_lineNum
-       * @param {boolean=} opt_leftSide
-       * @return {string}
-       */
-      getUrlForDiffById(changeNum, project, path, opt_patchNum,
-          opt_basePatchNum, opt_lineNum, opt_leftSide) {
-        if (opt_basePatchNum === PARENT_PATCHNUM) {
-          opt_basePatchNum = undefined;
-        }
-
-        this._checkPatchRange(opt_patchNum, opt_basePatchNum);
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.DIFF,
-          changeNum,
-          project,
-          path,
-          patchNum: opt_patchNum,
-          basePatchNum: opt_basePatchNum,
-          lineNum: opt_lineNum,
-          leftSide: opt_leftSide,
-        });
-      },
-
-      /**
-       * @param {{ _number: number, project: string }} change The change object.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @return {string}
-       */
-      getEditUrlForDiff(change, path, opt_patchNum) {
-        return this.getEditUrlForDiffById(change._number, change.project, path,
-            opt_patchNum);
-      },
-
-      /**
-       * @param {number} changeNum
-       * @param {string} project The name of the project.
-       * @param {string} path The file path.
-       * @param {number|string=} opt_patchNum The patchNum the file content
-       *    should be based on, or ${EDIT_PATCHNUM} if left undefined.
-       * @return {string}
-       */
-      getEditUrlForDiffById(changeNum, project, path, opt_patchNum) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.EDIT,
-          changeNum,
-          project,
-          path,
-          patchNum: opt_patchNum || EDIT_PATCHNUM,
-        });
-      },
-
-      /**
-       * @param {!Object} change The change object.
-       * @param {string} path The file path.
-       * @param {number=} opt_patchNum
-       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
-       *     used for none.
-       */
-      navigateToDiff(change, path, opt_patchNum, opt_basePatchNum) {
-        this._navigate(this.getUrlForDiff(change, path, opt_patchNum,
-            opt_basePatchNum));
-      },
-
-      /**
-       * @param {string} owner The name of the owner.
-       * @return {string}
-       */
-      getUrlForOwner(owner) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.SEARCH,
-          owner,
-        });
-      },
-
-      /**
-       * @param {string} user The name of the user.
-       * @return {string}
-       */
-      getUrlForUserDashboard(user) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.DASHBOARD,
-          user,
-        });
-      },
-
-      /**
-       * @return {string}
-       */
-      getUrlForRoot() {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.ROOT,
-        });
-      },
-
-      /**
-       * @param {string} repo The name of the repo.
-       * @param {string} dashboard The ID of the dashboard, in the form of
-       *     '<ref>:<path>'.
-       * @return {string}
-       */
-      getUrlForRepoDashboard(repo, dashboard) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.DASHBOARD,
-          repo,
-          dashboard,
-        });
-      },
-
-      /**
-       * Navigate to an arbitrary relative URL.
-       *
-       * @param {string} relativeUrl
-       */
-      navigateToRelativeUrl(relativeUrl) {
-        if (!relativeUrl.startsWith('/')) {
-          throw new Error('navigateToRelativeUrl with non-relative URL');
-        }
-        this._navigate(relativeUrl);
-      },
-
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepo(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-        });
-      },
-
-      /**
-       * Navigate to a repo settings page.
-       *
-       * @param {string} repoName
-       */
-      navigateToRepo(repoName) {
-        this._navigate(this.getUrlForRepo(repoName));
-      },
-
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoTags(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.TAGS,
-        });
-      },
-
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoBranches(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        });
-      },
-
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoAccess(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.ACCESS,
-        });
-      },
-
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoCommands(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-        });
-      },
-
-      /**
-       * @param {string} repoName
-       * @return {string}
-       */
-      getUrlForRepoDashboards(repoName) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.REPO,
-          repoName,
-          detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-        });
-      },
-
-      /**
-       * @param {string} groupId
-       * @return {string}
-       */
-      getUrlForGroup(groupId) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.GROUP,
-          groupId,
-        });
-      },
-
-      /**
-       * @param {string} groupId
-       * @return {string}
-       */
-      getUrlForGroupLog(groupId) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.GROUP,
-          groupId,
-          detail: Gerrit.Nav.GroupDetailView.LOG,
-        });
-      },
-
-      /**
-       * @param {string} groupId
-       * @return {string}
-       */
-      getUrlForGroupMembers(groupId) {
-        return this._getUrlFor({
-          view: Gerrit.Nav.View.GROUP,
-          groupId,
-          detail: Gerrit.Nav.GroupDetailView.MEMBERS,
-        });
-      },
-
-      getUrlForSettings() {
-        return this._getUrlFor({view: Gerrit.Nav.View.SETTINGS});
-      },
-
-      /**
-       * @param {string} repo
-       * @param {string} commit
-       * @param {string} file
-       * @param {Object=} opt_options
-       * @return {
-       *   Array<{label: string, url: string}>|
-       *   {label: string, url: string}
-       *  }
-       */
-      getFileWebLinks(repo, commit, file, opt_options) {
-        const params = {type: Gerrit.Nav.WeblinkType.FILE, repo, commit, file};
-        if (opt_options) {
-          params.options = opt_options;
-        }
-        return [].concat(this._generateWeblinks(params));
-      },
-
-      /**
-       * @param {string} repo
-       * @param {string} commit
-       * @param {Object=} opt_options
-       * @return {{label: string, url: string}}
-       */
-      getPatchSetWeblink(repo, commit, opt_options) {
-        const params = {type: Gerrit.Nav.WeblinkType.PATCHSET, repo, commit};
-        if (opt_options) {
-          params.options = opt_options;
-        }
-        const result = this._generateWeblinks(params);
-        if (Array.isArray(result)) {
-          return result.pop();
-        } else {
-          return result;
-        }
-      },
-
-      /**
-       * @param {string} repo
-       * @param {string} commit
-       * @param {Object=} opt_options
-       * @return {
-       *   Array<{label: string, url: string}>|
-       *   {label: string, url: string}
-       *  }
-       */
-      getChangeWeblinks(repo, commit, opt_options) {
-        const params = {type: Gerrit.Nav.WeblinkType.CHANGE, repo, commit};
-        if (opt_options) {
-          params.options = opt_options;
-        }
-        return [].concat(this._generateWeblinks(params));
-      },
-
-      getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
-          title = '') {
-        sections = sections
-            .filter(section => (user === 'self' || !section.selfOnly))
-            .map(section => Object.assign({}, section, {
-              name: section.name,
-              query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-            }));
-        return {title, sections};
-      },
-    };
-  })(window);
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
new file mode 100644
index 0000000..2b87548
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
@@ -0,0 +1,742 @@
+/**
+ * @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.
+ */
+
+// Navigation parameters object format:
+//
+// Each object has a `view` property with a value from GerritNav.View. The
+// remaining properties depend on the value used for view.
+//
+//  - GerritNav.View.CHANGE:
+//    - `changeNum`, required, String: the numeric ID of the change.
+//    - `project`, optional, String: the project name.
+//    - `patchNum`, optional, Number: the patch for the right-hand-side of
+//        the diff.
+//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
+//        of the diff. If `basePatchNum` is provided, then `patchNum` must
+//        also be provided.
+//    - `edit`, optional, Boolean: whether or not to load the file list with
+//        edit controls.
+//    - `messageHash`, optional, String: the hash of the change message to
+//        scroll to.
+//
+// - GerritNav.View.SEARCH:
+//    - `query`, optional, String: the literal search query. If provided,
+//        the string will be used as the query, and all other params will be
+//        ignored.
+//    - `owner`, optional, String: the owner name.
+//    - `project`, optional, String: the project name.
+//    - `branch`, optional, String: the branch name.
+//    - `topic`, optional, String: the topic name.
+//    - `hashtag`, optional, String: the hashtag name.
+//    - `statuses`, optional, Array<String>: the list of change statuses to
+//        search for. If more than one is provided, the search will OR them
+//        together.
+//    - `offset`, optional, Number: the offset for the query.
+//
+//  - GerritNav.View.DIFF:
+//    - `changeNum`, required, String: the numeric ID of the change.
+//    - `path`, required, String: the filepath of the diff.
+//    - `patchNum`, required, Number: the patch for the right-hand-side of
+//        the diff.
+//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
+//        of the diff. If `basePatchNum` is provided, then `patchNum` must
+//        also be provided.
+//    - `lineNum`, optional, Number: the line number to be selected on load.
+//    - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
+//        of true selects the line from base of the patch range. False by
+//        default.
+//
+//  - GerritNav.View.GROUP:
+//    - `groupId`, required, String: the ID of the group.
+//    - `detail`, optional, String: the name of the group detail view.
+//      Takes any value from GerritNav.GroupDetailView.
+//
+//  - GerritNav.View.REPO:
+//    - `repoName`, required, String: the name of the repo
+//    - `detail`, optional, String: the name of the repo detail view.
+//      Takes any value from GerritNav.RepoDetailView.
+//
+//  - GerritNav.View.DASHBOARD
+//    - `repo`, optional, String.
+//    - `sections`, optional, Array of objects with `title` and `query`
+//      strings.
+//    - `user`, optional, String.
+//
+//  - GerritNav.View.ROOT:
+//    - no possible parameters.
+
+const uninitialized = () => {
+  console.warn('Use of uninitialized routing');
+};
+
+const EDIT_PATCHNUM = 'edit';
+const PARENT_PATCHNUM = 'PARENT';
+
+const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
+
+// NOTE: These queries are tested in Java. Any changes made to definitions
+// here require corresponding changes to:
+// javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+const DEFAULT_SECTIONS = [
+  {
+    // Changes with unpublished draft comments. This section is omitted when
+    // viewing other users, so we don't need to filter anything out.
+    name: 'Has draft comments',
+    query: 'has:draft',
+    selfOnly: true,
+    hideIfEmpty: true,
+    suffixForDashboard: 'limit:10',
+  },
+  {
+    // Changes that are assigned to the viewed user.
+    name: 'Assigned reviews',
+    query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
+        'is:open -is:ignored',
+    hideIfEmpty: true,
+    suffixForDashboard: 'limit:25',
+  },
+  {
+    // WIP open changes owned by viewing user. This section is omitted when
+    // viewing other users, so we don't need to filter anything out.
+    name: 'Work in progress',
+    query: 'is:open owner:${user} is:wip',
+    selfOnly: true,
+    hideIfEmpty: true,
+    suffixForDashboard: 'limit:25',
+  },
+  {
+    // Non-WIP open changes owned by viewed user. Filter out changes ignored
+    // by the viewing user.
+    name: 'Outgoing reviews',
+    query: 'is:open owner:${user} -is:wip -is:ignored',
+    isOutgoing: true,
+    suffixForDashboard: 'limit:25',
+  },
+  {
+    // Non-WIP open changes not owned by the viewed user, that the viewed user
+    // is associated with (as either a reviewer or the assignee). Changes
+    // ignored by the viewing user are filtered out.
+    name: 'Incoming reviews',
+    query: 'is:open -owner:${user} -is:wip -is:ignored ' +
+        '(reviewer:${user} OR assignee:${user})',
+    suffixForDashboard: 'limit:25',
+  },
+  {
+    // Open changes the viewed user is CCed on. Changes ignored by the viewing
+    // user are filtered out.
+    name: 'CCed on',
+    query: 'is:open -is:ignored cc:${user}',
+    suffixForDashboard: 'limit:10',
+  },
+  {
+    name: 'Recently closed',
+    // Closed changes where viewed user is owner, reviewer, or assignee.
+    // Changes ignored by the viewing user are filtered out, and so are WIP
+    // changes not owned by the viewing user (the one instance of
+    // 'owner:self' is intentional and implements this logic).
+    query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
+        '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+        'OR cc:${user})',
+    suffixForDashboard: '-age:4w limit:10',
+  },
+];
+
+// TODO(dmfilippov) Convert to class, extract consts, give better name and
+// expose as a service from appContext
+export const GerritNav = {
+
+  View: {
+    ADMIN: 'admin',
+    AGREEMENTS: 'agreements',
+    CHANGE: 'change',
+    DASHBOARD: 'dashboard',
+    DIFF: 'diff',
+    DOCUMENTATION_SEARCH: 'documentation-search',
+    EDIT: 'edit',
+    GROUP: 'group',
+    PLUGIN_SCREEN: 'plugin-screen',
+    REPO: 'repo',
+    ROOT: 'root',
+    SEARCH: 'search',
+    SETTINGS: 'settings',
+  },
+
+  GroupDetailView: {
+    MEMBERS: 'members',
+    LOG: 'log',
+  },
+
+  RepoDetailView: {
+    ACCESS: 'access',
+    BRANCHES: 'branches',
+    COMMANDS: 'commands',
+    DASHBOARDS: 'dashboards',
+    TAGS: 'tags',
+  },
+
+  WeblinkType: {
+    CHANGE: 'change',
+    FILE: 'file',
+    PATCHSET: 'patchset',
+  },
+
+  /** @type {Function} */
+  _navigate: uninitialized,
+
+  /** @type {Function} */
+  _generateUrl: uninitialized,
+
+  /** @type {Function} */
+  _generateWeblinks: uninitialized,
+
+  /** @type {Function} */
+  mapCommentlinks: uninitialized,
+
+  /**
+   * @param {number=} patchNum
+   * @param {number|string=} basePatchNum
+   */
+  _checkPatchRange(patchNum, basePatchNum) {
+    if (basePatchNum && !patchNum) {
+      throw new Error('Cannot use base patch number without patch number.');
+    }
+  },
+
+  /**
+   * Setup router implementation.
+   *
+   * @param {function(!string)} navigate the router-abstracted equivalent of
+   *     `window.location.href = ...`. Takes a string.
+   * @param {function(!Object): string} generateUrl generates a URL given
+   *     navigation parameters, detailed in the file header.
+   * @param {function(!Object): string} generateWeblinks weblinks generator
+   *     function takes single payload parameter with type property that
+   *  determines which
+   *     part of the UI is the consumer of the weblinks. type property can
+   *     be one of file, change, or patchset.
+   *     - For file type, payload will also contain string properties: repo,
+   *         commit, file.
+   *     - For patchset type, payload will also contain string properties:
+   *         repo, commit.
+   *     - For change type, payload will also contain string properties:
+   *         repo, commit. If server provides weblinks, those will be passed
+   *         as options.weblinks property on the main payload object.
+   * @param {function(!Object): Object} mapCommentlinks provides an escape
+   *     hatch to modify the commentlinks object, e.g. if it contains any
+   *     relative URLs.
+   */
+  setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
+    this._navigate = navigate;
+    this._generateUrl = generateUrl;
+    this._generateWeblinks = generateWeblinks;
+    this.mapCommentlinks = mapCommentlinks;
+  },
+
+  destroy() {
+    this._navigate = uninitialized;
+    this._generateUrl = uninitialized;
+    this._generateWeblinks = uninitialized;
+    this.mapCommentlinks = uninitialized;
+  },
+
+  /**
+   * Generate a URL for the given route parameters.
+   *
+   * @param {Object} params
+   * @return {string}
+   */
+  _getUrlFor(params) {
+    return this._generateUrl(params);
+  },
+
+  getUrlForSearchQuery(query, opt_offset) {
+    return this._getUrlFor({
+      view: GerritNav.View.SEARCH,
+      query,
+      offset: opt_offset,
+    });
+  },
+
+  /**
+   * @param {!string} project The name of the project.
+   * @param {boolean=} opt_openOnly When true, only search open changes in
+   *     the project.
+   * @param {string=} opt_host The host in which to search.
+   * @return {string}
+   */
+  getUrlForProjectChanges(project, opt_openOnly, opt_host) {
+    return this._getUrlFor({
+      view: GerritNav.View.SEARCH,
+      project,
+      statuses: opt_openOnly ? ['open'] : [],
+      host: opt_host,
+    });
+  },
+
+  /**
+   * @param {string} branch The name of the branch.
+   * @param {string} project The name of the project.
+   * @param {string=} opt_status The status to search.
+   * @param {string=} opt_host The host in which to search.
+   * @return {string}
+   */
+  getUrlForBranch(branch, project, opt_status, opt_host) {
+    return this._getUrlFor({
+      view: GerritNav.View.SEARCH,
+      branch,
+      project,
+      statuses: opt_status ? [opt_status] : undefined,
+      host: opt_host,
+    });
+  },
+
+  /**
+   * @param {string} topic The name of the topic.
+   * @param {string=} opt_host The host in which to search.
+   * @return {string}
+   */
+  getUrlForTopic(topic, opt_host) {
+    return this._getUrlFor({
+      view: GerritNav.View.SEARCH,
+      topic,
+      statuses: ['open', 'merged'],
+      host: opt_host,
+    });
+  },
+
+  /**
+   * @param {string} hashtag The name of the hashtag.
+   * @return {string}
+   */
+  getUrlForHashtag(hashtag) {
+    return this._getUrlFor({
+      view: GerritNav.View.SEARCH,
+      hashtag,
+      statuses: ['open', 'merged'],
+    });
+  },
+
+  /**
+   * Navigate to a search for changes with the given status.
+   *
+   * @param {string} status
+   */
+  navigateToStatusSearch(status) {
+    this._navigate(this._getUrlFor({
+      view: GerritNav.View.SEARCH,
+      statuses: [status],
+    }));
+  },
+
+  /**
+   * Navigate to a search query
+   *
+   * @param {string} query
+   * @param {number=} opt_offset
+   */
+  navigateToSearchQuery(query, opt_offset) {
+    return this._navigate(this.getUrlForSearchQuery(query, opt_offset));
+  },
+
+  /**
+   * Navigate to the user's dashboard
+   */
+  navigateToUserDashboard() {
+    return this._navigate(this.getUrlForUserDashboard('self'));
+  },
+
+  /**
+   * @param {!Object} change The change object.
+   * @param {number=} opt_patchNum
+   * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+   *     used for none.
+   * @param {boolean=} opt_isEdit
+   * @param {string=} opt_messageHash
+   * @return {string}
+   */
+  getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
+      opt_messageHash) {
+    if (opt_basePatchNum === PARENT_PATCHNUM) {
+      opt_basePatchNum = undefined;
+    }
+
+    this._checkPatchRange(opt_patchNum, opt_basePatchNum);
+    return this._getUrlFor({
+      view: GerritNav.View.CHANGE,
+      changeNum: change._number,
+      project: change.project,
+      patchNum: opt_patchNum,
+      basePatchNum: opt_basePatchNum,
+      edit: opt_isEdit,
+      host: change.internalHost || undefined,
+      messageHash: opt_messageHash,
+    });
+  },
+
+  /**
+   * @param {number} changeNum
+   * @param {string} project The name of the project.
+   * @param {number=} opt_patchNum
+   * @return {string}
+   */
+  getUrlForChangeById(changeNum, project, opt_patchNum) {
+    return this._getUrlFor({
+      view: GerritNav.View.CHANGE,
+      changeNum,
+      project,
+      patchNum: opt_patchNum,
+    });
+  },
+
+  /**
+   * @param {!Object} change The change object.
+   * @param {number=} opt_patchNum
+   * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+   *     used for none.
+   * @param {boolean=} opt_isEdit
+   */
+  navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit) {
+    this._navigate(this.getUrlForChange(change, opt_patchNum,
+        opt_basePatchNum, opt_isEdit));
+  },
+
+  /**
+   * @param {{ _number: number, project: string }} change The change object.
+   * @param {string} path The file path.
+   * @param {number=} opt_patchNum
+   * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+   *     used for none.
+   * @param {number|string=} opt_lineNum
+   * @return {string}
+   */
+  getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum, opt_lineNum) {
+    return this.getUrlForDiffById(change._number, change.project, path,
+        opt_patchNum, opt_basePatchNum, opt_lineNum);
+  },
+
+  /**
+   * @param {number} changeNum
+   * @param {string} project The name of the project.
+   * @param {string} path The file path.
+   * @param {number=} opt_patchNum
+   * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+   *     used for none.
+   * @param {number=} opt_lineNum
+   * @param {boolean=} opt_leftSide
+   * @return {string}
+   */
+  getUrlForDiffById(changeNum, project, path, opt_patchNum,
+      opt_basePatchNum, opt_lineNum, opt_leftSide) {
+    if (opt_basePatchNum === PARENT_PATCHNUM) {
+      opt_basePatchNum = undefined;
+    }
+
+    this._checkPatchRange(opt_patchNum, opt_basePatchNum);
+    return this._getUrlFor({
+      view: GerritNav.View.DIFF,
+      changeNum,
+      project,
+      path,
+      patchNum: opt_patchNum,
+      basePatchNum: opt_basePatchNum,
+      lineNum: opt_lineNum,
+      leftSide: opt_leftSide,
+    });
+  },
+
+  /**
+   * @param {{ _number: number, project: string }} change The change object.
+   * @param {string} path The file path.
+   * @param {number=} opt_patchNum
+   * @return {string}
+   */
+  getEditUrlForDiff(change, path, opt_patchNum) {
+    return this.getEditUrlForDiffById(change._number, change.project, path,
+        opt_patchNum);
+  },
+
+  /**
+   * @param {number} changeNum
+   * @param {string} project The name of the project.
+   * @param {string} path The file path.
+   * @param {number|string=} opt_patchNum The patchNum the file content
+   *    should be based on, or ${EDIT_PATCHNUM} if left undefined.
+   * @return {string}
+   */
+  getEditUrlForDiffById(changeNum, project, path, opt_patchNum) {
+    return this._getUrlFor({
+      view: GerritNav.View.EDIT,
+      changeNum,
+      project,
+      path,
+      patchNum: opt_patchNum || EDIT_PATCHNUM,
+    });
+  },
+
+  /**
+   * @param {!Object} change The change object.
+   * @param {string} path The file path.
+   * @param {number=} opt_patchNum
+   * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+   *     used for none.
+   */
+  navigateToDiff(change, path, opt_patchNum, opt_basePatchNum) {
+    this._navigate(this.getUrlForDiff(change, path, opt_patchNum,
+        opt_basePatchNum));
+  },
+
+  /**
+   * @param {string} owner The name of the owner.
+   * @return {string}
+   */
+  getUrlForOwner(owner) {
+    return this._getUrlFor({
+      view: GerritNav.View.SEARCH,
+      owner,
+    });
+  },
+
+  /**
+   * @param {string} user The name of the user.
+   * @return {string}
+   */
+  getUrlForUserDashboard(user) {
+    return this._getUrlFor({
+      view: GerritNav.View.DASHBOARD,
+      user,
+    });
+  },
+
+  /**
+   * @return {string}
+   */
+  getUrlForRoot() {
+    return this._getUrlFor({
+      view: GerritNav.View.ROOT,
+    });
+  },
+
+  /**
+   * @param {string} repo The name of the repo.
+   * @param {string} dashboard The ID of the dashboard, in the form of
+   *     '<ref>:<path>'.
+   * @return {string}
+   */
+  getUrlForRepoDashboard(repo, dashboard) {
+    return this._getUrlFor({
+      view: GerritNav.View.DASHBOARD,
+      repo,
+      dashboard,
+    });
+  },
+
+  /**
+   * Navigate to an arbitrary relative URL.
+   *
+   * @param {string} relativeUrl
+   */
+  navigateToRelativeUrl(relativeUrl) {
+    if (!relativeUrl.startsWith('/')) {
+      throw new Error('navigateToRelativeUrl with non-relative URL');
+    }
+    this._navigate(relativeUrl);
+  },
+
+  /**
+   * @param {string} repoName
+   * @return {string}
+   */
+  getUrlForRepo(repoName) {
+    return this._getUrlFor({
+      view: GerritNav.View.REPO,
+      repoName,
+    });
+  },
+
+  /**
+   * Navigate to a repo settings page.
+   *
+   * @param {string} repoName
+   */
+  navigateToRepo(repoName) {
+    this._navigate(this.getUrlForRepo(repoName));
+  },
+
+  /**
+   * @param {string} repoName
+   * @return {string}
+   */
+  getUrlForRepoTags(repoName) {
+    return this._getUrlFor({
+      view: GerritNav.View.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.TAGS,
+    });
+  },
+
+  /**
+   * @param {string} repoName
+   * @return {string}
+   */
+  getUrlForRepoBranches(repoName) {
+    return this._getUrlFor({
+      view: GerritNav.View.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.BRANCHES,
+    });
+  },
+
+  /**
+   * @param {string} repoName
+   * @return {string}
+   */
+  getUrlForRepoAccess(repoName) {
+    return this._getUrlFor({
+      view: GerritNav.View.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.ACCESS,
+    });
+  },
+
+  /**
+   * @param {string} repoName
+   * @return {string}
+   */
+  getUrlForRepoCommands(repoName) {
+    return this._getUrlFor({
+      view: GerritNav.View.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.COMMANDS,
+    });
+  },
+
+  /**
+   * @param {string} repoName
+   * @return {string}
+   */
+  getUrlForRepoDashboards(repoName) {
+    return this._getUrlFor({
+      view: GerritNav.View.REPO,
+      repoName,
+      detail: GerritNav.RepoDetailView.DASHBOARDS,
+    });
+  },
+
+  /**
+   * @param {string} groupId
+   * @return {string}
+   */
+  getUrlForGroup(groupId) {
+    return this._getUrlFor({
+      view: GerritNav.View.GROUP,
+      groupId,
+    });
+  },
+
+  /**
+   * @param {string} groupId
+   * @return {string}
+   */
+  getUrlForGroupLog(groupId) {
+    return this._getUrlFor({
+      view: GerritNav.View.GROUP,
+      groupId,
+      detail: GerritNav.GroupDetailView.LOG,
+    });
+  },
+
+  /**
+   * @param {string} groupId
+   * @return {string}
+   */
+  getUrlForGroupMembers(groupId) {
+    return this._getUrlFor({
+      view: GerritNav.View.GROUP,
+      groupId,
+      detail: GerritNav.GroupDetailView.MEMBERS,
+    });
+  },
+
+  getUrlForSettings() {
+    return this._getUrlFor({view: GerritNav.View.SETTINGS});
+  },
+
+  /**
+   * @param {string} repo
+   * @param {string} commit
+   * @param {string} file
+   * @param {Object=} opt_options
+   * @return {
+   *   Array<{label: string, url: string}>|
+   *   {label: string, url: string}
+   *  }
+   */
+  getFileWebLinks(repo, commit, file, opt_options) {
+    const params = {type: GerritNav.WeblinkType.FILE, repo, commit, file};
+    if (opt_options) {
+      params.options = opt_options;
+    }
+    return [].concat(this._generateWeblinks(params));
+  },
+
+  /**
+   * @param {string} repo
+   * @param {string} commit
+   * @param {Object=} opt_options
+   * @return {{label: string, url: string}}
+   */
+  getPatchSetWeblink(repo, commit, opt_options) {
+    const params = {type: GerritNav.WeblinkType.PATCHSET, repo, commit};
+    if (opt_options) {
+      params.options = opt_options;
+    }
+    const result = this._generateWeblinks(params);
+    if (Array.isArray(result)) {
+      return result.pop();
+    } else {
+      return result;
+    }
+  },
+
+  /**
+   * @param {string} repo
+   * @param {string} commit
+   * @param {Object=} opt_options
+   * @return {
+   *   Array<{label: string, url: string}>|
+   *   {label: string, url: string}
+   *  }
+   */
+  getChangeWeblinks(repo, commit, opt_options) {
+    const params = {type: GerritNav.WeblinkType.CHANGE, repo, commit};
+    if (opt_options) {
+      params.options = opt_options;
+    }
+    return [].concat(this._generateWeblinks(params));
+  },
+
+  getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
+      title = '') {
+    sections = sections
+        .filter(section => (user === 'self' || !section.selfOnly))
+        .map(section => Object.assign({}, section, {
+          name: section.name,
+          query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+        }));
+    return {title, sections};
+  },
+};
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..8fc4c75 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
@@ -17,70 +17,72 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-navigation tests', () => {
-    test('invalid patch ranges throw exceptions', () => {
-      assert.throw(() => Gerrit.Nav.getUrlForChange('123', undefined, 12));
-      assert.throw(() => Gerrit.Nav.getUrlForDiff('123', 'x.c', undefined, 12));
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {GerritNav} from './gr-navigation.js';
+
+suite('gr-navigation tests', () => {
+  test('invalid patch ranges throw exceptions', () => {
+    assert.throw(() => GerritNav.getUrlForChange('123', undefined, 12));
+    assert.throw(() => GerritNav.getUrlForDiff('123', 'x.c', undefined, 12));
+  });
+
+  suite('_getUserDashboard', () => {
+    const sections = [
+      {name: 'section 1', query: 'query 1'},
+      {name: 'section 2', query: 'query 2 for ${user}'},
+      {name: 'section 3', query: 'self only query', selfOnly: true},
+      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+    ];
+
+    test('dashboard for self', () => {
+      const dashboard =
+           GerritNav.getUserDashboard('self', sections, 'title');
+      assert.deepEqual(
+          dashboard,
+          {
+            title: 'title',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2 for self'},
+              {
+                name: 'section 3',
+                query: 'self only query',
+                selfOnly: true,
+              }, {
+                name: 'section 4',
+                query: 'query 4',
+                suffixForDashboard: 'suffix',
+              },
+            ],
+          });
     });
 
-    suite('_getUserDashboard', () => {
-      const sections = [
-        {name: 'section 1', query: 'query 1'},
-        {name: 'section 2', query: 'query 2 for ${user}'},
-        {name: 'section 3', query: 'self only query', selfOnly: true},
-        {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
-      ];
-
-      test('dashboard for self', () => {
-        const dashboard =
-            Gerrit.Nav.getUserDashboard('self', sections, 'title');
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1'},
-                {name: 'section 2', query: 'query 2 for self'},
-                {
-                  name: 'section 3',
-                  query: 'self only query',
-                  selfOnly: true,
-                }, {
-                  name: 'section 4',
-                  query: 'query 4',
-                  suffixForDashboard: 'suffix',
-                },
-              ],
-            });
-      });
-
-      test('dashboard for other user', () => {
-        const dashboard =
-            Gerrit.Nav.getUserDashboard('user', sections, 'title');
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1'},
-                {name: 'section 2', query: 'query 2 for user'},
-                {
-                  name: 'section 4',
-                  query: 'query 4',
-                  suffixForDashboard: 'suffix',
-                },
-              ],
-            });
-      });
+    test('dashboard for other user', () => {
+      const dashboard =
+           GerritNav.getUserDashboard('user', sections, 'title');
+      assert.deepEqual(
+          dashboard,
+          {
+            title: 'title',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2 for user'},
+              {
+                name: 'section 4',
+                query: 'query 4',
+                suffixForDashboard: 'suffix',
+              },
+            ],
+          });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
deleted file mode 100644
index 0ba8a22..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
+++ /dev/null
@@ -1,22 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-reporting">
-  <script src="gr-reporting.js"></script>
-</dom-module>
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..c8b4ff5 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -14,525 +14,590 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Latency reporting constants.
-  const TIMING = {
-    TYPE: 'timing-report',
-    CATEGORY_UI_LATENCY: 'UI Latency',
-    CATEGORY_RPC: 'RPC Timing',
-    // Reported events - alphabetize below.
-    APP_STARTED: 'App Started',
-    PAGE_LOADED: 'Page Loaded',
-  };
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {appContext} from '../../../services/app-context.js';
 
-  // Plugin-related reporting constants.
-  const PLUGINS = {
-    TYPE: 'lifecycle',
-    // Reported events - alphabetize below.
-    INSTALLED: 'Plugins installed',
-  };
+// Latency reporting constants.
+const TIMING = {
+  TYPE: 'timing-report',
+  CATEGORY_UI_LATENCY: 'UI Latency',
+  CATEGORY_RPC: 'RPC Timing',
+  // Reported events - alphabetize below.
+  APP_STARTED: 'App Started',
+};
 
-  // Chrome extension-related reporting constants.
-  const EXTENSION = {
-    TYPE: 'lifecycle',
-    // Reported events - alphabetize below.
-    DETECTED: 'Extension detected',
-  };
+// Plugin-related reporting constants.
+const PLUGINS = {
+  TYPE: 'lifecycle',
+  // Reported events - alphabetize below.
+  INSTALLED: 'Plugins installed',
+};
 
-  // Page visibility related constants.
-  const PAGE_VISIBILITY = {
-    TYPE: 'lifecycle',
-    CATEGORY: 'Page Visibility',
-    // Reported events - alphabetize below.
-    STARTED_HIDDEN: 'hidden',
-  };
+// Chrome extension-related reporting constants.
+const EXTENSION = {
+  TYPE: 'lifecycle',
+  // Reported events - alphabetize below.
+  DETECTED: 'Extension detected',
+};
 
-  // Navigation reporting constants.
-  const NAVIGATION = {
-    TYPE: 'nav-report',
-    CATEGORY: 'Location Changed',
-    PAGE: 'Page',
-  };
+// Navigation reporting constants.
+const NAVIGATION = {
+  TYPE: 'nav-report',
+  CATEGORY: 'Location Changed',
+  PAGE: 'Page',
+};
 
-  const ERROR = {
-    TYPE: 'error',
-    CATEGORY: 'exception',
-  };
+const ERROR = {
+  TYPE: 'error',
+  CATEGORY: 'exception',
+};
 
-  const ERROR_DIALOG = {
-    TYPE: 'error',
-    CATEGORY: 'Error Dialog',
-  };
+const ERROR_DIALOG = {
+  TYPE: 'error',
+  CATEGORY: 'Error Dialog',
+};
 
-  const TIMER = {
-    CHANGE_DISPLAYED: 'ChangeDisplayed',
-    CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
-    DASHBOARD_DISPLAYED: 'DashboardDisplayed',
-    DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
-    DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
-    DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
-    FILE_LIST_DISPLAYED: 'FileListDisplayed',
-    PLUGINS_LOADED: 'PluginsLoaded',
-    STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
-    STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
-    STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
-    STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
-    STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
-    STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
-    STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
-    WEB_COMPONENTS_READY: 'WebComponentsReady',
-    METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
-  };
+const TIMER = {
+  CHANGE_DISPLAYED: 'ChangeDisplayed',
+  CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
+  DASHBOARD_DISPLAYED: 'DashboardDisplayed',
+  DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
+  DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
+  DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
+  FILE_LIST_DISPLAYED: 'FileListDisplayed',
+  PLUGINS_LOADED: 'PluginsLoaded',
+  STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
+  STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
+  STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
+  STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
+  STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
+  STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
+  STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
+  WEB_COMPONENTS_READY: 'WebComponentsReady',
+  METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
+};
 
-  const STARTUP_TIMERS = {};
-  STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
-  STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0;
-  STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
-  STARTUP_TIMERS[TIMING.APP_STARTED] = 0;
-  // WebComponentsReady timer is triggered from gr-router.
-  STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
+const STARTUP_TIMERS = {};
+STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
+STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0;
+STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
+STARTUP_TIMERS[TIMING.APP_STARTED] = 0;
+// WebComponentsReady timer is triggered from gr-router.
+STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
 
-  const INTERACTION_TYPE = 'interaction';
+const INTERACTION_TYPE = 'interaction';
 
-  const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-  const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
+const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
+const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
 
-  const pending = [];
+let pending = [];
+let slowRpcList = [];
+const SLOW_RPC_THRESHOLD = 500;
 
-  const loadedPlugins = [];
-  const detectedExtensions = [];
+// Variables that hold context info in global scope
+let reportRepoName = undefined;
 
-  const onError = function(oldOnError, msg, url, line, column, error) {
-    if (oldOnError) {
-      oldOnError(msg, url, line, column, error);
+const onError = function(oldOnError, msg, url, line, column, error) {
+  if (oldOnError) {
+    oldOnError(msg, url, line, column, error);
+  }
+  if (error) {
+    line = line || error.lineNumber;
+    column = column || error.columnNumber;
+    let shortenedErrorStack = msg;
+    if (error.stack) {
+      const errorStackLines = error.stack.split('\n');
+      shortenedErrorStack = errorStackLines.slice(0,
+          Math.min(3, errorStackLines.length)).join('\n');
     }
-    if (error) {
-      line = line || error.lineNumber;
-      column = column || error.columnNumber;
-      let shortenedErrorStack = msg;
-      if (error.stack) {
-        const errorStackLines = error.stack.split('\n');
-        shortenedErrorStack = errorStackLines.slice(0,
-            Math.min(3, errorStackLines.length)).join('\n');
-      }
-      msg = shortenedErrorStack || error.toString();
-    }
+    msg = shortenedErrorStack || error.toString();
+  }
+  const payload = {
+    url,
+    line,
+    column,
+    error,
+  };
+  GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
+  return true;
+};
+
+const catchErrors = function(opt_context) {
+  const context = opt_context || window;
+  context.onerror = onError.bind(null, context.onerror);
+  context.addEventListener('unhandledrejection', e => {
+    const msg = e.reason.message;
     const payload = {
-      url,
-      line,
-      column,
-      error,
+      error: e.reason,
     };
     GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
-    return true;
-  };
-
-  const catchErrors = function(opt_context) {
-    const context = opt_context || window;
-    context.onerror = onError.bind(null, context.onerror);
-    context.addEventListener('unhandledrejection', e => {
-      const msg = e.reason.message;
-      const payload = {
-        error: e.reason,
-      };
-      GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
-    });
-  };
-  catchErrors();
-
-  // The Polymer pass of JSCompiler requires this to be reassignable
-  // eslint-disable-next-line prefer-const
-  let GrReporting = Polymer({
-    is: 'gr-reporting',
-
-    properties: {
-      category: String,
-
-      _baselines: {
-        type: Object,
-        value: STARTUP_TIMERS, // Shared across all instances.
-      },
-
-      _timers: {
-        type: Object,
-        value: {timeBetweenDraftActions: null}, // Shared across all instances.
-      },
-    },
-
-    get performanceTiming() {
-      return window.performance.timing;
-    },
-
-    now() {
-      return window.performance.now();
-    },
-
-    _arePluginsLoaded() {
-      return this._baselines &&
-        !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
-    },
-
-    _isMetricsPluginLoaded() {
-      return this._arePluginsLoaded() || this._baselines &&
-        !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
-    },
-
-    reporter(...args) {
-      const report = (this._isMetricsPluginLoaded() && !pending.length) ?
-        this.defaultReporter : this.cachingReporter;
-      args.splice(4, 0, loadedPlugins, detectedExtensions);
-      report.apply(this, args);
-    },
-
-    /**
-     * The default reporter reports events immediately.
-     *
-     * @param {string} type
-     * @param {string} category
-     * @param {string} eventName
-     * @param {string|number} eventValue
-     * @param {Array} plugins
-     * @param {Array} extensions
-     * @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) {
-      const detail = {
-        type,
-        category,
-        name: eventName,
-        value: eventValue,
-      };
-      if (category === TIMING.CATEGORY_UI_LATENCY) {
-        detail.loadedPlugins = loadedPlugins;
-        detail.detectedExtensions = detectedExtensions;
-      }
-      document.dispatchEvent(new CustomEvent(type, {detail}));
-      if (opt_noLog) { return; }
-      if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
-        console.error(eventValue && eventValue.error || eventName);
-      } else {
-        if (eventValue !== undefined) {
-          console.log(`Reporting: ${eventName}: ${eventValue}`);
-        } else {
-          console.log(`Reporting: ${eventName}`);
-        }
-      }
-    },
-
-    /**
-     * The caching reporter will queue reports until plugins have loaded, and
-     * log events immediately if they're reported after plugins have loaded.
-     *
-     * @param {string} type
-     * @param {string} category
-     * @param {string} eventName
-     * @param {string|number} eventValue
-     * @param {Array} plugins
-     * @param {Array} extensions
-     * @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) {
-      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.reporter(type, category, eventName, eventValue,
-            plugins, extensions, opt_noLog);
-      } else {
-        pending.push([type, category, eventName, eventValue,
-          plugins, extensions, opt_noLog]);
-      }
-    },
-
-    /**
-     * User-perceived app start time, should be reported when the app is ready.
-     */
-    appStarted(hidden) {
-      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.
-     */
-    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 -
-            this.performanceTiming.navigationStart;
-        this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
-            TIMING.PAGE_LOADED, loadTime);
-      }
-    },
-
-    beforeLocationChanged() {
-      for (const prop of Object.keys(this._baselines)) {
-        delete this._baselines[prop];
-      }
-      this.time(TIMER.CHANGE_DISPLAYED);
-      this.time(TIMER.CHANGE_LOAD_FULL);
-      this.time(TIMER.DASHBOARD_DISPLAYED);
-      this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
-      this.time(TIMER.DIFF_VIEW_DISPLAYED);
-      this.time(TIMER.DIFF_VIEW_LOAD_FULL);
-      this.time(TIMER.FILE_LIST_DISPLAYED);
-    },
-
-    locationChanged(page) {
-      this.reporter(
-          NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
-    },
-
-    dashboardDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED);
-      } else {
-        this.timeEnd(TIMER.DASHBOARD_DISPLAYED);
-      }
-    },
-
-    changeDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED);
-      } else {
-        this.timeEnd(TIMER.CHANGE_DISPLAYED);
-      }
-    },
-
-    changeFullyLoaded() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_LOAD_FULL)) {
-        this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
-      } else {
-        this.timeEnd(TIMER.CHANGE_LOAD_FULL);
-      }
-    },
-
-    diffViewDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED);
-      } else {
-        this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED);
-      }
-    },
-
-    diffViewFullyLoaded() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
-        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
-      } else {
-        this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
-      }
-    },
-
-    diffViewContentDisplayed() {
-      if (this._baselines.hasOwnProperty(
-          TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
-      } else {
-        this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
-      }
-    },
-
-    fileListDisplayed() {
-      if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
-        this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
-      } else {
-        this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
-      }
-    },
-
-    reportExtension(name) {
-      this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name);
-      if (!detectedExtensions.includes(name)) {
-        detectedExtensions.push(name);
-      }
-    },
-
-    pluginLoaded(name) {
-      if (name.startsWith('metrics-')) {
-        this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
-      }
-      loadedPlugins.push(name);
-    },
-
-    pluginsLoaded(pluginsList) {
-      this.timeEnd(TIMER.PLUGINS_LOADED);
-      this.reporter(
-          PLUGINS.TYPE, PLUGINS.INSTALLED, (pluginsList || []).join(','));
-    },
-
-    /**
-     * Reset named timer.
-     */
-    time(name) {
-      this._baselines[name] = this.now();
-      window.performance.mark(`${name}-start`);
-    },
-
-    /**
-     * Finish named timer and report it to server.
-     */
-    timeEnd(name) {
-      if (!this._baselines.hasOwnProperty(name)) { return; }
-      const baseTime = this._baselines[name];
-      delete this._baselines[name];
-      this._reportTiming(name, this.now() - baseTime);
-
-      // Finalize the interval. Either from a registered start mark or
-      // the navigation start time (if baseTime is 0).
-      if (baseTime !== 0) {
-        window.performance.measure(name, `${name}-start`);
-      } else {
-        // Microsft Edge does not handle the 2nd param correctly
-        // (if undefined).
-        window.performance.measure(name);
-      }
-    },
-
-    /**
-     * Reports just line timeEnd, but additionally reports an average given a
-     * denominator and a separate reporiting name for the average.
-     *
-     * @param {string} name Timing name.
-     * @param {string} averageName Average timing name.
-     * @param {number} denominator Number by which to divide the total to
-     *     compute the average.
-     */
-    timeEndWithAverage(name, averageName, denominator) {
-      if (!this._baselines.hasOwnProperty(name)) { return; }
-      const baseTime = this._baselines[name];
-      this.timeEnd(name);
-
-      // Guard against division by zero.
-      if (!denominator) { return; }
-      const time = Math.round(this.now() - baseTime);
-      this._reportTiming(averageName, time / denominator);
-    },
-
-    /**
-     * Send a timing report with an arbitrary time value.
-     *
-     * @param {string} name Timing name.
-     * @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));
-    },
-
-    /**
-     * Get a timer object to for reporing a user timing. The start time will be
-     * the time that the object has been created, and the end time will be the
-     * time that the "end" method is called on the object.
-     *
-     * @param {string} name Timing name.
-     * @returns {!Object} The timer object.
-     */
-    getTimer(name) {
-      let called = false;
-      let start;
-      let max = null;
-
-      const timer = {
-
-        // Clear the timer and reset the start time.
-        reset: () => {
-          called = false;
-          start = this.now();
-          return timer;
-        },
-
-        // Stop the timer and report the intervening time.
-        end: () => {
-          if (called) {
-            throw new Error(`Timer for "${name}" already ended.`);
-          }
-          called = true;
-          const time = this.now() - start;
-
-          // If a maximum is specified and the time exceeds it, do not report.
-          if (max && time > max) { return timer; }
-
-          this._reportTiming(name, time);
-          return timer;
-        },
-
-        // Set a maximum reportable time. If a maximum is set and the timer is
-        // ended after the specified amount of time, the value is not reported.
-        withMaximum(maximum) {
-          max = maximum;
-          return timer;
-        },
-      };
-
-      // The timer is initialized to its creation time.
-      return timer.reset();
-    },
-
-    /**
-     * Log timing information for an RPC.
-     *
-     * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
-     * @param {number} elapsed The time elapsed of the RPC.
-     */
-    reportRpcTiming(anonymizedUrl, elapsed) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
-          elapsed, true);
-    },
-
-    reportInteraction(eventName, opt_msg) {
-      this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
-    },
-
-    /**
-     * A draft interaction was started. Update the time-betweeen-draft-actions
-     * timer.
-     */
-    recordDraftInteraction() {
-      // If there is no timer defined, then this is the first interaction.
-      // Set up the timer so that it's ready to record the intervening time when
-      // called again.
-      const timer = this._timers.timeBetweenDraftActions;
-      if (!timer) {
-        // Create a timer with a maximum length.
-        this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
-            .withMaximum(DRAFT_ACTION_TIMER_MAX);
-        return;
-      }
-
-      // Mark the time and reinitialize the timer.
-      timer.end().reset();
-    },
-
-    reportErrorDialog(message) {
-      this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
-          'ErrorDialog: ' + message, {error: new Error(message)});
-    },
   });
+};
+catchErrors();
 
-  window.GrReporting = GrReporting;
-  // Expose onerror installation so it would be accessible from tests.
-  window.GrReporting._catchErrors = catchErrors;
-  window.GrReporting.STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);
-})();
+// 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']});
+  }
+}
+
+document.addEventListener('visibilitychange', () => {
+  const eventName = `Visibility changed to ${document.visibilityState}`;
+  GrReporting.prototype.reporter(INTERACTION_TYPE, undefined, eventName,
+      undefined, {}, true);
+});
+
+// The Polymer pass of JSCompiler requires this to be reassignable
+// eslint-disable-next-line prefer-const
+let GrReporting = Polymer({
+  is: 'gr-reporting',
+
+  properties: {
+    category: String,
+
+    _baselines: {
+      type: Object,
+      value: STARTUP_TIMERS, // Shared across all instances.
+    },
+
+    _timers: {
+      type: Object,
+      value: {timeBetweenDraftActions: null}, // Shared across all instances.
+    },
+  },
+
+  get performanceTiming() {
+    return window.performance.timing;
+  },
+
+  get slowRpcSnapshot() {
+    return slowRpcList.slice();
+  },
+
+  now() {
+    return Math.round(window.performance.now());
+  },
+
+  _arePluginsLoaded() {
+    return this._baselines &&
+      !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
+  },
+
+  _isMetricsPluginLoaded() {
+    return this._arePluginsLoaded() || this._baselines &&
+      !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
+  },
+
+  /**
+   * Reporter reports events. Events will be queued if metrics plugin is not
+   * yet installed.
+   *
+   * @param {string} type
+   * @param {string} category
+   * @param {string} eventName
+   * @param {string|number} eventValue
+   * @param {Object} eventDetails
+   * @param {boolean|undefined} opt_noLog If true, the event will not be
+   *     logged to the JS console.
+   */
+  reporter(type, category, eventName, eventValue, eventDetails, opt_noLog) {
+    const eventInfo = this._createEventInfo(type, category,
+        eventName, eventValue, eventDetails);
+    if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
+      console.error(eventValue && eventValue.error || eventName);
+    }
+
+    // We report events immediately when metrics plugin is loaded
+    if (this._isMetricsPluginLoaded() && !pending.length) {
+      this._reportEvent(eventInfo, opt_noLog);
+    } else {
+      // We cache until metrics plugin is loaded
+      pending.push([eventInfo, opt_noLog]);
+      if (this._isMetricsPluginLoaded()) {
+        pending.forEach(([eventInfo, opt_noLog]) => {
+          this._reportEvent(eventInfo, opt_noLog);
+        });
+        pending = [];
+      }
+    }
+  },
+
+  _reportEvent(eventInfo, opt_noLog) {
+    const {type, value, name} = eventInfo;
+    document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
+    if (opt_noLog) { return; }
+    if (type !== ERROR.TYPE) {
+      if (value !== undefined) {
+        console.log(`Reporting: ${name}: ${value}`);
+      } else {
+        console.log(`Reporting: ${name}`);
+      }
+    }
+  },
+
+  _createEventInfo(type, category, name, value, eventDetails) {
+    const eventInfo = {
+      type,
+      category,
+      name,
+      value,
+      eventStart: this.now(),
+    };
+
+    if (typeof(eventDetails) === 'object' &&
+      Object.entries(eventDetails).length !== 0) {
+      eventInfo.eventDetails = JSON.stringify(eventDetails);
+    }
+
+    if (reportRepoName) {
+      eventInfo.repoName = reportRepoName;
+    }
+
+    const isInBackgroundTab = document.visibilityState === 'hidden';
+    if (isInBackgroundTab !== undefined) {
+      eventInfo.inBackgroundTab = isInBackgroundTab;
+    }
+
+    const enabledExperiments = appContext.flagsService.enabledExperiments;
+    if (enabledExperiments.length) {
+      eventInfo.enabledExperiments = JSON.stringify(enabledExperiments);
+    }
+
+    return eventInfo;
+  },
+
+  /**
+   * User-perceived app start time, should be reported when the app is ready.
+   */
+  appStarted() {
+    this.timeEnd(TIMING.APP_STARTED);
+    this._reportNavResTimes();
+  },
+
+  /**
+   * Browser's navigation and resource timings
+   */
+  _reportNavResTimes() {
+    const perfEvents = Object.keys(this.performanceTiming.toJSON());
+    perfEvents.forEach(
+        eventName => this._reportPerformanceTiming(eventName)
+    );
+  },
+
+  _reportPerformanceTiming(eventName, eventDetails) {
+    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,
+          `NavResTime - ${eventName}`, elapsedTime, eventDetails, true);
+    }
+  },
+
+  beforeLocationChanged() {
+    for (const prop of Object.keys(this._baselines)) {
+      delete this._baselines[prop];
+    }
+    this.time(TIMER.CHANGE_DISPLAYED);
+    this.time(TIMER.CHANGE_LOAD_FULL);
+    this.time(TIMER.DASHBOARD_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_LOAD_FULL);
+    this.time(TIMER.FILE_LIST_DISPLAYED);
+    reportRepoName = undefined;
+    // reset slow rpc list since here start page loads which report these rpcs
+    slowRpcList = [];
+  },
+
+  locationChanged(page) {
+    this.reporter(
+        NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
+  },
+
+  dashboardDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
+    }
+  },
+
+  changeDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
+    }
+  },
+
+  changeFullyLoaded() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.CHANGE_LOAD_FULL);
+    }
+  },
+
+  diffViewDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+    }
+  },
+
+  diffViewFullyLoaded() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
+    }
+  },
+
+  diffViewContentDisplayed() {
+    if (this._baselines.hasOwnProperty(
+        TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+    }
+  },
+
+  fileListDisplayed() {
+    if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
+    }
+  },
+
+  _pageLoadDetails() {
+    const details = {
+      rpcList: this.slowRpcSnapshot,
+    };
+
+    if (window.screen) {
+      details.screenSize = {
+        width: window.screen.width,
+        height: window.screen.height,
+      };
+    }
+
+    if (document && document.documentElement) {
+      details.viewport = {
+        width: document.documentElement.clientWidth,
+        height: document.documentElement.clientHeight,
+      };
+    }
+
+    if (window.performance && window.performance.memory) {
+      const toMb = bytes => Math.round((bytes / (1024 * 1024)) * 100) / 100;
+      details.usedJSHeapSizeMb =
+        toMb(window.performance.memory.usedJSHeapSize);
+    }
+
+    return details;
+  },
+
+  reportExtension(name) {
+    this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name);
+  },
+
+  pluginLoaded(name) {
+    if (name.startsWith('metrics-')) {
+      this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
+    }
+  },
+
+  pluginsLoaded(pluginsList) {
+    this.timeEnd(TIMER.PLUGINS_LOADED);
+    this.reporter(
+        PLUGINS.TYPE, PLUGINS.INSTALLED, PLUGINS.INSTALLED, undefined,
+        {pluginsList: pluginsList || []}, true);
+  },
+
+  /**
+   * Reset named timer.
+   */
+  time(name) {
+    this._baselines[name] = this.now();
+    window.performance.mark(`${name}-start`);
+  },
+
+  /**
+   * Finish named timer and report it to server.
+   */
+  timeEnd(name, eventDetails) {
+    if (!this._baselines.hasOwnProperty(name)) { return; }
+    const baseTime = this._baselines[name];
+    delete this._baselines[name];
+    this._reportTiming(name, this.now() - baseTime, eventDetails);
+
+    // Finalize the interval. Either from a registered start mark or
+    // the navigation start time (if baseTime is 0).
+    if (baseTime !== 0) {
+      window.performance.measure(name, `${name}-start`);
+    } else {
+      // Microsft Edge does not handle the 2nd param correctly
+      // (if undefined).
+      window.performance.measure(name);
+    }
+  },
+
+  /**
+   * Reports just line timeEnd, but additionally reports an average given a
+   * denominator and a separate reporiting name for the average.
+   *
+   * @param {string} name Timing name.
+   * @param {string} averageName Average timing name.
+   * @param {number} denominator Number by which to divide the total to
+   *     compute the average.
+   */
+  timeEndWithAverage(name, averageName, denominator) {
+    if (!this._baselines.hasOwnProperty(name)) { return; }
+    const baseTime = this._baselines[name];
+    this.timeEnd(name);
+
+    // Guard against division by zero.
+    if (!denominator) { return; }
+    const time = this.now() - baseTime;
+    this._reportTiming(averageName, time / denominator);
+  },
+
+  /**
+   * Send a timing report with an arbitrary time value.
+   *
+   * @param {string} name Timing name.
+   * @param {number} time The time to report as an integer of milliseconds.
+   * @param {Object} eventDetails non sensitive details
+   */
+  _reportTiming(name, time, eventDetails) {
+    this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name, time,
+        eventDetails);
+  },
+
+  /**
+   * Get a timer object to for reporing a user timing. The start time will be
+   * the time that the object has been created, and the end time will be the
+   * time that the "end" method is called on the object.
+   *
+   * @param {string} name Timing name.
+   * @returns {!Object} The timer object.
+   */
+  getTimer(name) {
+    let called = false;
+    let start;
+    let max = null;
+
+    const timer = {
+
+      // Clear the timer and reset the start time.
+      reset: () => {
+        called = false;
+        start = this.now();
+        return timer;
+      },
+
+      // Stop the timer and report the intervening time.
+      end: () => {
+        if (called) {
+          throw new Error(`Timer for "${name}" already ended.`);
+        }
+        called = true;
+        const time = this.now() - start;
+
+        // If a maximum is specified and the time exceeds it, do not report.
+        if (max && time > max) { return timer; }
+
+        this._reportTiming(name, time);
+        return timer;
+      },
+
+      // Set a maximum reportable time. If a maximum is set and the timer is
+      // ended after the specified amount of time, the value is not reported.
+      withMaximum(maximum) {
+        max = maximum;
+        return timer;
+      },
+    };
+
+    // The timer is initialized to its creation time.
+    return timer.reset();
+  },
+
+  /**
+   * Log timing information for an RPC.
+   *
+   * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
+   * @param {number} elapsed The time elapsed of the RPC.
+   */
+  reportRpcTiming(anonymizedUrl, elapsed) {
+    this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
+        elapsed, {}, true);
+    if (elapsed >= SLOW_RPC_THRESHOLD) {
+      slowRpcList.push({anonymizedUrl, elapsed});
+    }
+  },
+
+  reportInteraction(eventName, details) {
+    this.reporter(INTERACTION_TYPE, this.category, eventName, undefined,
+        details, true);
+  },
+
+  /**
+   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * timer.
+   */
+  recordDraftInteraction() {
+    // If there is no timer defined, then this is the first interaction.
+    // Set up the timer so that it's ready to record the intervening time when
+    // called again.
+    const timer = this._timers.timeBetweenDraftActions;
+    if (!timer) {
+      // Create a timer with a maximum length.
+      this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
+          .withMaximum(DRAFT_ACTION_TIMER_MAX);
+      return;
+    }
+
+    // Mark the time and reinitialize the timer.
+    timer.end().reset();
+  },
+
+  reportErrorDialog(message) {
+    this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
+        'ErrorDialog: ' + message, {error: new Error(message)});
+  },
+
+  setRepoName(repoName) {
+    reportRepoName = repoName;
+  },
+});
+
+window.GrReporting = GrReporting;
+// Expose onerror installation so it would be accessible from tests.
+window.GrReporting._catchErrors = catchErrors;
+window.GrReporting.STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);
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..e140d6c 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-reporting.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,354 +31,407 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-reporting tests', () => {
-    let element;
-    let sandbox;
-    let clock;
-    let fakePerformance;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-reporting.js';
+suite('gr-reporting tests', () => {
+  let element;
+  let sandbox;
+  let clock;
+  let fakePerformance;
 
-    const NOW_TIME = 100;
+  const NOW_TIME = 100;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    clock = sinon.useFakeTimers(NOW_TIME);
+    element = fixture('basic');
+    element._baselines = Object.assign({}, GrReporting.STARTUP_TIMERS);
+    fakePerformance = {
+      navigationStart: 1,
+      loadEventEnd: 2,
+    };
+    fakePerformance.toJSON = () => fakePerformance;
+    sinon.stub(element, 'performanceTiming',
+        {get() { return fakePerformance; }});
+    sandbox.stub(element, 'reporter');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    clock.restore();
+  });
+
+  test('appStarted', () => {
+    sandbox.stub(element, 'now').returns(42);
+    element.appStarted();
+    assert.isTrue(
+        element.reporter.calledWithMatch(
+            'timing-report', 'UI Latency', 'App Started', 42
+        ));
+    assert.isTrue(
+        element.reporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
+            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+            undefined, true)
+    );
+  });
+
+  test('WebComponentsReady', () => {
+    sandbox.stub(element, 'now').returns(42);
+    element.timeEnd('WebComponentsReady');
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'WebComponentsReady', 42
+    ));
+  });
+
+  test('beforeLocationChanged', () => {
+    element._baselines['garbage'] = 'monster';
+    sandbox.stub(element, 'time');
+    element.beforeLocationChanged();
+    assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
+    assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
+    assert.isTrue(element.time.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
+    assert.isTrue(element.time.calledWithExactly('FileListDisplayed'));
+    assert.isFalse(element._baselines.hasOwnProperty('garbage'));
+  });
+
+  test('changeDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.changeDisplayed();
+    assert.isFalse(element.timeEnd.calledWith('ChangeDisplayed'));
+    assert.isTrue(element.timeEnd.calledWith('StartupChangeDisplayed'));
+    element.changeDisplayed();
+    assert.isTrue(element.timeEnd.calledWith('ChangeDisplayed'));
+  });
+
+  test('changeFullyLoaded', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.changeFullyLoaded();
+    assert.isFalse(
+        element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
+    element.changeFullyLoaded();
+    assert.isTrue(element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+  });
+
+  test('diffViewDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.diffViewDisplayed();
+    assert.isFalse(element.timeEnd.calledWith('DiffViewDisplayed'));
+    assert.isTrue(element.timeEnd.calledWith('StartupDiffViewDisplayed'));
+    element.diffViewDisplayed();
+    assert.isTrue(element.timeEnd.calledWith('DiffViewDisplayed'));
+  });
+
+  test('fileListDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.fileListDisplayed();
+    assert.isFalse(
+        element.timeEnd.calledWithExactly('FileListDisplayed'));
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupFileListDisplayed'));
+    element.fileListDisplayed();
+    assert.isTrue(element.timeEnd.calledWithExactly('FileListDisplayed'));
+  });
+
+  test('dashboardDisplayed', () => {
+    sandbox.spy(element, 'timeEnd');
+    element.dashboardDisplayed();
+    assert.isFalse(element.timeEnd.calledWith('DashboardDisplayed'));
+    assert.isTrue(element.timeEnd.calledWith('StartupDashboardDisplayed'));
+    element.dashboardDisplayed();
+    assert.isTrue(element.timeEnd.calledWith('DashboardDisplayed'));
+  });
+
+  test('dashboardDisplayed details', () => {
+    sandbox.spy(element, 'timeEnd');
+    sandbox.stub(window, 'performance', {
+      memory: {
+        usedJSHeapSize: 1024 * 1024,
+      },
+      measure: () => {},
+    });
+    sandbox.stub(element, 'now').returns(42);
+    element.reportRpcTiming('/changes/*~*/comments', 500);
+    element.dashboardDisplayed();
+    assert.isTrue(
+        element.timeEnd.calledWithExactly('StartupDashboardDisplayed',
+            {rpcList: [
+              {
+                anonymizedUrl: '/changes/*~*/comments',
+                elapsed: 500,
+              },
+            ],
+            screenSize: {
+              width: window.screen.width,
+              height: window.screen.height,
+            },
+            viewport: {
+              width: document.documentElement.clientWidth,
+              height: document.documentElement.clientHeight,
+            },
+            usedJSHeapSizeMb: 1,
+            }
+        ));
+  });
+
+  test('time and timeEnd', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(0);
+    element.time('foo');
+    nowStub.returns(1);
+    element.time('bar');
+    nowStub.returns(2);
+    element.timeEnd('bar');
+    nowStub.returns(3);
+    element.timeEnd('foo');
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 3
+    ));
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 1
+    ));
+  });
+
+  test('timer object', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(100);
+    const timer = element.getTimer('foo-bar');
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo-bar', 50));
+  });
+
+  test('timer object double call', () => {
+    const timer = element.getTimer('foo-bar');
+    timer.end();
+    assert.isTrue(element.reporter.calledOnce);
+    assert.throws(() => {
+      timer.end();
+    }, 'Timer for "foo-bar" already ended.');
+  });
+
+  test('timer object maximum', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(100);
+    const timer = element.getTimer('foo-bar').withMaximum(100);
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(element.reporter.calledOnce);
+
+    timer.reset();
+    nowStub.returns(260);
+    timer.end();
+    assert.isTrue(element.reporter.calledOnce);
+  });
+
+  test('recordDraftInteraction', () => {
+    const key = 'TimeBetweenDraftActions';
+    const nowStub = sandbox.stub(element, 'now').returns(100);
+    const timingStub = sandbox.stub(element, '_reportTiming');
+    element.recordDraftInteraction();
+    assert.isFalse(timingStub.called);
+
+    nowStub.returns(200);
+    element.recordDraftInteraction();
+    assert.isTrue(timingStub.calledOnce);
+    assert.equal(timingStub.lastCall.args[0], key);
+    assert.equal(timingStub.lastCall.args[1], 100);
+
+    nowStub.returns(350);
+    element.recordDraftInteraction();
+    assert.isTrue(timingStub.calledTwice);
+    assert.equal(timingStub.lastCall.args[0], key);
+    assert.equal(timingStub.lastCall.args[1], 150);
+
+    nowStub.returns(370 + 2 * 60 * 1000);
+    element.recordDraftInteraction();
+    assert.isFalse(timingStub.calledThrice);
+  });
+
+  test('timeEndWithAverage', () => {
+    const nowStub = sandbox.stub(element, 'now').returns(0);
+    nowStub.returns(1000);
+    element.time('foo');
+    nowStub.returns(1100);
+    element.timeEndWithAverage('foo', 'bar', 10);
+    assert.isTrue(element.reporter.calledTwice);
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 100));
+    assert.isTrue(element.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 10));
+  });
+
+  test('reportExtension', () => {
+    element.reportExtension('foo');
+    assert.isTrue(element.reporter.calledWithExactly(
+        'lifecycle', 'Extension detected', 'foo'
+    ));
+  });
+
+  test('reportInteraction', () => {
+    element.reporter.restore();
+    sandbox.spy(element, '_reportEvent');
+    element.pluginsLoaded(); // so we don't cache
+    element.reportInteraction('button-click', {name: 'sendReply'});
+    assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
+        {
+          type: 'interaction',
+          name: 'button-click',
+          eventDetails: JSON.stringify({name: 'sendReply'}),
+        }
+    ));
+  });
+
+  test('report start time', () => {
+    element.reporter.restore();
+    sandbox.stub(element, 'now').returns(42);
+    sandbox.spy(element, '_reportEvent');
+    const dispatchStub = sandbox.spy(document, 'dispatchEvent');
+    element.pluginsLoaded();
+    element.time('timeAction');
+    element.timeEnd('timeAction');
+    assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
+        {
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'timeAction',
+          value: 0,
+          eventStart: 42,
+        }
+    ));
+    assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
+  });
+
+  suite('plugins', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      clock = sinon.useFakeTimers(NOW_TIME);
-      element = fixture('basic');
-      element._baselines = Object.assign({}, GrReporting.STARTUP_TIMERS);
-      fakePerformance = {
-        navigationStart: 1,
-        loadEventEnd: 2,
-      };
-      sinon.stub(element, 'performanceTiming',
-          {get() { return fakePerformance; }});
-      sandbox.stub(element, 'reporter');
+      element.reporter.restore();
+      sandbox.stub(element, '_reportEvent');
     });
 
-    teardown(() => {
-      sandbox.restore();
-      clock.restore();
-    });
-
-    test('appStarted', () => {
+    test('pluginsLoaded reports time', () => {
       sandbox.stub(element, 'now').returns(42);
-      element.appStarted(true);
-      assert.isTrue(
-          element.reporter.calledWithExactly(
-              'timing-report', 'UI Latency', 'App Started', 42
-          ));
-      assert.isTrue(
-          element.reporter.calledWithExactly(
-              'lifecycle', 'Page Visibility', 'hidden'
-          ));
-    });
-
-    test('WebComponentsReady', () => {
-      sandbox.stub(element, 'now').returns(42);
-      element.timeEnd('WebComponentsReady');
-      assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'WebComponentsReady', 42
+      element.pluginsLoaded();
+      assert.isTrue(element._reportEvent.calledWithMatch(
+          {
+            type: 'timing-report',
+            category: 'UI Latency',
+            name: 'PluginsLoaded',
+            value: 42,
+          }
       ));
     });
 
-    test('pageLoaded', () => {
-      element.pageLoaded();
-      assert.isTrue(
-          element.reporter.calledWithExactly(
-              'timing-report', 'UI Latency', 'Page Loaded',
-              fakePerformance.loadEventEnd - fakePerformance.navigationStart)
-      );
+    test('pluginsLoaded reports plugins', () => {
+      element.pluginsLoaded(['foo', 'bar']);
+      assert.isTrue(element._reportEvent.calledWithMatch(
+          {
+            type: 'lifecycle',
+            category: 'Plugins installed',
+            eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
+          }
+      ));
     });
 
-    test('beforeLocationChanged', () => {
-      element._baselines['garbage'] = 'monster';
-      sandbox.stub(element, 'time');
-      element.beforeLocationChanged();
-      assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
-      assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
-      assert.isTrue(element.time.calledWithExactly('ChangeFullyLoaded'));
-      assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
-      assert.isTrue(element.time.calledWithExactly('FileListDisplayed'));
-      assert.isFalse(element._baselines.hasOwnProperty('garbage'));
-    });
-
-    test('changeDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.changeDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('ChangeDisplayed'));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupChangeDisplayed'));
-      element.changeDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('ChangeDisplayed'));
-    });
-
-    test('changeFullyLoaded', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.changeFullyLoaded();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
-      element.changeFullyLoaded();
-      assert.isTrue(element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-    });
-
-    test('diffViewDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.diffViewDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('DiffViewDisplayed'));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupDiffViewDisplayed'));
-      element.diffViewDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('DiffViewDisplayed'));
-    });
-
-    test('fileListDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.fileListDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('FileListDisplayed'));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupFileListDisplayed'));
-      element.fileListDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('FileListDisplayed'));
-    });
-
-    test('dashboardDisplayed', () => {
-      sandbox.spy(element, 'timeEnd');
-      element.dashboardDisplayed();
-      assert.isFalse(
-          element.timeEnd.calledWithExactly('DashboardDisplayed'));
-      assert.isTrue(
-          element.timeEnd.calledWithExactly('StartupDashboardDisplayed'));
-      element.dashboardDisplayed();
-      assert.isTrue(element.timeEnd.calledWithExactly('DashboardDisplayed'));
-    });
-
-    test('time and timeEnd', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(0);
-      element.time('foo');
-      nowStub.returns(1.1);
-      element.time('bar');
-      nowStub.returns(2);
-      element.timeEnd('bar');
-      nowStub.returns(3.511);
+    test('caches reports if plugins are not loaded', () => {
       element.timeEnd('foo');
-      assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'foo', 4
-      ));
-      assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'bar', 1
-      ));
+      assert.isFalse(element._reportEvent.called);
     });
 
-    test('timer object', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(100);
-      const timer = element.getTimer('foo-bar');
-      nowStub.returns(150);
-      timer.end();
-      assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'foo-bar', 50));
+    test('reports if plugins are loaded', () => {
+      element.pluginsLoaded();
+      assert.isTrue(element._reportEvent.called);
     });
 
-    test('timer object double call', () => {
-      const timer = element.getTimer('foo-bar');
-      timer.end();
-      assert.isTrue(element.reporter.calledOnce);
-      assert.throws(() => {
-        timer.end();
-      }, 'Timer for "foo-bar" already ended.');
+    test('reports if metrics plugin xyz is loaded', () => {
+      element.pluginLoaded('metrics-xyz');
+      assert.isTrue(element._reportEvent.called);
     });
 
-    test('timer object maximum', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(100);
-      const timer = element.getTimer('foo-bar').withMaximum(100);
-      nowStub.returns(150);
-      timer.end();
-      assert.isTrue(element.reporter.calledOnce);
-
-      timer.reset();
-      nowStub.returns(260);
-      timer.end();
-      assert.isTrue(element.reporter.calledOnce);
-    });
-
-    test('recordDraftInteraction', () => {
-      const key = 'TimeBetweenDraftActions';
-      const nowStub = sandbox.stub(element, 'now').returns(100);
-      const timingStub = sandbox.stub(element, '_reportTiming');
-      element.recordDraftInteraction();
-      assert.isFalse(timingStub.called);
-
-      nowStub.returns(200);
-      element.recordDraftInteraction();
-      assert.isTrue(timingStub.calledOnce);
-      assert.equal(timingStub.lastCall.args[0], key);
-      assert.equal(timingStub.lastCall.args[1], 100);
-
-      nowStub.returns(350);
-      element.recordDraftInteraction();
-      assert.isTrue(timingStub.calledTwice);
-      assert.equal(timingStub.lastCall.args[0], key);
-      assert.equal(timingStub.lastCall.args[1], 150);
-
-      nowStub.returns(370 + 2 * 60 * 1000);
-      element.recordDraftInteraction();
-      assert.isFalse(timingStub.calledThrice);
-    });
-
-    test('timeEndWithAverage', () => {
-      const nowStub = sandbox.stub(element, 'now').returns(0);
-      nowStub.returns(1000);
+    test('reports cached events preserving order', () => {
       element.time('foo');
-      nowStub.returns(1100);
-      element.timeEndWithAverage('foo', 'bar', 10);
-      assert.isTrue(element.reporter.calledTwice);
-      assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'foo', 100));
-      assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'bar', 10));
-    });
-
-    test('reportExtension', () => {
-      element.reportExtension('foo');
-      assert.isTrue(element.reporter.calledWithExactly(
-          'lifecycle', 'Extension detected', 'foo'
+      element.time('bar');
+      element.timeEnd('foo');
+      element.pluginsLoaded();
+      element.timeEnd('bar');
+      assert.isTrue(element._reportEvent.getCall(0).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'foo'}
       ));
-    });
-
-    suite('plugins', () => {
-      setup(() => {
-        element.reporter.restore();
-        sandbox.stub(element, 'defaultReporter');
-      });
-
-      test('pluginsLoaded reports time', () => {
-        sandbox.stub(element, 'now').returns(42);
-        element.pluginsLoaded();
-        assert.isTrue(element.defaultReporter.calledWith(
-            'timing-report', 'UI Latency', 'PluginsLoaded', 42
-        ));
-      });
-
-      test('pluginsLoaded reports plugins', () => {
-        element.pluginsLoaded(['foo', 'bar']);
-        assert.isTrue(element.defaultReporter.calledWith(
-            'lifecycle', 'Plugins installed', 'foo,bar'
-        ));
-      });
-
-      test('caches reports if plugins are not loaded', () => {
-        element.timeEnd('foo');
-        assert.isFalse(element.defaultReporter.called);
-      });
-
-      test('reports if plugins are loaded', () => {
-        element.pluginsLoaded();
-        assert.isTrue(element.defaultReporter.called);
-      });
-
-      test('reports plugins in timing events', () => {
-        element.pluginsLoaded = [];
-        sandbox.stub(element, 'now').returns(42);
-        element.pluginLoaded('metrics-xyz1');
-        // element.pluginLoaded('foo');
-        element.time('timeAction');
-        element.timeEnd('timeAction');
-        assert.isTrue(element.defaultReporter.getCall(1).calledWith(
-            'timing-report', 'UI Latency', 'timeAction', 0,
-            ['metrics-xyz1']
-        ));
-      });
-
-      test('reports if metrics plugin xyz is loaded', () => {
-        element.pluginLoaded('metrics-xyz');
-        assert.isTrue(element.defaultReporter.called);
-      });
-
-      test('reports cached events preserving order', () => {
-        element.time('foo');
-        element.time('bar');
-        element.timeEnd('foo');
-        element.pluginsLoaded();
-        element.timeEnd('bar');
-        assert.isTrue(element.defaultReporter.getCall(0).calledWith(
-            'timing-report', 'UI Latency', 'foo'
-        ));
-        assert.isTrue(element.defaultReporter.getCall(1).calledWith(
-            'timing-report', 'UI Latency', 'PluginsLoaded'
-        ));
-        assert.isTrue(element.defaultReporter.getCall(2).calledWith(
-            'lifecycle', 'Plugins installed'
-        ));
-        assert.isTrue(element.defaultReporter.getCall(3).calledWith(
-            'timing-report', 'UI Latency', 'bar'
-        ));
-      });
-    });
-
-    test('search', () => {
-      element.locationChanged('_handleSomeRoute');
-      assert.isTrue(element.reporter.calledWithExactly(
-          'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
-    });
-
-    suite('exception logging', () => {
-      let fakeWindow;
-      let reporter;
-
-      const emulateThrow = function(msg, url, line, column, error) {
-        return fakeWindow.onerror(msg, url, line, column, error);
-      };
-
-      setup(() => {
-        reporter = sandbox.stub(GrReporting.prototype, 'reporter');
-        fakeWindow = {
-          handlers: {},
-          addEventListener(type, handler) {
-            this.handlers[type] = handler;
-          },
-        };
-        sandbox.stub(console, 'error');
-        window.GrReporting._catchErrors(fakeWindow);
-      });
-
-      test('is reported', () => {
-        const error = new Error('bar');
-        error.stack = undefined;
-        emulateThrow('bar', 'http://url', 4, 2, error);
-        assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-        const payload = reporter.lastCall.args[3];
-        assert.deepEqual(payload, {
-          url: 'http://url',
-          line: 4,
-          column: 2,
-          error,
-        });
-      });
-
-      test('is reported with 3 lines of stack', () => {
-        const error = new Error('bar');
-        emulateThrow('bar', 'http://url', 4, 2, error);
-        const expectedStack = error.stack.split('\n').slice(0, 3)
-            .join('\n');
-        assert.isTrue(reporter.calledWith('error', 'exception',
-            expectedStack));
-      });
-
-      test('prevent default event handler', () => {
-        assert.isTrue(emulateThrow());
-      });
-
-      test('unhandled rejection', () => {
-        fakeWindow.handlers['unhandledrejection']({
-          reason: {
-            message: 'bar',
-          },
-        });
-        assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-      });
+      assert.isTrue(element._reportEvent.getCall(1).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency',
+            name: 'PluginsLoaded'}
+      ));
+      assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
+          {type: 'lifecycle', category: 'Plugins installed'}
+      ));
+      assert.isTrue(element._reportEvent.getCall(3).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'bar'}
+      ));
     });
   });
+
+  test('search', () => {
+    element.locationChanged('_handleSomeRoute');
+    assert.isTrue(element.reporter.calledWithExactly(
+        'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
+  });
+
+  suite('exception logging', () => {
+    let fakeWindow;
+    let reporter;
+
+    const emulateThrow = function(msg, url, line, column, error) {
+      return fakeWindow.onerror(msg, url, line, column, error);
+    };
+
+    setup(() => {
+      reporter = sandbox.stub(GrReporting.prototype, 'reporter');
+      fakeWindow = {
+        handlers: {},
+        addEventListener(type, handler) {
+          this.handlers[type] = handler;
+        },
+      };
+      sandbox.stub(console, 'error');
+      window.GrReporting._catchErrors(fakeWindow);
+    });
+
+    test('is reported', () => {
+      const error = new Error('bar');
+      error.stack = undefined;
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+      const payload = reporter.lastCall.args[3];
+      assert.deepEqual(payload, {
+        url: 'http://url',
+        line: 4,
+        column: 2,
+        error,
+      });
+    });
+
+    test('is reported with 3 lines of stack', () => {
+      const error = new Error('bar');
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      const expectedStack = error.stack.split('\n').slice(0, 3)
+          .join('\n');
+      assert.isTrue(reporter.calledWith('error', 'exception',
+          expectedStack));
+    });
+
+    test('prevent default event handler', () => {
+      assert.isTrue(emulateThrow());
+    });
+
+    test('unhandled rejection', () => {
+      fakeWindow.handlers['unhandledrejection']({
+        reason: {
+          message: 'bar',
+        },
+      });
+      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
deleted file mode 100644
index 71a5832..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.html
+++ /dev/null
@@ -1,34 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-reporting/gr-reporting.html">
-
-<dom-module id="gr-router">
-  <template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="/bower_components/page/page.js"></script>
-  <script src="gr-router.js"></script>
-</dom-module>
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 738a89b..e861f2e 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -14,206 +14,226 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const RoutePattern = {
-    ROOT: '/',
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-reporting/gr-reporting.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import page from 'page/page.mjs';
+import {htmlTemplate} from './gr-router_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {GerritNav} from '../gr-navigation/gr-navigation.js';
 
-    DASHBOARD: /^\/dashboard\/(.+)$/,
-    CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
-    PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
-    LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
+const RoutePattern = {
+  ROOT: '/',
 
-    AGREEMENTS: /^\/settings\/agreements\/?/,
-    NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
-    REGISTER: /^\/register(\/.*)?$/,
+  DASHBOARD: /^\/dashboard\/(.+)$/,
+  CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+  PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+  LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
 
-    // Pattern for login and logout URLs intended to be passed-through. May
-    // include a return URL.
-    LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+  AGREEMENTS: /^\/settings\/agreements\/?/,
+  NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+  REGISTER: /^\/register(\/.*)?$/,
 
-    // Pattern for a catchall route when no other pattern is matched.
-    DEFAULT: /.*/,
+  // Pattern for login and logout URLs intended to be passed-through. May
+  // include a return URL.
+  LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
 
-    // Matches /admin/groups/[uuid-]<group>
-    GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+  // Pattern for a catchall route when no other pattern is matched.
+  DEFAULT: /.*/,
 
-    // Redirects /groups/self to /settings/#Groups for GWT compatibility
-    GROUP_SELF: /^\/groups\/self/,
+  // Matches /admin/groups/[uuid-]<group>
+  GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
 
-    // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
-    // Redirects to /admin/groups/[uuid-]<group>
-    GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+  // Redirects /groups/self to /settings/#Groups for GWT compatibility
+  GROUP_SELF: /^\/groups\/self/,
 
-    // Matches /admin/groups/<group>,audit-log
-    GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+  // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
+  // Redirects to /admin/groups/[uuid-]<group>
+  GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
 
-    // Matches /admin/groups/[uuid-]<group>,members
-    GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+  // Matches /admin/groups/<group>,audit-log
+  GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
 
-    // Matches /admin/groups[,<offset>][/].
-    GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
-    GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
-    GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
+  // Matches /admin/groups/[uuid-]<group>,members
+  GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
 
-    // Matches /admin/create-project
-    LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
+  // Matches /admin/groups[,<offset>][/].
+  GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
+  GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
+  GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
 
-    // Matches /admin/create-project
-    LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
+  // Matches /admin/create-project
+  LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
 
-    PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+  // Matches /admin/create-project
+  LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
 
-    // Matches /admin/repos/<repo>
-    REPO: /^\/admin\/repos\/([^,]+)$/,
+  PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
 
-    // Matches /admin/repos/<repo>,commands.
-    REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+  // Matches /admin/repos/<repo>
+  REPO: /^\/admin\/repos\/([^,]+)$/,
 
-    // Matches /admin/repos/<repos>,access.
-    REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+  // Matches /admin/repos/<repo>,commands.
+  REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
 
-    // Matches /admin/repos/<repos>,access.
-    REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
+  // Matches /admin/repos/<repos>,access.
+  REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
 
-    // Matches /admin/repos[,<offset>][/].
-    REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
-    REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
-    REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
+  // Matches /admin/repos/<repos>,access.
+  REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-    // Matches /admin/repos/<repo>,branches[,<offset>].
-    BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
-    BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
-    BRANCH_LIST_FILTER_OFFSET:
-        '/admin/repos/:repo,branches/q/filter::filter,:offset',
+  // Matches /admin/repos[,<offset>][/].
+  REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
+  REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
+  REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
 
-    // Matches /admin/repos/<repo>,tags[,<offset>].
-    TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
-    TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
-    TAG_LIST_FILTER_OFFSET:
-        '/admin/repos/:repo,tags/q/filter::filter,:offset',
+  // Matches /admin/repos/<repo>,branches[,<offset>].
+  BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
+  BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
+  BRANCH_LIST_FILTER_OFFSET:
+      '/admin/repos/:repo,branches/q/filter::filter,:offset',
 
-    PLUGINS: /^\/plugins\/(.+)$/,
+  // Matches /admin/repos/<repo>,tags[,<offset>].
+  TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
+  TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
+  TAG_LIST_FILTER_OFFSET:
+      '/admin/repos/:repo,tags/q/filter::filter,:offset',
 
-    PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
+  PLUGINS: /^\/plugins\/(.+)$/,
 
-    // Matches /admin/plugins[,<offset>][/].
-    PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
-    PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
-    PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
+  PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
 
-    QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+  // Matches /admin/plugins[,<offset>][/].
+  PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
+  PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
+  PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
 
-    /**
-     * Support vestigial params from GWT UI.
-     *
-     * @see Issue 7673.
-     * @type {!RegExp}
-     */
-    QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
-
-    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-    CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
-    CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
-
-    // Matches
-    // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
-    // TODO(kaspern): Migrate completely to project based URLs, with backwards
-    // compatibility for change-only.
-    CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
-
-    // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
-    CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
-
-    // Matches
-    // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
-    // TODO(kaspern): Migrate completely to project based URLs, with backwards
-    // compatibility for change-only.
-    // eslint-disable-next-line max-len
-    DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
-
-    // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
-    DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(\#\d+)?$/,
-
-    // Matches non-project-relative
-    // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-    DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
-
-    // Matches diff routes using @\d+ to specify a file name (whether or not
-    // the project name is included).
-    // eslint-disable-next-line max-len
-    DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
-
-    SETTINGS: /^\/settings\/?/,
-    SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
-
-    // Matches /c/<changeNum>/ /<URL tail>
-    // Catches improperly encoded URLs (context: Issue 7100)
-    IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
-
-    PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
-
-    DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
-    DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
-    DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
-  };
+  QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
 
   /**
-   * Pattern to recognize and parse the diff line locations as they appear in
-   * the hash of diff URLs. In this format, a number on its own indicates that
-   * line number in the revision of the diff. A number prefixed by either an 'a'
-   * or a 'b' indicates that line number of the base of the diff.
+   * Support vestigial params from GWT UI.
    *
-   * @type {RegExp}
+   * @see Issue 7673.
+   * @type {!RegExp}
    */
-  const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
+  QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
 
-  /**
-   * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
-   */
-  const PLUS_PATTERN = /\+/g;
+  // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
+  CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+  CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
 
-  /**
-   * Pattern to recognize leading '?' in window.location.search, for stripping.
-   */
-  const QUESTION_PATTERN = /^\?*/;
+  // Matches
+  // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
+  // TODO(kaspern): Migrate completely to project based URLs, with backwards
+  // compatibility for change-only.
+  CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
 
-  /**
-   * GWT UI would use @\d+ at the end of a path to indicate linenum.
-   */
-  const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
+  // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
+  CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
 
-  const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
+  // Matches
+  // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
+  // TODO(kaspern): Migrate completely to project based URLs, with backwards
+  // compatibility for change-only.
+  // eslint-disable-next-line max-len
+  DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
 
-  const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+  // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
+  DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(\#\d+)?$/,
 
-  // Polymer makes `app` intrinsically defined on the window by virtue of the
-  // custom element having the id "app", but it is made explicit here.
-  const app = document.querySelector('#app');
-  if (!app) {
-    console.log('No gr-app found (running tests)');
-  }
+  // Matches non-project-relative
+  // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
+  DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
 
-  // Setup listeners outside of the router component initialization.
-  (function() {
-    const reporting = document.createElement('gr-reporting');
+  // Matches diff routes using @\d+ to specify a file name (whether or not
+  // the project name is included).
+  // eslint-disable-next-line max-len
+  DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
 
-    window.addEventListener('load', () => {
-      setTimeout(() => {
-        reporting.pageLoaded();
-      }, 0);
-    });
+  SETTINGS: /^\/settings\/?/,
+  SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
 
-    window.addEventListener('WebComponentsReady', () => {
-      reporting.timeEnd('WebComponentsReady');
-    });
-  })();
+  // Matches /c/<changeNum>/ /<URL tail>
+  // Catches improperly encoded URLs (context: Issue 7100)
+  IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
 
-  Polymer({
-    is: 'gr-router',
+  PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
 
-    properties: {
+  DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+  DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+  DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
+};
+
+/**
+ * Pattern to recognize and parse the diff line locations as they appear in
+ * the hash of diff URLs. In this format, a number on its own indicates that
+ * line number in the revision of the diff. A number prefixed by either an 'a'
+ * or a 'b' indicates that line number of the base of the diff.
+ *
+ * @type {RegExp}
+ */
+const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
+
+/**
+ * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
+ */
+const PLUS_PATTERN = /\+/g;
+
+/**
+ * Pattern to recognize leading '?' in window.location.search, for stripping.
+ */
+const QUESTION_PATTERN = /^\?*/;
+
+/**
+ * GWT UI would use @\d+ at the end of a path to indicate linenum.
+ */
+const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
+
+const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
+
+const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+
+// Polymer makes `app` intrinsically defined on the window by virtue of the
+// custom element having the id "app", but it is made explicit here.
+// If you move this code to other place, please update comment about
+// gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
+const app = document.querySelector('#app');
+if (!app) {
+  console.log('No gr-app found (running tests)');
+}
+
+// Setup listeners outside of the router component initialization.
+(function() {
+  const reporting = document.createElement('gr-reporting');
+
+  window.addEventListener('WebComponentsReady', () => {
+    reporting.timeEnd('WebComponentsReady');
+  });
+})();
+
+/**
+ * @extends Polymer.Element
+ */
+class GrRouter extends mixinBehaviors( [
+  BaseUrlBehavior,
+  PatchSetBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-router'; }
+
+  static get properties() {
+    return {
       _app: {
         type: Object,
         value: app,
@@ -225,1298 +245,1329 @@
         type: Boolean,
         value: true,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  start() {
+    if (!this._app) { return; }
+    this._startRouter();
+  }
 
-    start() {
-      if (!this._app) { return; }
-      this._startRouter();
-    },
+  _setParams(params) {
+    this._appElement().params = params;
+  }
 
-    _setParams(params) {
-      this._appElement().params = params;
-    },
+  _appElement() {
+    // In Polymer2 you have to reach through the shadow root of the app
+    // element. This obviously breaks encapsulation.
+    // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
+    // explicitly in app, or by delegating to it.
+    return document.getElementById('app-element') ||
+        document.getElementById('app').shadowRoot.getElementById(
+            'app-element');
+  }
 
-    _appElement() {
-      // In Polymer2 you have to reach through the shadow root of the app
-      // element. This obviously breaks encapsulation.
-      // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
-      // explicitly in app, or by delegating to it.
-      return document.getElementById('app-element') ||
-          document.getElementById('app').shadowRoot.getElementById(
-              'app-element');
-    },
+  _redirect(url) {
+    this._isRedirecting = true;
+    page.redirect(url);
+  }
 
-    _redirect(url) {
-      this._isRedirecting = true;
-      page.redirect(url);
-    },
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateUrl(params) {
+    const base = this.getBaseUrl();
+    let url = '';
+    const Views = GerritNav.View;
 
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateUrl(params) {
-      const base = this.getBaseUrl();
-      let url = '';
-      const Views = Gerrit.Nav.View;
+    if (params.view === Views.SEARCH) {
+      url = this._generateSearchUrl(params);
+    } else if (params.view === Views.CHANGE) {
+      url = this._generateChangeUrl(params);
+    } else if (params.view === Views.DASHBOARD) {
+      url = this._generateDashboardUrl(params);
+    } else if (params.view === Views.DIFF || params.view === Views.EDIT) {
+      url = this._generateDiffOrEditUrl(params);
+    } else if (params.view === Views.GROUP) {
+      url = this._generateGroupUrl(params);
+    } else if (params.view === Views.REPO) {
+      url = this._generateRepoUrl(params);
+    } else if (params.view === Views.ROOT) {
+      url = '/';
+    } else if (params.view === Views.SETTINGS) {
+      url = this._generateSettingsUrl(params);
+    } else {
+      throw new Error('Can\'t generate');
+    }
 
-      if (params.view === Views.SEARCH) {
-        url = this._generateSearchUrl(params);
-      } else if (params.view === Views.CHANGE) {
-        url = this._generateChangeUrl(params);
-      } else if (params.view === Views.DASHBOARD) {
-        url = this._generateDashboardUrl(params);
-      } else if (params.view === Views.DIFF || params.view === Views.EDIT) {
-        url = this._generateDiffOrEditUrl(params);
-      } else if (params.view === Views.GROUP) {
-        url = this._generateGroupUrl(params);
-      } else if (params.view === Views.REPO) {
-        url = this._generateRepoUrl(params);
-      } else if (params.view === Views.ROOT) {
-        url = '/';
-      } else if (params.view === Views.SETTINGS) {
-        url = this._generateSettingsUrl(params);
+    return base + url;
+  }
+
+  _generateWeblinks(params) {
+    const type = params.type;
+    switch (type) {
+      case GerritNav.WeblinkType.FILE:
+        return this._getFileWebLinks(params);
+      case GerritNav.WeblinkType.CHANGE:
+        return this._getChangeWeblinks(params);
+      case GerritNav.WeblinkType.PATCHSET:
+        return this._getPatchSetWeblink(params);
+      default:
+        console.warn(`Unsupported weblink ${type}!`);
+    }
+  }
+
+  _getPatchSetWeblink(params) {
+    const {commit, options} = params;
+    const {weblinks, config} = options || {};
+    const name = commit && commit.slice(0, 7);
+    const weblink = this._getBrowseCommitWeblink(weblinks, config);
+    if (!weblink || !weblink.url) {
+      return {name};
+    } else {
+      return {name, url: weblink.url};
+    }
+  }
+
+  _firstCodeBrowserWeblink(weblinks) {
+    // This is an ordered whitelist of web link types that provide direct
+    // links to the commit in the url property.
+    const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
+    for (let i = 0; i < codeBrowserLinks.length; i++) {
+      const weblink =
+        weblinks.find(weblink => weblink.name === codeBrowserLinks[i]);
+      if (weblink) { return weblink; }
+    }
+    return null;
+  }
+
+  _getBrowseCommitWeblink(weblinks, config) {
+    if (!weblinks) { return null; }
+    let weblink;
+    // Use primary weblink if configured and exists.
+    if (config && config.gerrit && config.gerrit.primary_weblink_name) {
+      weblink = weblinks.find(
+          weblink => weblink.name === config.gerrit.primary_weblink_name
+      );
+    }
+    if (!weblink) {
+      weblink = this._firstCodeBrowserWeblink(weblinks);
+    }
+    if (!weblink) { return null; }
+    return weblink;
+  }
+
+  _getChangeWeblinks({repo, commit, options: {weblinks, config}}) {
+    if (!weblinks || !weblinks.length) return [];
+    const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
+    return weblinks.filter(weblink =>
+      !commitWeblink ||
+      !commitWeblink.name ||
+      weblink.name !== commitWeblink.name);
+  }
+
+  _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
+    return weblinks;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateSearchUrl(params) {
+    let offsetExpr = '';
+    if (params.offset && params.offset > 0) {
+      offsetExpr = ',' + params.offset;
+    }
+
+    if (params.query) {
+      return '/q/' + this.encodeURL(params.query, true) + offsetExpr;
+    }
+
+    const operators = [];
+    if (params.owner) {
+      operators.push('owner:' + this.encodeURL(params.owner, false));
+    }
+    if (params.project) {
+      operators.push('project:' + this.encodeURL(params.project, false));
+    }
+    if (params.branch) {
+      operators.push('branch:' + this.encodeURL(params.branch, false));
+    }
+    if (params.topic) {
+      operators.push('topic:"' + this.encodeURL(params.topic, false) + '"');
+    }
+    if (params.hashtag) {
+      operators.push('hashtag:"' +
+          this.encodeURL(params.hashtag.toLowerCase(), false) + '"');
+    }
+    if (params.statuses) {
+      if (params.statuses.length === 1) {
+        operators.push(
+            'status:' + this.encodeURL(params.statuses[0], false));
+      } else if (params.statuses.length > 1) {
+        operators.push(
+            '(' +
+            params.statuses.map(s => `status:${this.encodeURL(s, false)}`)
+                .join(' OR ') +
+            ')');
+      }
+    }
+
+    return '/q/' + operators.join('+') + offsetExpr;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateChangeUrl(params) {
+    let range = this._getPatchRangeExpression(params);
+    if (range.length) { range = '/' + range; }
+    let suffix = `${range}`;
+    if (params.querystring) {
+      suffix += '?' + params.querystring;
+    } else if (params.edit) {
+      suffix += ',edit';
+    }
+    if (params.messageHash) {
+      suffix += params.messageHash;
+    }
+    if (params.project) {
+      const encodedProject = this.encodeURL(params.project, true);
+      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+    } else {
+      return `/c/${params.changeNum}${suffix}`;
+    }
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateDashboardUrl(params) {
+    const repoName = params.repo || params.project || null;
+    if (params.sections) {
+      // Custom dashboard.
+      const queryParams = this._sectionsToEncodedParams(params.sections,
+          repoName);
+      if (params.title) {
+        queryParams.push('title=' + encodeURIComponent(params.title));
+      }
+      const user = params.user ? params.user : '';
+      return `/dashboard/${user}?${queryParams.join('&')}`;
+    } else if (repoName) {
+      // Project dashboard.
+      const encodedRepo = this.encodeURL(repoName, true);
+      return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
+    } else {
+      // User dashboard.
+      return `/dashboard/${params.user || 'self'}`;
+    }
+  }
+
+  /**
+   * @param {!Array<!{name: string, query: string}>} sections
+   * @param {string=} opt_repoName
+   * @return {!Array<string>}
+   */
+  _sectionsToEncodedParams(sections, opt_repoName) {
+    return sections.map(section => {
+      // If there is a repo name provided, make sure to substitute it into the
+      // ${repo} (or legacy ${project}) query tokens.
+      const query = opt_repoName ?
+        section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
+        section.query;
+      return encodeURIComponent(section.name) + '=' +
+          encodeURIComponent(query);
+    });
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateDiffOrEditUrl(params) {
+    let range = this._getPatchRangeExpression(params);
+    if (range.length) { range = '/' + range; }
+
+    let suffix = `${range}/${this.encodeURL(params.path, true)}`;
+
+    if (params.view === GerritNav.View.EDIT) { suffix += ',edit'; }
+
+    if (params.lineNum) {
+      suffix += '#';
+      if (params.leftSide) { suffix += 'b'; }
+      suffix += params.lineNum;
+    }
+
+    if (params.project) {
+      const encodedProject = this.encodeURL(params.project, true);
+      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+    } else {
+      return `/c/${params.changeNum}${suffix}`;
+    }
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateGroupUrl(params) {
+    let url = `/admin/groups/${this.encodeURL(params.groupId + '', true)}`;
+    if (params.detail === GerritNav.GroupDetailView.MEMBERS) {
+      url += ',members';
+    } else if (params.detail === GerritNav.GroupDetailView.LOG) {
+      url += ',audit-log';
+    }
+    return url;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateRepoUrl(params) {
+    let url = `/admin/repos/${this.encodeURL(params.repoName + '', true)}`;
+    if (params.detail === GerritNav.RepoDetailView.ACCESS) {
+      url += ',access';
+    } else if (params.detail === GerritNav.RepoDetailView.BRANCHES) {
+      url += ',branches';
+    } else if (params.detail === GerritNav.RepoDetailView.TAGS) {
+      url += ',tags';
+    } else if (params.detail === GerritNav.RepoDetailView.COMMANDS) {
+      url += ',commands';
+    } else if (params.detail === GerritNav.RepoDetailView.DASHBOARDS) {
+      url += ',dashboards';
+    }
+    return url;
+  }
+
+  /**
+   * @param {!Object} params
+   * @return {string}
+   */
+  _generateSettingsUrl(params) {
+    return '/settings';
+  }
+
+  /**
+   * Given an object of parameters, potentially including a `patchNum` or a
+   * `basePatchNum` or both, return a string representation of that range. If
+   * no range is indicated in the params, the empty string is returned.
+   *
+   * @param {!Object} params
+   * @return {string}
+   */
+  _getPatchRangeExpression(params) {
+    let range = '';
+    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
+   * API project lookup and then sets the app params.
+   *
+   * @param {?Object} params
+   */
+  _normalizeLegacyRouteParams(params) {
+    if (!params.changeNum) { return Promise.resolve(); }
+
+    return this.$.restAPI.getFromProjectLookup(params.changeNum)
+        .then(project => {
+          // Show a 404 and terminate if the lookup request failed. Attempting
+          // to redirect after failing to get the project loops infinitely.
+          if (!project) {
+            this._show404();
+            return;
+          }
+
+          params.project = project;
+          this._normalizePatchRangeParams(params);
+          this._redirect(this._generateUrl(params));
+        });
+  }
+
+  /**
+   * Normalizes the params object, and determines if the URL needs to be
+   * modified to fit the proper schema.
+   *
+   * @param {*} params
+   * @return {boolean} whether or not the URL needs to be upgraded.
+   */
+  _normalizePatchRangeParams(params) {
+    const hasBasePatchNum = params.basePatchNum !== null &&
+        params.basePatchNum !== undefined;
+    const hasPatchNum = params.patchNum !== null &&
+        params.patchNum !== undefined;
+    let needsRedirect = false;
+
+    // Diffing a patch against itself is invalid, so if the base and revision
+    // patches are equal clear the base.
+    if (hasBasePatchNum &&
+        this.patchNumEquals(params.basePatchNum, params.patchNum)) {
+      needsRedirect = true;
+      params.basePatchNum = null;
+    } else if (hasBasePatchNum && !hasPatchNum) {
+      // Regexes set basePatchNum instead of patchNum when only one is
+      // specified. Redirect is not needed in this case.
+      params.patchNum = params.basePatchNum;
+      params.basePatchNum = null;
+    }
+    return needsRedirect;
+  }
+
+  /**
+   * Redirect the user to login using the given return-URL for redirection
+   * after authentication success.
+   *
+   * @param {string} returnUrl
+   */
+  _redirectToLogin(returnUrl) {
+    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"
+   * is parsed to have a hash of "b" rather than "b#c". Instead, this method
+   * parses hashes correctly. Will return an empty string if there is no hash.
+   *
+   * @param {!string} canonicalPath
+   * @return {!string} Everything after the first '#' ("a#b#c" -> "b#c").
+   */
+  _getHashFromCanonicalPath(canonicalPath) {
+    return canonicalPath.split('#').slice(1)
+        .join('#');
+  }
+
+  _parseLineAddress(hash) {
+    const match = hash.match(LINE_ADDRESS_PATTERN);
+    if (!match) { return null; }
+    return {
+      leftSide: !!match[1],
+      lineNum: parseInt(match[2], 10),
+    };
+  }
+
+  /**
+   * Check to see if the user is logged in and return a promise that only
+   * resolves if the user is logged in. If the user us not logged in, the
+   * promise is rejected and the page is redirected to the login flow.
+   *
+   * @param {!Object} data The parsed route data.
+   * @return {!Promise<!Object>} A promise yielding the original route data
+   *     (if it resolves).
+   */
+  _redirectIfNotLoggedIn(data) {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return Promise.resolve();
       } else {
-        throw new Error('Can\'t generate');
+        this._redirectToLogin(data.canonicalPath);
+        return Promise.reject(new Error());
       }
+    });
+  }
 
-      return base + url;
-    },
+  /**  Page.js middleware that warms the REST API's logged-in cache line. */
+  _loadUserMiddleware(ctx, next) {
+    this.$.restAPI.getLoggedIn().then(() => { next(); });
+  }
 
-    _generateWeblinks(params) {
-      const type = params.type;
-      switch (type) {
-        case Gerrit.Nav.WeblinkType.FILE:
-          return this._getFileWebLinks(params);
-        case Gerrit.Nav.WeblinkType.CHANGE:
-          return this._getChangeWeblinks(params);
-        case Gerrit.Nav.WeblinkType.PATCHSET:
-          return this._getPatchSetWeblink(params);
-        default:
-          console.warn(`Unsupported weblink ${type}!`);
-      }
-    },
-
-    _getPatchSetWeblink(params) {
-      const {commit, options} = params;
-      const {weblinks, config} = options || {};
-      const name = commit && commit.slice(0, 7);
-      const weblink = this._getBrowseCommitWeblink(weblinks, config);
-      if (!weblink || !weblink.url) {
-        return {name};
+  /**  Page.js middleware that try parse the querystring into queryMap. */
+  _queryStringMiddleware(ctx, next) {
+    let queryMap = new Map();
+    if (ctx.querystring) {
+      // https://caniuse.com/#search=URLSearchParams
+      if (window.URLSearchParams) {
+        queryMap = new URLSearchParams(ctx.querystring);
       } else {
-        return {name, url: weblink.url};
+        queryMap = new Map(this._parseQueryString(ctx.querystring));
       }
-    },
+    }
+    ctx.queryMap = queryMap;
+    next();
+  }
 
-    _firstCodeBrowserWeblink(weblinks) {
-      // This is an ordered whitelist of web link types that provide direct
-      // links to the commit in the url property.
-      const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
-      for (let i = 0; i < codeBrowserLinks.length; i++) {
-        const weblink =
-          weblinks.find(weblink => weblink.name === codeBrowserLinks[i]);
-        if (weblink) { return weblink; }
+  /**
+   * Map a route to a method on the router.
+   *
+   * @param {!string|!RegExp} pattern The page.js pattern for the route.
+   * @param {!string} handlerName The method name for the handler. If the
+   *     route is matched, the handler will be executed with `this` referring
+   *     to the component. Its return value will be discarded so that it does
+   *     not interfere with page.js.
+   * @param  {?boolean=} opt_authRedirect If true, then auth is checked before
+   *     executing the handler. If the user is not logged in, it will redirect
+   *     to the login flow and the handler will not be executed. The login
+   *     redirect specifies the matched URL to be used after successfull auth.
+   */
+  _mapRoute(pattern, handlerName, opt_authRedirect) {
+    if (!this[handlerName]) {
+      console.error('Attempted to map route to unknown method: ',
+          handlerName);
+      return;
+    }
+    page(pattern,
+        (ctx, next) => this._loadUserMiddleware(ctx, next),
+        (ctx, next) => this._queryStringMiddleware(ctx, next),
+        data => {
+          this.$.reporting.locationChanged(handlerName);
+          const promise = opt_authRedirect ?
+            this._redirectIfNotLoggedIn(data) : Promise.resolve();
+          promise.then(() => { this[handlerName](data); });
+        });
+  }
+
+  _startRouter() {
+    const base = this.getBaseUrl();
+    if (base) {
+      page.base(base);
+    }
+
+    GerritNav.setup(
+        url => { page.show(url); },
+        this._generateUrl.bind(this),
+        params => this._generateWeblinks(params),
+        x => x
+    );
+
+    page.exit('*', (ctx, next) => {
+      if (!this._isRedirecting) {
+        this.$.reporting.beforeLocationChanged();
       }
-      return null;
-    },
+      this._isRedirecting = false;
+      this._isInitialLoad = false;
+      next();
+    });
 
+    // Middleware
+    page((ctx, next) => {
+      document.body.scrollTop = 0;
 
-    _getBrowseCommitWeblink(weblinks, config) {
-      if (!weblinks) { return null; }
-      let weblink;
-      // Use primary weblink if configured and exists.
-      if (config && config.gerrit && config.gerrit.primary_weblink_name) {
-        weblink = weblinks.find(
-            weblink => weblink.name === config.gerrit.primary_weblink_name
-        );
-      }
-      if (!weblink) {
-        weblink = this._firstCodeBrowserWeblink(weblinks);
-      }
-      if (!weblink) { return null; }
-      return weblink;
-    },
-
-    _getChangeWeblinks({repo, commit, options: {weblinks, config}}) {
-      if (!weblinks || !weblinks.length) return [];
-      const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
-      return weblinks.filter(weblink =>
-        !commitWeblink ||
-        !commitWeblink.name ||
-        weblink.name !== commitWeblink.name);
-    },
-
-    _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
-      return weblinks;
-    },
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateSearchUrl(params) {
-      let offsetExpr = '';
-      if (params.offset && params.offset > 0) {
-        offsetExpr = ',' + params.offset;
-      }
-
-      if (params.query) {
-        return '/q/' + this.encodeURL(params.query, true) + offsetExpr;
-      }
-
-      const operators = [];
-      if (params.owner) {
-        operators.push('owner:' + this.encodeURL(params.owner, false));
-      }
-      if (params.project) {
-        operators.push('project:' + this.encodeURL(params.project, false));
-      }
-      if (params.branch) {
-        operators.push('branch:' + this.encodeURL(params.branch, false));
-      }
-      if (params.topic) {
-        operators.push('topic:"' + this.encodeURL(params.topic, false) + '"');
-      }
-      if (params.hashtag) {
-        operators.push('hashtag:"' +
-            this.encodeURL(params.hashtag.toLowerCase(), false) + '"');
-      }
-      if (params.statuses) {
-        if (params.statuses.length === 1) {
-          operators.push(
-              'status:' + this.encodeURL(params.statuses[0], false));
-        } else if (params.statuses.length > 1) {
-          operators.push(
-              '(' +
-              params.statuses.map(s => `status:${this.encodeURL(s, false)}`)
-                  .join(' OR ') +
-              ')');
-        }
-      }
-
-      return '/q/' + operators.join('+') + offsetExpr;
-    },
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateChangeUrl(params) {
-      let range = this._getPatchRangeExpression(params);
-      if (range.length) { range = '/' + range; }
-      let suffix = `${range}`;
-      if (params.querystring) {
-        suffix += '?' + params.querystring;
-      } else if (params.edit) {
-        suffix += ',edit';
-      }
-      if (params.messageHash) {
-        suffix += params.messageHash;
-      }
-      if (params.project) {
-        const encodedProject = this.encodeURL(params.project, true);
-        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-      } else {
-        return `/c/${params.changeNum}${suffix}`;
-      }
-    },
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateDashboardUrl(params) {
-      const repoName = params.repo || params.project || null;
-      if (params.sections) {
-        // Custom dashboard.
-        const queryParams = this._sectionsToEncodedParams(params.sections,
-            repoName);
-        if (params.title) {
-          queryParams.push('title=' + encodeURIComponent(params.title));
-        }
-        const user = params.user ? params.user : '';
-        return `/dashboard/${user}?${queryParams.join('&')}`;
-      } else if (repoName) {
-        // Project dashboard.
-        const encodedRepo = this.encodeURL(repoName, true);
-        return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
-      } else {
-        // User dashboard.
-        return `/dashboard/${params.user || 'self'}`;
-      }
-    },
-
-    /**
-     * @param {!Array<!{name: string, query: string}>} sections
-     * @param {string=} opt_repoName
-     * @return {!Array<string>}
-     */
-    _sectionsToEncodedParams(sections, opt_repoName) {
-      return sections.map(section => {
-        // If there is a repo name provided, make sure to substitute it into the
-        // ${repo} (or legacy ${project}) query tokens.
-        const query = opt_repoName ?
-          section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
-          section.query;
-        return encodeURIComponent(section.name) + '=' +
-            encodeURIComponent(query);
-      });
-    },
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateDiffOrEditUrl(params) {
-      let range = this._getPatchRangeExpression(params);
-      if (range.length) { range = '/' + range; }
-
-      let suffix = `${range}/${this.encodeURL(params.path, true)}`;
-
-      if (params.view === Gerrit.Nav.View.EDIT) { suffix += ',edit'; }
-
-      if (params.lineNum) {
-        suffix += '#';
-        if (params.leftSide) { suffix += 'b'; }
-        suffix += params.lineNum;
-      }
-
-      if (params.project) {
-        const encodedProject = this.encodeURL(params.project, true);
-        return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-      } else {
-        return `/c/${params.changeNum}${suffix}`;
-      }
-    },
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateGroupUrl(params) {
-      let url = `/admin/groups/${this.encodeURL(params.groupId + '', true)}`;
-      if (params.detail === Gerrit.Nav.GroupDetailView.MEMBERS) {
-        url += ',members';
-      } else if (params.detail === Gerrit.Nav.GroupDetailView.LOG) {
-        url += ',audit-log';
-      }
-      return url;
-    },
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateRepoUrl(params) {
-      let url = `/admin/repos/${this.encodeURL(params.repoName + '', true)}`;
-      if (params.detail === Gerrit.Nav.RepoDetailView.ACCESS) {
-        url += ',access';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.BRANCHES) {
-        url += ',branches';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.TAGS) {
-        url += ',tags';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.COMMANDS) {
-        url += ',commands';
-      } else if (params.detail === Gerrit.Nav.RepoDetailView.DASHBOARDS) {
-        url += ',dashboards';
-      }
-      return url;
-    },
-
-    /**
-     * @param {!Object} params
-     * @return {string}
-     */
-    _generateSettingsUrl(params) {
-      return '/settings';
-    },
-
-    /**
-     * Given an object of parameters, potentially including a `patchNum` or a
-     * `basePatchNum` or both, return a string representation of that range. If
-     * no range is indicated in the params, the empty string is returned.
-     *
-     * @param {!Object} params
-     * @return {string}
-     */
-    _getPatchRangeExpression(params) {
-      let range = '';
-      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
-     * API project lookup and then sets the app params.
-     *
-     * @param {?Object} params
-     */
-    _normalizeLegacyRouteParams(params) {
-      if (!params.changeNum) { return Promise.resolve(); }
-
-      return this.$.restAPI.getFromProjectLookup(params.changeNum)
-          .then(project => {
-            // Show a 404 and terminate if the lookup request failed. Attempting
-            // to redirect after failing to get the project loops infinitely.
-            if (!project) {
-              this._show404();
-              return;
-            }
-
-            params.project = project;
-            this._normalizePatchRangeParams(params);
-            this._redirect(this._generateUrl(params));
-          });
-    },
-
-    /**
-     * Normalizes the params object, and determines if the URL needs to be
-     * modified to fit the proper schema.
-     *
-     * @param {*} params
-     * @return {boolean} whether or not the URL needs to be upgraded.
-     */
-    _normalizePatchRangeParams(params) {
-      const hasBasePatchNum = params.basePatchNum !== null &&
-          params.basePatchNum !== undefined;
-      const hasPatchNum = params.patchNum !== null &&
-          params.patchNum !== undefined;
-      let needsRedirect = false;
-
-      // Diffing a patch against itself is invalid, so if the base and revision
-      // patches are equal clear the base.
-      if (hasBasePatchNum &&
-          this.patchNumEquals(params.basePatchNum, params.patchNum)) {
-        needsRedirect = true;
-        params.basePatchNum = null;
-      } else if (hasBasePatchNum && !hasPatchNum) {
-        // Regexes set basePatchNum instead of patchNum when only one is
-        // specified. Redirect is not needed in this case.
-        params.patchNum = params.basePatchNum;
-        params.basePatchNum = null;
-      }
-      return needsRedirect;
-    },
-
-    /**
-     * Redirect the user to login using the given return-URL for redirection
-     * after authentication success.
-     *
-     * @param {string} returnUrl
-     */
-    _redirectToLogin(returnUrl) {
-      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"
-     * is parsed to have a hash of "b" rather than "b#c". Instead, this method
-     * parses hashes correctly. Will return an empty string if there is no hash.
-     *
-     * @param {!string} canonicalPath
-     * @return {!string} Everything after the first '#' ("a#b#c" -> "b#c").
-     */
-    _getHashFromCanonicalPath(canonicalPath) {
-      return canonicalPath.split('#').slice(1).join('#');
-    },
-
-    _parseLineAddress(hash) {
-      const match = hash.match(LINE_ADDRESS_PATTERN);
-      if (!match) { return null; }
-      return {
-        leftSide: !!match[1],
-        lineNum: parseInt(match[2], 10),
-      };
-    },
-
-    /**
-     * Check to see if the user is logged in and return a promise that only
-     * resolves if the user is logged in. If the user us not logged in, the
-     * promise is rejected and the page is redirected to the login flow.
-     *
-     * @param {!Object} data The parsed route data.
-     * @return {!Promise<!Object>} A promise yielding the original route data
-     *     (if it resolves).
-     */
-    _redirectIfNotLoggedIn(data) {
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          return Promise.resolve();
-        } else {
-          this._redirectToLogin(data.canonicalPath);
-          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.
-     *
-     * @param {!string|!RegExp} pattern The page.js pattern for the route.
-     * @param {!string} handlerName The method name for the handler. If the
-     *     route is matched, the handler will be executed with `this` referring
-     *     to the component. Its return value will be discarded so that it does
-     *     not interfere with page.js.
-     * @param  {?boolean=} opt_authRedirect If true, then auth is checked before
-     *     executing the handler. If the user is not logged in, it will redirect
-     *     to the login flow and the handler will not be executed. The login
-     *     redirect specifies the matched URL to be used after successfull auth.
-     */
-    _mapRoute(pattern, handlerName, opt_authRedirect) {
-      if (!this[handlerName]) {
-        console.error('Attempted to map route to unknown method: ',
-            handlerName);
+      if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
+        // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
+        // This is needed to allow plugins to add basic #/x/ screen links to
+        // any location.
+        this._redirect(ctx.hash);
         return;
       }
-      page(pattern, this._loadUserMiddleware.bind(this), data => {
-        this.$.reporting.locationChanged(handlerName);
-        const promise = opt_authRedirect ?
-          this._redirectIfNotLoggedIn(data) : Promise.resolve();
-        promise.then(() => { this[handlerName](data); });
-      });
-    },
 
-    _startRouter() {
-      const base = this.getBaseUrl();
-      if (base) {
-        page.base(base);
-      }
-
-      Gerrit.Nav.setup(
-          url => { page.show(url); },
-          this._generateUrl.bind(this),
-          params => this._generateWeblinks(params),
-          x => x
-      );
-
-      page.exit('*', (ctx, next) => {
-        if (!this._isRedirecting) {
-          this.$.reporting.beforeLocationChanged();
-        }
-        this._isRedirecting = false;
-        this._isInitialLoad = false;
-        next();
-      });
-
-      // Middleware
-      page((ctx, next) => {
-        document.body.scrollTop = 0;
-
-        if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
-          // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
-          // This is needed to allow plugins to add basic #/x/ screen links to
-          // any location.
-          this._redirect(ctx.hash);
-          return;
-        }
-
-        // Fire asynchronously so that the URL is changed by the time the event
-        // is processed.
-        this.async(() => {
-          this.fire('location-change', {
+      // Fire asynchronously so that the URL is changed by the time the event
+      // is processed.
+      this.async(() => {
+        this.dispatchEvent(new CustomEvent('location-change', {
+          detail: {
             hash: window.location.hash,
             pathname: window.location.pathname,
-          });
-        }, 1);
-        next();
-      });
+          },
+          composed: true, bubbles: true,
+        }));
+      }, 1);
+      next();
+    });
 
-      this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
+    this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
 
-      this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
+    this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
 
-      this._mapRoute(RoutePattern.CUSTOM_DASHBOARD,
-          '_handleCustomDashboardRoute');
+    this._mapRoute(RoutePattern.CUSTOM_DASHBOARD,
+        '_handleCustomDashboardRoute');
 
-      this._mapRoute(RoutePattern.PROJECT_DASHBOARD,
-          '_handleProjectDashboardRoute');
+    this._mapRoute(RoutePattern.PROJECT_DASHBOARD,
+        '_handleProjectDashboardRoute');
 
-      this._mapRoute(RoutePattern.LEGACY_PROJECT_DASHBOARD,
-          '_handleLegacyProjectDashboardRoute');
+    this._mapRoute(RoutePattern.LEGACY_PROJECT_DASHBOARD,
+        '_handleLegacyProjectDashboardRoute');
 
-      this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
+    this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
 
-      this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
-          true);
+    this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
+        true);
 
-      this._mapRoute(RoutePattern.GROUP_MEMBERS, '_handleGroupMembersRoute',
-          true);
+    this._mapRoute(RoutePattern.GROUP_MEMBERS, '_handleGroupMembersRoute',
+        true);
 
-      this._mapRoute(RoutePattern.GROUP_LIST_OFFSET,
-          '_handleGroupListOffsetRoute', true);
+    this._mapRoute(RoutePattern.GROUP_LIST_OFFSET,
+        '_handleGroupListOffsetRoute', true);
 
-      this._mapRoute(RoutePattern.GROUP_LIST_FILTER_OFFSET,
-          '_handleGroupListFilterOffsetRoute', true);
+    this._mapRoute(RoutePattern.GROUP_LIST_FILTER_OFFSET,
+        '_handleGroupListFilterOffsetRoute', true);
 
-      this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
-          '_handleGroupListFilterRoute', true);
+    this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
+        '_handleGroupListFilterRoute', true);
 
-      this._mapRoute(RoutePattern.GROUP_SELF, '_handleGroupSelfRedirectRoute',
-          true);
+    this._mapRoute(RoutePattern.GROUP_SELF, '_handleGroupSelfRedirectRoute',
+        true);
 
-      this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
+    this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
 
-      this._mapRoute(RoutePattern.PROJECT_OLD,
-          '_handleProjectsOldRoute');
+    this._mapRoute(RoutePattern.PROJECT_OLD,
+        '_handleProjectsOldRoute');
 
-      this._mapRoute(RoutePattern.REPO_COMMANDS,
-          '_handleRepoCommandsRoute', true);
+    this._mapRoute(RoutePattern.REPO_COMMANDS,
+        '_handleRepoCommandsRoute', true);
 
-      this._mapRoute(RoutePattern.REPO_ACCESS,
-          '_handleRepoAccessRoute');
+    this._mapRoute(RoutePattern.REPO_ACCESS,
+        '_handleRepoAccessRoute');
 
-      this._mapRoute(RoutePattern.REPO_DASHBOARDS,
-          '_handleRepoDashboardsRoute');
+    this._mapRoute(RoutePattern.REPO_DASHBOARDS,
+        '_handleRepoDashboardsRoute');
 
-      this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
-          '_handleBranchListOffsetRoute');
+    this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
+        '_handleBranchListOffsetRoute');
 
-      this._mapRoute(RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-          '_handleBranchListFilterOffsetRoute');
+    this._mapRoute(RoutePattern.BRANCH_LIST_FILTER_OFFSET,
+        '_handleBranchListFilterOffsetRoute');
 
-      this._mapRoute(RoutePattern.BRANCH_LIST_FILTER,
-          '_handleBranchListFilterRoute');
+    this._mapRoute(RoutePattern.BRANCH_LIST_FILTER,
+        '_handleBranchListFilterRoute');
 
-      this._mapRoute(RoutePattern.TAG_LIST_OFFSET,
-          '_handleTagListOffsetRoute');
+    this._mapRoute(RoutePattern.TAG_LIST_OFFSET,
+        '_handleTagListOffsetRoute');
 
-      this._mapRoute(RoutePattern.TAG_LIST_FILTER_OFFSET,
-          '_handleTagListFilterOffsetRoute');
+    this._mapRoute(RoutePattern.TAG_LIST_FILTER_OFFSET,
+        '_handleTagListFilterOffsetRoute');
 
-      this._mapRoute(RoutePattern.TAG_LIST_FILTER,
-          '_handleTagListFilterRoute');
+    this._mapRoute(RoutePattern.TAG_LIST_FILTER,
+        '_handleTagListFilterRoute');
 
-      this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
-          '_handleCreateGroupRoute', true);
+    this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
+        '_handleCreateGroupRoute', true);
 
-      this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
-          '_handleCreateProjectRoute', true);
+    this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
+        '_handleCreateProjectRoute', true);
 
-      this._mapRoute(RoutePattern.REPO_LIST_OFFSET,
-          '_handleRepoListOffsetRoute');
+    this._mapRoute(RoutePattern.REPO_LIST_OFFSET,
+        '_handleRepoListOffsetRoute');
 
-      this._mapRoute(RoutePattern.REPO_LIST_FILTER_OFFSET,
-          '_handleRepoListFilterOffsetRoute');
+    this._mapRoute(RoutePattern.REPO_LIST_FILTER_OFFSET,
+        '_handleRepoListFilterOffsetRoute');
 
-      this._mapRoute(RoutePattern.REPO_LIST_FILTER,
-          '_handleRepoListFilterRoute');
+    this._mapRoute(RoutePattern.REPO_LIST_FILTER,
+        '_handleRepoListFilterRoute');
 
-      this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
+    this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
 
-      this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+    this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
 
-      this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
-          '_handlePluginListOffsetRoute', true);
+    this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
+        '_handlePluginListOffsetRoute', true);
 
-      this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-          '_handlePluginListFilterOffsetRoute', true);
+    this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
+        '_handlePluginListFilterOffsetRoute', true);
 
-      this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER,
-          '_handlePluginListFilterRoute', true);
+    this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER,
+        '_handlePluginListFilterRoute', true);
 
-      this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
+    this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
 
-      this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
-          '_handleQueryLegacySuffixRoute');
+    this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
+        '_handleQueryLegacySuffixRoute');
 
-      this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
+    this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
 
-      this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
+    this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
 
-      this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
-          '_handleChangeNumberLegacyRoute');
+    this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
+        '_handleChangeNumberLegacyRoute');
 
-      this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
+    this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
 
-      this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
+    this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
 
-      this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
+    this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
 
-      this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
+    this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
 
-      this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
+    this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
 
-      this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
+    this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
 
-      this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
+    this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
 
-      this._mapRoute(RoutePattern.NEW_AGREEMENTS, '_handleNewAgreementsRoute',
-          true);
+    this._mapRoute(RoutePattern.NEW_AGREEMENTS, '_handleNewAgreementsRoute',
+        true);
 
-      this._mapRoute(RoutePattern.SETTINGS_LEGACY,
-          '_handleSettingsLegacyRoute', true);
+    this._mapRoute(RoutePattern.SETTINGS_LEGACY,
+        '_handleSettingsLegacyRoute', true);
 
-      this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
+    this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
 
-      this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
+    this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
 
-      this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
+    this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
 
-      this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
-          '_handleImproperlyEncodedPlusRoute');
+    this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
+        '_handleImproperlyEncodedPlusRoute');
 
-      this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
+    this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
 
-      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
-          '_handleDocumentationSearchRoute');
+    this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
+        '_handleDocumentationSearchRoute');
 
-      // redirects /Documentation/q/* to /Documentation/q/filter:*
-      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH,
-          '_handleDocumentationSearchRedirectRoute');
+    // redirects /Documentation/q/* to /Documentation/q/filter:*
+    this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH,
+        '_handleDocumentationSearchRedirectRoute');
 
-      // Makes sure /Documentation/* links work (doin't return 404)
-      this._mapRoute(RoutePattern.DOCUMENTATION,
-          '_handleDocumentationRedirectRoute');
+    // Makes sure /Documentation/* links work (doin't return 404)
+    this._mapRoute(RoutePattern.DOCUMENTATION,
+        '_handleDocumentationRedirectRoute');
 
-      // Note: this route should appear last so it only catches URLs unmatched
-      // by other patterns.
-      this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
+    // Note: this route should appear last so it only catches URLs unmatched
+    // by other patterns.
+    this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
 
-      page.start();
-    },
+    page.start();
+  }
 
-    /**
-     * @param {!Object} data
-     * @return {Promise|null} if handling the route involves asynchrony, then a
-     *     promise is returned. Otherwise, synchronous handling returns null.
-     */
-    _handleRootRoute(data) {
-      if (data.querystring.match(/^closeAfterLogin/)) {
-        // Close child window on redirect after login.
-        window.close();
-        return null;
+  /**
+   * @param {!Object} data
+   * @return {Promise|null} if handling the route involves asynchrony, then a
+   *     promise is returned. Otherwise, synchronous handling returns null.
+   */
+  _handleRootRoute(data) {
+    if (data.querystring.match(/^closeAfterLogin/)) {
+      // Close child window on redirect after login.
+      window.close();
+      return null;
+    }
+    let hash = this._getHashFromCanonicalPath(data.canonicalPath);
+    // For backward compatibility with GWT links.
+    if (hash) {
+      // In certain login flows the server may redirect to a hash without
+      // a leading slash, which page.js doesn't handle correctly.
+      if (hash[0] !== '/') {
+        hash = '/' + hash;
       }
-      let hash = this._getHashFromCanonicalPath(data.canonicalPath);
-      // For backward compatibility with GWT links.
-      if (hash) {
-        // In certain login flows the server may redirect to a hash without
-        // a leading slash, which page.js doesn't handle correctly.
-        if (hash[0] !== '/') {
-          hash = '/' + hash;
-        }
-        if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
-          // Path decodes all '+' to ' ' -- this breaks project-based URLs.
-          // See Issue 6888.
-          hash = hash.replace('/ /', '/+/');
-        }
-        const base = this.getBaseUrl();
-        let newUrl = base + hash;
-        if (hash.startsWith('/VE/')) {
-          newUrl = base + '/settings' + hash;
-        }
-        this._redirect(newUrl);
-        return null;
+      if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+        // Path decodes all '+' to ' ' -- this breaks project-based URLs.
+        // See Issue 6888.
+        hash = hash.replace('/ /', '/+/');
       }
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          this._redirect('/dashboard/self');
+      const base = this.getBaseUrl();
+      let newUrl = base + hash;
+      if (hash.startsWith('/VE/')) {
+        newUrl = base + '/settings' + hash;
+      }
+      this._redirect(newUrl);
+      return null;
+    }
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this._redirect('/dashboard/self');
+      } else {
+        this._redirect('/q/status:open+-is:wip');
+      }
+    });
+  }
+
+  /**
+   * Decode an application/x-www-form-urlencoded string.
+   *
+   * @param {string} qs The application/x-www-form-urlencoded string.
+   * @return {string} The decoded string.
+   */
+  _decodeQueryString(qs) {
+    return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
+  }
+
+  /**
+   * Parse a query string (e.g. window.location.search) into an array of
+   * name/value pairs.
+   *
+   * @param {string} qs The application/x-www-form-urlencoded query string.
+   * @return {!Array<!Array<string>>} An array of name/value pairs, where each
+   *     element is a 2-element array.
+   */
+  _parseQueryString(qs) {
+    qs = qs.replace(QUESTION_PATTERN, '');
+    if (!qs) {
+      return [];
+    }
+    const params = [];
+    qs.split('&').forEach(param => {
+      const idx = param.indexOf('=');
+      let name;
+      let value;
+      if (idx < 0) {
+        name = this._decodeQueryString(param);
+        value = '';
+      } else {
+        name = this._decodeQueryString(param.substring(0, idx));
+        value = this._decodeQueryString(param.substring(idx + 1));
+      }
+      if (name) {
+        params.push([name, value]);
+      }
+    });
+    return params;
+  }
+
+  /**
+   * Handle dashboard routes. These may be user, or project dashboards.
+   *
+   * @param {!Object} data The parsed route data.
+   */
+  _handleDashboardRoute(data) {
+    // User dashboard. We require viewing user to be logged in, else we
+    // redirect to login for self dashboard or simple owner search for
+    // other user dashboard.
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        if (data.params[0].toLowerCase() === 'self') {
+          this._redirectToLogin(data.canonicalPath);
         } else {
-          this._redirect('/q/status:open');
+          this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
         }
-      });
-    },
-
-    /**
-     * Decode an application/x-www-form-urlencoded string.
-     *
-     * @param {string} qs The application/x-www-form-urlencoded string.
-     * @return {string} The decoded string.
-     */
-    _decodeQueryString(qs) {
-      return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
-    },
-
-    /**
-     * Parse a query string (e.g. window.location.search) into an array of
-     * name/value pairs.
-     *
-     * @param {string} qs The application/x-www-form-urlencoded query string.
-     * @return {!Array<!Array<string>>} An array of name/value pairs, where each
-     *     element is a 2-element array.
-     */
-    _parseQueryString(qs) {
-      qs = qs.replace(QUESTION_PATTERN, '');
-      if (!qs) {
-        return [];
-      }
-      const params = [];
-      qs.split('&').forEach(param => {
-        const idx = param.indexOf('=');
-        let name;
-        let value;
-        if (idx < 0) {
-          name = this._decodeQueryString(param);
-          value = '';
-        } else {
-          name = this._decodeQueryString(param.substring(0, idx));
-          value = this._decodeQueryString(param.substring(idx + 1));
-        }
-        if (name) {
-          params.push([name, value]);
-        }
-      });
-      return params;
-    },
-
-    /**
-     * Handle dashboard routes. These may be user, or project dashboards.
-     *
-     * @param {!Object} data The parsed route data.
-     */
-    _handleDashboardRoute(data) {
-      // User dashboard. We require viewing user to be logged in, else we
-      // redirect to login for self dashboard or simple owner search for
-      // other user dashboard.
-      return this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (!loggedIn) {
-          if (data.params[0].toLowerCase() === 'self') {
-            this._redirectToLogin(data.canonicalPath);
-          } else {
-            this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
-          }
-        } else {
-          this._setParams({
-            view: Gerrit.Nav.View.DASHBOARD,
-            user: data.params[0],
-          });
-        }
-      });
-    },
-
-    /**
-     * Handle custom dashboard routes.
-     *
-     * @param {!Object} data The parsed route data.
-     * @param {string=} opt_qs Optional query string associated with the route.
-     *     If not given, window.location.search is used. (Used by tests).
-     */
-    _handleCustomDashboardRoute(data, opt_qs) {
-      // opt_qs may be provided by a test, and it may have a falsy value
-      const qs = opt_qs !== undefined ? opt_qs : window.location.search;
-      const queryParams = this._parseQueryString(qs);
-      let title = 'Custom Dashboard';
-      const titleParam = queryParams.find(
-          elem => elem[0].toLowerCase() === 'title');
-      if (titleParam) {
-        title = titleParam[1];
-      }
-      // Dashboards support a foreach param which adds a base query to any
-      // additional query.
-      const forEachParam = queryParams.find(
-          elem => elem[0].toLowerCase() === 'foreach');
-      let forEachQuery = null;
-      if (forEachParam) {
-        forEachQuery = forEachParam[1];
-      }
-      const sectionParams = queryParams.filter(
-          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 {
-          name: elem[0],
-          query,
-        };
-      });
-
-      if (sections.length > 0) {
-        // Custom dashboard view.
+      } else {
         this._setParams({
-          view: Gerrit.Nav.View.DASHBOARD,
-          user: 'self',
-          sections,
-          title,
+          view: GerritNav.View.DASHBOARD,
+          user: data.params[0],
         });
-        return Promise.resolve();
       }
+    });
+  }
 
-      // Redirect /dashboard/ -> /dashboard/self.
-      this._redirect('/dashboard/self');
+  /**
+   * Handle custom dashboard routes.
+   *
+   * @param {!Object} data The parsed route data.
+   * @param {string=} opt_qs Optional query string associated with the route.
+   *     If not given, window.location.search is used. (Used by tests).
+   */
+  _handleCustomDashboardRoute(data, opt_qs) {
+    // opt_qs may be provided by a test, and it may have a falsy value
+    const qs = opt_qs !== undefined ? opt_qs : window.location.search;
+    const queryParams = this._parseQueryString(qs);
+    let title = 'Custom Dashboard';
+    const titleParam = queryParams.find(
+        elem => elem[0].toLowerCase() === 'title');
+    if (titleParam) {
+      title = titleParam[1];
+    }
+    // Dashboards support a foreach param which adds a base query to any
+    // additional query.
+    const forEachParam = queryParams.find(
+        elem => elem[0].toLowerCase() === 'foreach');
+    let forEachQuery = null;
+    if (forEachParam) {
+      forEachQuery = forEachParam[1];
+    }
+    const sectionParams = queryParams.filter(
+        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 {
+        name: elem[0],
+        query,
+      };
+    });
+
+    if (sections.length > 0) {
+      // Custom dashboard view.
+      this._setParams({
+        view: GerritNav.View.DASHBOARD,
+        user: 'self',
+        sections,
+        title,
+      });
       return Promise.resolve();
-    },
+    }
 
-    _handleProjectDashboardRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.DASHBOARD,
-        project: data.params[0],
-        dashboard: decodeURIComponent(data.params[1]),
-      });
-    },
+    // Redirect /dashboard/ -> /dashboard/self.
+    this._redirect('/dashboard/self');
+    return Promise.resolve();
+  }
 
-    _handleLegacyProjectDashboardRoute(data) {
-      this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
-    },
+  _handleProjectDashboardRoute(data) {
+    const project = data.params[0];
+    this._setParams({
+      view: GerritNav.View.DASHBOARD,
+      project,
+      dashboard: decodeURIComponent(data.params[1]),
+    });
+    this.$.reporting.setRepoName(project);
+  }
 
-    _handleGroupInfoRoute(data) {
-      this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
-    },
+  _handleLegacyProjectDashboardRoute(data) {
+    this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
+  }
 
-    _handleGroupSelfRedirectRoute(data) {
-      this._redirect('/settings/#Groups');
-    },
+  _handleGroupInfoRoute(data) {
+    this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
+  }
 
-    _handleGroupRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.GROUP,
-        groupId: data.params[0],
-      });
-    },
+  _handleGroupSelfRedirectRoute(data) {
+    this._redirect('/settings/#Groups');
+  }
 
-    _handleGroupAuditLogRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.GROUP,
-        detail: Gerrit.Nav.GroupDetailView.LOG,
-        groupId: data.params[0],
-      });
-    },
+  _handleGroupRoute(data) {
+    this._setParams({
+      view: GerritNav.View.GROUP,
+      groupId: data.params[0],
+    });
+  }
 
-    _handleGroupMembersRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.GROUP,
-        detail: Gerrit.Nav.GroupDetailView.MEMBERS,
-        groupId: data.params[0],
-      });
-    },
+  _handleGroupAuditLogRoute(data) {
+    this._setParams({
+      view: GerritNav.View.GROUP,
+      detail: GerritNav.GroupDetailView.LOG,
+      groupId: data.params[0],
+    });
+  }
 
-    _handleGroupListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-group-list',
-        offset: data.params[1] || 0,
-        filter: null,
-        openCreateModal: data.hash === 'create',
-      });
-    },
+  _handleGroupMembersRoute(data) {
+    this._setParams({
+      view: GerritNav.View.GROUP,
+      detail: GerritNav.GroupDetailView.MEMBERS,
+      groupId: data.params[0],
+    });
+  }
 
-    _handleGroupListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-group-list',
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    },
+  _handleGroupListOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-admin-group-list',
+      offset: data.params[1] || 0,
+      filter: null,
+      openCreateModal: data.hash === 'create',
+    });
+  }
 
-    _handleGroupListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-group-list',
-        filter: data.params.filter || null,
-      });
-    },
+  _handleGroupListFilterOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-admin-group-list',
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
 
-    _handleProjectsOldRoute(data) {
-      let params = '';
-      if (data.params[1]) {
-        params = encodeURIComponent(data.params[1]);
-        if (data.params[1].includes(',')) {
-          params =
-              encodeURIComponent(data.params[1]).replace('%2C', ',');
-        }
+  _handleGroupListFilterRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-admin-group-list',
+      filter: data.params.filter || null,
+    });
+  }
+
+  _handleProjectsOldRoute(data) {
+    let params = '';
+    if (data.params[1]) {
+      params = encodeURIComponent(data.params[1]);
+      if (data.params[1].includes(',')) {
+        params =
+            encodeURIComponent(data.params[1]).replace('%2C', ',');
       }
+    }
 
-      this._redirect(`/admin/repos/${params}`);
-    },
+    this._redirect(`/admin/repos/${params}`);
+  }
 
-    _handleRepoCommandsRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-        repo: data.params[0],
-      });
-    },
+  _handleRepoCommandsRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.COMMANDS,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
 
-    _handleRepoAccessRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.ACCESS,
-        repo: data.params[0],
-      });
-    },
+  _handleRepoAccessRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.ACCESS,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
 
-    _handleRepoDashboardsRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-        repo: data.params[0],
-      });
-    },
+  _handleRepoDashboardsRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.DASHBOARDS,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
 
-    _handleBranchListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        repo: data.params[0],
-        offset: data.params[2] || 0,
-        filter: null,
-      });
-    },
+  _handleBranchListOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.BRANCHES,
+      repo: data.params[0],
+      offset: data.params[2] || 0,
+      filter: null,
+    });
+  }
 
-    _handleBranchListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        repo: data.params.repo,
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    },
+  _handleBranchListFilterOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.BRANCHES,
+      repo: data.params.repo,
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
 
-    _handleBranchListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-        repo: data.params.repo,
-        filter: data.params.filter || null,
-      });
-    },
+  _handleBranchListFilterRoute(data) {
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.BRANCHES,
+      repo: data.params.repo,
+      filter: data.params.filter || null,
+    });
+  }
 
-    _handleTagListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.TAGS,
-        repo: data.params[0],
-        offset: data.params[2] || 0,
-        filter: null,
-      });
-    },
+  _handleTagListOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.TAGS,
+      repo: data.params[0],
+      offset: data.params[2] || 0,
+      filter: null,
+    });
+  }
 
-    _handleTagListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.TAGS,
-        repo: data.params.repo,
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    },
+  _handleTagListFilterOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.TAGS,
+      repo: data.params.repo,
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
 
-    _handleTagListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        detail: Gerrit.Nav.RepoDetailView.TAGS,
-        repo: data.params.repo,
-        filter: data.params.filter || null,
-      });
-    },
+  _handleTagListFilterRoute(data) {
+    this._setParams({
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.TAGS,
+      repo: data.params.repo,
+      filter: data.params.filter || null,
+    });
+  }
 
-    _handleRepoListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-repo-list',
-        offset: data.params[1] || 0,
-        filter: null,
-        openCreateModal: data.hash === 'create',
-      });
-    },
+  _handleRepoListOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-repo-list',
+      offset: data.params[1] || 0,
+      filter: null,
+      openCreateModal: data.hash === 'create',
+    });
+  }
 
-    _handleRepoListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-repo-list',
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    },
+  _handleRepoListFilterOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-repo-list',
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
 
-    _handleRepoListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-repo-list',
-        filter: data.params.filter || null,
-      });
-    },
+  _handleRepoListFilterRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      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');
-    },
+  _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');
-    },
+  _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) {
-      this._setParams({
-        view: Gerrit.Nav.View.REPO,
-        repo: data.params[0],
-      });
-    },
+  _handleRepoRoute(data) {
+    const repo = data.params[0];
+    this._setParams({
+      view: GerritNav.View.REPO,
+      repo,
+    });
+    this.$.reporting.setRepoName(repo);
+  }
 
-    _handlePluginListOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-        offset: data.params[1] || 0,
-        filter: null,
-      });
-    },
+  _handlePluginListOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+      offset: data.params[1] || 0,
+      filter: null,
+    });
+  }
 
-    _handlePluginListFilterOffsetRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-        offset: data.params.offset,
-        filter: data.params.filter,
-      });
-    },
+  _handlePluginListFilterOffsetRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+      offset: data.params.offset,
+      filter: data.params.filter,
+    });
+  }
 
-    _handlePluginListFilterRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-        filter: data.params.filter || null,
-      });
-    },
+  _handlePluginListFilterRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+      filter: data.params.filter || null,
+    });
+  }
 
-    _handlePluginListRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-plugin-list',
-      });
-    },
+  _handlePluginListRoute(data) {
+    this._setParams({
+      view: GerritNav.View.ADMIN,
+      adminView: 'gr-plugin-list',
+    });
+  }
 
-    _handleQueryRoute(data) {
-      this._setParams({
-        view: Gerrit.Nav.View.SEARCH,
-        query: data.params[0],
-        offset: data.params[2],
-      });
-    },
+  _handleQueryRoute(data) {
+    this._setParams({
+      view: GerritNav.View.SEARCH,
+      query: data.params[0],
+      offset: data.params[2],
+    });
+  }
 
-    _handleQueryLegacySuffixRoute(ctx) {
-      this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
-    },
+  _handleQueryLegacySuffixRoute(ctx) {
+    this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
+  }
 
-    _handleChangeNumberLegacyRoute(ctx) {
-      this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
-    },
+  _handleChangeNumberLegacyRoute(ctx) {
+    this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+  }
 
-    _handleChangeRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        project: ctx.params[0],
-        changeNum: ctx.params[1],
-        basePatchNum: ctx.params[4],
-        patchNum: ctx.params[6],
-        view: Gerrit.Nav.View.CHANGE,
-      };
+  _handleChangeRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      project: ctx.params[0],
+      changeNum: ctx.params[1],
+      basePatchNum: ctx.params[4],
+      patchNum: ctx.params[6],
+      view: GerritNav.View.CHANGE,
+      queryMap: ctx.queryMap,
+    };
 
-      this._redirectOrNavigate(params);
-    },
+    this.$.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
 
-    _handleDiffRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        project: ctx.params[0],
-        changeNum: ctx.params[1],
-        basePatchNum: ctx.params[4],
-        patchNum: ctx.params[6],
-        path: ctx.params[8],
-        view: Gerrit.Nav.View.DIFF,
-      };
+  _handleDiffRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      project: ctx.params[0],
+      changeNum: ctx.params[1],
+      basePatchNum: ctx.params[4],
+      patchNum: ctx.params[6],
+      path: ctx.params[8],
+      view: GerritNav.View.DIFF,
+    };
 
-      const address = this._parseLineAddress(ctx.hash);
-      if (address) {
-        params.leftSide = address.leftSide;
-        params.lineNum = address.lineNum;
-      }
+    const address = this._parseLineAddress(ctx.hash);
+    if (address) {
+      params.leftSide = address.leftSide;
+      params.lineNum = address.lineNum;
+    }
+    this.$.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
 
-      this._redirectOrNavigate(params);
-    },
+  _handleChangeLegacyRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      changeNum: ctx.params[0],
+      basePatchNum: ctx.params[3],
+      patchNum: ctx.params[5],
+      view: GerritNav.View.CHANGE,
+      querystring: ctx.querystring,
+    };
 
-    _handleChangeLegacyRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        changeNum: ctx.params[0],
-        basePatchNum: ctx.params[3],
-        patchNum: ctx.params[5],
-        view: Gerrit.Nav.View.CHANGE,
-        querystring: ctx.querystring,
-      };
+    this._normalizeLegacyRouteParams(params);
+  }
 
-      this._normalizeLegacyRouteParams(params);
-    },
+  _handleLegacyLinenum(ctx) {
+    this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
+  }
 
-    _handleLegacyLinenum(ctx) {
-      this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
-    },
+  _handleDiffLegacyRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const params = {
+      changeNum: ctx.params[0],
+      basePatchNum: ctx.params[2],
+      patchNum: ctx.params[4],
+      path: ctx.params[5],
+      view: GerritNav.View.DIFF,
+    };
 
-    _handleDiffLegacyRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        changeNum: ctx.params[0],
-        basePatchNum: ctx.params[2],
-        patchNum: ctx.params[4],
-        path: ctx.params[5],
-        view: Gerrit.Nav.View.DIFF,
-      };
+    const address = this._parseLineAddress(ctx.hash);
+    if (address) {
+      params.leftSide = address.leftSide;
+      params.lineNum = address.lineNum;
+    }
 
-      const address = this._parseLineAddress(ctx.hash);
-      if (address) {
-        params.leftSide = address.leftSide;
-        params.lineNum = address.lineNum;
-      }
+    this._normalizeLegacyRouteParams(params);
+  }
 
-      this._normalizeLegacyRouteParams(params);
-    },
+  _handleDiffEditRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const project = ctx.params[0];
+    this._redirectOrNavigate({
+      project,
+      changeNum: ctx.params[1],
+      patchNum: ctx.params[2],
+      path: ctx.params[3],
+      lineNum: ctx.hash,
+      view: GerritNav.View.EDIT,
+    });
+    this.$.reporting.setRepoName(project);
+  }
 
-    _handleDiffEditRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      this._redirectOrNavigate({
-        project: ctx.params[0],
-        changeNum: ctx.params[1],
-        patchNum: ctx.params[2],
-        path: ctx.params[3],
-        lineNum: ctx.hash,
-        view: Gerrit.Nav.View.EDIT,
-      });
-    },
+  _handleChangeEditRoute(ctx) {
+    // Parameter order is based on the regex group number matched.
+    const project = ctx.params[0];
+    this._redirectOrNavigate({
+      project,
+      changeNum: ctx.params[1],
+      patchNum: ctx.params[3],
+      view: GerritNav.View.CHANGE,
+      edit: true,
+    });
+    this.$.reporting.setRepoName(project);
+  }
 
-    _handleChangeEditRoute(ctx) {
-      // Parameter order is based on the regex group number matched.
-      this._redirectOrNavigate({
-        project: ctx.params[0],
-        changeNum: ctx.params[1],
-        patchNum: ctx.params[3],
-        view: Gerrit.Nav.View.CHANGE,
-        edit: true,
-      });
-    },
+  /**
+   * Normalize the patch range params for a the change or diff view and
+   * redirect if URL upgrade is needed.
+   */
+  _redirectOrNavigate(params) {
+    const needsRedirect = this._normalizePatchRangeParams(params);
+    if (needsRedirect) {
+      this._redirect(this._generateUrl(params));
+    } else {
+      this._setParams(params);
+    }
+  }
 
-    /**
-     * Normalize the patch range params for a the change or diff view and
-     * redirect if URL upgrade is needed.
-     */
-    _redirectOrNavigate(params) {
-      const needsRedirect = this._normalizePatchRangeParams(params);
-      if (needsRedirect) {
-        this._redirect(this._generateUrl(params));
-      } else {
-        this._setParams(params);
-      }
-    },
+  _handleAgreementsRoute() {
+    this._redirect('/settings/#Agreements');
+  }
 
-    _handleAgreementsRoute() {
-      this._redirect('/settings/#Agreements');
-    },
+  _handleNewAgreementsRoute(data) {
+    data.params.view = GerritNav.View.AGREEMENTS;
+    this._setParams(data.params);
+  }
 
-    _handleNewAgreementsRoute(data) {
-      data.params.view = Gerrit.Nav.View.AGREEMENTS;
-      this._setParams(data.params);
-    },
+  _handleSettingsLegacyRoute(data) {
+    // email tokens may contain '+' but no space.
+    // The parameter parsing replaces all '+' with a space,
+    // undo that to have valid tokens.
+    const token = data.params[0].replace(/ /g, '+');
+    this._setParams({
+      view: GerritNav.View.SETTINGS,
+      emailToken: token,
+    });
+  }
 
-    _handleSettingsLegacyRoute(data) {
-      // email tokens may contain '+' but no space.
-      // The parameter parsing replaces all '+' with a space,
-      // undo that to have valid tokens.
-      const token = data.params[0].replace(/ /g, '+');
-      this._setParams({
-        view: Gerrit.Nav.View.SETTINGS,
-        emailToken: token,
-      });
-    },
+  _handleSettingsRoute(data) {
+    this._setParams({view: GerritNav.View.SETTINGS});
+  }
 
-    _handleSettingsRoute(data) {
-      this._setParams({view: Gerrit.Nav.View.SETTINGS});
-    },
+  _handleRegisterRoute(ctx) {
+    this._setParams({justRegistered: true});
+    let path = ctx.params[0] || '/';
 
-    _handleRegisterRoute(ctx) {
-      this._setParams({justRegistered: true});
-      let path = ctx.params[0] || '/';
+    // Prevent redirect looping.
+    if (path.startsWith('/register')) { path = '/'; }
 
-      // Prevent redirect looping.
-      if (path.startsWith('/register')) { path = '/'; }
+    if (path[0] !== '/') { return; }
+    this._redirect(this.getBaseUrl() + path);
+  }
 
-      if (path[0] !== '/') { return; }
-      this._redirect(this.getBaseUrl() + path);
-    },
+  /**
+   * Handler for routes that should pass through the router and not be caught
+   * by the catchall _handleDefaultRoute handler.
+   */
+  _handlePassThroughRoute() {
+    location.reload();
+  }
 
-    /**
-     * Handler for routes that should pass through the router and not be caught
-     * by the catchall _handleDefaultRoute handler.
-     */
-    _handlePassThroughRoute() {
+  /**
+   * URL may sometimes have /+/ encoded to / /.
+   * Context: Issue 6888, Issue 7100
+   */
+  _handleImproperlyEncodedPlusRoute(ctx) {
+    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 = GerritNav.View.PLUGIN_SCREEN;
+    const plugin = ctx.params[0];
+    const screen = ctx.params[1];
+    this._setParams({view, plugin, screen});
+  }
+
+  _handleDocumentationSearchRoute(data) {
+    this._setParams({
+      view: GerritNav.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]) {
       location.reload();
-    },
+    } else {
+      // Redirect /Documentation to /Documentation/index.html
+      this._redirect('/Documentation/index.html');
+    }
+  }
 
+  /**
+   * Catchall route for when no other route is matched.
+   */
+  _handleDefaultRoute() {
+    if (this._isInitialLoad) {
+      // Server recognized this route as polygerrit, so we show 404.
+      this._show404();
+    } else {
+      // Route can be recognized by server, so we pass it to server.
+      this._handlePassThroughRoute();
+    }
+  }
 
-    /**
-     * URL may sometimes have /+/ encoded to / /.
-     * Context: Issue 6888, Issue 7100
-     */
-    _handleImproperlyEncodedPlusRoute(ctx) {
-      let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
-      if (hash.length) { hash = '#' + hash; }
-      this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
-    },
+  _show404() {
+    // Note: the app's 404 display is tightly-coupled with catching 404
+    // network responses, so we simulate a 404 response status to display it.
+    // TODO: Decouple the gr-app error view from network responses.
+    this._appElement().dispatchEvent(new CustomEvent('page-error',
+        {detail: {response: {status: 404}}}));
+  }
+}
 
-    _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]) {
-        location.reload();
-      } else {
-        // Redirect /Documentation to /Documentation/index.html
-        this._redirect('/Documentation/index.html');
-      }
-    },
-
-    /**
-     * Catchall route for when no other route is matched.
-     */
-    _handleDefaultRoute() {
-      if (this._isInitialLoad) {
-        // Server recognized this route as polygerrit, so we show 404.
-        this._show404();
-      } else {
-        // 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
-      // network responses, so we simulate a 404 response status to display it.
-      // 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_html.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
new file mode 100644
index 0000000..07f067e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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 5c7addc..2b2db0b 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-router.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,1625 +31,1631 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-router tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-router.js';
+import page from 'page/page.mjs';
+import {GerritNav} from '../gr-navigation/gr-navigation.js';
+
+suite('gr-router tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_firstCodeBrowserWeblink', () => {
+    assert.deepEqual(element._firstCodeBrowserWeblink([
+      {name: 'gitweb'},
+      {name: 'gitiles'},
+      {name: 'browse'},
+      {name: 'test'}]), {name: 'gitiles'});
+
+    assert.deepEqual(element._firstCodeBrowserWeblink([
+      {name: 'gitweb'},
+      {name: 'test'}]), {name: 'gitweb'});
+  });
+
+  test('_getBrowseCommitWeblink', () => {
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const link = {name: 'test', url: 'test/url'};
+    const weblinks = [browserLink, link];
+    const config = {gerrit: {primary_weblink_name: browserLink.name}};
+    sandbox.stub(element, '_firstCodeBrowserWeblink').returns(link);
+
+    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
+        browserLink);
+
+    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
+  });
+
+  test('_getChangeWeblinks', () => {
+    const link = {name: 'test', url: 'test/url'};
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
+    sandbox.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
+
+    assert.deepEqual(
+        element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
+        {name: 'test', url: 'test/url'});
+
+    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+        {name: 'test', url: 'test/url'});
+
+    link.url = 'https://' + link.url;
+    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+        {name: 'test', url: 'https://test/url'});
+  });
+
+  test('_getHashFromCanonicalPath', () => {
+    let url = '/foo/bar';
+    let hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '/foo#bar';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar');
+
+    url = '/foo#bar#baz';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar#baz');
+
+    url = '#foo#bar#baz';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'foo#bar#baz');
+  });
+
+  suite('_parseLineAddress', () => {
+    test('returns null for empty and invalid hashes', () => {
+      let actual = element._parseLineAddress('');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('foobar');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('foo123');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('123bar');
+      assert.isNull(actual);
+    });
+
+    test('parses correctly', () => {
+      let actual = element._parseLineAddress('1234');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 1234);
+      assert.isFalse(actual.leftSide);
+
+      actual = element._parseLineAddress('a4');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 4);
+      assert.isTrue(actual.leftSide);
+
+      actual = element._parseLineAddress('b77');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 77);
+      assert.isTrue(actual.leftSide);
+    });
+  });
+
+  test('_startRouter requires auth for the right handlers', () => {
+    // This test encodes the lists of route handler methods that gr-router
+    // automatically checks for authentication before triggering.
+
+    const requiresAuth = {};
+    const doesNotRequireAuth = {};
+    sandbox.stub(GerritNav, 'setup');
+    sandbox.stub(page, 'start');
+    sandbox.stub(page, 'base');
+    sandbox.stub(element, '_mapRoute', (pattern, methodName, usesAuth) => {
+      if (usesAuth) {
+        requiresAuth[methodName] = true;
+      } else {
+        doesNotRequireAuth[methodName] = true;
+      }
+    });
+    element._startRouter();
+
+    const actualRequiresAuth = Object.keys(requiresAuth);
+    actualRequiresAuth.sort();
+    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
+    actualDoesNotRequireAuth.sort();
+
+    const shouldRequireAutoAuth = [
+      '_handleAgreementsRoute',
+      '_handleChangeEditRoute',
+      '_handleCreateGroupRoute',
+      '_handleCreateProjectRoute',
+      '_handleDiffEditRoute',
+      '_handleGroupAuditLogRoute',
+      '_handleGroupInfoRoute',
+      '_handleGroupListFilterOffsetRoute',
+      '_handleGroupListFilterRoute',
+      '_handleGroupListOffsetRoute',
+      '_handleGroupMembersRoute',
+      '_handleGroupRoute',
+      '_handleGroupSelfRedirectRoute',
+      '_handleNewAgreementsRoute',
+      '_handlePluginListFilterOffsetRoute',
+      '_handlePluginListFilterRoute',
+      '_handlePluginListOffsetRoute',
+      '_handlePluginListRoute',
+      '_handleRepoCommandsRoute',
+      '_handleSettingsLegacyRoute',
+      '_handleSettingsRoute',
+    ];
+    assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
+
+    const unauthenticatedHandlers = [
+      '_handleBranchListFilterOffsetRoute',
+      '_handleBranchListFilterRoute',
+      '_handleBranchListOffsetRoute',
+      '_handleChangeNumberLegacyRoute',
+      '_handleChangeRoute',
+      '_handleDiffRoute',
+      '_handleDefaultRoute',
+      '_handleChangeLegacyRoute',
+      '_handleDiffLegacyRoute',
+      '_handleDocumentationRedirectRoute',
+      '_handleDocumentationSearchRoute',
+      '_handleDocumentationSearchRedirectRoute',
+      '_handleLegacyLinenum',
+      '_handleImproperlyEncodedPlusRoute',
+      '_handlePassThroughRoute',
+      '_handleProjectDashboardRoute',
+      '_handleLegacyProjectDashboardRoute',
+      '_handleProjectsOldRoute',
+      '_handleRepoAccessRoute',
+      '_handleRepoDashboardsRoute',
+      '_handleRepoListFilterOffsetRoute',
+      '_handleRepoListFilterRoute',
+      '_handleRepoListOffsetRoute',
+      '_handleRepoRoute',
+      '_handleQueryLegacySuffixRoute',
+      '_handleQueryRoute',
+      '_handleRegisterRoute',
+      '_handleTagListFilterOffsetRoute',
+      '_handleTagListFilterRoute',
+      '_handleTagListOffsetRoute',
+      '_handlePluginScreen',
+    ];
+
+    // Handler names that check authentication themselves, and thus don't need
+    // it performed for them.
+    const selfAuthenticatingHandlers = [
+      '_handleDashboardRoute',
+      '_handleCustomDashboardRoute',
+      '_handleRootRoute',
+    ];
+
+    const shouldNotRequireAuth = unauthenticatedHandlers
+        .concat(selfAuthenticatingHandlers);
+    shouldNotRequireAuth.sort();
+    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+  });
+
+  test('_redirectIfNotLoggedIn while logged in', () => {
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    const data = {canonicalPath: ''};
+    const redirectStub = sandbox.stub(element, '_redirectToLogin');
+    return element._redirectIfNotLoggedIn(data).then(() => {
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  test('_redirectIfNotLoggedIn while logged out', () => {
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    const redirectStub = sandbox.stub(element, '_redirectToLogin');
+    const data = {canonicalPath: ''};
+    return new Promise(resolve => {
+      element._redirectIfNotLoggedIn(data)
+          .then(() => {
+            assert.isTrue(false, 'Should never execute');
+          })
+          .catch(() => {
+            assert.isTrue(redirectStub.calledOnce);
+            resolve();
+          });
+    });
+  });
+
+  suite('generateUrl', () => {
+    test('search', () => {
+      let params = {
+        view: GerritNav.View.SEARCH,
+        owner: 'a%b',
+        project: 'c%d',
+        branch: 'e%f',
+        topic: 'g%h',
+        statuses: ['op%en'],
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:"g%2525h"+status:op%2525en');
+
+      params.offset = 100;
+      assert.equal(element._generateUrl(params),
+          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:"g%2525h"+status:op%2525en,100');
+      delete params.offset;
+
+      // The presence of the query param overrides other params.
+      params.query = 'foo$bar';
+      assert.equal(element._generateUrl(params), '/q/foo%2524bar');
+
+      params.offset = 100;
+      assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        statuses: ['a', 'b', 'c'],
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/(status:a OR status:b OR status:c)');
+    });
+
+    test('change', () => {
+      const params = {
+        view: GerritNav.View.CHANGE,
+        changeNum: '1234',
+        project: 'test',
+      };
+      const paramsWithQuery = {
+        view: GerritNav.View.CHANGE,
+        changeNum: '1234',
+        project: 'test',
+        querystring: 'revert&foo=bar',
+      };
+
+      assert.equal(element._generateUrl(params), '/c/test/+/1234');
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234?revert&foo=bar');
+
+      params.patchNum = 10;
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
+      paramsWithQuery.patchNum = 10;
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234/10?revert&foo=bar');
+
+      params.basePatchNum = 5;
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
+      paramsWithQuery.basePatchNum = 5;
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234/5..10?revert&foo=bar');
+
+      params.messageHash = '#123';
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
+    });
+
+    test('change with repo name encoding', () => {
+      const params = {
+        view: GerritNav.View.CHANGE,
+        changeNum: '1234',
+        project: 'x+/y+/z+/w',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/x%252B/y%252B/z%252B/w/+/1234');
+    });
+
+    test('diff', () => {
+      const params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        path: 'x+y/path.cpp',
+        patchNum: 12,
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/42/12/x%252By/path.cpp');
+
+      params.project = 'test';
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/12/x%252By/path.cpp');
+
+      params.basePatchNum = 6;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/6..12/x%252By/path.cpp');
+
+      params.path = 'foo bar/my+file.txt%';
+      params.patchNum = 2;
+      delete params.basePatchNum;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
+
+      params.path = 'file.cpp';
+      params.lineNum = 123;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/file.cpp#123');
+
+      params.leftSide = true;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/file.cpp#b123');
+    });
+
+    test('diff with repo name encoding', () => {
+      const params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        path: 'x+y/path.cpp',
+        patchNum: 12,
+        project: 'x+/y',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+    });
+
+    test('edit', () => {
+      const params = {
+        view: GerritNav.View.EDIT,
+        changeNum: '42',
+        project: 'test',
+        path: 'x+y/path.cpp',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/x%252By/path.cpp,edit');
+    });
+
+    test('_getPatchRangeExpression', () => {
+      const params = {};
+      let actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '');
+
+      params.patchNum = 4;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '4');
+
+      params.basePatchNum = 2;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '2..4');
+
+      delete params.patchNum;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '2..');
+    });
+
+    suite('dashboard', () => {
+      test('self dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+        };
+        assert.equal(element._generateUrl(params), '/dashboard/self');
+      });
+
+      test('user dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          user: 'user',
+        };
+        assert.equal(element._generateUrl(params), '/dashboard/user');
+      });
+
+      test('custom self dashboard, no title', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: 'query 2'},
+          ],
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/?section%201=query%201&section%202=query%202');
+      });
+
+      test('custom repo dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1 ${project}'},
+            {name: 'section 2', query: 'query 2 ${repo}'},
+          ],
+          repo: 'repo-name',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/?section%201=query%201%20repo-name&' +
+            'section%202=query%202%20repo-name');
+      });
+
+      test('custom user dashboard, with title', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          user: 'user',
+          sections: [{name: 'name', query: 'query'}],
+          title: 'custom dashboard',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/user?name=query&title=custom%20dashboard');
+      });
+
+      test('repo dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          repo: 'gerrit/repo',
+          dashboard: 'default:main',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/p/gerrit/repo/+/dashboard/default:main');
+      });
+
+      test('project dashboard (legacy)', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          project: 'gerrit/project',
+          dashboard: 'default:main',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/p/gerrit/project/+/dashboard/default:main');
+      });
+    });
+
+    suite('groups', () => {
+      test('group info', () => {
+        const params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+        };
+        assert.equal(element._generateUrl(params), '/admin/groups/1234');
+      });
+
+      test('group members', () => {
+        const params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+          detail: 'members',
+        };
+        assert.equal(element._generateUrl(params),
+            '/admin/groups/1234,members');
+      });
+
+      test('group audit log', () => {
+        const params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+          detail: 'log',
+        };
+        assert.equal(element._generateUrl(params),
+            '/admin/groups/1234,audit-log');
+      });
+    });
+  });
+
+  suite('param normalization', () => {
+    let projectLookupStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      projectLookupStub = sandbox
+          .stub(element.$.restAPI, 'getFromProjectLookup');
+      sandbox.stub(element, '_generateUrl');
     });
 
-    teardown(() => { sandbox.restore(); });
+    suite('_normalizeLegacyRouteParams', () => {
+      let rangeStub;
+      let redirectStub;
+      let show404Stub;
 
-    test('_firstCodeBrowserWeblink', () => {
-      assert.deepEqual(element._firstCodeBrowserWeblink([
-        {name: 'gitweb'},
-        {name: 'gitiles'},
-        {name: 'browse'},
-        {name: 'test'}]), {name: 'gitiles'});
-
-      assert.deepEqual(element._firstCodeBrowserWeblink([
-        {name: 'gitweb'},
-        {name: 'test'}]), {name: 'gitweb'});
-    });
-
-    test('_getBrowseCommitWeblink', () => {
-      const browserLink = {name: 'browser', url: 'browser/url'};
-      const link = {name: 'test', url: 'test/url'};
-      const weblinks = [browserLink, link];
-      const config = {gerrit: {primary_weblink_name: browserLink.name}};
-      sandbox.stub(element, '_firstCodeBrowserWeblink').returns(link);
-
-      assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
-          browserLink);
-
-      assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
-    });
-
-    test('_getChangeWeblinks', () => {
-      const link = {name: 'test', url: 'test/url'};
-      const browserLink = {name: 'browser', url: 'browser/url'};
-      const mapLinksToConfig = weblinks => ({options: {weblinks}});
-      sandbox.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
-
-      assert.deepEqual(
-          element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
-          {name: 'test', url: 'test/url'});
-
-      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-          {name: 'test', url: 'test/url'});
-
-      link.url = 'https://' + link.url;
-      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-          {name: 'test', url: 'https://test/url'});
-    });
-
-    test('_getHashFromCanonicalPath', () => {
-      let url = '/foo/bar';
-      let hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, '');
-
-      url = '';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, '');
-
-      url = '/foo#bar';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, 'bar');
-
-      url = '/foo#bar#baz';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, 'bar#baz');
-
-      url = '#foo#bar#baz';
-      hash = element._getHashFromCanonicalPath(url);
-      assert.equal(hash, 'foo#bar#baz');
-    });
-
-    suite('_parseLineAddress', () => {
-      test('returns null for empty and invalid hashes', () => {
-        let actual = element._parseLineAddress('');
-        assert.isNull(actual);
-
-        actual = element._parseLineAddress('foobar');
-        assert.isNull(actual);
-
-        actual = element._parseLineAddress('foo123');
-        assert.isNull(actual);
-
-        actual = element._parseLineAddress('123bar');
-        assert.isNull(actual);
+      setup(() => {
+        rangeStub = sandbox.stub(element, '_normalizePatchRangeParams')
+            .returns(Promise.resolve());
+        redirectStub = sandbox.stub(element, '_redirect');
+        show404Stub = sandbox.stub(element, '_show404');
       });
 
-      test('parses correctly', () => {
-        let actual = element._parseLineAddress('1234');
-        assert.isOk(actual);
-        assert.equal(actual.lineNum, 1234);
-        assert.isFalse(actual.leftSide);
+      test('w/o changeNum', () => {
+        projectLookupStub.returns(Promise.resolve('foo/bar'));
+        const params = {};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isFalse(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          assert.isNotOk(params.project);
+          assert.isFalse(redirectStub.called);
+          assert.isFalse(show404Stub.called);
+        });
+      });
 
-        actual = element._parseLineAddress('a4');
-        assert.isOk(actual);
-        assert.equal(actual.lineNum, 4);
-        assert.isTrue(actual.leftSide);
+      test('w/ changeNum', () => {
+        projectLookupStub.returns(Promise.resolve('foo/bar'));
+        const params = {changeNum: 1234};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isTrue(projectLookupStub.called);
+          assert.isTrue(rangeStub.called);
+          assert.equal(params.project, 'foo/bar');
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isFalse(show404Stub.called);
+        });
+      });
 
-        actual = element._parseLineAddress('b77');
-        assert.isOk(actual);
-        assert.equal(actual.lineNum, 77);
-        assert.isTrue(actual.leftSide);
+      test('halts on project lookup failure', () => {
+        projectLookupStub.returns(Promise.resolve(undefined));
+        const params = {changeNum: 1234};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isTrue(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          assert.isUndefined(params.project);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(show404Stub.calledOnce);
+        });
       });
     });
 
-    test('_startRouter requires auth for the right handlers', () => {
-      // This test encodes the lists of route handler methods that gr-router
-      // automatically checks for authentication before triggering.
-
-      const requiresAuth = {};
-      const doesNotRequireAuth = {};
-      sandbox.stub(Gerrit.Nav, 'setup');
-      sandbox.stub(window.page, 'start');
-      sandbox.stub(window.page, 'base');
-      sandbox.stub(window, 'page');
-      sandbox.stub(element, '_mapRoute', (pattern, methodName, usesAuth) => {
-        if (usesAuth) {
-          requiresAuth[methodName] = true;
-        } else {
-          doesNotRequireAuth[methodName] = true;
-        }
+    suite('_normalizePatchRangeParams', () => {
+      test('range n..n normalizes to n', () => {
+        const params = {basePatchNum: 4, patchNum: 4};
+        const needsRedirect = element._normalizePatchRangeParams(params);
+        assert.isTrue(needsRedirect);
+        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.patchNum, 4);
       });
+
+      test('range n.. normalizes to n', () => {
+        const params = {basePatchNum: 4};
+        const needsRedirect = element._normalizePatchRangeParams(params);
+        assert.isFalse(needsRedirect);
+        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.patchNum, 4);
+      });
+    });
+  });
+
+  suite('route handlers', () => {
+    let redirectStub;
+    let setParamsStub;
+    let handlePassThroughRoute;
+
+    // Simple route handlers are direct mappings from parsed route data to a
+    // new set of app.params. This test helper asserts that passing `data`
+    // into `methodName` results in setting the params specified in `params`.
+    function assertDataToParams(data, methodName, params) {
+      element[methodName](data);
+      assert.deepEqual(setParamsStub.lastCall.args[0], params);
+    }
+
+    setup(() => {
+      redirectStub = sandbox.stub(element, '_redirect');
+      setParamsStub = sandbox.stub(element, '_setParams');
+      handlePassThroughRoute = sandbox.stub(element, '_handlePassThroughRoute');
+    });
+
+    test('_handleLegacyProjectDashboardRoute', () => {
+      const params = {0: 'gerrit/project', 1: 'dashboard:main'};
+      element._handleLegacyProjectDashboardRoute({params});
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0],
+          '/p/gerrit/project/+/dashboard/dashboard:main');
+    });
+
+    test('_handleAgreementsRoute', () => {
+      const data = {params: {}};
+      element._handleAgreementsRoute(data);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    });
+
+    test('_handleNewAgreementsRoute', () => {
+      element._handleNewAgreementsRoute({params: {}});
+      assert.isTrue(setParamsStub.calledOnce);
+      assert.equal(setParamsStub.lastCall.args[0].view,
+          GerritNav.View.AGREEMENTS);
+    });
+
+    test('_handleSettingsLegacyRoute', () => {
+      const data = {params: {0: 'my-token'}};
+      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+        view: GerritNav.View.SETTINGS,
+        emailToken: 'my-token',
+      });
+    });
+
+    test('_handleSettingsLegacyRoute with +', () => {
+      const data = {params: {0: 'my-token test'}};
+      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+        view: GerritNav.View.SETTINGS,
+        emailToken: 'my-token+test',
+      });
+    });
+
+    test('_handleSettingsRoute', () => {
+      const data = {};
+      assertDataToParams(data, '_handleSettingsRoute', {
+        view: GerritNav.View.SETTINGS,
+      });
+    });
+
+    test('_handleDefaultRoute on first load', () => {
+      const appElementStub = {dispatchEvent: sinon.stub()};
+      element._appElement = () => appElementStub;
+      element._handleDefaultRoute();
+      assert.isTrue(appElementStub.dispatchEvent.calledOnce);
+      assert.equal(
+          appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
+          404);
+    });
+
+    test('_handleDefaultRoute after internal navigation', () => {
+      let onExit = null;
+      const onRegisteringExit = (match, _onExit) => {
+        onExit = _onExit;
+      };
+      sandbox.stub(page, 'exit', onRegisteringExit);
+      sandbox.stub(GerritNav, 'setup');
+      sandbox.stub(page, 'start');
+      sandbox.stub(page, 'base');
       element._startRouter();
 
-      const actualRequiresAuth = Object.keys(requiresAuth);
-      actualRequiresAuth.sort();
-      const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
-      actualDoesNotRequireAuth.sort();
+      const appElementStub = {dispatchEvent: sinon.stub()};
+      element._appElement = () => appElementStub;
+      element._handleDefaultRoute();
 
-      const shouldRequireAutoAuth = [
-        '_handleAgreementsRoute',
-        '_handleChangeEditRoute',
-        '_handleCreateGroupRoute',
-        '_handleCreateProjectRoute',
-        '_handleDiffEditRoute',
-        '_handleGroupAuditLogRoute',
-        '_handleGroupInfoRoute',
-        '_handleGroupListFilterOffsetRoute',
-        '_handleGroupListFilterRoute',
-        '_handleGroupListOffsetRoute',
-        '_handleGroupMembersRoute',
-        '_handleGroupRoute',
-        '_handleGroupSelfRedirectRoute',
-        '_handleNewAgreementsRoute',
-        '_handlePluginListFilterOffsetRoute',
-        '_handlePluginListFilterRoute',
-        '_handlePluginListOffsetRoute',
-        '_handlePluginListRoute',
-        '_handleRepoCommandsRoute',
-        '_handleSettingsLegacyRoute',
-        '_handleSettingsRoute',
-      ];
-      assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
+      onExit('', () => {}); // we left page;
 
-      const unauthenticatedHandlers = [
-        '_handleBranchListFilterOffsetRoute',
-        '_handleBranchListFilterRoute',
-        '_handleBranchListOffsetRoute',
-        '_handleChangeNumberLegacyRoute',
-        '_handleChangeRoute',
-        '_handleDiffRoute',
-        '_handleDefaultRoute',
-        '_handleChangeLegacyRoute',
-        '_handleDiffLegacyRoute',
-        '_handleDocumentationRedirectRoute',
-        '_handleDocumentationSearchRoute',
-        '_handleDocumentationSearchRedirectRoute',
-        '_handleLegacyLinenum',
-        '_handleImproperlyEncodedPlusRoute',
-        '_handlePassThroughRoute',
-        '_handleProjectDashboardRoute',
-        '_handleLegacyProjectDashboardRoute',
-        '_handleProjectsOldRoute',
-        '_handleRepoAccessRoute',
-        '_handleRepoDashboardsRoute',
-        '_handleRepoListFilterOffsetRoute',
-        '_handleRepoListFilterRoute',
-        '_handleRepoListOffsetRoute',
-        '_handleRepoRoute',
-        '_handleQueryLegacySuffixRoute',
-        '_handleQueryRoute',
-        '_handleRegisterRoute',
-        '_handleTagListFilterOffsetRoute',
-        '_handleTagListFilterRoute',
-        '_handleTagListOffsetRoute',
-        '_handlePluginScreen',
-      ];
-
-      // Handler names that check authentication themselves, and thus don't need
-      // it performed for them.
-      const selfAuthenticatingHandlers = [
-        '_handleDashboardRoute',
-        '_handleCustomDashboardRoute',
-        '_handleRootRoute',
-      ];
-
-      const shouldNotRequireAuth = unauthenticatedHandlers
-          .concat(selfAuthenticatingHandlers);
-      shouldNotRequireAuth.sort();
-      assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+      element._handleDefaultRoute();
+      assert.isTrue(handlePassThroughRoute.calledOnce);
     });
 
-    test('_redirectIfNotLoggedIn while logged in', () => {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(true));
-      const data = {canonicalPath: ''};
-      const redirectStub = sandbox.stub(element, '_redirectToLogin');
-      return element._redirectIfNotLoggedIn(data).then(() => {
+    test('_handleImproperlyEncodedPlusRoute', () => {
+      // Regression test for Issue 7100.
+      element._handleImproperlyEncodedPlusRoute(
+          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(
+          redirectStub.lastCall.args[0],
+          '/c/test/+/42');
+
+      sandbox.stub(element, '_getHashFromCanonicalPath').returns('foo');
+      element._handleImproperlyEncodedPlusRoute(
+          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+      assert.equal(
+          redirectStub.lastCall.args[0],
+          '/c/test/+/42#foo');
+    });
+
+    test('_handleQueryRoute', () => {
+      const data = {params: ['project:foo/bar/baz']};
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      });
+
+      data.params.push(',123', '123');
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      });
+    });
+
+    test('_handleQueryLegacySuffixRoute', () => {
+      element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    });
+
+    test('_handleQueryRoute', () => {
+      const data = {params: ['project:foo/bar/baz']};
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      });
+
+      data.params.push(',123', '123');
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      });
+    });
+
+    suite('_handleRegisterRoute', () => {
+      test('happy path', () => {
+        const ctx = {params: ['/foo/bar']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('no param', () => {
+        const ctx = {params: ['']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('prevent redirect', () => {
+        const ctx = {params: ['/register']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+    });
+
+    suite('_handleRootRoute', () => {
+      test('closes for closeAfterLogin', () => {
+        const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
+        const closeStub = sandbox.stub(window, 'close');
+        const result = element._handleRootRoute(data);
+        assert.isNotOk(result);
+        assert.isTrue(closeStub.called);
         assert.isFalse(redirectStub.called);
       });
-    });
 
-    test('_redirectIfNotLoggedIn while logged out', () => {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(false));
-      const redirectStub = sandbox.stub(element, '_redirectToLogin');
-      const data = {canonicalPath: ''};
-      return new Promise(resolve => {
-        element._redirectIfNotLoggedIn(data)
-            .then(() => {
-              assert.isTrue(false, 'Should never execute');
-            })
-            .catch(() => {
-              assert.isTrue(redirectStub.calledOnce);
-              resolve();
-            });
-      });
-    });
-
-    suite('generateUrl', () => {
-      test('search', () => {
-        let params = {
-          view: Gerrit.Nav.View.SEARCH,
-          owner: 'a%b',
-          project: 'c%d',
-          branch: 'e%f',
-          topic: 'g%h',
-          statuses: ['op%en'],
+      test('redirects to dashboard if logged in', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(true));
+        const data = {
+          canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
-        assert.equal(element._generateUrl(params),
-            '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-            'topic:"g%2525h"+status:op%2525en');
+        const result = element._handleRootRoute(data);
+        assert.isOk(result);
+        return result.then(() => {
+          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
+        });
+      });
 
-        params.offset = 100;
-        assert.equal(element._generateUrl(params),
-            '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-            'topic:"g%2525h"+status:op%2525en,100');
-        delete params.offset;
-
-        // The presence of the query param overrides other params.
-        params.query = 'foo$bar';
-        assert.equal(element._generateUrl(params), '/q/foo%2524bar');
-
-        params.offset = 100;
-        assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
-
-        params = {
-          view: Gerrit.Nav.View.SEARCH,
-          statuses: ['a', 'b', 'c'],
+      test('redirects to open changes if not logged in', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {
+          canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
-        assert.equal(element._generateUrl(params),
-            '/q/(status:a OR status:b OR status:c)');
+        const result = element._handleRootRoute(data);
+        assert.isOk(result);
+        return result.then(() => {
+          assert.isTrue(
+              redirectStub.calledWithExactly('/q/status:open+-is:wip'));
+        });
       });
 
-      test('change', () => {
-        const params = {
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: '1234',
-          project: 'test',
-        };
-        const paramsWithQuery = {
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: '1234',
-          project: 'test',
-          querystring: 'revert&foo=bar',
-        };
-
-        assert.equal(element._generateUrl(params), '/c/test/+/1234');
-        assert.equal(element._generateUrl(paramsWithQuery),
-            '/c/test/+/1234?revert&foo=bar');
-
-        params.patchNum = 10;
-        assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
-        paramsWithQuery.patchNum = 10;
-        assert.equal(element._generateUrl(paramsWithQuery),
-            '/c/test/+/1234/10?revert&foo=bar');
-
-        params.basePatchNum = 5;
-        assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
-        paramsWithQuery.basePatchNum = 5;
-        assert.equal(element._generateUrl(paramsWithQuery),
-            '/c/test/+/1234/5..10?revert&foo=bar');
-
-        params.messageHash = '#123';
-        assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
-      });
-
-      test('change with repo name encoding', () => {
-        const params = {
-          view: Gerrit.Nav.View.CHANGE,
-          changeNum: '1234',
-          project: 'x+/y+/z+/w',
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/x%252B/y%252B/z%252B/w/+/1234');
-      });
-
-      test('diff', () => {
-        const params = {
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: '42',
-          path: 'x+y/path.cpp',
-          patchNum: 12,
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/42/12/x%252By/path.cpp');
-
-        params.project = 'test';
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/12/x%252By/path.cpp');
-
-        params.basePatchNum = 6;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/6..12/x%252By/path.cpp');
-
-        params.path = 'foo bar/my+file.txt%';
-        params.patchNum = 2;
-        delete params.basePatchNum;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
-
-        params.path = 'file.cpp';
-        params.lineNum = 123;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/2/file.cpp#123');
-
-        params.leftSide = true;
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/2/file.cpp#b123');
-      });
-
-      test('diff with repo name encoding', () => {
-        const params = {
-          view: Gerrit.Nav.View.DIFF,
-          changeNum: '42',
-          path: 'x+y/path.cpp',
-          patchNum: 12,
-          project: 'x+/y',
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/x%252B/y/+/42/12/x%252By/path.cpp');
-      });
-
-      test('edit', () => {
-        const params = {
-          view: Gerrit.Nav.View.EDIT,
-          changeNum: '42',
-          project: 'test',
-          path: 'x+y/path.cpp',
-        };
-        assert.equal(element._generateUrl(params),
-            '/c/test/+/42/x%252By/path.cpp,edit');
-      });
-
-      test('_getPatchRangeExpression', () => {
-        const params = {};
-        let actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '');
-
-        params.patchNum = 4;
-        actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '4');
-
-        params.basePatchNum = 2;
-        actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '2..4');
-
-        delete params.patchNum;
-        actual = element._getPatchRangeExpression(params);
-        assert.equal(actual, '2..');
-      });
-
-      suite('dashboard', () => {
-        test('self dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
+      suite('GWT hash-path URLs', () => {
+        test('redirects hash-path URLs', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar/baz',
+            hash: '/foo/bar/baz',
+            querystring: '',
           };
-          assert.equal(element._generateUrl(params), '/dashboard/self');
-        });
-
-        test('user dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            user: 'user',
-          };
-          assert.equal(element._generateUrl(params), '/dashboard/user');
-        });
-
-        test('custom self dashboard, no title', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2'},
-            ],
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/dashboard/?section%201=query%201&section%202=query%202');
-        });
-
-        test('custom repo dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            sections: [
-              {name: 'section 1', query: 'query 1 ${project}'},
-              {name: 'section 2', query: 'query 2 ${repo}'},
-            ],
-            repo: 'repo-name',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/dashboard/?section%201=query%201%20repo-name&' +
-              'section%202=query%202%20repo-name');
-        });
-
-        test('custom user dashboard, with title', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            user: 'user',
-            sections: [{name: 'name', query: 'query'}],
-            title: 'custom dashboard',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/dashboard/user?name=query&title=custom%20dashboard');
-        });
-
-        test('repo dashboard', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            repo: 'gerrit/repo',
-            dashboard: 'default:main',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/p/gerrit/repo/+/dashboard/default:main');
-        });
-
-        test('project dashboard (legacy)', () => {
-          const params = {
-            view: Gerrit.Nav.View.DASHBOARD,
-            project: 'gerrit/project',
-            dashboard: 'default:main',
-          };
-          assert.equal(
-              element._generateUrl(params),
-              '/p/gerrit/project/+/dashboard/default:main');
-        });
-      });
-
-      suite('groups', () => {
-        test('group info', () => {
-          const params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-          };
-          assert.equal(element._generateUrl(params), '/admin/groups/1234');
-        });
-
-        test('group members', () => {
-          const params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-            detail: 'members',
-          };
-          assert.equal(element._generateUrl(params),
-              '/admin/groups/1234,members');
-        });
-
-        test('group audit log', () => {
-          const params = {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 1234,
-            detail: 'log',
-          };
-          assert.equal(element._generateUrl(params),
-              '/admin/groups/1234,audit-log');
-        });
-      });
-    });
-
-    suite('param normalization', () => {
-      let projectLookupStub;
-
-      setup(() => {
-        projectLookupStub = sandbox
-            .stub(element.$.restAPI, 'getFromProjectLookup');
-        sandbox.stub(element, '_generateUrl');
-      });
-
-      suite('_normalizeLegacyRouteParams', () => {
-        let rangeStub;
-        let redirectStub;
-        let show404Stub;
-
-        setup(() => {
-          rangeStub = sandbox.stub(element, '_normalizePatchRangeParams')
-              .returns(Promise.resolve());
-          redirectStub = sandbox.stub(element, '_redirect');
-          show404Stub = sandbox.stub(element, '_show404');
-        });
-
-        test('w/o changeNum', () => {
-          projectLookupStub.returns(Promise.resolve('foo/bar'));
-          const params = {};
-          return element._normalizeLegacyRouteParams(params).then(() => {
-            assert.isFalse(projectLookupStub.called);
-            assert.isFalse(rangeStub.called);
-            assert.isNotOk(params.project);
-            assert.isFalse(redirectStub.called);
-            assert.isFalse(show404Stub.called);
-          });
-        });
-
-        test('w/ changeNum', () => {
-          projectLookupStub.returns(Promise.resolve('foo/bar'));
-          const params = {changeNum: 1234};
-          return element._normalizeLegacyRouteParams(params).then(() => {
-            assert.isTrue(projectLookupStub.called);
-            assert.isTrue(rangeStub.called);
-            assert.equal(params.project, 'foo/bar');
-            assert.isTrue(redirectStub.calledOnce);
-            assert.isFalse(show404Stub.called);
-          });
-        });
-
-        test('halts on project lookup failure', () => {
-          projectLookupStub.returns(Promise.resolve(undefined));
-          const params = {changeNum: 1234};
-          return element._normalizeLegacyRouteParams(params).then(() => {
-            assert.isTrue(projectLookupStub.called);
-            assert.isFalse(rangeStub.called);
-            assert.isUndefined(params.project);
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(show404Stub.calledOnce);
-          });
-        });
-      });
-
-      suite('_normalizePatchRangeParams', () => {
-        test('range n..n normalizes to n', () => {
-          const params = {basePatchNum: 4, patchNum: 4};
-          const needsRedirect = element._normalizePatchRangeParams(params);
-          assert.isTrue(needsRedirect);
-          assert.isNotOk(params.basePatchNum);
-          assert.equal(params.patchNum, 4);
-        });
-
-        test('range n.. normalizes to n', () => {
-          const params = {basePatchNum: 4};
-          const needsRedirect = element._normalizePatchRangeParams(params);
-          assert.isFalse(needsRedirect);
-          assert.isNotOk(params.basePatchNum);
-          assert.equal(params.patchNum, 4);
-        });
-      });
-    });
-
-    suite('route handlers', () => {
-      let redirectStub;
-      let setParamsStub;
-      let handlePassThroughRoute;
-
-      // Simple route handlers are direct mappings from parsed route data to a
-      // new set of app.params. This test helper asserts that passing `data`
-      // into `methodName` results in setting the params specified in `params`.
-      function assertDataToParams(data, methodName, params) {
-        element[methodName](data);
-        assert.deepEqual(setParamsStub.lastCall.args[0], params);
-      }
-
-      setup(() => {
-        redirectStub = sandbox.stub(element, '_redirect');
-        setParamsStub = sandbox.stub(element, '_setParams');
-        handlePassThroughRoute = sandbox.stub(element, '_handlePassThroughRoute');
-      });
-
-      test('_handleLegacyProjectDashboardRoute', () => {
-        const params = {0: 'gerrit/project', 1: 'dashboard:main'};
-        element._handleLegacyProjectDashboardRoute({params});
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0],
-            '/p/gerrit/project/+/dashboard/dashboard:main');
-      });
-
-      test('_handleAgreementsRoute', () => {
-        const data = {params: {}};
-        element._handleAgreementsRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
-      });
-
-      test('_handleNewAgreementsRoute', () => {
-        element._handleNewAgreementsRoute({params: {}});
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.equal(setParamsStub.lastCall.args[0].view,
-            Gerrit.Nav.View.AGREEMENTS);
-      });
-
-      test('_handleSettingsLegacyRoute', () => {
-        const data = {params: {0: 'my-token'}};
-        assertDataToParams(data, '_handleSettingsLegacyRoute', {
-          view: Gerrit.Nav.View.SETTINGS,
-          emailToken: 'my-token',
-        });
-      });
-
-      test('_handleSettingsLegacyRoute with +', () => {
-        const data = {params: {0: 'my-token test'}};
-        assertDataToParams(data, '_handleSettingsLegacyRoute', {
-          view: Gerrit.Nav.View.SETTINGS,
-          emailToken: 'my-token+test',
-        });
-      });
-
-      test('_handleSettingsRoute', () => {
-        const data = {};
-        assertDataToParams(data, '_handleSettingsRoute', {
-          view: Gerrit.Nav.View.SETTINGS,
-        });
-      });
-
-      test('_handleDefaultRoute on first load', () => {
-        const appElementStub = {dispatchEvent: sinon.stub()};
-        element._appElement = () => appElementStub;
-        element._handleDefaultRoute();
-        assert.isTrue(appElementStub.dispatchEvent.calledOnce);
-        assert.equal(
-            appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
-            404);
-      });
-
-      test('_handleDefaultRoute after internal navigation', () => {
-        let onExit = null;
-        const onRegisteringExit = (match, _onExit) => {
-          onExit = _onExit;
-        };
-        sandbox.stub(window.page, 'exit', onRegisteringExit);
-        sandbox.stub(Gerrit.Nav, 'setup');
-        sandbox.stub(window.page, 'start');
-        sandbox.stub(window.page, 'base');
-        sandbox.stub(window, 'page');
-        element._startRouter();
-
-        const appElementStub = {dispatchEvent: sinon.stub()};
-        element._appElement = () => appElementStub;
-        element._handleDefaultRoute();
-
-        onExit('', () => {}); // we left page;
-
-        element._handleDefaultRoute();
-        assert.isTrue(handlePassThroughRoute.calledOnce);
-      });
-
-      test('_handleImproperlyEncodedPlusRoute', () => {
-        // Regression test for Issue 7100.
-        element._handleImproperlyEncodedPlusRoute(
-            {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-            redirectStub.lastCall.args[0],
-            '/c/test/+/42');
-
-        sandbox.stub(element, '_getHashFromCanonicalPath').returns('foo');
-        element._handleImproperlyEncodedPlusRoute(
-            {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-        assert.equal(
-            redirectStub.lastCall.args[0],
-            '/c/test/+/42#foo');
-      });
-
-      test('_handleQueryRoute', () => {
-        const data = {params: ['project:foo/bar/baz']};
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: undefined,
-        });
-
-        data.params.push(',123', '123');
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: '123',
-        });
-      });
-
-      test('_handleQueryLegacySuffixRoute', () => {
-        element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
-      });
-
-      test('_handleQueryRoute', () => {
-        const data = {params: ['project:foo/bar/baz']};
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: undefined,
-        });
-
-        data.params.push(',123', '123');
-        assertDataToParams(data, '_handleQueryRoute', {
-          view: Gerrit.Nav.View.SEARCH,
-          query: 'project:foo/bar/baz',
-          offset: '123',
-        });
-      });
-
-      suite('_handleRegisterRoute', () => {
-        test('happy path', () => {
-          const ctx = {params: ['/foo/bar']};
-          element._handleRegisterRoute(ctx);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-        });
-
-        test('no param', () => {
-          const ctx = {params: ['']};
-          element._handleRegisterRoute(ctx);
-          assert.isTrue(redirectStub.calledWithExactly('/'));
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-        });
-
-        test('prevent redirect', () => {
-          const ctx = {params: ['/register']};
-          element._handleRegisterRoute(ctx);
-          assert.isTrue(redirectStub.calledWithExactly('/'));
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-        });
-      });
-
-      suite('_handleRootRoute', () => {
-        test('closes for closeAfterLogin', () => {
-          const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
-          const closeStub = sandbox.stub(window, 'close');
           const result = element._handleRootRoute(data);
           assert.isNotOk(result);
-          assert.isTrue(closeStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('redirects hash-path URLs w/o leading slash', () => {
+          const data = {
+            canonicalPath: '/#foo/bar/baz',
+            querystring: '',
+            hash: 'foo/bar/baz',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('normalizes "/ /" in hash to "/+/"', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar/+/123/4',
+            querystring: '',
+            hash: '/foo/bar/ /123/4',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        });
+
+        test('prepends baseurl to hash-path', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar',
+            querystring: '',
+            hash: '/foo/bar',
+          };
+          sandbox.stub(element, 'getBaseUrl').returns('/baz');
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+        });
+
+        test('normalizes /VE/ settings hash-paths', () => {
+          const data = {
+            canonicalPath: '/#/VE/foo/bar',
+            querystring: '',
+            hash: '/VE/foo/bar',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly(
+              '/settings/VE/foo/bar'));
+        });
+
+        test('does not drop "inner hashes"', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar#baz',
+            querystring: '',
+            hash: '/foo/bar',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        });
+      });
+    });
+
+    suite('_handleDashboardRoute', () => {
+      let redirectToLoginStub;
+
+      setup(() => {
+        redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
+      });
+
+      test('own dashboard but signed out redirects to login', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isTrue(redirectToLoginStub.calledOnce);
           assert.isFalse(redirectStub.called);
-        });
-
-        test('redirects to dashboard if logged in', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(true));
-          const data = {
-            canonicalPath: '/', path: '/', querystring: '', hash: '',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isOk(result);
-          return result.then(() => {
-            assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-          });
-        });
-
-        test('redirects to open changes if not logged in', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(false));
-          const data = {
-            canonicalPath: '/', path: '/', querystring: '', hash: '',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isOk(result);
-          return result.then(() => {
-            assert.isTrue(redirectStub.calledWithExactly('/q/status:open'));
-          });
-        });
-
-        suite('GWT hash-path URLs', () => {
-          test('redirects hash-path URLs', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar/baz',
-              hash: '/foo/bar/baz',
-              querystring: '',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-          });
-
-          test('redirects hash-path URLs w/o leading slash', () => {
-            const data = {
-              canonicalPath: '/#foo/bar/baz',
-              querystring: '',
-              hash: 'foo/bar/baz',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-          });
-
-          test('normalizes "/ /" in hash to "/+/"', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar/+/123/4',
-              querystring: '',
-              hash: '/foo/bar/ /123/4',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
-          });
-
-          test('prepends baseurl to hash-path', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar',
-              querystring: '',
-              hash: '/foo/bar',
-            };
-            sandbox.stub(element, 'getBaseUrl').returns('/baz');
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
-          });
-
-          test('normalizes /VE/ settings hash-paths', () => {
-            const data = {
-              canonicalPath: '/#/VE/foo/bar',
-              querystring: '',
-              hash: '/VE/foo/bar',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly(
-                '/settings/VE/foo/bar'));
-          });
-
-          test('does not drop "inner hashes"', () => {
-            const data = {
-              canonicalPath: '/#/foo/bar#baz',
-              querystring: '',
-              hash: '/foo/bar',
-            };
-            const result = element._handleRootRoute(data);
-            assert.isNotOk(result);
-            assert.isTrue(redirectStub.called);
-            assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
-          });
+          assert.isFalse(setParamsStub.called);
         });
       });
 
-      suite('_handleDashboardRoute', () => {
-        let redirectToLoginStub;
-
-        setup(() => {
-          redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
-        });
-
-        test('own dashboard but signed out redirects to login', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(false));
-          const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
-          return element._handleDashboardRoute(data, '').then(() => {
-            assert.isTrue(redirectToLoginStub.calledOnce);
-            assert.isFalse(redirectStub.called);
-            assert.isFalse(setParamsStub.called);
-          });
-        });
-
-        test('non-self dashboard but signed out does not redirect', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(false));
-          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-          return element._handleDashboardRoute(data, '').then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.calledOnce);
-            assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-          });
-        });
-
-        test('dashboard while signed in sets params', () => {
-          sandbox.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(true));
-          const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-          return element._handleDashboardRoute(data, '').then(() => {
-            assert.isFalse(redirectToLoginStub.called);
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(setParamsStub.calledOnce);
-            assert.deepEqual(setParamsStub.lastCall.args[0], {
-              view: Gerrit.Nav.View.DASHBOARD,
-              user: 'foo',
-            });
-          });
-        });
-      });
-
-      suite('_handleCustomDashboardRoute', () => {
-        let redirectToLoginStub;
-
-        setup(() => {
-          redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
-        });
-
-        test('no user specified', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data, '').then(() => {
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.called);
-            assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
-          });
-        });
-
-        test('custom dashboard without title', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
-              .then(() => {
-                assert.isFalse(redirectStub.called);
-                assert.isTrue(setParamsStub.calledOnce);
-                assert.deepEqual(setParamsStub.lastCall.args[0], {
-                  view: Gerrit.Nav.View.DASHBOARD,
-                  user: 'self',
-                  sections: [
-                    {name: 'a', query: 'b'},
-                    {name: 'd', query: 'e'},
-                  ],
-                  title: 'Custom Dashboard',
-                });
-              });
-        });
-
-        test('custom dashboard with title', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data,
-              '?a=b&c&d=&=e&title=t')
-              .then(() => {
-                assert.isFalse(redirectToLoginStub.called);
-                assert.isFalse(redirectStub.called);
-                assert.isTrue(setParamsStub.calledOnce);
-                assert.deepEqual(setParamsStub.lastCall.args[0], {
-                  view: Gerrit.Nav.View.DASHBOARD,
-                  user: 'self',
-                  sections: [
-                    {name: 'a', query: 'b'},
-                  ],
-                  title: 't',
-                });
-              });
-        });
-
-        test('custom dashboard with foreach', () => {
-          const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-          return element._handleCustomDashboardRoute(data,
-              '?a=b&c&d=&=e&foreach=is:open')
-              .then(() => {
-                assert.isFalse(redirectToLoginStub.called);
-                assert.isFalse(redirectStub.called);
-                assert.isTrue(setParamsStub.calledOnce);
-                assert.deepEqual(setParamsStub.lastCall.args[0], {
-                  view: Gerrit.Nav.View.DASHBOARD,
-                  user: 'self',
-                  sections: [
-                    {name: 'a', query: 'is:open b'},
-                  ],
-                  title: 'Custom Dashboard',
-                });
-              });
-        });
-      });
-
-      suite('group routes', () => {
-        test('_handleGroupInfoRoute', () => {
-          const data = {params: {0: 1234}};
-          element._handleGroupInfoRoute(data);
+      test('non-self dashboard but signed out does not redirect', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(setParamsStub.called);
           assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
+        });
+      });
+
+      test('dashboard while signed in sets params', () => {
+        sandbox.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(true));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setParamsStub.calledOnce);
+          assert.deepEqual(setParamsStub.lastCall.args[0], {
+            view: GerritNav.View.DASHBOARD,
+            user: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('_handleCustomDashboardRoute', () => {
+      let redirectToLoginStub;
+
+      setup(() => {
+        redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
+      });
+
+      test('no user specified', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data, '').then(() => {
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+        });
+      });
+
+      test('custom dashboard without title', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
+            .then(() => {
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: GerritNav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'b'},
+                  {name: 'd', query: 'e'},
+                ],
+                title: 'Custom Dashboard',
+              });
+            });
+      });
+
+      test('custom dashboard with title', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data,
+            '?a=b&c&d=&=e&title=t')
+            .then(() => {
+              assert.isFalse(redirectToLoginStub.called);
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: GerritNav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'b'},
+                ],
+                title: 't',
+              });
+            });
+      });
+
+      test('custom dashboard with foreach', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data,
+            '?a=b&c&d=&=e&foreach=is:open')
+            .then(() => {
+              assert.isFalse(redirectToLoginStub.called);
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: GerritNav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'is:open b'},
+                ],
+                title: 'Custom Dashboard',
+              });
+            });
+      });
+    });
+
+    suite('group routes', () => {
+      test('_handleGroupInfoRoute', () => {
+        const data = {params: {0: 1234}};
+        element._handleGroupInfoRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      });
+
+      test('_handleGroupAuditLogRoute', () => {
+        const data = {params: {0: 1234}};
+        assertDataToParams(data, '_handleGroupAuditLogRoute', {
+          view: GerritNav.View.GROUP,
+          detail: 'log',
+          groupId: 1234,
+        });
+      });
+
+      test('_handleGroupMembersRoute', () => {
+        const data = {params: {0: 1234}};
+        assertDataToParams(data, '_handleGroupMembersRoute', {
+          view: GerritNav.View.GROUP,
+          detail: 'members',
+          groupId: 1234,
+        });
+      });
+
+      test('_handleGroupListOffsetRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 0,
+          filter: null,
+          openCreateModal: false,
         });
 
-        test('_handleGroupAuditLogRoute', () => {
-          const data = {params: {0: 1234}};
-          assertDataToParams(data, '_handleGroupAuditLogRoute', {
-            view: Gerrit.Nav.View.GROUP,
-            detail: 'log',
-            groupId: 1234,
+        data.params[1] = 42;
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: null,
+          openCreateModal: false,
+        });
+
+        data.hash = 'create';
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: null,
+          openCreateModal: true,
+        });
+      });
+
+      test('_handleGroupListFilterOffsetRoute', () => {
+        const data = {params: {filter: 'foo', offset: 42}};
+        assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: 'foo',
+        });
+      });
+
+      test('_handleGroupListFilterRoute', () => {
+        const data = {params: {filter: 'foo'}};
+        assertDataToParams(data, '_handleGroupListFilterRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          filter: 'foo',
+        });
+      });
+
+      test('_handleGroupRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleGroupRoute', {
+          view: GerritNav.View.GROUP,
+          groupId: 4321,
+        });
+      });
+    });
+
+    suite('repo routes', () => {
+      test('_handleProjectsOldRoute', () => {
+        const data = {params: {}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
+      });
+
+      test('_handleProjectsOldRoute test', () => {
+        const data = {params: {1: 'test'}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
+      });
+
+      test('_handleProjectsOldRoute test,branches', () => {
+        const data = {params: {1: 'test,branches'}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+            redirectStub.lastCall.args[0], '/admin/repos/test,branches');
+      });
+
+      test('_handleRepoRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoRoute', {
+          view: GerritNav.View.REPO,
+          repo: 4321,
+        });
+      });
+
+      test('_handleRepoCommandsRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoCommandsRoute', {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.COMMANDS,
+          repo: 4321,
+        });
+      });
+
+      test('_handleRepoAccessRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoAccessRoute', {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.ACCESS,
+          repo: 4321,
+        });
+      });
+
+      suite('branch list routes', () => {
+        test('_handleBranchListOffsetRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 0,
+            filter: null,
+          });
+
+          data.params[2] = 42;
+          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 42,
+            filter: null,
           });
         });
 
-        test('_handleGroupMembersRoute', () => {
-          const data = {params: {0: 1234}};
-          assertDataToParams(data, '_handleGroupMembersRoute', {
-            view: Gerrit.Nav.View.GROUP,
-            detail: 'members',
-            groupId: 1234,
+        test('_handleBranchListFilterOffsetRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 42,
+            filter: 'foo',
           });
         });
 
-        test('_handleGroupListOffsetRoute', () => {
+        test('_handleBranchListFilterRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo'}};
+          assertDataToParams(data, '_handleBranchListFilterRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('tag list routes', () => {
+        test('_handleTagListOffsetRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleTagListOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            offset: 0,
+            filter: null,
+          });
+        });
+
+        test('_handleTagListFilterOffsetRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handleTagListFilterRoute', () => {
+          const data = {params: {repo: 4321}};
+          assertDataToParams(data, '_handleTagListFilterRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, '_handleTagListFilterRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('repo list routes', () => {
+        test('_handleRepoListOffsetRoute', () => {
           const data = {params: {}};
-          assertDataToParams(data, '_handleGroupListOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
             offset: 0,
             filter: null,
             openCreateModal: false,
           });
 
           data.params[1] = 42;
-          assertDataToParams(data, '_handleGroupListOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
             offset: 42,
             filter: null,
             openCreateModal: false,
           });
 
           data.hash = 'create';
-          assertDataToParams(data, '_handleGroupListOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
             offset: 42,
             filter: null,
             openCreateModal: true,
           });
         });
 
-        test('_handleGroupListFilterOffsetRoute', () => {
+        test('_handleRepoListFilterOffsetRoute', () => {
           const data = {params: {filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
+          assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
             offset: 42,
             filter: 'foo',
           });
         });
 
-        test('_handleGroupListFilterRoute', () => {
-          const data = {params: {filter: 'foo'}};
-          assertDataToParams(data, '_handleGroupListFilterRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
-            filter: 'foo',
-          });
-        });
-
-        test('_handleGroupRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleGroupRoute', {
-            view: Gerrit.Nav.View.GROUP,
-            groupId: 4321,
-          });
-        });
-      });
-
-      suite('repo routes', () => {
-        test('_handleProjectsOldRoute', () => {
+        test('_handleRepoListFilterRoute', () => {
           const data = {params: {}};
-          element._handleProjectsOldRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-        });
-
-        test('_handleProjectsOldRoute test', () => {
-          const data = {params: {1: 'test'}};
-          element._handleProjectsOldRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-        });
-
-        test('_handleProjectsOldRoute test,branches', () => {
-          const data = {params: {1: 'test,branches'}};
-          element._handleProjectsOldRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(
-              redirectStub.lastCall.args[0], '/admin/repos/test,branches');
-        });
-
-        test('_handleRepoRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleRepoRoute', {
-            view: Gerrit.Nav.View.REPO,
-            repo: 4321,
-          });
-        });
-
-        test('_handleRepoCommandsRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleRepoCommandsRoute', {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-            repo: 4321,
-          });
-        });
-
-        test('_handleRepoAccessRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleRepoAccessRoute', {
-            view: Gerrit.Nav.View.REPO,
-            detail: Gerrit.Nav.RepoDetailView.ACCESS,
-            repo: 4321,
-          });
-        });
-
-        suite('branch list routes', () => {
-          test('_handleBranchListOffsetRoute', () => {
-            const data = {params: {0: 4321}};
-            assertDataToParams(data, '_handleBranchListOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              offset: 0,
-              filter: null,
-            });
-
-            data.params[2] = 42;
-            assertDataToParams(data, '_handleBranchListOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              offset: 42,
-              filter: null,
-            });
-          });
-
-          test('_handleBranchListFilterOffsetRoute', () => {
-            const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-            assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              offset: 42,
-              filter: 'foo',
-            });
-          });
-
-          test('_handleBranchListFilterRoute', () => {
-            const data = {params: {repo: 4321, filter: 'foo'}};
-            assertDataToParams(data, '_handleBranchListFilterRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.BRANCHES,
-              repo: 4321,
-              filter: 'foo',
-            });
-          });
-        });
-
-        suite('tag list routes', () => {
-          test('_handleTagListOffsetRoute', () => {
-            const data = {params: {0: 4321}};
-            assertDataToParams(data, '_handleTagListOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              offset: 0,
-              filter: null,
-            });
-          });
-
-          test('_handleTagListFilterOffsetRoute', () => {
-            const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-            assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              offset: 42,
-              filter: 'foo',
-            });
-          });
-
-          test('_handleTagListFilterRoute', () => {
-            const data = {params: {repo: 4321}};
-            assertDataToParams(data, '_handleTagListFilterRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              filter: null,
-            });
-
-            data.params.filter = 'foo';
-            assertDataToParams(data, '_handleTagListFilterRoute', {
-              view: Gerrit.Nav.View.REPO,
-              detail: Gerrit.Nav.RepoDetailView.TAGS,
-              repo: 4321,
-              filter: 'foo',
-            });
-          });
-        });
-
-        suite('repo list routes', () => {
-          test('_handleRepoListOffsetRoute', () => {
-            const data = {params: {}};
-            assertDataToParams(data, '_handleRepoListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 0,
-              filter: null,
-              openCreateModal: false,
-            });
-
-            data.params[1] = 42;
-            assertDataToParams(data, '_handleRepoListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 42,
-              filter: null,
-              openCreateModal: false,
-            });
-
-            data.hash = 'create';
-            assertDataToParams(data, '_handleRepoListOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 42,
-              filter: null,
-              openCreateModal: true,
-            });
-          });
-
-          test('_handleRepoListFilterOffsetRoute', () => {
-            const data = {params: {filter: 'foo', offset: 42}};
-            assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              offset: 42,
-              filter: 'foo',
-            });
-          });
-
-          test('_handleRepoListFilterRoute', () => {
-            const data = {params: {}};
-            assertDataToParams(data, '_handleRepoListFilterRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              filter: null,
-            });
-
-            data.params.filter = 'foo';
-            assertDataToParams(data, '_handleRepoListFilterRoute', {
-              view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-repo-list',
-              filter: 'foo',
-            });
-          });
-        });
-      });
-
-      suite('plugin routes', () => {
-        test('_handlePluginListOffsetRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handlePluginListOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: 0,
-            filter: null,
-          });
-
-          data.params[1] = 42;
-          assertDataToParams(data, '_handlePluginListOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: 42,
-            filter: null,
-          });
-        });
-
-        test('_handlePluginListFilterOffsetRoute', () => {
-          const data = {params: {filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handlePluginListFilterRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handlePluginListFilterRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
             filter: null,
           });
 
           data.params.filter = 'foo';
-          assertDataToParams(data, '_handlePluginListFilterRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
             filter: 'foo',
           });
         });
-
-        test('_handlePluginListRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handlePluginListRoute', {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-          });
-        });
-      });
-
-      suite('change/diff routes', () => {
-        test('_handleChangeNumberLegacyRoute', () => {
-          const data = {params: {0: 12345}};
-          element._handleChangeNumberLegacyRoute(data);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
-        });
-
-        test('_handleChangeLegacyRoute', () => {
-          const normalizeRouteStub = sandbox.stub(element,
-              '_normalizeLegacyRouteParams');
-          const ctx = {
-            params: [
-              1234, // 0 Change number
-              null, // 1 Unused
-              null, // 2 Unused
-              6, // 3 Base patch number
-              null, // 4 Unused
-              9, // 5 Patch number
-            ],
-            querystring: '',
-          };
-          element._handleChangeLegacyRoute(ctx);
-          assert.isTrue(normalizeRouteStub.calledOnce);
-          assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-            changeNum: 1234,
-            basePatchNum: 6,
-            patchNum: 9,
-            view: Gerrit.Nav.View.CHANGE,
-            querystring: '',
-          });
-        });
-
-        test('_handleDiffLegacyRoute', () => {
-          const normalizeRouteStub = sandbox.stub(element,
-              '_normalizeLegacyRouteParams');
-          const ctx = {
-            params: [
-              1234, // 0 Change number
-              null, // 1 Unused
-              3, // 2 Base patch number
-              null, // 3 Unused
-              8, // 4 Patch number
-              'foo/bar', // 5 Diff path
-            ],
-            path: '/c/1234/3..8/foo/bar',
-            hash: 'b123',
-          };
-          element._handleDiffLegacyRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRouteStub.calledOnce);
-          assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-            changeNum: 1234,
-            basePatchNum: 3,
-            patchNum: 8,
-            view: Gerrit.Nav.View.DIFF,
-            path: 'foo/bar',
-            lineNum: 123,
-            leftSide: true,
-          });
-        });
-
-        test('_handleLegacyLinenum w/ @321', () => {
-          const ctx = {path: '/c/1234/3..8/foo/bar@321'};
-          element._handleLegacyLinenum(ctx);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly(
-              '/c/1234/3..8/foo/bar#321'));
-        });
-
-        test('_handleLegacyLinenum w/ @b123', () => {
-          const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
-          element._handleLegacyLinenum(ctx);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly(
-              '/c/1234/3..8/foo/bar#b123'));
-        });
-
-        suite('_handleChangeRoute', () => {
-          let normalizeRangeStub;
-
-          function makeParams(path, hash) {
-            return {
-              params: [
-                'foo/bar', // 0 Project
-                1234, // 1 Change number
-                null, // 2 Unused
-                null, // 3 Unused
-                4, // 4 Base patch number
-                null, // 5 Unused
-                7, // 6 Patch number
-              ],
-            };
-          }
-
-          setup(() => {
-            normalizeRangeStub = sandbox.stub(element,
-                '_normalizePatchRangeParams');
-            sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          });
-
-          test('needs redirect', () => {
-            normalizeRangeStub.returns(true);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams(null, '');
-            element._handleChangeRoute(ctx);
-            assert.isTrue(normalizeRangeStub.called);
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.calledOnce);
-            assert.isTrue(redirectStub.calledWithExactly('foo'));
-          });
-
-          test('change view', () => {
-            normalizeRangeStub.returns(false);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams(null, '');
-            assertDataToParams(ctx, '_handleChangeRoute', {
-              view: Gerrit.Nav.View.CHANGE,
-              project: 'foo/bar',
-              changeNum: 1234,
-              basePatchNum: 4,
-              patchNum: 7,
-            });
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(normalizeRangeStub.called);
-          });
-        });
-
-        suite('_handleDiffRoute', () => {
-          let normalizeRangeStub;
-
-          function makeParams(path, hash) {
-            return {
-              params: [
-                'foo/bar', // 0 Project
-                1234, // 1 Change number
-                null, // 2 Unused
-                null, // 3 Unused
-                4, // 4 Base patch number
-                null, // 5 Unused
-                7, // 6 Patch number
-                null, // 7 Unused,
-                path, // 8 Diff path
-              ],
-              hash,
-            };
-          }
-
-          setup(() => {
-            normalizeRangeStub = sandbox.stub(element,
-                '_normalizePatchRangeParams');
-            sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          });
-
-          test('needs redirect', () => {
-            normalizeRangeStub.returns(true);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams(null, '');
-            element._handleDiffRoute(ctx);
-            assert.isTrue(normalizeRangeStub.called);
-            assert.isFalse(setParamsStub.called);
-            assert.isTrue(redirectStub.calledOnce);
-            assert.isTrue(redirectStub.calledWithExactly('foo'));
-          });
-
-          test('diff view', () => {
-            normalizeRangeStub.returns(false);
-            sandbox.stub(element, '_generateUrl').returns('foo');
-            const ctx = makeParams('foo/bar/baz', 'b44');
-            assertDataToParams(ctx, '_handleDiffRoute', {
-              view: Gerrit.Nav.View.DIFF,
-              project: 'foo/bar',
-              changeNum: 1234,
-              basePatchNum: 4,
-              patchNum: 7,
-              path: 'foo/bar/baz',
-              leftSide: true,
-              lineNum: 44,
-            });
-            assert.isFalse(redirectStub.called);
-            assert.isTrue(normalizeRangeStub.called);
-          });
-        });
-
-        test('_handleDiffEditRoute', () => {
-          const normalizeRangeSpy =
-              sandbox.spy(element, '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          const ctx = {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              3, // 2 Patch num
-              'foo/bar/baz', // 3 File path
-            ],
-          };
-          const appParams = {
-            project: 'foo/bar',
-            changeNum: 1234,
-            view: Gerrit.Nav.View.EDIT,
-            path: 'foo/bar/baz',
-            patchNum: 3,
-            lineNum: undefined,
-          };
-
-          element._handleDiffEditRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeSpy.calledOnce);
-          assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-          assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-          assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-        });
-
-        test('_handleDiffEditRoute with lineNum', () => {
-          const normalizeRangeSpy =
-              sandbox.spy(element, '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          const ctx = {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              3, // 2 Patch num
-              'foo/bar/baz', // 3 File path
-            ],
-            hash: 4,
-          };
-          const appParams = {
-            project: 'foo/bar',
-            changeNum: 1234,
-            view: Gerrit.Nav.View.EDIT,
-            path: 'foo/bar/baz',
-            patchNum: 3,
-            lineNum: 4,
-          };
-
-          element._handleDiffEditRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeSpy.calledOnce);
-          assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-          assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-          assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-        });
-
-        test('_handleChangeEditRoute', () => {
-          const normalizeRangeSpy =
-              sandbox.spy(element, '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-          const ctx = {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null,
-              3, // 3 Patch num
-            ],
-          };
-          const appParams = {
-            project: 'foo/bar',
-            changeNum: 1234,
-            view: Gerrit.Nav.View.CHANGE,
-            patchNum: 3,
-            edit: true,
-          };
-
-          element._handleChangeEditRoute(ctx);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeSpy.calledOnce);
-          assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-          assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-          assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-        });
-      });
-
-      test('_handlePluginScreen', () => {
-        const ctx = {params: ['foo', 'bar']};
-        assertDataToParams(ctx, '_handlePluginScreen', {
-          view: Gerrit.Nav.View.PLUGIN_SCREEN,
-          plugin: 'foo',
-          screen: 'bar',
-        });
-        assert.isFalse(redirectStub.called);
       });
     });
 
-    suite('_parseQueryString', () => {
-      test('empty queries', () => {
-        assert.deepEqual(element._parseQueryString(''), []);
-        assert.deepEqual(element._parseQueryString('?'), []);
-        assert.deepEqual(element._parseQueryString('??'), []);
-        assert.deepEqual(element._parseQueryString('&&&'), []);
+    suite('plugin routes', () => {
+      test('_handlePluginListOffsetRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 0,
+          filter: null,
+        });
+
+        data.params[1] = 42;
+        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 42,
+          filter: null,
+        });
       });
 
-      test('url decoding', () => {
-        assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
-        assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
-        assert.deepEqual(
-            element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
-            [['name', 'value']]);
+      test('_handlePluginListFilterOffsetRoute', () => {
+        const data = {params: {filter: 'foo', offset: 42}};
+        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 42,
+          filter: 'foo',
+        });
       });
 
-      test('multiple parameters', () => {
-        assert.deepEqual(
-            element._parseQueryString('a=b&c=d&e=f'),
-            [['a', 'b'], ['c', 'd'], ['e', 'f']]);
-        assert.deepEqual(
-            element._parseQueryString('&a=b&&&e=f&'),
-            [['a', 'b'], ['e', 'f']]);
+      test('_handlePluginListFilterRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListFilterRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: null,
+        });
+
+        data.params.filter = 'foo';
+        assertDataToParams(data, '_handlePluginListFilterRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: 'foo',
+        });
       });
+
+      test('_handlePluginListRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+        });
+      });
+    });
+
+    suite('change/diff routes', () => {
+      test('_handleChangeNumberLegacyRoute', () => {
+        const data = {params: {0: 12345}};
+        element._handleChangeNumberLegacyRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+      });
+
+      test('_handleChangeLegacyRoute', () => {
+        const normalizeRouteStub = sandbox.stub(element,
+            '_normalizeLegacyRouteParams');
+        const ctx = {
+          params: [
+            1234, // 0 Change number
+            null, // 1 Unused
+            null, // 2 Unused
+            6, // 3 Base patch number
+            null, // 4 Unused
+            9, // 5 Patch number
+          ],
+          querystring: '',
+        };
+        element._handleChangeLegacyRoute(ctx);
+        assert.isTrue(normalizeRouteStub.calledOnce);
+        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+          changeNum: 1234,
+          basePatchNum: 6,
+          patchNum: 9,
+          view: GerritNav.View.CHANGE,
+          querystring: '',
+        });
+      });
+
+      test('_handleDiffLegacyRoute', () => {
+        const normalizeRouteStub = sandbox.stub(element,
+            '_normalizeLegacyRouteParams');
+        const ctx = {
+          params: [
+            1234, // 0 Change number
+            null, // 1 Unused
+            3, // 2 Base patch number
+            null, // 3 Unused
+            8, // 4 Patch number
+            'foo/bar', // 5 Diff path
+          ],
+          path: '/c/1234/3..8/foo/bar',
+          hash: 'b123',
+        };
+        element._handleDiffLegacyRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRouteStub.calledOnce);
+        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+          changeNum: 1234,
+          basePatchNum: 3,
+          patchNum: 8,
+          view: GerritNav.View.DIFF,
+          path: 'foo/bar',
+          lineNum: 123,
+          leftSide: true,
+        });
+      });
+
+      test('_handleLegacyLinenum w/ @321', () => {
+        const ctx = {path: '/c/1234/3..8/foo/bar@321'};
+        element._handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly(
+            '/c/1234/3..8/foo/bar#321'));
+      });
+
+      test('_handleLegacyLinenum w/ @b123', () => {
+        const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
+        element._handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly(
+            '/c/1234/3..8/foo/bar#b123'));
+      });
+
+      suite('_handleChangeRoute', () => {
+        let normalizeRangeStub;
+
+        function makeParams(path, hash) {
+          return {
+            params: [
+              'foo/bar', // 0 Project
+              1234, // 1 Change number
+              null, // 2 Unused
+              null, // 3 Unused
+              4, // 4 Base patch number
+              null, // 5 Unused
+              7, // 6 Patch number
+            ],
+            queryMap: new Map(),
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sandbox.stub(element,
+              '_normalizePatchRangeParams');
+          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          element._handleChangeRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('change view', () => {
+          normalizeRangeStub.returns(false);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          assertDataToParams(ctx, '_handleChangeRoute', {
+            view: GerritNav.View.CHANGE,
+            project: 'foo/bar',
+            changeNum: 1234,
+            basePatchNum: 4,
+            patchNum: 7,
+            queryMap: new Map(),
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+      });
+
+      suite('_handleDiffRoute', () => {
+        let normalizeRangeStub;
+
+        function makeParams(path, hash) {
+          return {
+            params: [
+              'foo/bar', // 0 Project
+              1234, // 1 Change number
+              null, // 2 Unused
+              null, // 3 Unused
+              4, // 4 Base patch number
+              null, // 5 Unused
+              7, // 6 Patch number
+              null, // 7 Unused,
+              path, // 8 Diff path
+            ],
+            hash,
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sandbox.stub(element,
+              '_normalizePatchRangeParams');
+          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          element._handleDiffRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('diff view', () => {
+          normalizeRangeStub.returns(false);
+          sandbox.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams('foo/bar/baz', 'b44');
+          assertDataToParams(ctx, '_handleDiffRoute', {
+            view: GerritNav.View.DIFF,
+            project: 'foo/bar',
+            changeNum: 1234,
+            basePatchNum: 4,
+            patchNum: 7,
+            path: 'foo/bar/baz',
+            leftSide: true,
+            lineNum: 44,
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+      });
+
+      test('_handleDiffEditRoute', () => {
+        const normalizeRangeSpy =
+            sandbox.spy(element, '_normalizePatchRangeParams');
+        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            3, // 2 Patch num
+            'foo/bar/baz', // 3 File path
+          ],
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: GerritNav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3,
+          lineNum: undefined,
+        };
+
+        element._handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('_handleDiffEditRoute with lineNum', () => {
+        const normalizeRangeSpy =
+            sandbox.spy(element, '_normalizePatchRangeParams');
+        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            3, // 2 Patch num
+            'foo/bar/baz', // 3 File path
+          ],
+          hash: 4,
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: GerritNav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3,
+          lineNum: 4,
+        };
+
+        element._handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('_handleChangeEditRoute', () => {
+        const normalizeRangeSpy =
+            sandbox.spy(element, '_normalizePatchRangeParams');
+        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            null,
+            3, // 3 Patch num
+          ],
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: GerritNav.View.CHANGE,
+          patchNum: 3,
+          edit: true,
+        };
+
+        element._handleChangeEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+    });
+
+    test('_handlePluginScreen', () => {
+      const ctx = {params: ['foo', 'bar']};
+      assertDataToParams(ctx, '_handlePluginScreen', {
+        view: GerritNav.View.PLUGIN_SCREEN,
+        plugin: 'foo',
+        screen: 'bar',
+      });
+      assert.isFalse(redirectStub.called);
     });
   });
+
+  suite('_parseQueryString', () => {
+    test('empty queries', () => {
+      assert.deepEqual(element._parseQueryString(''), []);
+      assert.deepEqual(element._parseQueryString('?'), []);
+      assert.deepEqual(element._parseQueryString('??'), []);
+      assert.deepEqual(element._parseQueryString('&&&'), []);
+    });
+
+    test('url decoding', () => {
+      assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
+      assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
+      assert.deepEqual(
+          element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
+          [['name', 'value']]);
+    });
+
+    test('multiple parameters', () => {
+      assert.deepEqual(
+          element._parseQueryString('a=b&c=d&e=f'),
+          [['a', 'b'], ['c', 'd'], ['e', 'f']]);
+      assert.deepEqual(
+          element._parseQueryString('&a=b&&&e=f&c'),
+          [['a', 'b'], ['e', 'f'], ['c', '']]);
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index 0cdef8c..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ /dev/null
@@ -1,55 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-search-bar">
-  <template>
-    <style include="shared-styles">
-      form {
-        display: flex;
-      }
-      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>
-      <gr-autocomplete
-          show-search-icon
-          id="searchInput"
-          text="{{_inputVal}}"
-          query="[[query]]"
-          on-commit="_handleInputCommit"
-          allow-non-suggested-values
-          multi
-          borderless
-          threshold="[[_threshold]]"
-          tab-complete
-          vertical-offset="30"></gr-autocomplete>
-    </form>
-  </template>
-  <script src="gr-search-bar.js"></script>
-</dom-module>
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..a06fd85 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,109 +14,126 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-search-bar_html.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  // Possible static search options for auto complete, without negations.
-  const SEARCH_OPERATORS = [
-    'added:',
-    'age:',
-    'age:1week', // Give an example age
-    'assignee:',
-    'author:',
-    'branch:',
-    'bug:',
-    'cc:',
-    'cc:self',
-    'change:',
-    'comment:',
-    'commentby:',
-    'commit:',
-    'committer:',
-    'conflicts:',
-    'deleted:',
-    'delta:',
-    'dir:',
-    'directory:',
-    'ext:',
-    'extension:',
-    'file:',
-    'footer:',
-    'from:',
-    'has:',
-    'has:draft',
-    'has:edit',
-    'has:star',
-    'has:stars',
-    'has:unresolved',
-    'hashtag:',
-    'intopic:',
-    'is:',
-    'is:abandoned',
-    'is:assigned',
-    'is:closed',
-    'is:ignored',
-    'is:mergeable',
-    'is:merged',
-    'is:open',
-    'is:owner',
-    'is:private',
-    'is:reviewed',
-    'is:reviewer',
-    'is:starred',
-    'is:submittable',
-    'is:watched',
-    'is:wip',
-    'label:',
-    'message:',
-    'onlyexts:',
-    'onlyextensions:',
-    'owner:',
-    'ownerin:',
-    'parentproject:',
-    'project:',
-    'projects:',
-    'query:',
-    'ref:',
-    'reviewedby:',
-    'reviewer:',
-    'reviewer:self',
-    'reviewerin:',
-    'size:',
-    'star:',
-    'status:',
-    'status:abandoned',
-    'status:closed',
-    'status:merged',
-    'status:open',
-    'status:reviewed',
-    'topic:',
-    'tr:',
-  ];
+// Possible static search options for auto complete, without negations.
+const SEARCH_OPERATORS = [
+  'added:',
+  'age:',
+  'age:1week', // Give an example age
+  'assignee:',
+  'author:',
+  'branch:',
+  'bug:',
+  'cc:',
+  'cc:self',
+  'change:',
+  'cherrypickof:',
+  'comment:',
+  'commentby:',
+  'commit:',
+  'committer:',
+  'conflicts:',
+  'deleted:',
+  'delta:',
+  'dir:',
+  'directory:',
+  'ext:',
+  'extension:',
+  'file:',
+  'footer:',
+  'from:',
+  'has:',
+  'has:draft',
+  'has:edit',
+  'has:star',
+  'has:stars',
+  'has:unresolved',
+  'hashtag:',
+  'intopic:',
+  'is:',
+  'is:abandoned',
+  'is:assigned',
+  'is:closed',
+  'is:ignored',
+  'is:merge',
+  'is:merged',
+  'is:open',
+  'is:owner',
+  'is:private',
+  'is:reviewed',
+  'is:reviewer',
+  'is:starred',
+  'is:submittable',
+  'is:watched',
+  'is:wip',
+  'label:',
+  'message:',
+  'onlyexts:',
+  'onlyextensions:',
+  'owner:',
+  'ownerin:',
+  'parentproject:',
+  'project:',
+  'projects:',
+  'query:',
+  'ref:',
+  'reviewedby:',
+  'reviewer:',
+  'reviewer:self',
+  'reviewerin:',
+  'size:',
+  'star:',
+  'status:',
+  'status:abandoned',
+  'status:closed',
+  'status:merged',
+  'status:open',
+  'status:reviewed',
+  'submissionid:',
+  'topic:',
+  'tr:',
+];
 
-  // All of the ops, with corresponding negations.
-  const SEARCH_OPERATORS_WITH_NEGATIONS =
-    SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`));
+// All of the ops, with corresponding negations.
+const SEARCH_OPERATORS_WITH_NEGATIONS_SET =
+  new Set(SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`)));
 
-  const MAX_AUTOCOMPLETE_RESULTS = 10;
+const MAX_AUTOCOMPLETE_RESULTS = 10;
 
-  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
-  Polymer({
-    is: 'gr-search-bar',
+/**
+ * @extends Polymer.Element
+ */
+class GrSearchBar extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when a search is committed
-     *
-     * @event handle-search
-     */
+  static get is() { return 'gr-search-bar'; }
+  /**
+   * Fired when a search is committed
+   *
+   * @event handle-search
+   */
 
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    properties: {
+  static get properties() {
+    return {
       value: {
         type: String,
         value: '',
@@ -156,151 +173,171 @@
         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:
-     *   - e.target is the search button
-     *   - e.target is the gr-autocomplete widget (#searchInput)
-     *   - e.target is the input element wrapped within #searchInput
-     *
-     * @param {!Event} e
-     */
-    _preventDefaultAndNavigateToInputVal(e) {
-      e.preventDefault();
-      const target = Polymer.dom(e).rootTarget;
-      // If the target is the #searchInput or has a sub-input component, that
-      // is what holds the focus as opposed to the target from the DOM event.
-      if (target.$.input) {
-        target.$.input.blur();
-      } else {
-        target.blur();
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(serverConfig => {
+      const mergeability = serverConfig
+       && serverConfig.index
+        && serverConfig.index.mergeabilityComputationBehavior;
+      if (mergeability === 'API_REF_UPDATED_AND_CHANGE_REINDEX'
+      || mergeability === 'REF_UPDATED_AND_CHANGE_REINDEX') {
+        // add 'is:mergeable' to SEARCH_OPERATORS_WITH_NEGATIONS_SET
+        this._addOperator('is:mergeable');
       }
-      const trimmedInput = this._inputVal && this._inputVal.trim();
-      if (trimmedInput) {
-        const predefinedOpOnlyQuery = SEARCH_OPERATORS_WITH_NEGATIONS.some(
-            op => {
-              return op.endsWith(':') && op === trimmedInput;
-            }
-        );
-        if (predefinedOpOnlyQuery) {
-          return;
-        }
-        this.dispatchEvent(new CustomEvent('handle-search', {
-          detail: {inputVal: this._inputVal},
-        }));
+    });
+  }
+
+  _addOperator(name, include_neg = true) {
+    SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(name);
+    if (include_neg) {
+      SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(`-${name}`);
+    }
+  }
+
+  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:
+   *   - e.target is the search button
+   *   - e.target is the gr-autocomplete widget (#searchInput)
+   *   - e.target is the input element wrapped within #searchInput
+   *
+   * @param {!Event} e
+   */
+  _preventDefaultAndNavigateToInputVal(e) {
+    e.preventDefault();
+    const target = dom(e).rootTarget;
+    // If the target is the #searchInput or has a sub-input component, that
+    // is what holds the focus as opposed to the target from the DOM event.
+    if (target.$.input) {
+      target.$.input.blur();
+    } else {
+      target.blur();
+    }
+    const trimmedInput = this._inputVal && this._inputVal.trim();
+    if (trimmedInput) {
+      const predefinedOpOnlyQuery = [...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
+          .some(op => op.endsWith(':') && op === trimmedInput);
+      if (predefinedOpOnlyQuery) {
+        return;
       }
-    },
+      this.dispatchEvent(new CustomEvent('handle-search', {
+        detail: {inputVal: this._inputVal},
+      }));
+    }
+  }
 
-    /**
-     * Determine what array of possible suggestions should be provided
-     *     to _getSearchSuggestions.
-     *
-     * @param {string} input - The full search term, in lowercase.
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     suggestion objects.
-     */
-    _fetchSuggestions(input) {
-      // Split the input on colon to get a two part predicate/expression.
-      const splitInput = input.split(':');
-      const predicate = splitInput[0];
-      const expression = splitInput[1] || '';
-      // Switch on the predicate to determine what to autocomplete.
-      switch (predicate) {
-        case 'ownerin':
-        case 'reviewerin':
-          // Fetch groups.
-          return this.groupSuggestions(predicate, expression);
+  /**
+   * Determine what array of possible suggestions should be provided
+   *     to _getSearchSuggestions.
+   *
+   * @param {string} input - The full search term, in lowercase.
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     suggestion objects.
+   */
+  _fetchSuggestions(input) {
+    // Split the input on colon to get a two part predicate/expression.
+    const splitInput = input.split(':');
+    const predicate = splitInput[0];
+    const expression = splitInput[1] || '';
+    // Switch on the predicate to determine what to autocomplete.
+    switch (predicate) {
+      case 'ownerin':
+      case 'reviewerin':
+        // Fetch groups.
+        return this.groupSuggestions(predicate, expression);
 
-        case 'parentproject':
-        case 'project':
-          // Fetch projects.
-          return this.projectSuggestions(predicate, expression);
+      case 'parentproject':
+      case 'project':
+        // Fetch projects.
+        return this.projectSuggestions(predicate, expression);
 
-        case 'author':
-        case 'cc':
-        case 'commentby':
-        case 'committer':
-        case 'from':
-        case 'owner':
-        case 'reviewedby':
-        case 'reviewer':
-          // Fetch accounts.
-          return this.accountSuggestions(predicate, expression);
+      case 'author':
+      case 'cc':
+      case 'commentby':
+      case 'committer':
+      case 'from':
+      case 'owner':
+      case 'reviewedby':
+      case 'reviewer':
+        // Fetch accounts.
+        return this.accountSuggestions(predicate, expression);
 
-        default:
-          return Promise.resolve(SEARCH_OPERATORS_WITH_NEGATIONS
-              .filter(operator => operator.includes(input))
-              .map(operator => ({text: operator})));
-      }
-    },
+      default:
+        return Promise.resolve([...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
+            .filter(operator => operator.includes(input))
+            .map(operator => { return {text: operator}; }));
+    }
+  }
 
-    /**
-     * Get the sorted, pruned list of suggestions for the current search query.
-     *
-     * @param {string} input - The complete search query.
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     suggestions.
-     */
-    _getSearchSuggestions(input) {
-      // Allow spaces within quoted terms.
-      const tokens = input.match(TOKENIZE_REGEX);
-      const trimmedInput = tokens[tokens.length - 1].toLowerCase();
+  /**
+   * Get the sorted, pruned list of suggestions for the current search query.
+   *
+   * @param {string} input - The complete search query.
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     suggestions.
+   */
+  _getSearchSuggestions(input) {
+    // Allow spaces within quoted terms.
+    const tokens = input.match(TOKENIZE_REGEX);
+    const trimmedInput = tokens[tokens.length - 1].toLowerCase();
 
-      return this._fetchSuggestions(trimmedInput)
-          .then(suggestions => {
-            if (!suggestions || !suggestions.length) { return []; }
-            return suggestions
-                // Prioritize results that start with the input.
-                .sort((a, b) => {
-                  const aContains = a.text.toLowerCase().indexOf(trimmedInput);
-                  const bContains = b.text.toLowerCase().indexOf(trimmedInput);
-                  if (aContains === bContains) {
-                    return a.text.localeCompare(b.text);
-                  }
-                  if (aContains === -1) {
-                    return 1;
-                  }
-                  if (bContains === -1) {
-                    return -1;
-                  }
-                  return aContains - bContains;
-                })
-                // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
-                .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
-                // Map to an object to play nice with gr-autocomplete.
-                .map(({text, label}) => {
-                  return {
-                    name: text,
-                    value: text,
-                    label,
-                  };
-                });
-          });
-    },
+    return this._fetchSuggestions(trimmedInput)
+        .then(suggestions => {
+          if (!suggestions || !suggestions.length) { return []; }
+          return suggestions
+              // Prioritize results that start with the input.
+              .sort((a, b) => {
+                const aContains = a.text.toLowerCase().indexOf(trimmedInput);
+                const bContains = b.text.toLowerCase().indexOf(trimmedInput);
+                if (aContains === bContains) {
+                  return a.text.localeCompare(b.text);
+                }
+                if (aContains === -1) {
+                  return 1;
+                }
+                if (bContains === -1) {
+                  return -1;
+                }
+                return aContains - bContains;
+              })
+              // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
+              .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
+              // Map to an object to play nice with gr-autocomplete.
+              .map(({text, label}) => {
+                return {
+                  name: text,
+                  value: text,
+                  label,
+                };
+              });
+        });
+  }
 
-    _handleSearch(e) {
-      const keyboardEvent = this.getKeyboardEvent(e);
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
+  _handleSearch(e) {
+    const keyboardEvent = this.getKeyboardEvent(e);
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
 
-      e.preventDefault();
-      this.$.searchInput.focus();
-      this.$.searchInput.selectAll();
-    },
-  });
-})();
+    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_html.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
new file mode 100644
index 0000000..e26f8a3
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    form {
+      display: flex;
+    }
+    gr-autocomplete {
+      background-color: var(--view-background-color);
+      border-radius: var(--border-radius);
+      flex: 1;
+      outline: none;
+    }
+  </style>
+  <form>
+    <gr-autocomplete
+      show-search-icon=""
+      id="searchInput"
+      text="{{_inputVal}}"
+      query="[[query]]"
+      on-commit="_handleInputCommit"
+      allow-non-suggested-values=""
+      multi=""
+      threshold="[[_threshold]]"
+      tab-complete=""
+      vertical-offset="30"
+    ></gr-autocomplete>
+  </form>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..3b37e09 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
@@ -17,19 +17,20 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-search-bar.html">
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
-<script>void(0);</script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-search-bar.js';
+void (0);
+</script>
 
 <test-fixture id="basic">
   <template>
@@ -37,159 +38,202 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-search-bar tests', () => {
-    const kb = window.Gerrit.KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.SEARCH, '/');
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-search-bar.js';
+import '../../../scripts/util.js';
+import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+suite('gr-search-bar tests', () => {
+  const kb = KeyboardShortcutBinder;
+  kb.bindShortcut(kb.Shortcut.SEARCH, '/');
 
-    let element;
-    let sandbox;
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    flush(done);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('value is propagated to _inputVal', () => {
+    element.value = 'foo';
+    assert.equal(element._inputVal, 'foo');
+  });
+
+  const getActiveElement = () => (document.activeElement.shadowRoot ?
+    document.activeElement.shadowRoot.activeElement :
+    document.activeElement);
+
+  test('enter in search input fires event', done => {
+    element.addEventListener('handle-search', () => {
+      assert.notEqual(getActiveElement(), element.$.searchInput);
+      assert.notEqual(getActiveElement(), element.$.searchButton);
+      done();
+    });
+    element.value = 'test';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+  });
+
+  test('input blurred after commit', () => {
+    const blurSpy = sandbox.spy(element.$.searchInput.$.input, 'blur');
+    element.$.searchInput.text = 'fate/stay';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(blurSpy.called);
+  });
+
+  test('empty search query does not trigger nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = '';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('Predefined query op with no predication doesnt trigger nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'added:';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('predefined predicate query triggers nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'age:1week';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('undefined predicate query triggers nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:1week';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('empty undefined predicate query triggers nav', () => {
+    const searchSpy = sandbox.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('keyboard shortcuts', () => {
+    const focusSpy = sandbox.spy(element.$.searchInput, 'focus');
+    const selectAllSpy = sandbox.spy(element.$.searchInput, 'selectAll');
+    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+    assert.isTrue(focusSpy.called);
+    assert.isTrue(selectAllSpy.called);
+  });
+
+  suite('_getSearchSuggestions', () => {
+    test('Autocompletes accounts', () => {
+      sandbox.stub(element, 'accountSuggestions', () =>
+        Promise.resolve([{text: 'owner:fred@goog.co'}])
+      );
+      return element._getSearchSuggestions('owner:fr').then(s => {
+        assert.equal(s[0].value, 'owner:fred@goog.co');
+      });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('value is propagated to _inputVal', () => {
-      element.value = 'foo';
-      assert.equal(element._inputVal, 'foo');
-    });
-
-    getActiveElement = () => {
-      return document.activeElement.shadowRoot ?
-        document.activeElement.shadowRoot.activeElement :
-        document.activeElement;
-    };
-
-    test('enter in search input fires event', done => {
-      element.addEventListener('handle-search', () => {
-        assert.notEqual(getActiveElement(), element.$.searchInput);
-        assert.notEqual(getActiveElement(), element.$.searchButton);
+    test('Autocompletes groups', done => {
+      sandbox.stub(element, 'groupSuggestions', () =>
+        Promise.resolve([
+          {text: 'ownerin:Polygerrit'},
+          {text: 'ownerin:gerrit'},
+        ])
+      );
+      element._getSearchSuggestions('ownerin:pol').then(s => {
+        assert.equal(s[0].value, 'ownerin:Polygerrit');
         done();
       });
-      element.value = 'test';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
     });
 
-    test('input blurred after commit', () => {
-      const blurSpy = sandbox.spy(element.$.searchInput.$.input, 'blur');
-      element.$.searchInput.text = 'fate/stay';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(blurSpy.called);
+    test('Autocompletes projects', done => {
+      sandbox.stub(element, 'projectSuggestions', () =>
+        Promise.resolve([
+          {text: 'project:Polygerrit'},
+          {text: 'project:gerrit'},
+          {text: 'project:gerrittest'},
+        ])
+      );
+      element._getSearchSuggestions('project:pol').then(s => {
+        assert.equal(s[0].value, 'project:Polygerrit');
+        done();
+      });
     });
 
-    test('empty search query does not trigger nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = '';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isFalse(searchSpy.called);
+    test('Autocompletes simple searches', done => {
+      element._getSearchSuggestions('is:o').then(s => {
+        assert.equal(s[0].name, 'is:open');
+        assert.equal(s[0].value, 'is:open');
+        assert.equal(s[1].name, 'is:owner');
+        assert.equal(s[1].value, 'is:owner');
+        done();
+      });
     });
 
-    test('Predefined query op with no predication doesnt trigger nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'added:';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isFalse(searchSpy.called);
+    test('Does not autocomplete with no match', done => {
+      element._getSearchSuggestions('asdasdasdasd').then(s => {
+        assert.equal(s.length, 0);
+        done();
+      });
     });
 
-    test('predefined predicate query triggers nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'age:1week';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(searchSpy.called);
+    test('Autocompltes without is:mergable when disabled', done => {
+      element._getSearchSuggestions('is:mergeab').then(s => {
+        assert.equal(s.length, 0);
+        done();
+      });
     });
+  });
 
-    test('undefined predicate query triggers nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'random:1week';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(searchSpy.called);
-    });
-
-    test('empty undefined predicate query triggers nav', () => {
-      const searchSpy = sandbox.spy();
-      element.addEventListener('handle-search', searchSpy);
-      element.value = 'random:';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-          null, 'enter');
-      assert.isTrue(searchSpy.called);
-    });
-
-    test('keyboard shortcuts', () => {
-      const focusSpy = sandbox.spy(element.$.searchInput, 'focus');
-      const selectAllSpy = sandbox.spy(element.$.searchInput, 'selectAll');
-      MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
-      assert.isTrue(focusSpy.called);
-      assert.isTrue(selectAllSpy.called);
-    });
-
-    suite('_getSearchSuggestions', () => {
-      test('Autocompletes accounts', () => {
-        sandbox.stub(element, 'accountSuggestions', () =>
-          Promise.resolve([{text: 'owner:fred@goog.co'}])
-        );
-        return element._getSearchSuggestions('owner:fr').then(s => {
-          assert.equal(s[0].value, 'owner:fred@goog.co');
+  [
+    'API_REF_UPDATED_AND_CHANGE_REINDEX',
+    'REF_UPDATED_AND_CHANGE_REINDEX',
+  ].forEach(mergeability => {
+    suite(`mergeability as ${mergeability}`, () => {
+      setup(done => {
+        stub('gr-rest-api-interface', {
+          getConfig() {
+            return Promise.resolve({
+              index: {
+                mergeabilityComputationBehavior: mergeability,
+              },
+            });
+          },
         });
+
+        element = fixture('basic');
+        flush(done);
       });
 
-      test('Autocompletes groups', done => {
-        sandbox.stub(element, 'groupSuggestions', () =>
-          Promise.resolve([
-            {text: 'ownerin:Polygerrit'},
-            {text: 'ownerin:gerrit'},
-          ])
-        );
-        element._getSearchSuggestions('ownerin:pol').then(s => {
-          assert.equal(s[0].value, 'ownerin:Polygerrit');
-          done();
-        });
-      });
-
-      test('Autocompletes projects', done => {
-        sandbox.stub(element, 'projectSuggestions', () =>
-          Promise.resolve([
-            {text: 'project:Polygerrit'},
-            {text: 'project:gerrit'},
-            {text: 'project:gerrittest'},
-          ])
-        );
-        element._getSearchSuggestions('project:pol').then(s => {
-          assert.equal(s[0].value, 'project:Polygerrit');
-          done();
-        });
-      });
-
-      test('Autocompletes simple searches', done => {
-        element._getSearchSuggestions('is:o').then(s => {
-          assert.equal(s[0].name, 'is:open');
-          assert.equal(s[0].value, 'is:open');
-          assert.equal(s[1].name, 'is:owner');
-          assert.equal(s[1].value, 'is:owner');
-          done();
-        });
-      });
-
-      test('Does not autocomplete with no match', done => {
-        element._getSearchSuggestions('asdasdasdasd').then(s => {
-          assert.equal(s.length, 0);
+      test('Autocompltes with is:mergable when enabled', done => {
+        element._getSearchSuggestions('is:mergeab').then(s => {
+          assert.equal(s.length, 2);
+          assert.equal(s[0].name, 'is:mergeable');
+          assert.equal(s[0].value, 'is:mergeable');
+          assert.equal(s[1].name, '-is:mergeable');
+          assert.equal(s[1].value, '-is:mergeable');
           done();
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
deleted file mode 100644
index c4ae41b..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
+++ /dev/null
@@ -1,38 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-search-bar/gr-search-bar.html">
-
-<dom-module id="gr-smart-search">
-  <template>
-    <style include="shared-styles">
-
-    </style>
-    <gr-search-bar id="search"
-        value="{{searchQuery}}"
-        on-handle-search="_handleSearch"
-        project-suggestions="[[_projectSuggestions]]"
-        group-suggestions="[[_groupSuggestions]]"
-        account-suggestions="[[_accountSuggestions]]"></gr-search-bar>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-smart-search.js"></script>
-</dom-module>
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..dcece30 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
@@ -14,17 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_AUTOCOMPLETE_RESULTS = 10;
-  const SELF_EXPRESSION = 'self';
-  const ME_EXPRESSION = 'me';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-search-bar/gr-search-bar.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-smart-search_html.js';
+import {DisplayNameBehavior} from '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
+import {GerritNav} from '../gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-smart-search',
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrSmartSearch extends mixinBehaviors( [
+  DisplayNameBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-smart-search'; }
+
+  static get properties() {
+    return {
       searchQuery: String,
       _config: Object,
       _projectSuggestions: {
@@ -45,111 +64,111 @@
           return this._fetchAccounts.bind(this);
         },
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.DisplayNameBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+  }
 
-    attached() {
-      this.$.restAPI.getConfig().then(cfg => {
-        this._config = cfg;
-      });
-    },
+  _handleSearch(e) {
+    const input = e.detail.inputVal;
+    if (input) {
+      GerritNav.navigateToSearchQuery(input);
+    }
+  }
 
-    _handleSearch(e) {
-      const input = e.detail.inputVal;
-      if (input) {
-        Gerrit.Nav.navigateToSearchQuery(input);
-      }
-    },
+  /**
+   * Fetch from the API the predicted projects.
+   *
+   * @param {string} predicate - The first part of the search term, e.g.
+   *     'project'
+   * @param {string} expression - The second part of the search term, e.g.
+   *     'gerr'
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     strings.
+   */
+  _fetchProjects(predicate, expression) {
+    return this.$.restAPI.getSuggestedProjects(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(projects => {
+          if (!projects) { return []; }
+          const keys = Object.keys(projects);
+          return keys.map(key => { return {text: predicate + ':' + key}; });
+        });
+  }
 
-    _accountOrAnon(name) {
-      return this.getUserName(this._serverConfig, name, false);
-    },
+  /**
+   * Fetch from the API the predicted groups.
+   *
+   * @param {string} predicate - The first part of the search term, e.g.
+   *     'ownerin'
+   * @param {string} expression - The second part of the search term, e.g.
+   *     'polyger'
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     strings.
+   */
+  _fetchGroups(predicate, expression) {
+    if (expression.length === 0) { return Promise.resolve([]); }
+    return this.$.restAPI.getSuggestedGroups(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(groups => {
+          if (!groups) { return []; }
+          const keys = Object.keys(groups);
+          return keys.map(key => { return {text: predicate + ':' + key}; });
+        });
+  }
 
-    /**
-     * Fetch from the API the predicted projects.
-     *
-     * @param {string} predicate - The first part of the search term, e.g.
-     *     'project'
-     * @param {string} expression - The second part of the search term, e.g.
-     *     'gerr'
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
-     */
-    _fetchProjects(predicate, expression) {
-      return this.$.restAPI.getSuggestedProjects(
-          expression,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(projects => {
-            if (!projects) { return []; }
-            const keys = Object.keys(projects);
-            return keys.map(key => ({text: predicate + ':' + key}));
-          });
-    },
+  /**
+   * Fetch from the API the predicted accounts.
+   *
+   * @param {string} predicate - The first part of the search term, e.g.
+   *     'owner'
+   * @param {string} expression - The second part of the search term, e.g.
+   *     'kasp'
+   * @return {!Promise} This returns a promise that resolves to an array of
+   *     strings.
+   */
+  _fetchAccounts(predicate, expression) {
+    if (expression.length === 0) { return Promise.resolve([]); }
+    return this.$.restAPI.getSuggestedAccounts(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS)
+        .then(accounts => {
+          if (!accounts) { return []; }
+          return this._mapAccountsHelper(accounts, predicate);
+        })
+        .then(accounts => {
+          // When the expression supplied is a beginning substring of 'self',
+          // add it as an autocomplete option.
+          if (SELF_EXPRESSION.startsWith(expression)) {
+            return accounts.concat(
+                [{text: predicate + ':' + SELF_EXPRESSION}]);
+          } else if (ME_EXPRESSION.startsWith(expression)) {
+            return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
+          } else {
+            return accounts;
+          }
+        });
+  }
 
-    /**
-     * Fetch from the API the predicted groups.
-     *
-     * @param {string} predicate - The first part of the search term, e.g.
-     *     'ownerin'
-     * @param {string} expression - The second part of the search term, e.g.
-     *     'polyger'
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
-     */
-    _fetchGroups(predicate, expression) {
-      if (expression.length === 0) { return Promise.resolve([]); }
-      return this.$.restAPI.getSuggestedGroups(
-          expression,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(groups => {
-            if (!groups) { return []; }
-            const keys = Object.keys(groups);
-            return keys.map(key => ({text: predicate + ':' + key}));
-          });
-    },
-
-    /**
-     * Fetch from the API the predicted accounts.
-     *
-     * @param {string} predicate - The first part of the search term, e.g.
-     *     'owner'
-     * @param {string} expression - The second part of the search term, e.g.
-     *     'kasp'
-     * @return {!Promise} This returns a promise that resolves to an array of
-     *     strings.
-     */
-    _fetchAccounts(predicate, expression) {
-      if (expression.length === 0) { return Promise.resolve([]); }
-      return this.$.restAPI.getSuggestedAccounts(
-          expression,
-          MAX_AUTOCOMPLETE_RESULTS)
-          .then(accounts => {
-            if (!accounts) { return []; }
-            return this._mapAccountsHelper(accounts, predicate);
-          }).then(accounts => {
-            // When the expression supplied is a beginning substring of 'self',
-            // add it as an autocomplete option.
-            if (SELF_EXPRESSION.startsWith(expression)) {
-              return accounts.concat(
-                  [{text: predicate + ':' + SELF_EXPRESSION}]);
-            } else if (ME_EXPRESSION.startsWith(expression)) {
-              return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
-            } else {
-              return accounts;
-            }
-          });
-    },
-
-    _mapAccountsHelper(accounts, predicate) {
-      return accounts.map(account => ({
+  _mapAccountsHelper(accounts, predicate) {
+    return accounts.map(account => {
+      const userName = this.getUserName(this._serverConfig, account);
+      return {
         label: account.name || '',
         text: account.email ?
           `${predicate}:${account.email}` :
-          `${predicate}:"${this._accountOrAnon(account)}"`,
-      }));
-    },
-  });
-})();
+          `${predicate}:"${userName}"`,
+      };
+    });
+  }
+}
+
+customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
new file mode 100644
index 0000000..bb741ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles"></style>
+  <gr-search-bar
+    id="search"
+    value="{{searchQuery}}"
+    on-handle-search="_handleSearch"
+    project-suggestions="[[_projectSuggestions]]"
+    group-suggestions="[[_groupSuggestions]]"
+    account-suggestions="[[_accountSuggestions]]"
+  ></gr-search-bar>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..87dfaf4 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-smart-search.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,119 +31,126 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-smart-search tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-smart-search.js';
+suite('gr-smart-search tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-
-    test('Autocompletes accounts', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            name: 'fred',
-            email: 'fred@goog.co',
-          },
-        ])
-      );
-      return element._fetchAccounts('owner', 'fr').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-      });
-    });
-
-    test('Inserts self as option when valid', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            name: 'fred',
-            email: 'fred@goog.co',
-          },
-        ])
-      );
-      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', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([
-          {
-            name: 'fred',
-            email: 'fred@goog.co',
-          },
-        ])
-      );
-      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', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
-        Promise.resolve({
-          Polygerrit: 0,
-          gerrit: 0,
-          gerrittest: 0,
-        })
-      );
-      return element._fetchGroups('ownerin', 'pol').then(s => {
-        assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-      });
-    });
-
-    test('Autocompletes projects', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedProjects', () =>
-        Promise.resolve({Polygerrit: 0}));
-      return element._fetchProjects('project', 'pol').then(s => {
-        assert.deepEqual(s[0], {text: 'project:Polygerrit'});
-      });
-    });
-
-    test('Autocomplete doesnt override exact matches to input', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
-        Promise.resolve({
-          Polygerrit: 0,
-          gerrit: 0,
-          gerrittest: 0,
-        })
-      );
-      return element._fetchGroups('ownerin', 'gerrit').then(s => {
-        assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-        assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
-        assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
-      });
-    });
-
-    test('Autocompletes accounts with no email', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([{name: 'fred'}]));
-      return element._fetchAccounts('owner', 'fr').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
-      });
-    });
-
-    test('Autocompletes accounts with email', () => {
-      sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-        Promise.resolve([{email: 'fred@goog.co'}]));
-      return element._fetchAccounts('owner', 'fr').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
-      });
+  test('Autocompletes accounts', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
     });
   });
+
+  test('Inserts self as option when valid', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    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', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    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', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
+      Promise.resolve({
+        Polygerrit: 0,
+        gerrit: 0,
+        gerrittest: 0,
+      })
+    );
+    return element._fetchGroups('ownerin', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+    });
+  });
+
+  test('Autocompletes projects', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedProjects', () =>
+      Promise.resolve({Polygerrit: 0}));
+    return element._fetchProjects('project', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
+    });
+  });
+
+  test('Autocomplete doesnt override exact matches to input', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
+      Promise.resolve({
+        Polygerrit: 0,
+        gerrit: 0,
+        gerrittest: 0,
+      })
+    );
+    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
+      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
+    });
+  });
+
+  test('Autocompletes accounts with no email', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([{name: 'fred'}]));
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
+    });
+  });
+
+  test('Autocompletes accounts with email', () => {
+    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+      Promise.resolve([{email: 'fred@goog.co'}]));
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
index 4cf35f1..6ac9c20 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.html
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.html
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../test/common-test-setup.html"/>
-<link rel="import" href="./gr-app.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="element">
   <template>
@@ -34,68 +31,73 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-app custom dark theme tests', () => {
-    let sandbox;
-    let element;
+<script type="module">
+import '../test/common-test-setup.js';
+import './gr-app.js';
+import {util} from '../scripts/util.js';
+import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-reporting', {
-        appStarted: sandbox.stub(),
-      });
-      stub('gr-account-dropdown', {
-        _getTopContent: sinon.stub(),
-      });
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-        getConfig() {
-          return Promise.resolve({
-            plugin: {
-              js_resource_paths: [],
-              html_resource_paths: [
-                new URL('test/plugin.html', window.location.href).toString(),
-              ],
-            },
+suite('gr-app custom dark theme tests', () => {
+  let sandbox;
+  let element;
+
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-reporting', {
+      appStarted: sandbox.stub(),
+    });
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+      getAccountCapabilities() { return Promise.resolve({}); },
+      getConfig() {
+        return Promise.resolve({
+          plugin: {
+            js_resource_paths: [],
+            html_resource_paths: [
+              new URL('test/plugin.html', window.location.href).toString(),
+            ],
+          },
+        });
+      },
+      getVersion() { return Promise.resolve(42); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    window.localStorage.setItem('dark-theme', 'true');
+
+    element = fixture('element');
+
+    const importSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForAll,
+        '_import');
+    const importForThemeSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForTheme,
+        '_import');
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
+          .then(() => {
+            flush(done);
           });
-        },
-        getVersion() { return Promise.resolve(42); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      window.localStorage.setItem('dark-theme', 'true');
-
-      element = fixture('element');
-
-      const importSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForAll,
-          '_import');
-      const importForThemeSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForTheme,
-          '_import');
-      Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-            .then(() => {
-              flush(done);
-            });
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('applies the right theme', () => {
-      assert.equal(
-          util.getComputedStyleValue('--primary-text-color', element),
-          'red');
-      assert.equal(
-          util.getComputedStyleValue('--header-background-color', element),
-          'black');
-      assert.equal(
-          util.getComputedStyleValue('--footer-background-color', element),
-          'yellow');
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        util.getComputedStyleValue('--primary-text-color', element),
+        'red');
+    assert.equal(
+        util.getComputedStyleValue('--header-background-color', element),
+        'black');
+    assert.equal(
+        util.getComputedStyleValue('--footer-background-color', element),
+        'yellow');
+  });
+});
 </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..f8a749c 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.html
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.html
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../test/common-test-setup.html"/>
-<link rel="import" href="gr-app.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="element">
   <template>
@@ -34,68 +31,73 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-app custom light theme tests', () => {
-    let sandbox;
-    let element;
+<script type="module">
+import '../test/common-test-setup.js';
+import './gr-app.js';
+import {util} from '../scripts/util.js';
+import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-reporting', {
-        appStarted: sandbox.stub(),
-      });
-      stub('gr-account-dropdown', {
-        _getTopContent: sinon.stub(),
-      });
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-        getConfig() {
-          return Promise.resolve({
-            plugin: {
-              js_resource_paths: [],
-              html_resource_paths: [
-                new URL('test/plugin.html', window.location.href).toString(),
-              ],
-            },
+suite('gr-app custom light theme tests', () => {
+  let sandbox;
+  let element;
+
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-reporting', {
+      appStarted: sandbox.stub(),
+    });
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+      getAccountCapabilities() { return Promise.resolve({}); },
+      getConfig() {
+        return Promise.resolve({
+          plugin: {
+            js_resource_paths: [],
+            html_resource_paths: [
+              new URL('test/plugin.html', window.location.href).toString(),
+            ],
+          },
+        });
+      },
+      getVersion() { return Promise.resolve(42); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    window.localStorage.removeItem('dark-theme');
+
+    element = fixture('element');
+
+    const importSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForAll,
+        '_import');
+    const importForThemeSpy = sandbox.spy(
+        element.$['app-element'].$.externalStyleForTheme,
+        '_import');
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
+          .then(() => {
+            flush(done);
           });
-        },
-        getVersion() { return Promise.resolve(42); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      window.localStorage.removeItem('dark-theme');
-
-      element = fixture('element');
-
-      const importSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForAll,
-          '_import');
-      const importForThemeSpy = sandbox.spy(
-          element.$['app-element'].$.externalStyleForTheme,
-          '_import');
-      Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-            .then(() => {
-              flush(done);
-            });
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('applies the right theme', () => {
-      assert.equal(
-          util.getComputedStyleValue('--primary-text-color', element),
-          '#F00BAA');
-      assert.equal(
-          util.getComputedStyleValue('--header-background-color', element),
-          '#F01BAA');
-      assert.equal(
-          util.getComputedStyleValue('--footer-background-color', element),
-          '#F02BAA');
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        util.getComputedStyleValue('--primary-text-color', element),
+        '#F00BAA');
+    assert.equal(
+        util.getComputedStyleValue('--header-background-color', element),
+        '#F01BAA');
+    assert.equal(
+        util.getComputedStyleValue('--footer-background-color', element),
+        '#F02BAA');
+  });
+});
 </script>
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..9beb243
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
@@ -0,0 +1,252 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-diff/gr-diff.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-apply-fix-dialog_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+/**
+ * @extends Polymer.Element
+ */
+class GrApplyFixDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-apply-fix-dialog'; }
+
+  static get properties() {
+    return {
+      // 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,
+      _disableApplyFixButton: {
+        type: Boolean,
+        computed: '_computeDisableApplyFixButton(_isApplyFixLoading, change, '
+          + '_patchNum)',
+      },
+    };
+  }
+
+  /**
+   * 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.dispatchEvent(
+              new CustomEvent('iron-resize', {
+                composed: true, bubbles: true,
+              }));
+        });
+  }
+
+  attached() {
+    super.attached();
+    this.refitOverlay = () => {
+      // re-center the dialog as content changed
+      this.$.applyFixOverlay.dispatchEvent(
+          new CustomEvent('iron-resize', {
+            composed: true, bubbles: true,
+          }));
+    };
+    this.addEventListener('diff-context-expanded', this.refitOverlay);
+  }
+
+  detached() {
+    super.detached();
+    this.removeEventListener('diff-context-expanded', this.refitOverlay);
+  }
+
+  _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();
+          throw 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._fixSuggestions &&
+      this._selectedFixIdx < this._fixSuggestions.length) {
+      this._selectedFixIdx += 1;
+      return this._showSelectedFixSuggestion(
+          this._fixSuggestions[this._selectedFixIdx]);
+    }
+  }
+
+  _noPrevFix(_selectedFixIdx) {
+    return _selectedFixIdx === 0;
+  }
+
+  _noNextFix(_selectedFixIdx, fixSuggestions) {
+    if (fixSuggestions == null) return true;
+    return _selectedFixIdx === 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';
+  }
+
+  _computeTooltip(change, patchNum) {
+    if (!change || patchNum == undefined) return '';
+    // If change is defined, change.revisions and change.current_revisions
+    // must be defined
+    const latestPatchNum = change.revisions[change.current_revision]._number;
+    return latestPatchNum !== patchNum ?
+      'Fix can only be applied to the latest patchset' : '';
+  }
+
+  _computeDisableApplyFixButton(isApplyFixLoading, change, patchNum) {
+    if (!change || isApplyFixLoading == undefined || patchNum == undefined) {
+      return true;
+    }
+    const currentPatchNum = change.revisions[change.current_revision]._number;
+    if (patchNum !== currentPatchNum) {
+      return true;
+    }
+    return isApplyFixLoading;
+  }
+
+  _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 => {
+          if (res && res.ok) {
+            GerritNav.navigateToChange(this.change, 'edit', this._patchNum);
+            this._close();
+          }
+          this._isApplyFixLoading = false;
+        });
+  }
+
+  getFixDescription(currentFix) {
+    return currentFix != null && currentFix.description ?
+      currentFix.description : '';
+  }
+}
+
+customElements.define(GrApplyFixDialog.is, GrApplyFixDialog);
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
new file mode 100644
index 0000000..a5a6ff2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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="[[_disableApplyFixButton]]"
+      confirm-tooltip="[[_computeTooltip(change, _patchNum)]]"
+      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
+          id="prevFix"
+          on-click="_onPrevFixClick"
+          disabled$="[[_noPrevFix(_selectedFixIdx)]]"
+        >
+          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+        </gr-button>
+        <gr-button
+          id="nextFix"
+          on-click="_onNextFixClick"
+          disabled$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]"
+        >
+          <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>
+`;
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..8874f71
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
@@ -0,0 +1,323 @@
+<!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="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-apply-fix-dialog.js';
+void (0);
+</script>
+
+<test-fixture id='basic'>
+  <template>
+    <gr-apply-fix-dialog></gr-apply-fix-dialog>
+  </template>
+</test-fixture>
+
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup.js';
+import './gr-apply-fix-dialog.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+suite('gr-apply-fix-dialog tests', () => {
+  let element;
+  let sandbox;
+  const ROBOT_COMMENT_WITH_TWO_FIXES = {
+    robot_id: 'robot_1',
+    fix_suggestions: [{fix_id: 'fix_1'}, {fix_id: 'fix_2'}],
+  };
+
+  const ROBOT_COMMENT_WITH_ONE_FIX = {
+    robot_id: 'robot_1',
+    fix_suggestions: [{fix_id: 'fix_1'}],
+  };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.changeNum = '1';
+    element._patchNum = 2;
+    element.change = {
+      _number: '1',
+      project: 'project',
+      revisions: {
+        abcd: {_number: 1},
+        efgh: {_number: 2},
+      },
+      current_revision: 'efgh',
+    };
+    element.prefs = {
+      font_size: 12,
+      line_length: 100,
+      tab_size: 4,
+    };
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('dialog open', () => {
+    setup(() => {
+      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());
+    });
+
+    test('dialog opens fetch and sets previews', done => {
+      element.open({detail: {patchNum: 2,
+        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
+          .then(() => {
+            assert.equal(element._currentFix.fix_id, 'fix_1');
+            assert.equal(element._currentPreviews.length, 2);
+            assert.equal(element._robotId, 'robot_1');
+            const button = element.shadowRoot.querySelector(
+                '#applyFixDialog').shadowRoot.querySelector('#confirm');
+            assert.isFalse(button.hasAttribute('disabled'));
+            assert.equal(button.getAttribute('title'), '');
+            done();
+          });
+    });
+
+    test('tooltip is hidden if apply fix is loading', done => {
+      element.open({detail: {patchNum: 2,
+        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
+          .then(() => {
+            element._isApplyFixLoading = true;
+            const button = element.shadowRoot.querySelector(
+                '#applyFixDialog').shadowRoot.querySelector('#confirm');
+            assert.isTrue(button.hasAttribute('disabled'));
+            assert.equal(button.getAttribute('title'), '');
+            done();
+          });
+    });
+
+    test('apply fix button is disabled on older patchset', done => {
+      element.change = {
+        _number: '1',
+        project: 'project',
+        revisions: {
+          abcd: {_number: 1},
+          efgh: {_number: 2},
+        },
+        current_revision: 'abcd',
+      };
+      element.open({detail: {patchNum: 2,
+        comment: ROBOT_COMMENT_WITH_ONE_FIX}})
+          .then(() => {
+            flush(() => {
+              const button = element.shadowRoot.querySelector(
+                  '#applyFixDialog').shadowRoot.querySelector('#confirm');
+              assert.isTrue(button.hasAttribute('disabled'));
+              assert.equal(button.getAttribute('title'),
+                  'Fix can only be applied to the latest patchset');
+              done();
+            });
+          });
+    });
+  });
+
+  test('next button state updated when suggestions changed', done => {
+    sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+        .returns(Promise.resolve({}));
+    sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_ONE_FIX}})
+        .then(() => assert.isTrue(element.$.nextFix.disabled))
+        .then(() =>
+          element.open({detail: {patchNum: 2,
+            comment: ROBOT_COMMENT_WITH_TWO_FIXES}}))
+        .then(() => {
+          assert.isFalse(element.$.nextFix.disabled);
+          done();
+        });
+  });
+
+  test('preview endpoint throws error should reset dialog', done => {
+    sandbox.stub(window, 'fetch', (url => {
+      if (url.endsWith('/preview')) {
+        return Promise.reject(new Error('backend error'));
+      }
+      return Promise.resolve({
+        ok: true,
+        text() { return Promise.resolve(''); },
+        status: 200,
+      });
+    }));
+    const errorStub = sinon.stub();
+    document.addEventListener('network-error', errorStub);
+    element.open({detail: {patchNum: 2,
+      comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
+    flush(() => {
+      assert.isTrue(errorStub.called);
+      assert.deepEqual(element._currentFix, {});
+      done();
+    });
+  });
+
+  test('apply fix button should call apply ' +
+  'and navigate to change view', done => {
+    sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({ok: true}));
+    sandbox.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+
+    element._handleApplyFix().then(() => {
+      assert.isTrue(element.$.restAPI.applyFixSuggestion
+          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(GerritNav.navigateToChange.calledWithExactly({
+        _number: '1',
+        project: 'project',
+        revisions: {
+          abcd: {_number: 1},
+          efgh: {_number: 2},
+        },
+        current_revision: 'efgh',
+      }, 'edit', 2));
+
+      // reset gr-apply-fix-dialog and close
+      assert.deepEqual(element._currentFix, {});
+      assert.equal(element._currentPreviews.length, 0);
+      done();
+    });
+  });
+
+  test('should not navigate to change view if incorect reponse', done => {
+    sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({}));
+    sandbox.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+
+    element._handleApplyFix().then(() => {
+      assert.isTrue(element.$.restAPI.applyFixSuggestion
+          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(GerritNav.navigateToChange.notCalled);
+
+      assert.equal(element._isApplyFixLoading, false);
+      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_WITH_TWO_FIXES}})
+        .then(() => {
+          element._onNextFixClick();
+          assert.equal(element._currentFix.fix_id, 'fix_2');
+          element._onPrevFixClick();
+          assert.equal(element._currentFix.fix_id, 'fix_1');
+          done();
+        });
+  });
+
+  test('server-error should throw for failed apply call', done => {
+    sandbox.stub(window, 'fetch', (url => {
+      if (url.endsWith('/apply')) {
+        return Promise.reject(new Error('backend error'));
+      }
+      return Promise.resolve({
+        ok: true,
+        text() { return Promise.resolve(''); },
+        status: 200,
+      });
+    }));
+    const errorStub = sinon.stub();
+    document.addEventListener('network-error', errorStub);
+    sandbox.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+    element._handleApplyFix();
+    flush(() => {
+      assert.isFalse(GerritNav.navigateToChange.called);
+      assert.isTrue(errorStub.called);
+      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
deleted file mode 100644
index b7994e6..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
+++ /dev/null
@@ -1,50 +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.
- */
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'comment-api-mock',
-
-    properties: {
-      _changeComments: Object,
-    },
-
-    loadComments() {
-      return this._reloadComments();
-    },
-
-    /**
-     * For the purposes of the mock, _reloadDrafts is not included because its
-     * response is the same type as reloadComments, just makes less API
-     * requests. Since this is for test purposes/mocked data anyway, keep this
-     * file simpler by just using _reloadComments here instead.
-     */
-    _reloadDraftsWithCallback(e) {
-      return this._reloadComments().then(() => {
-        return e.detail.resolve();
-      });
-    },
-
-    _reloadComments() {
-      return this.$.commentAPI.loadAll(this._changeNum)
-          .then(comments => {
-            this._changeComments = this.$.commentAPI._changeComments;
-          });
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock_test.js
new file mode 100644
index 0000000..77c72d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock_test.js
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+
+class CommentApiMock extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'comment-api-mock'; }
+
+  static get properties() {
+    return {
+      _changeComments: Object,
+    };
+  }
+
+  loadComments() {
+    return this._reloadComments();
+  }
+
+  /**
+   * For the purposes of the mock, _reloadDrafts is not included because its
+   * response is the same type as reloadComments, just makes less API
+   * requests. Since this is for test purposes/mocked data anyway, keep this
+   * file simpler by just using _reloadComments here instead.
+   */
+  _reloadDraftsWithCallback(e) {
+    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.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
deleted file mode 100644
index 317e9e5..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-comment-api">
-  <template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-comment-api.js"></script>
-</dom-module>
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..3f7da5a 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
@@ -14,46 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PARENT = 'PARENT';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment-api_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {util} from '../../../scripts/util.js';
 
-  /**
-   * Construct a change comments object, which can be data-bound to child
-   * elements of that which uses the gr-comment-api.
-   *
-   * @param {!Object} comments
-   * @param {!Object} robotComments
-   * @param {!Object} drafts
-   * @param {number} changeNum
-   * @constructor
-   */
-  function ChangeComments(comments, robotComments, drafts, changeNum) {
-    this._comments = comments;
-    this._robotComments = robotComments;
-    this._drafts = drafts;
+const PARENT = 'PARENT';
+
+/**
+ * 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
+ */
+class ChangeComments {
+  constructor(comments, robotComments, drafts, changeNum) {
+    // TODO(taoalpha): replace these with exported methods from patchset behavior
+    this._patchNumEquals =
+      PatchSetBehavior.patchNumEquals;
+    this._isMergeParent =
+      PatchSetBehavior.isMergeParent;
+    this._getParentIndex =
+      PatchSetBehavior.getParentIndex;
+
+    this._comments = comments || {};
+    this._robotComments = robotComments || {};
+    this._drafts = drafts || {};
     this._changeNum = changeNum;
   }
 
-  ChangeComments.prototype = {
-    get comments() {
-      return this._comments;
-    },
-    get drafts() {
-      return this._drafts;
-    },
-    get robotComments() {
-      return this._robotComments;
-    },
-  };
+  get comments() {
+    return this._comments;
+  }
 
-  ChangeComments.prototype._patchNumEquals =
-      Gerrit.PatchSetBehavior.patchNumEquals;
-  ChangeComments.prototype._isMergeParent =
-      Gerrit.PatchSetBehavior.isMergeParent;
-  ChangeComments.prototype._getParentIndex =
-      Gerrit.PatchSetBehavior.getParentIndex;
+  get drafts() {
+    return this._drafts;
+  }
+
+  get robotComments() {
+    return this._robotComments;
+  }
 
   /**
    * Get an object mapping file paths to a boolean representing whether that
@@ -67,23 +77,23 @@
    *     patchNum and basePatchNum properties to represent the range.
    * @return {!Object}
    */
-  ChangeComments.prototype.getPaths = function(opt_patchRange) {
+  getPaths(opt_patchRange) {
     const responses = [this.comments, this.drafts, this.robotComments];
     const commentMap = {};
     for (const response of responses) {
       for (const path in response) {
         if (response.hasOwnProperty(path) &&
-            response[path].some(c => {
-              // If don't care about patch range, we know that the path exists.
-              if (!opt_patchRange) { return true; }
-              return this._isInPatchRange(c, opt_patchRange);
-            })) {
+          response[path].some(c => {
+            // If don't care about patch range, we know that the path exists.
+            if (!opt_patchRange) { return true; }
+            return this._isInPatchRange(c, opt_patchRange);
+          })) {
           commentMap[path] = true;
         }
       }
     }
     return commentMap;
-  };
+  }
 
   /**
    * Gets all the comments and robot comments for the given change.
@@ -91,9 +101,9 @@
    * @param {number=} opt_patchNum
    * @return {!Object}
    */
-  ChangeComments.prototype.getAllPublishedComments = function(opt_patchNum) {
+  getAllPublishedComments(opt_patchNum) {
     return this.getAllComments(false, opt_patchNum);
-  };
+  }
 
   /**
    * Gets all the comments for a particular thread group. Used for refreshing
@@ -102,7 +112,7 @@
    * @param {string} rootId
    * @return {!Array} an array of comments
    */
-  ChangeComments.prototype.getCommentsForThread = function(rootId) {
+  getCommentsForThread(rootId) {
     const allThreads = this.getAllThreadsForChange();
     const threadMatch = allThreads.find(t => t.rootId === rootId);
 
@@ -110,7 +120,7 @@
     // and the diff view is updating comments, there will no longer be a thread
     // found.  In this case, return null.
     return threadMatch ? threadMatch.comments : null;
-  };
+  }
 
   /**
    * Filters an array of comments by line and side
@@ -123,10 +133,10 @@
    * @param {number=} opt_line line number, can be undefined if file comment
    * @return {!Array} an array of comments
    */
-  ChangeComments.prototype._filterCommentsBySideAndLine = function(comments,
+  _filterCommentsBySideAndLine(comments,
       parentOnly, commentSide, opt_line) {
     return comments.filter(c => {
-      // if parentOnly, only match comments with PARENT for the side.
+    // if parentOnly, only match comments with PARENT for the side.
       let sideMatch = parentOnly ? c.side === PARENT : c.side !== PARENT;
       if (parentOnly) {
         sideMatch = sideMatch && c.side === PARENT;
@@ -136,7 +146,7 @@
       c.__commentSide = commentSide;
       return c;
     });
-  };
+  }
 
   /**
    * Gets all the comments and robot comments for the given change.
@@ -145,7 +155,7 @@
    * @param {number=} opt_patchNum
    * @return {!Object}
    */
-  ChangeComments.prototype.getAllComments = function(opt_includeDrafts,
+  getAllComments(opt_includeDrafts,
       opt_patchNum) {
     const paths = this.getPaths();
     const publishedComments = {};
@@ -159,7 +169,7 @@
       publishedComments[path] = commentsToAdd;
     }
     return publishedComments;
-  };
+  }
 
   /**
    * Gets all the comments and robot comments for the given change.
@@ -167,14 +177,14 @@
    * @param {number=} opt_patchNum
    * @return {!Object}
    */
-  ChangeComments.prototype.getAllDrafts = function(opt_patchNum) {
+  getAllDrafts(opt_patchNum) {
     const paths = this.getPaths();
     const drafts = {};
     for (const path of Object.keys(paths)) {
       drafts[path] = this.getAllDraftsForPath(path, opt_patchNum);
     }
     return drafts;
-  };
+  }
 
   /**
    * Get the comments (robot comments) for a path and optional patch num.
@@ -184,7 +194,7 @@
    * @param {boolean=} opt_includeDrafts
    * @return {!Array}
    */
-  ChangeComments.prototype.getAllCommentsForPath = function(path,
+  getAllCommentsForPath(path,
       opt_patchNum, opt_includeDrafts) {
     const comments = this._comments[path] || [];
     const robotComments = this._robotComments[path] || [];
@@ -198,7 +208,32 @@
     return (allComments || []).filter(c =>
       this._patchNumEquals(c.patch_set, opt_patchNum)
     );
-  };
+  }
+
+  /**
+   * Get the comments (robot comments) for a file.
+   *
+   * // TODO(taoalpha): maybe merge in *ForPath
+   *
+   * @param {!{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {boolean=} opt_includeDrafts
+   * @return {!Array}
+   */
+  getAllCommentsForFile(file, opt_includeDrafts) {
+    let allComments = this.getAllCommentsForPath(
+        file.path, file.patchNum, opt_includeDrafts
+    );
+
+    if (file.oldPath) {
+      allComments = allComments.concat(
+          this.getAllCommentsForPath(
+              file.oldPath, file.patchNum, opt_includeDrafts
+          )
+      );
+    }
+
+    return allComments;
+  }
 
   /**
    * Get the drafts for a path and optional patch num.
@@ -207,14 +242,32 @@
    * @param {number=} opt_patchNum
    * @return {!Array}
    */
-  ChangeComments.prototype.getAllDraftsForPath = function(path,
+  getAllDraftsForPath(path,
       opt_patchNum) {
     const comments = this._drafts[path] || [];
     if (!opt_patchNum) { return comments; }
     return (comments || []).filter(c =>
       this._patchNumEquals(c.patch_set, opt_patchNum)
     );
-  };
+  }
+
+  /**
+   * Get the drafts for a file.
+   *
+   * // TODO(taoalpha): maybe merge in *ForPath
+   *
+   * @param {!{path: string, oldPath?: string, patchNum?: number}} file
+   * @return {!Array}
+   */
+  getAllDraftsForFile(file) {
+    let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
+    if (file.oldPath) {
+      allDrafts = allDrafts.concat(
+          this.getAllDraftsForPath(file.oldPath, file.patchNum)
+      );
+    }
+    return allDrafts;
+  }
 
   /**
    * Get the comments (with drafts and robot comments) for a path and
@@ -228,7 +281,7 @@
    *     include in the meta sub-object.
    * @return {!Gerrit.CommentsBySide}
    */
-  ChangeComments.prototype.getCommentsBySideForPath = function(path,
+  getCommentsBySideForPath(path,
       patchRange, opt_projectConfig) {
     let comments = [];
     let drafts = [];
@@ -262,7 +315,36 @@
       left: baseComments,
       right: revisionComments,
     };
-  };
+  }
+
+  /**
+   * Get the comments (with drafts and robot comments) for a file and
+   * patch-range. Returns an object with left and right properties mapping to
+   * arrays of comments in on either side of the patch range for that path.
+   *
+   * // TODO(taoalpha): maybe merge *ForPath so find all comments in one pass
+   *
+   * @param {!{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum
+   *     and basePatchNum properties to represent the range.
+   * @param {Object=} opt_projectConfig Optional project config object to
+   *     include in the meta sub-object.
+   * @return {!Gerrit.CommentsBySide}
+   */
+  getCommentsBySideForFile(file, patchRange, opt_projectConfig) {
+    const comments = this.getCommentsBySideForPath(
+        file.path, patchRange, opt_projectConfig
+    );
+    if (file.oldPath) {
+      const commentsForOldPath = this.getCommentsBySideForPath(
+          file.oldPath, patchRange, opt_projectConfig
+      );
+      // merge in the left and right
+      comments.left = comments.left.concat(commentsForOldPath.left);
+      comments.right = comments.right.concat(commentsForOldPath.right);
+    }
+    return comments;
+  }
 
   /**
    * @param {!Object} comments Object keyed by file, with a value of an array
@@ -271,7 +353,7 @@
    *   also includes the file that it was left on, which was the key of the
    *   originall object.
    */
-  ChangeComments.prototype._commentObjToArrayWithFile = function(comments) {
+  _commentObjToArrayWithFile(comments) {
     let commentArr = [];
     for (const file of Object.keys(comments)) {
       const commentsForFile = [];
@@ -281,66 +363,61 @@
       commentArr = commentArr.concat(commentsForFile);
     }
     return commentArr;
-  };
+  }
 
-  ChangeComments.prototype._commentObjToArray = function(comments) {
+  _commentObjToArray(comments) {
     let commentArr = [];
     for (const file of Object.keys(comments)) {
       commentArr = commentArr.concat(comments[file]);
     }
     return commentArr;
-  };
+  }
 
   /**
-   * Computes a string counting the number of commens in a given file and path.
+   * Computes a string counting the number of commens in a given file.
    *
-   * @param {number} patchNum
-   * @param {string=} opt_path
+   * @param {{path: string, oldPath?: string, patchNum?: number}} file
    * @return {number}
    */
-  ChangeComments.prototype.computeCommentCount = function(patchNum, opt_path) {
-    if (opt_path) {
-      return this.getAllCommentsForPath(opt_path, patchNum).length;
+  computeCommentCount(file) {
+    if (file.path) {
+      return this.getAllCommentsForFile(file).length;
     }
-    const allComments = this.getAllPublishedComments(patchNum);
+    const allComments = this.getAllPublishedComments(file.patchNum);
     return this._commentObjToArray(allComments).length;
-  };
+  }
 
   /**
    * Computes a string counting the number of draft comments in the entire
    * change, optionally filtered by path and/or patchNum.
    *
-   * @param {number=} opt_patchNum
-   * @param {string=} opt_path
+   * @param {?{path: string, oldPath?: string, patchNum?: number}} file
    * @return {number}
    */
-  ChangeComments.prototype.computeDraftCount = function(opt_patchNum,
-      opt_path) {
-    if (opt_path) {
-      return this.getAllDraftsForPath(opt_path, opt_patchNum).length;
+  computeDraftCount(file) {
+    if (file && file.path) {
+      return this.getAllDraftsForFile(file).length;
     }
-    const allDrafts = this.getAllDrafts(opt_patchNum);
+    const allDrafts = this.getAllDrafts(file && file.patchNum);
     return this._commentObjToArray(allDrafts).length;
-  };
+  }
 
   /**
    * Computes a number of unresolved comment threads in a given file and path.
    *
-   * @param {number} patchNum
-   * @param {string=} opt_path
+   * @param {{path: string, oldPath?: string, patchNum?: number}} file
    * @return {number}
    */
-  ChangeComments.prototype.computeUnresolvedNum = function(patchNum,
-      opt_path) {
+  computeUnresolvedNum(file) {
     let comments = [];
     let drafts = [];
 
-    if (opt_path) {
-      comments = this.getAllCommentsForPath(opt_path, patchNum);
-      drafts = this.getAllDraftsForPath(opt_path, patchNum);
+    if (file.path) {
+      comments = this.getAllCommentsForFile(file);
+      drafts = this.getAllDraftsForFile(file);
     } else {
       comments = this._commentObjToArray(
-          this.getAllPublishedComments(patchNum));
+          this.getAllPublishedComments(file.patchNum));
     }
 
     comments = comments.concat(drafts);
@@ -350,22 +427,30 @@
     const unresolvedThreads = threads
         .filter(thread =>
           thread.comments.length &&
-          thread.comments[thread.comments.length - 1].unresolved);
+        thread.comments[thread.comments.length - 1].unresolved);
 
     return unresolvedThreads.length;
-  };
+  }
 
-  ChangeComments.prototype.getAllThreadsForChange = function() {
+  getAllThreadsForChange() {
     const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
     const sortedComments = this._sortComments(comments);
     return this.getCommentThreads(sortedComments);
-  };
+  }
 
-  ChangeComments.prototype._sortComments = function(comments) {
-    return comments.slice(0).sort((c1, c2) => {
-      return util.parseDate(c1.updated) - util.parseDate(c2.updated);
-    });
-  };
+  _sortComments(comments) {
+    return comments.slice(0)
+        .sort(
+            (c1, c2) => {
+              const dateDiff =
+                  util.parseDate(c1.updated) - util.parseDate(c2.updated);
+              if (dateDiff) {
+                return dateDiff;
+              }
+              return c1.id - c2.id;
+            }
+        );
+  }
 
   /**
    * Computes all of the comments in thread format.
@@ -373,12 +458,12 @@
    * @param {!Array} comments sorted by updated timestamp.
    * @return {!Array}
    */
-  ChangeComments.prototype.getCommentThreads = function(comments) {
+  getCommentThreads(comments) {
     const threads = [];
     const idThreadMap = {};
     for (const comment of comments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
+    // If the comment is in reply to another comment, find that comment's
+    // thread and append to it.
       if (comment.in_reply_to) {
         const thread = idThreadMap[comment.in_reply_to];
         if (thread) {
@@ -403,7 +488,7 @@
       idThreadMap[comment.id] = newThread;
     }
     return threads;
-  };
+  }
 
   /**
    * Whether the given comment should be included in the base side of the
@@ -413,29 +498,29 @@
    * @param {!Gerrit.PatchRange} range
    * @return {boolean}
    */
-  ChangeComments.prototype._isInBaseOfPatchRange = function(comment, range) {
-    // If the base of the patch range is a parent of a merge, and the comment
-    // appears on a specific parent then only show the comment if the parent
-    // index of the comment matches that of the range.
+  _isInBaseOfPatchRange(comment, range) {
+  // If the base of the patch range is a parent of a merge, and the comment
+  // appears on a specific parent then only show the comment if the parent
+  // index of the comment matches that of the range.
     if (comment.parent && comment.side === PARENT) {
       return this._isMergeParent(range.basePatchNum) &&
-          comment.parent === this._getParentIndex(range.basePatchNum);
+        comment.parent === this._getParentIndex(range.basePatchNum);
     }
 
     // If the base of the range is the parent of the patch:
     if (range.basePatchNum === PARENT &&
-        comment.side === PARENT &&
-        this._patchNumEquals(comment.patch_set, range.patchNum)) {
+      comment.side === PARENT &&
+      this._patchNumEquals(comment.patch_set, range.patchNum)) {
       return true;
     }
     // If the base of the range is not the parent of the patch:
     if (range.basePatchNum !== PARENT &&
-        comment.side !== PARENT &&
-        this._patchNumEquals(comment.patch_set, range.basePatchNum)) {
+      comment.side !== PARENT &&
+      this._patchNumEquals(comment.patch_set, range.basePatchNum)) {
       return true;
     }
     return false;
-  };
+  }
 
   /**
    * Whether the given comment should be included in the revision side of the
@@ -445,11 +530,11 @@
    * @param {!Gerrit.PatchRange} range
    * @return {boolean}
    */
-  ChangeComments.prototype._isInRevisionOfPatchRange = function(comment,
+  _isInRevisionOfPatchRange(comment,
       range) {
     return comment.side !== PARENT &&
-        this._patchNumEquals(comment.patch_set, range.patchNum);
-  };
+      this._patchNumEquals(comment.patch_set, range.patchNum);
+  }
 
   /**
    * Whether the given comment should be included in the given patch range.
@@ -458,64 +543,76 @@
    * @param {!Gerrit.PatchRange} range
    * @return {boolean|undefined}
    */
-  ChangeComments.prototype._isInPatchRange = function(comment, range) {
+  _isInPatchRange(comment, range) {
     return this._isInBaseOfPatchRange(comment, range) ||
-        this._isInRevisionOfPatchRange(comment, range);
-  };
+      this._isInRevisionOfPatchRange(comment, range);
+  }
+}
 
-  Polymer({
-    is: 'gr-comment-api',
+/**
+ * @extends Polymer.Element
+ */
+class GrCommentApi extends mixinBehaviors( [
+  PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-comment-api'; }
+
+  static get properties() {
+    return {
       _changeComments: Object,
-    },
+    };
+  }
 
-    listeners: {
-      'reload-drafts': 'reloadDrafts',
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('reload-drafts',
+        changeNum => this.reloadDrafts(changeNum));
+  }
 
-    behaviors: [
-      Gerrit.PatchSetBehavior,
-    ],
+  /**
+   * Load all comments (with drafts and robot comments) for the given change
+   * number. The returned promise resolves when the comments have loaded, but
+   * does not yield the comment data.
+   *
+   * @param {number} changeNum
+   * @return {!Promise<!Object>}
+   */
+  loadAll(changeNum) {
+    const promises = [];
+    promises.push(this.$.restAPI.getDiffComments(changeNum));
+    promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
+    promises.push(this.$.restAPI.getDiffDrafts(changeNum));
 
-    /**
-     * Load all comments (with drafts and robot comments) for the given change
-     * number. The returned promise resolves when the comments have loaded, but
-     * does not yield the comment data.
-     *
-     * @param {number} changeNum
-     * @return {!Promise<!Object>}
-     */
-    loadAll(changeNum) {
-      const promises = [];
-      promises.push(this.$.restAPI.getDiffComments(changeNum));
-      promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
-      promises.push(this.$.restAPI.getDiffDrafts(changeNum));
+    return Promise.all(promises).then(([comments, robotComments, drafts]) => {
+      this._changeComments = new ChangeComments(comments,
+          robotComments, drafts, changeNum);
+      return this._changeComments;
+    });
+  }
 
-      return Promise.all(promises).then(([comments, robotComments, drafts]) => {
-        this._changeComments = new ChangeComments(comments,
-            robotComments, drafts, changeNum);
-        return this._changeComments;
-      });
-    },
+  /**
+   * Re-initialize _changeComments with a new ChangeComments object, that
+   * uses the previous values for comments and robot comments, but fetches
+   * updated draft comments.
+   *
+   * @param {number} changeNum
+   * @return {!Promise<!Object>}
+   */
+  reloadDrafts(changeNum) {
+    if (!this._changeComments) {
+      return this.loadAll(changeNum);
+    }
+    return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
+      this._changeComments = new ChangeComments(this._changeComments.comments,
+          this._changeComments.robotComments, drafts, changeNum);
+      return this._changeComments;
+    });
+  }
+}
 
-    /**
-     * Re-initialize _changeComments with a new ChangeComments object, that
-     * uses the previous values for comments and robot comments, but fetches
-     * updated draft comments.
-     *
-     * @param {number} changeNum
-     * @return {!Promise<!Object>}
-     */
-    reloadDrafts(changeNum) {
-      if (!this._changeComments) {
-        return this.loadAll(changeNum);
-      }
-      return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
-        this._changeComments = new ChangeComments(this._changeComments.comments,
-            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_html.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
new file mode 100644
index 0000000..8aa0835
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..29262e3 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="./gr-comment-api.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,694 +31,725 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-comment-api tests', () => {
-    const PARENT = 'PARENT';
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-comment-api.js';
+suite('gr-comment-api tests', () => {
+  const PARENT = 'PARENT';
 
-    let element;
-    let sandbox;
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('loads logged-out', () => {
+    const changeNum = 1234;
+
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    sandbox.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+        .returns(Promise.resolve({}));
+
+    return element.loadAll(changeNum).then(() => {
+      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+          changeNum));
+      assert.isOk(element._changeComments._comments);
+      assert.isOk(element._changeComments._robotComments);
+      assert.deepEqual(element._changeComments._drafts, {});
+    });
+  });
+
+  test('loads logged-in', () => {
+    const changeNum = 1234;
+
+    sandbox.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    sandbox.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
+
+    return element.loadAll(changeNum).then(() => {
+      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+          changeNum));
+      assert.isOk(element._changeComments._comments);
+      assert.isOk(element._changeComments._robotComments);
+      assert.notDeepEqual(element._changeComments._drafts, {});
+    });
+  });
+
+  suite('reloadDrafts', () => {
+    let commentStub;
+    let robotCommentStub;
+    let draftStub;
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('loads logged-out', () => {
-      const changeNum = 1234;
-
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(false));
-      sandbox.stub(element.$.restAPI, 'getDiffComments')
-          .returns(Promise.resolve({
-            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-          }));
-      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
-          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-      sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+      commentStub = sandbox.stub(element.$.restAPI, 'getDiffComments')
           .returns(Promise.resolve({}));
+      robotCommentStub = sandbox.stub(element.$.restAPI,
+          'getDiffRobotComments').returns(Promise.resolve({}));
+      draftStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+          .returns(Promise.resolve({}));
+    });
 
-      return element.loadAll(changeNum).then(() => {
-        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-            changeNum));
-        assert.isOk(element._changeComments._comments);
-        assert.isOk(element._changeComments._robotComments);
-        assert.deepEqual(element._changeComments._drafts, {});
+    test('without loadAll first', done => {
+      assert.isNotOk(element._changeComments);
+      sandbox.spy(element, 'loadAll');
+      element.reloadDrafts().then(() => {
+        assert.isTrue(element.loadAll.called);
+        assert.isOk(element._changeComments);
+        assert.equal(commentStub.callCount, 1);
+        assert.equal(robotCommentStub.callCount, 1);
+        assert.equal(draftStub.callCount, 1);
+        done();
       });
     });
 
-    test('loads logged-in', () => {
+    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();
+          });
+    });
+  });
+
+  suite('_changeComment methods', () => {
+    setup(done => {
       const changeNum = 1234;
-
-      sandbox.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(true));
-      sandbox.stub(element.$.restAPI, 'getDiffComments')
-          .returns(Promise.resolve({
-            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-          }));
-      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
-          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-      sandbox.stub(element.$.restAPI, 'getDiffDrafts')
-          .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
-
-      return element.loadAll(changeNum).then(() => {
-        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-            changeNum));
-        assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-            changeNum));
-        assert.isOk(element._changeComments._comments);
-        assert.isOk(element._changeComments._robotComments);
-        assert.notDeepEqual(element._changeComments._drafts, {});
+      stub('gr-rest-api-interface', {
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      element.loadAll(changeNum).then(() => {
+        done();
       });
     });
 
-    suite('reloadDrafts', () => {
-      let commentStub;
-      let robotCommentStub;
-      let draftStub;
+    test('_isInBaseOfPatchRange', () => {
+      const comment = {patch_set: 1};
+      const patchRange = {basePatchNum: 1, patchNum: 2};
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.patch_set = 2;
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      patchRange.basePatchNum = -2;
+      comment.side = PARENT;
+      comment.parent = 1;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.parent = 2;
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+    });
+
+    test('_isInRevisionOfPatchRange', () => {
+      const comment = {patch_set: 123};
+      const patchRange = {basePatchNum: 122, patchNum: 124};
+      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+
+      patchRange.patchNum = 123;
+      assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+    });
+
+    test('_isInPatchRange', () => {
+      const patchRange1 = {basePatchNum: 122, patchNum: 124};
+      const patchRange2 = {basePatchNum: 123, patchNum: 125};
+      const patchRange3 = {basePatchNum: 124, patchNum: 125};
+
+      const isInBasePatchStub = sandbox.stub(element._changeComments,
+          '_isInBaseOfPatchRange');
+      const isInRevisionPatchStub = sandbox.stub(element._changeComments,
+          '_isInRevisionOfPatchRange');
+
+      isInBasePatchStub.withArgs({}, patchRange1).returns(true);
+      isInBasePatchStub.withArgs({}, patchRange2).returns(false);
+      isInBasePatchStub.withArgs({}, patchRange3).returns(false);
+
+      isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
+      isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
+      isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
+
+      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
+      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
+      assert.isFalse(element._changeComments._isInPatchRange({},
+          patchRange3));
+    });
+
+    suite('comment ranges and paths', () => {
+      function makeTime(mins) {
+        return `2013-02-26 15:0${mins}:43.986000000`;
+      }
+
       setup(() => {
-        commentStub = sandbox.stub(element.$.restAPI, 'getDiffComments')
-            .returns(Promise.resolve({}));
-        robotCommentStub = sandbox.stub(element.$.restAPI,
-            'getDiffRobotComments').returns(Promise.resolve({}));
-        draftStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts')
-            .returns(Promise.resolve({}));
+        element._changeComments._drafts = {
+          'file/one': [
+            {
+              id: 11,
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(3),
+            },
+            {
+              id: 12,
+              in_reply_to: 2,
+              patch_set: 2,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+          'file/two': [
+            {
+              id: 5,
+              patch_set: 3,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+        };
+        element._changeComments._robotComments = {
+          'file/one': [
+            {
+              id: 1,
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(1),
+              range: {
+                start_line: 1,
+                start_character: 2,
+                end_line: 2,
+                end_character: 2,
+              },
+            }, {
+              id: 2,
+              in_reply_to: 4,
+              patch_set: 2,
+              unresolved: true,
+              line: 1,
+              updated: makeTime(2),
+            },
+          ],
+        };
+        element._changeComments._comments = {
+          'file/one': [
+            {id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
+            {id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
+          ],
+          'file/two': [
+            {id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
+            {id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
+          ],
+          'file/three': [
+            {
+              id: 7,
+              patch_set: 2,
+              side: PARENT,
+              unresolved: true,
+              line: 1,
+              updated: makeTime(1),
+            },
+            {id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
+          ],
+          'file/four': [
+            {id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
+            {id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
+          ],
+        };
       });
 
-      test('without loadAll first', done => {
-        assert.isNotOk(element._changeComments);
-        sandbox.spy(element, 'loadAll');
-        element.reloadDrafts().then(() => {
-          assert.isTrue(element.loadAll.called);
-          assert.isOk(element._changeComments);
-          assert.equal(commentStub.callCount, 1);
-          assert.equal(robotCommentStub.callCount, 1);
-          assert.equal(draftStub.callCount, 1);
-          done();
-        });
-      });
-
-      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();
-        });
-      });
-    });
-
-    suite('_changeComment methods', () => {
-      setup(done => {
-        const changeNum = 1234;
-        stub('gr-rest-api-interface', {
-          getDiffComments() { return Promise.resolve({}); },
-          getDiffRobotComments() { return Promise.resolve({}); },
-          getDiffDrafts() { return Promise.resolve({}); },
-        });
-        element.loadAll(changeNum).then(() => {
-          done();
-        });
-      });
-
-      test('_isInBaseOfPatchRange', () => {
-        const comment = {patch_set: 1};
-        const patchRange = {basePatchNum: 1, patchNum: 2};
-        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+      test('getPaths', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 4};
+        let paths = element._changeComments.getPaths(patchRange);
+        assert.equal(Object.keys(paths).length, 0);
 
         patchRange.basePatchNum = PARENT;
-        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+        patchRange.patchNum = 3;
+        paths = element._changeComments.getPaths(patchRange);
+        assert.notProperty(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
 
-        comment.side = PARENT;
-        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+        patchRange.patchNum = 2;
+        paths = element._changeComments.getPaths(patchRange);
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
 
-        comment.patch_set = 2;
-        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
-
-        patchRange.basePatchNum = -2;
-        comment.side = PARENT;
-        comment.parent = 1;
-        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
-
-        comment.parent = 2;
-        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-            patchRange));
+        paths = element._changeComments.getPaths();
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.property(paths, 'file/four');
       });
 
-      test('_isInRevisionOfPatchRange', () => {
-        const comment = {patch_set: 123};
-        const patchRange = {basePatchNum: 122, patchNum: 124};
-        assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
-            comment, patchRange));
+      test('getCommentsBySideForPath', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 3};
+        let path = 'file/one';
+        let comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.meta.changeNum, 1234);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 0);
 
-        patchRange.patchNum = 123;
-        assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
-            comment, patchRange));
+        path = 'file/two';
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 2);
 
-        comment.side = PARENT;
-        assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
-            comment, patchRange));
+        patchRange.basePatchNum = 2;
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 1);
+        assert.equal(comments.right.length, 2);
+
+        patchRange.basePatchNum = PARENT;
+        path = 'file/three';
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 1);
       });
 
-      test('_isInPatchRange', () => {
-        const patchRange1 = {basePatchNum: 122, patchNum: 124};
-        const patchRange2 = {basePatchNum: 123, patchNum: 125};
-        const patchRange3 = {basePatchNum: 124, patchNum: 125};
-
-        const isInBasePatchStub = sandbox.stub(element._changeComments,
-            '_isInBaseOfPatchRange');
-        const isInRevisionPatchStub = sandbox.stub(element._changeComments,
-            '_isInRevisionOfPatchRange');
-
-        isInBasePatchStub.withArgs({}, patchRange1).returns(true);
-        isInBasePatchStub.withArgs({}, patchRange2).returns(false);
-        isInBasePatchStub.withArgs({}, patchRange3).returns(false);
-
-        isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
-        isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
-        isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
-
-        assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
-        assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
-        assert.isFalse(element._changeComments._isInPatchRange({},
-            patchRange3));
+      test('getAllCommentsForPath', () => {
+        let path = 'file/one';
+        let comments = element._changeComments.getAllCommentsForPath(path);
+        assert.deepEqual(comments.length, 4);
+        path = 'file/two';
+        comments = element._changeComments.getAllCommentsForPath(path, 2);
+        assert.deepEqual(comments.length, 1);
       });
 
-      suite('comment ranges and paths', () => {
-        function makeTime(mins) {
-          return `2013-02-26 15:0${mins}:43.986000000`;
-        }
+      test('getAllDraftsForPath', () => {
+        const path = 'file/one';
+        const drafts = element._changeComments.getAllDraftsForPath(path);
+        assert.deepEqual(drafts.length, 2);
+      });
 
-        setup(() => {
-          element._changeComments._drafts = {
-            'file/one': [
-              {
-                id: 11,
-                patch_set: 2,
-                side: PARENT,
-                line: 1,
-                updated: makeTime(3),
-              },
-              {
-                id: 12,
-                in_reply_to: 2,
-                patch_set: 2,
-                line: 1,
-                updated: makeTime(3),
-              },
-            ],
-            'file/two': [
-              {
-                id: 5,
-                patch_set: 3,
-                line: 1,
-                updated: makeTime(3),
-              },
-            ],
-          };
-          element._changeComments._robotComments = {
-            'file/one': [
+      test('computeUnresolvedNum', () => {
+        assert.equal(element._changeComments
+            .computeUnresolvedNum({
+              patchNum: 2,
+              path: 'file/one',
+            }), 0);
+        assert.equal(element._changeComments
+            .computeUnresolvedNum({
+              patchNum: 1,
+              path: 'file/one',
+            }), 0);
+        assert.equal(element._changeComments
+            .computeUnresolvedNum({
+              patchNum: 2,
+              path: 'file/three',
+            }), 1);
+      });
+
+      test('computeUnresolvedNum w/ non-linear thread', () => {
+        element._changeComments._drafts = {};
+        element._changeComments._robotComments = {};
+        element._changeComments._comments = {
+          path: [{
+            id: '9c6ba3c6_28b7d467',
+            patch_set: 1,
+            updated: '2018-02-28 14:41:13.000000000',
+            unresolved: true,
+          }, {
+            id: '3df7b331_0bead405',
+            patch_set: 1,
+            in_reply_to: '1c346623_ab85d14a',
+            updated: '2018-02-28 23:07:55.000000000',
+            unresolved: false,
+          }, {
+            id: '6153dce6_69958d1e',
+            patch_set: 1,
+            in_reply_to: '9c6ba3c6_28b7d467',
+            updated: '2018-02-28 17:11:31.000000000',
+            unresolved: true,
+          }, {
+            id: '1c346623_ab85d14a',
+            patch_set: 1,
+            in_reply_to: '9c6ba3c6_28b7d467',
+            updated: '2018-02-28 23:01:39.000000000',
+            unresolved: false,
+          }],
+        };
+        assert.equal(
+            element._changeComments.computeUnresolvedNum(1, 'path'), 0);
+      });
+
+      test('computeCommentCount', () => {
+        assert.equal(element._changeComments
+            .computeCommentCount({
+              patchNum: 2,
+              path: 'file/one',
+            }), 4);
+        assert.equal(element._changeComments
+            .computeCommentCount({
+              patchNum: 1,
+              path: 'file/one',
+            }), 0);
+        assert.equal(element._changeComments
+            .computeCommentCount({
+              patchNum: 2,
+              path: 'file/three',
+            }), 1);
+      });
+
+      test('computeDraftCount', () => {
+        assert.equal(element._changeComments
+            .computeDraftCount({
+              patchNum: 2,
+              path: 'file/one',
+            }), 2);
+        assert.equal(element._changeComments
+            .computeDraftCount({
+              patchNum: 1,
+              path: 'file/one',
+            }), 0);
+        assert.equal(element._changeComments
+            .computeDraftCount({
+              patchNum: 2,
+              path: 'file/three',
+            }), 0);
+        assert.equal(element._changeComments
+            .computeDraftCount(), 3);
+      });
+
+      test('getAllPublishedComments', () => {
+        let publishedComments = element._changeComments
+            .getAllPublishedComments();
+        assert.equal(Object.keys(publishedComments).length, 4);
+        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
+        assert.equal(Object.keys(publishedComments[['file/two']]).length, 2);
+        publishedComments = element._changeComments
+            .getAllPublishedComments(2);
+        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
+        assert.equal(Object.keys(publishedComments[['file/two']]).length, 1);
+      });
+
+      test('getAllComments', () => {
+        let comments = element._changeComments.getAllComments();
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 4);
+        assert.equal(Object.keys(comments[['file/two']]).length, 2);
+        comments = element._changeComments.getAllComments(false, 2);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 4);
+        assert.equal(Object.keys(comments[['file/two']]).length, 1);
+        // Include drafts
+        comments = element._changeComments.getAllComments(true);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 6);
+        assert.equal(Object.keys(comments[['file/two']]).length, 3);
+        comments = element._changeComments.getAllComments(true, 2);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments[['file/one']]).length, 6);
+        assert.equal(Object.keys(comments[['file/two']]).length, 1);
+      });
+
+      test('computeAllThreads', () => {
+        const expectedThreads = [
+          {
+            comments: [
               {
                 id: 1,
                 patch_set: 2,
-                side: PARENT,
+                side: 'PARENT',
                 line: 1,
-                updated: makeTime(1),
+                updated: '2013-02-26 15:01:43.986000000',
                 range: {
                   start_line: 1,
                   start_character: 2,
                   end_line: 2,
                   end_character: 2,
                 },
-              }, {
+                __path: 'file/one',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+            rootId: 1,
+          }, {
+            comments: [
+              {
+                id: 3,
+                patch_set: 2,
+                side: 'PARENT',
+                line: 2,
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 2,
+            rootId: 3,
+          }, {
+            comments: [
+              {
+                id: 4,
+                patch_set: 2,
+                line: 1,
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+              {
                 id: 2,
                 in_reply_to: 4,
                 patch_set: 2,
                 unresolved: true,
                 line: 1,
-                updated: makeTime(2),
+                __path: 'file/one',
+                updated: '2013-02-26 15:02:43.986000000',
+              },
+              {
+                id: 12,
+                in_reply_to: 2,
+                patch_set: 2,
+                line: 1,
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
               },
             ],
-          };
-          element._changeComments._comments = {
-            'file/one': [
-              {id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
-              {id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+            rootId: 4,
+          }, {
+            comments: [
+              {
+                id: 5,
+                patch_set: 2,
+                line: 2,
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
             ],
-            'file/two': [
-              {id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
-              {id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
+            patchNum: 2,
+            path: 'file/two',
+            line: 2,
+            rootId: 5,
+          }, {
+            comments: [
+              {
+                id: 6,
+                patch_set: 3,
+                line: 2,
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
             ],
-            'file/three': [
+            patchNum: 3,
+            path: 'file/two',
+            line: 2,
+            rootId: 6,
+          }, {
+            comments: [
               {
                 id: 7,
                 patch_set: 2,
-                side: PARENT,
+                side: 'PARENT',
                 unresolved: true,
                 line: 1,
-                updated: makeTime(1),
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
               },
-              {id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
             ],
-            'file/four': [
-              {id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
-              {id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
-            ],
-          };
-        });
-
-        test('getPaths', () => {
-          const patchRange = {basePatchNum: 1, patchNum: 4};
-          let paths = element._changeComments.getPaths(patchRange);
-          assert.equal(Object.keys(paths).length, 0);
-
-          patchRange.basePatchNum = PARENT;
-          patchRange.patchNum = 3;
-          paths = element._changeComments.getPaths(patchRange);
-          assert.notProperty(paths, 'file/one');
-          assert.property(paths, 'file/two');
-          assert.property(paths, 'file/three');
-          assert.notProperty(paths, 'file/four');
-
-          patchRange.patchNum = 2;
-          paths = element._changeComments.getPaths(patchRange);
-          assert.property(paths, 'file/one');
-          assert.property(paths, 'file/two');
-          assert.property(paths, 'file/three');
-          assert.notProperty(paths, 'file/four');
-
-          paths = element._changeComments.getPaths();
-          assert.property(paths, 'file/one');
-          assert.property(paths, 'file/two');
-          assert.property(paths, 'file/three');
-          assert.property(paths, 'file/four');
-        });
-
-        test('getCommentsBySideForPath', () => {
-          const patchRange = {basePatchNum: 1, patchNum: 3};
-          let path = 'file/one';
-          let comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.meta.changeNum, 1234);
-          assert.equal(comments.left.length, 0);
-          assert.equal(comments.right.length, 0);
-
-          path = 'file/two';
-          comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.left.length, 0);
-          assert.equal(comments.right.length, 2);
-
-          patchRange.basePatchNum = 2;
-          comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.left.length, 1);
-          assert.equal(comments.right.length, 2);
-
-          patchRange.basePatchNum = PARENT;
-          path = 'file/three';
-          comments = element._changeComments.getCommentsBySideForPath(path,
-              patchRange);
-          assert.equal(comments.left.length, 0);
-          assert.equal(comments.right.length, 1);
-        });
-
-        test('getAllCommentsForPath', () => {
-          let path = 'file/one';
-          let comments = element._changeComments.getAllCommentsForPath(path);
-          assert.deepEqual(comments.length, 4);
-          path = 'file/two';
-          comments = element._changeComments.getAllCommentsForPath(path, 2);
-          assert.deepEqual(comments.length, 1);
-        });
-
-        test('getAllDraftsForPath', () => {
-          const path = 'file/one';
-          const drafts = element._changeComments.getAllDraftsForPath(path);
-          assert.deepEqual(drafts.length, 2);
-        });
-
-        test('computeUnresolvedNum', () => {
-          assert.equal(element._changeComments
-              .computeUnresolvedNum(2, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeUnresolvedNum(1, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeUnresolvedNum(2, 'file/three'), 1);
-        });
-
-        test('computeUnresolvedNum w/ non-linear thread', () => {
-          element._changeComments._drafts = {};
-          element._changeComments._robotComments = {};
-          element._changeComments._comments = {
-            path: [{
-              id: '9c6ba3c6_28b7d467',
-              patch_set: 1,
-              updated: '2018-02-28 14:41:13.000000000',
-              unresolved: true,
-            }, {
-              id: '3df7b331_0bead405',
-              patch_set: 1,
-              in_reply_to: '1c346623_ab85d14a',
-              updated: '2018-02-28 23:07:55.000000000',
-              unresolved: false,
-            }, {
-              id: '6153dce6_69958d1e',
-              patch_set: 1,
-              in_reply_to: '9c6ba3c6_28b7d467',
-              updated: '2018-02-28 17:11:31.000000000',
-              unresolved: true,
-            }, {
-              id: '1c346623_ab85d14a',
-              patch_set: 1,
-              in_reply_to: '9c6ba3c6_28b7d467',
-              updated: '2018-02-28 23:01:39.000000000',
-              unresolved: false,
-            }],
-          };
-          assert.equal(
-              element._changeComments.computeUnresolvedNum(1, 'path'), 0);
-        });
-
-        test('computeCommentCount', () => {
-          assert.equal(element._changeComments
-              .computeCommentCount(2, 'file/one'), 4);
-          assert.equal(element._changeComments
-              .computeCommentCount(1, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeCommentCount(2, 'file/three'), 1);
-        });
-
-        test('computeDraftCount', () => {
-          assert.equal(element._changeComments
-              .computeDraftCount(2, 'file/one'), 2);
-          assert.equal(element._changeComments
-              .computeDraftCount(1, 'file/one'), 0);
-          assert.equal(element._changeComments
-              .computeDraftCount(2, 'file/three'), 0);
-          assert.equal(element._changeComments
-              .computeDraftCount(), 3);
-        });
-
-        test('getAllPublishedComments', () => {
-          let publishedComments = element._changeComments
-              .getAllPublishedComments();
-          assert.equal(Object.keys(publishedComments).length, 4);
-          assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-          assert.equal(Object.keys(publishedComments[['file/two']]).length, 2);
-          publishedComments = element._changeComments
-              .getAllPublishedComments(2);
-          assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-          assert.equal(Object.keys(publishedComments[['file/two']]).length, 1);
-        });
-
-        test('getAllComments', () => {
-          let comments = element._changeComments.getAllComments();
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 4);
-          assert.equal(Object.keys(comments[['file/two']]).length, 2);
-          comments = element._changeComments.getAllComments(false, 2);
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 4);
-          assert.equal(Object.keys(comments[['file/two']]).length, 1);
-          // Include drafts
-          comments = element._changeComments.getAllComments(true);
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 6);
-          assert.equal(Object.keys(comments[['file/two']]).length, 3);
-          comments = element._changeComments.getAllComments(true, 2);
-          assert.equal(Object.keys(comments).length, 4);
-          assert.equal(Object.keys(comments[['file/one']]).length, 6);
-          assert.equal(Object.keys(comments[['file/two']]).length, 1);
-        });
-
-        test('computeAllThreads', () => {
-          const expectedThreads = [
-            {
-              comments: [
-                {
-                  id: 5,
-                  patch_set: 2,
-                  line: 2,
-                  __path: 'file/two',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              patchNum: 2,
-              path: 'file/two',
-              line: 2,
-              rootId: 5,
-            }, {
-              comments: [
-                {
-                  id: 3,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  line: 2,
-                  __path: 'file/one',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/one',
-              line: 2,
-              rootId: 3,
-            }, {
-              comments: [
-                {
-                  id: 1,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  line: 1,
-                  updated: '2013-02-26 15:01:43.986000000',
-                  range: {
-                    start_line: 1,
-                    start_character: 2,
-                    end_line: 2,
-                    end_character: 2,
-                  },
-                  __path: 'file/one',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/one',
-              line: 1,
-              rootId: 1,
-            }, {
-              comments: [
-                {
-                  id: 9,
-                  patch_set: 5,
-                  side: 'PARENT',
-                  line: 1,
-                  __path: 'file/four',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 5,
-              path: 'file/four',
-              line: 1,
-              rootId: 9,
-            }, {
-              comments: [
-                {
-                  id: 8,
-                  patch_set: 3,
-                  line: 1,
-                  __path: 'file/three',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              patchNum: 3,
-              path: 'file/three',
-              line: 1,
-              rootId: 8,
-            }, {
-              comments: [
-                {
-                  id: 7,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  unresolved: true,
-                  line: 1,
-                  __path: 'file/three',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/three',
-              line: 1,
-              rootId: 7,
-            }, {
-              comments: [
-                {
-                  id: 4,
-                  patch_set: 2,
-                  line: 1,
-                  __path: 'file/one',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-                {
-                  id: 2,
-                  in_reply_to: 4,
-                  patch_set: 2,
-                  unresolved: true,
-                  line: 1,
-                  __path: 'file/one',
-                  updated: '2013-02-26 15:02:43.986000000',
-                },
-                {
-                  id: 12,
-                  in_reply_to: 2,
-                  patch_set: 2,
-                  line: 1,
-                  __path: 'file/one',
-                  __draft: true,
-                  updated: '2013-02-26 15:03:43.986000000',
-                },
-              ],
-              patchNum: 2,
-              path: 'file/one',
-              line: 1,
-              rootId: 4,
-            }, {
-              comments: [
-                {
-                  id: 6,
-                  patch_set: 3,
-                  line: 2,
-                  __path: 'file/two',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              patchNum: 3,
-              path: 'file/two',
-              line: 2,
-              rootId: 6,
-            }, {
-              comments: [
-                {
-                  id: 10,
-                  patch_set: 5,
-                  line: 1,
-                  __path: 'file/four',
-                  updated: '2013-02-26 15:01:43.986000000',
-                },
-              ],
-              rootId: 10,
-              patchNum: 5,
-              path: 'file/four',
-              line: 1,
-            }, {
-              comments: [
-                {
-                  id: 5,
-                  patch_set: 3,
-                  line: 1,
-                  __path: 'file/two',
-                  __draft: true,
-                  updated: '2013-02-26 15:03:43.986000000',
-                },
-              ],
-              rootId: 5,
-              patchNum: 3,
-              path: 'file/two',
-              line: 1,
-            }, {
-              comments: [
-                {
-                  id: 11,
-                  patch_set: 2,
-                  side: 'PARENT',
-                  line: 1,
-                  __path: 'file/one',
-                  __draft: true,
-                  updated: '2013-02-26 15:03:43.986000000',
-                },
-              ],
-              rootId: 11,
-              commentSide: 'PARENT',
-              patchNum: 2,
-              path: 'file/one',
-              line: 1,
-            },
-          ];
-          const threads = element._changeComments.getAllThreadsForChange();
-          assert.deepEqual(threads, expectedThreads);
-        });
-
-        test('getCommentsForThreadGroup', () => {
-          let expectedComments = [
-            {
-              __path: 'file/one',
-              id: 4,
-              patch_set: 2,
-              line: 1,
-              updated: '2013-02-26 15:01:43.986000000',
-            },
-            {
-              __path: 'file/one',
-              id: 2,
-              in_reply_to: 4,
-              patch_set: 2,
-              unresolved: true,
-              line: 1,
-              updated: '2013-02-26 15:02:43.986000000',
-            },
-            {
-              __path: 'file/one',
-              __draft: true,
-              id: 12,
-              in_reply_to: 2,
-              patch_set: 2,
-              line: 1,
-              updated: '2013-02-26 15:03:43.986000000',
-            },
-          ];
-          assert.deepEqual(element._changeComments.getCommentsForThread(4),
-              expectedComments);
-
-          expectedComments = [{
-            id: 11,
-            patch_set: 2,
-            side: 'PARENT',
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/three',
             line: 1,
+            rootId: 7,
+          }, {
+            comments: [
+              {
+                id: 8,
+                patch_set: 3,
+                line: 1,
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 3,
+            path: 'file/three',
+            line: 1,
+            rootId: 8,
+          }, {
+            comments: [
+              {
+                id: 9,
+                patch_set: 5,
+                side: 'PARENT',
+                line: 1,
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+            rootId: 9,
+          }, {
+            comments: [
+              {
+                id: 10,
+                patch_set: 5,
+                line: 1,
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            rootId: 10,
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: 5,
+                patch_set: 3,
+                line: 1,
+                __path: 'file/two',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: 5,
+            patchNum: 3,
+            path: 'file/two',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: 11,
+                patch_set: 2,
+                side: 'PARENT',
+                line: 1,
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: 11,
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+          },
+        ];
+        const threads = element._changeComments.getAllThreadsForChange();
+        assert.deepEqual(threads, expectedThreads);
+      });
+
+      test('getCommentsForThreadGroup', () => {
+        let expectedComments = [
+          {
+            __path: 'file/one',
+            id: 4,
+            patch_set: 2,
+            line: 1,
+            updated: '2013-02-26 15:01:43.986000000',
+          },
+          {
+            __path: 'file/one',
+            id: 2,
+            in_reply_to: 4,
+            patch_set: 2,
+            unresolved: true,
+            line: 1,
+            updated: '2013-02-26 15:02:43.986000000',
+          },
+          {
             __path: 'file/one',
             __draft: true,
+            id: 12,
+            in_reply_to: 2,
+            patch_set: 2,
+            line: 1,
             updated: '2013-02-26 15:03:43.986000000',
-          }];
+          },
+        ];
+        assert.deepEqual(element._changeComments.getCommentsForThread(4),
+            expectedComments);
 
-          assert.deepEqual(element._changeComments.getCommentsForThread(11),
-              expectedComments);
+        expectedComments = [{
+          id: 11,
+          patch_set: 2,
+          side: 'PARENT',
+          line: 1,
+          __path: 'file/one',
+          __draft: true,
+          updated: '2013-02-26 15:03:43.986000000',
+        }];
 
-          assert.deepEqual(element._changeComments.getCommentsForThread(1000),
-              null);
-        });
+        assert.deepEqual(element._changeComments.getCommentsForThread(11),
+            expectedComments);
+
+        assert.deepEqual(element._changeComments.getCommentsForThread(1000),
+            null);
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
deleted file mode 100644
index 549bf43..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.html
+++ /dev/null
@@ -1,25 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<dom-module id="gr-coverage-layer">
-  <template>
-  </template>
-  <script src="../../../types/types.js"></script>
-  <script src="../gr-diff-highlight/gr-annotation.js"></script>
-  <script src="gr-coverage-layer.js"></script>
-</dom-module>
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..cdd6d8f 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
@@ -14,26 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const TOOLTIP_MAP = new Map([
-    [Gerrit.CoverageType.COVERED, 'Covered by tests.'],
-    [Gerrit.CoverageType.NOT_COVERED, 'Not covered by tests.'],
-    [Gerrit.CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
-    [Gerrit.CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
-  ]);
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-coverage-layer_html.js';
+import {CoverageType} from '../../../types/types.js';
 
-  Polymer({
-    is: 'gr-coverage-layer',
+const TOOLTIP_MAP = new Map([
+  [CoverageType.COVERED, 'Covered by tests.'],
+  [CoverageType.NOT_COVERED, 'Not covered by tests.'],
+  [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
+  [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
+]);
 
-    properties: {
-      /**
-       * Must be sorted by code_range.start_line.
-       * Must only contain ranges that match the side.
-       *
-       * @type {!Array<!Gerrit.CoverageRange>}
-       */
+/** @extends Polymer.Element */
+class GrCoverageLayer extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-coverage-layer'; }
+
+  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,
 
@@ -53,55 +64,57 @@
         type: Number,
         value: 0,
       },
-    },
+    };
+  }
 
-    /**
-     * Layer method to add annotations to a line.
-     *
-     * @param {!HTMLElement} el Not used for this layer.
-     * @param {!HTMLElement} lineNumberEl The <td> element with the line number.
-     * @param {!Object} line Not used for this layer.
-     */
-    annotate(el, lineNumberEl, line) {
-      if (!lineNumberEl || !lineNumberEl.classList.contains(this.side)) {
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param {!HTMLElement} el Not used for this layer.
+   * @param {!HTMLElement} lineNumberEl The <td> element with the line number.
+   * @param {!Object} line Not used for this layer.
+   */
+  annotate(el, lineNumberEl, line) {
+    if (!lineNumberEl || !lineNumberEl.classList.contains(this.side)) {
+      return;
+    }
+    const elementLineNumber = parseInt(
+        lineNumberEl.getAttribute('data-value'), 10);
+    if (!elementLineNumber || elementLineNumber < 1) return;
+
+    // If the line number is smaller than before, then we have to reset our
+    // algorithm and start searching the coverage ranges from the beginning.
+    // That happens for example when you expand diff sections.
+    if (elementLineNumber < this._lineNumber) {
+      this._index = 0;
+    }
+    this._lineNumber = elementLineNumber;
+
+    // We simply loop through all the coverage ranges until we find one that
+    // matches the line number.
+    while (this._index < this.coverageRanges.length) {
+      const coverageRange = this.coverageRanges[this._index];
+
+      // If the line number has moved past the current coverage range, then
+      // try the next coverage range.
+      if (this._lineNumber > coverageRange.code_range.end_line) {
+        this._index++;
+        continue;
+      }
+
+      // If the line number has not reached the next coverage range (and the
+      // range before also did not match), then this line has not been
+      // instrumented. Nothing to do for this line.
+      if (this._lineNumber < coverageRange.code_range.start_line) {
         return;
       }
-      const elementLineNumber = parseInt(
-          lineNumberEl.getAttribute('data-value'), 10);
-      if (!elementLineNumber || elementLineNumber < 1) return;
 
-      // If the line number is smaller than before, then we have to reset our
-      // algorithm and start searching the coverage ranges from the beginning.
-      // That happens for example when you expand diff sections.
-      if (elementLineNumber < this._lineNumber) {
-        this._index = 0;
-      }
-      this._lineNumber = elementLineNumber;
+      // The line number is within the current coverage range. Style it!
+      lineNumberEl.classList.add(coverageRange.type);
+      lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type);
+      return;
+    }
+  }
+}
 
-      // We simply loop through all the coverage ranges until we find one that
-      // matches the line number.
-      while (this._index < this.coverageRanges.length) {
-        const coverageRange = this.coverageRanges[this._index];
-
-        // If the line number has moved past the current coverage range, then
-        // try the next coverage range.
-        if (this._lineNumber > coverageRange.code_range.end_line) {
-          this._index++;
-          continue;
-        }
-
-        // If the line number has not reached the next coverage range (and the
-        // range before also did not match), then this line has not been
-        // instrumented. Nothing to do for this line.
-        if (this._lineNumber < coverageRange.code_range.start_line) {
-          return;
-        }
-
-        // The line number is within the current coverage range. Style it!
-        lineNumberEl.classList.add(coverageRange.type);
-        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_html.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
new file mode 100644
index 0000000..3ed33d1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html``;
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..b80c56f3 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-coverage-layer.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,105 +31,108 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-coverage-layer', () => {
-    let element;
+<script type="module">
+import '../gr-diff/gr-diff-line.js';
+import '../../../test/common-test-setup.js';
+import './gr-coverage-layer.js';
+suite('gr-coverage-layer', () => {
+  let element;
 
-    setup(() => {
-      const initialCoverageRanges = [
-        {
-          type: 'COVERED',
-          side: 'right',
-          code_range: {
-            start_line: 1,
-            end_line: 2,
-          },
+  setup(() => {
+    const initialCoverageRanges = [
+      {
+        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,
-          },
+      },
+      {
+        type: 'NOT_COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 3,
+          end_line: 4,
         },
-        {
-          type: 'PARTIALLY_COVERED',
-          side: 'right',
-          code_range: {
-            start_line: 5,
-            end_line: 6,
-          },
+      },
+      {
+        type: 'PARTIALLY_COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 5,
+          end_line: 6,
         },
-        {
-          type: 'NOT_INSTRUMENTED',
-          side: 'right',
-          code_range: {
-            start_line: 8,
-            end_line: 9,
-          },
+      },
+      {
+        type: 'NOT_INSTRUMENTED',
+        side: 'right',
+        code_range: {
+          start_line: 8,
+          end_line: 9,
         },
-      ];
+      },
+    ];
 
-      element = fixture('basic');
-      element.coverageRanges = initialCoverageRanges;
-      element.side = 'right';
+    element = fixture('basic');
+    element.coverageRanges = initialCoverageRanges;
+    element.side = 'right';
+  });
+
+  suite('annotate', () => {
+    function createLine(lineNumber) {
+      const lineEl = document.createElement('div');
+      lineEl.setAttribute('data-side', 'right');
+      lineEl.setAttribute('data-value', lineNumber);
+      lineEl.className = 'right';
+      return lineEl;
+    }
+
+    function checkLine(lineNumber, className, opt_negated) {
+      const line = createLine(lineNumber);
+      element.annotate(undefined, line, undefined);
+      let contains = line.classList.contains(className);
+      if (opt_negated) contains = !contains;
+      assert.isTrue(contains);
+    }
+
+    test('line 1-2 are covered', () => {
+      checkLine(1, 'COVERED');
+      checkLine(2, 'COVERED');
     });
 
-    suite('annotate', () => {
-      function createLine(lineNumber) {
-        lineEl = document.createElement('div');
-        lineEl.setAttribute('data-side', 'right');
-        lineEl.setAttribute('data-value', lineNumber);
-        lineEl.className = 'right';
-        return lineEl;
-      }
+    test('line 3-4 are not covered', () => {
+      checkLine(3, 'NOT_COVERED');
+      checkLine(4, 'NOT_COVERED');
+    });
 
-      function checkLine(lineNumber, className, opt_negated) {
-        const line = createLine(lineNumber);
-        element.annotate(undefined, line, undefined);
-        let contains = line.classList.contains(className);
-        if (opt_negated) contains = !contains;
-        assert.isTrue(contains);
-      }
+    test('line 5-6 are partially covered', () => {
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+    });
 
-      test('line 1-2 are covered', () => {
-        checkLine(1, 'COVERED');
-        checkLine(2, 'COVERED');
-      });
+    test('line 7 is implicitly not instrumented', () => {
+      checkLine(7, 'COVERED', true);
+      checkLine(7, 'NOT_COVERED', true);
+      checkLine(7, 'PARTIALLY_COVERED', true);
+      checkLine(7, 'NOT_INSTRUMENTED', true);
+    });
 
-      test('line 3-4 are not covered', () => {
-        checkLine(3, 'NOT_COVERED');
-        checkLine(4, 'NOT_COVERED');
-      });
+    test('line 8-9 are not instrumented', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+    });
 
-      test('line 5-6 are partially covered', () => {
-        checkLine(5, 'PARTIALLY_COVERED');
-        checkLine(6, 'PARTIALLY_COVERED');
-      });
-
-      test('line 7 is implicitly not instrumented', () => {
-        checkLine(7, 'COVERED', true);
-        checkLine(7, 'NOT_COVERED', true);
-        checkLine(7, 'PARTIALLY_COVERED', true);
-        checkLine(7, 'NOT_INSTRUMENTED', true);
-      });
-
-      test('line 8-9 are not instrumented', () => {
-        checkLine(8, 'NOT_INSTRUMENTED');
-        checkLine(9, 'NOT_INSTRUMENTED');
-      });
-
-      test('coverage correct, if annotate is called out of order', () => {
-        checkLine(8, 'NOT_INSTRUMENTED');
-        checkLine(1, 'COVERED');
-        checkLine(5, 'PARTIALLY_COVERED');
-        checkLine(3, 'NOT_COVERED');
-        checkLine(6, 'PARTIALLY_COVERED');
-        checkLine(4, 'NOT_COVERED');
-        checkLine(9, 'NOT_INSTRUMENTED');
-        checkLine(2, 'COVERED');
-      });
+    test('coverage correct, if annotate is called out of order', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(1, 'COVERED');
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(3, 'NOT_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+      checkLine(4, 'NOT_COVERED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+      checkLine(2, 'COVERED');
     });
   });
+});
 </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..a65fdca 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
@@ -14,33 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffBuilder) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrDiffBuilderBinary) { return; }
+import {GrDiffBuilder} from './gr-diff-builder.js';
 
-  function GrDiffBuilderBinary(diff, prefs, outputEl) {
-    GrDiffBuilder.call(this, diff, prefs, outputEl);
-  }
+/** @constructor */
+export function GrDiffBuilderBinary(diff, prefs, outputEl) {
+  GrDiffBuilder.call(this, diff, prefs, outputEl);
+}
 
-  GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
-  GrDiffBuilderBinary.prototype.constructor = GrDiffBuilderBinary;
+GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
+GrDiffBuilderBinary.prototype.constructor = GrDiffBuilderBinary;
 
-  // This method definition is a no-op to satisfy the parent type.
-  GrDiffBuilderBinary.prototype.addColumns = function(outputEl, fontSize) {};
+// This method definition is a no-op to satisfy the parent type.
+GrDiffBuilderBinary.prototype.addColumns = function(outputEl, fontSize) {};
 
-  GrDiffBuilderBinary.prototype.buildSectionElement = function() {
-    const section = this._createElement('tbody', 'binary-diff');
-    const row = this._createElement('tr');
-    const cell = this._createElement('td');
-    const label = this._createElement('label');
-    label.textContent = 'Difference in binary files';
-    cell.appendChild(label);
-    row.appendChild(cell);
-    section.appendChild(row);
-    return section;
-  };
-
-  window.GrDiffBuilderBinary = GrDiffBuilderBinary;
-})(window, GrDiffBuilder);
+GrDiffBuilderBinary.prototype.buildSectionElement = function() {
+  const section = this._createElement('tbody', 'binary-diff');
+  const row = this._createElement('tr');
+  const cell = this._createElement('td');
+  const label = this._createElement('label');
+  label.textContent = 'Difference in binary files';
+  cell.appendChild(label);
+  row.appendChild(cell);
+  section.appendChild(row);
+  return section;
+};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
new file mode 100644
index 0000000..b38543c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
@@ -0,0 +1,444 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import '../gr-coverage-layer/gr-coverage-layer.js';
+import '../gr-diff-processor/gr-diff-processor.js';
+import '../../shared/gr-hovercard/gr-hovercard.js';
+import '../gr-ranged-comment-layer/gr-ranged-comment-layer.js';
+import './gr-diff-builder-side-by-side.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-builder-element_html.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffBuilder} from './gr-diff-builder.js';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
+import {GrDiffBuilderImage} from './gr-diff-builder-image.js';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary.js';
+import {util} from '../../../scripts/util.js';
+
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
+
+// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+/**
+ * @extends Polymer.Element
+ */
+class GrDiffBuilderElement extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-builder'; }
+  /**
+   * Fired when the diff begins rendering.
+   *
+   * @event render-start
+   */
+
+  /**
+   * Fired when the diff finishes rendering text content.
+   *
+   * @event render-content
+   */
+
+  static get properties() {
+    return {
+      diff: Object,
+      changeNum: String,
+      patchNum: String,
+      viewMode: String,
+      isImageDiff: Boolean,
+      baseImage: Object,
+      revisionImage: Object,
+      parentIndex: Number,
+      path: String,
+      projectName: String,
+
+      _builder: Object,
+      _groups: Array,
+      _layers: Array,
+      _showTabs: Boolean,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: {
+        type: Array,
+        value: () => [],
+      },
+      /** @type {!Array<!Gerrit.CoverageRange>} */
+      coverageRanges: {
+        type: Array,
+        value: () => [],
+      },
+      _leftCoverageRanges: {
+        type: Array,
+        computed: '_computeLeftCoverageRanges(coverageRanges)',
+      },
+      _rightCoverageRanges: {
+        type: Array,
+        computed: '_computeRightCoverageRanges(coverageRanges)',
+      },
+      /**
+       * The promise last returned from `render()` while the asynchronous
+       * rendering is running - `null` otherwise. Provides a `cancel()`
+       * method that rejects it with `{isCancelled: true}`.
+       *
+       * @type {?Object}
+       */
+      _cancelableRenderPromise: Object,
+      layers: {
+        type: Array,
+        value: [],
+      },
+    };
+  }
+
+  get diffElement() {
+    return this.queryEffectiveChildren('#diffTable');
+  }
+
+  static get observers() {
+    return [
+      '_groupsChanged(_groups.splices)',
+    ];
+  }
+
+  _computeLeftCoverageRanges(coverageRanges) {
+    return coverageRanges.filter(range => range && range.side === 'left');
+  }
+
+  _computeRightCoverageRanges(coverageRanges) {
+    return coverageRanges.filter(range => range && range.side === 'right');
+  }
+
+  render(keyLocations, prefs) {
+    // Setting up annotation layers must happen after plugins are
+    // installed, and |render| satisfies the requirement, however,
+    // |attached| doesn't because in the diff view page, the element is
+    // attached before plugins are installed.
+    this._setupAnnotationLayers();
+
+    this._showTabs = !!prefs.show_tabs;
+    this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
+
+    // Stop the processor if it's running.
+    this.cancel();
+
+    this._builder = this._getDiffBuilder(this.diff, prefs);
+
+    this.$.processor.context = prefs.context;
+    this.$.processor.keyLocations = keyLocations;
+
+    this._clearDiffContent();
+    this._builder.addColumns(this.diffElement, prefs.font_size);
+
+    const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+    this.dispatchEvent(new CustomEvent(
+        'render-start', {bubbles: true, composed: true}));
+    this._cancelableRenderPromise = util.makeCancelable(
+        this.$.processor.process(this.diff.content, isBinary)
+            .then(() => {
+              if (this.isImageDiff) {
+                this._builder.renderDiff();
+              }
+              this.dispatchEvent(new CustomEvent('render-content',
+                  {bubbles: true, composed: true}));
+            }));
+    return this._cancelableRenderPromise
+        .finally(() => { this._cancelableRenderPromise = null; })
+    // Mocca testing does not like uncaught rejections, so we catch
+    // the cancels which are expected and should not throw errors in
+    // tests.
+        .catch(e => { if (!e.isCanceled) return Promise.reject(e); });
+  }
+
+  _setupAnnotationLayers() {
+    const layers = [
+      this._createTrailingWhitespaceLayer(),
+      this._createIntralineLayer(),
+      this._createTabIndicatorLayer(),
+      this.$.rangeLayer,
+      this.$.coverageLayerLeft,
+      this.$.coverageLayerRight,
+    ];
+
+    if (this.layers) {
+      layers.push(...this.layers);
+    }
+    this._layers = layers;
+  }
+
+  getLineElByChild(node) {
+    while (node) {
+      if (node instanceof Element) {
+        if (node.classList.contains('lineNum')) {
+          return node;
+        }
+        if (node.classList.contains('section')) {
+          return null;
+        }
+      }
+      node = node.previousSibling || node.parentElement;
+    }
+    return null;
+  }
+
+  getLineNumberByChild(node) {
+    const lineEl = this.getLineElByChild(node);
+    return lineEl ?
+      parseInt(lineEl.getAttribute('data-value'), 10) :
+      null;
+  }
+
+  getContentByLine(lineNumber, opt_side, opt_root) {
+    return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
+  }
+
+  _getDiffRowByChild(child) {
+    while (!child.classList.contains('diff-row') && child.parentElement) {
+      child = child.parentElement;
+    }
+    return child;
+  }
+
+  getContentByLineEl(lineEl) {
+    if (!lineEl) return;
+    const line = lineEl.getAttribute('data-value');
+    const side = this.getSideByLineEl(lineEl);
+    // Performance optimization because we already have an element in the
+    // correct row
+    const row = dom(this._getDiffRowByChild(lineEl));
+    return this.getContentByLine(line, side, row);
+  }
+
+  getLineElByNumber(lineNumber, opt_side) {
+    const sideSelector = opt_side ? ('.' + opt_side) : '';
+    return this.diffElement.querySelector(
+        '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
+  }
+
+  getContentsByLineRange(startLine, endLine, opt_side) {
+    const result = [];
+    this._builder.findLinesByRange(startLine, endLine, opt_side, null,
+        result);
+    return result;
+  }
+
+  getSideByLineEl(lineEl) {
+    return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
+      GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
+  }
+
+  emitGroup(group, sectionEl) {
+    this._builder.emitGroup(group, sectionEl);
+  }
+
+  showContext(newGroups, sectionEl) {
+    const groups = this._builder.groups;
+
+    const contextIndex = groups.findIndex(group =>
+      group.element === sectionEl
+    );
+    groups.splice(contextIndex, 1, ...newGroups);
+
+    for (const newGroup of newGroups) {
+      this._builder.emitGroup(newGroup, sectionEl);
+    }
+    sectionEl.parentNode.removeChild(sectionEl);
+
+    this.async(() => this.dispatchEvent(new CustomEvent('render-content', {
+      composed: true, bubbles: true,
+    })), 1);
+  }
+
+  cancel() {
+    this.$.processor.cancel();
+    if (this._cancelableRenderPromise) {
+      this._cancelableRenderPromise.cancel();
+      this._cancelableRenderPromise = null;
+    }
+  }
+
+  _handlePreferenceError(pref) {
+    const message = `The value of the '${pref}' user preference is ` +
+        `invalid. Fix in diff preferences`;
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message,
+      }, bubbles: true, composed: true}));
+    throw Error(`Invalid preference value: ${pref}`);
+  }
+
+  _getDiffBuilder(diff, prefs) {
+    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+      this._handlePreferenceError('tab size');
+      return;
+    }
+
+    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+      this._handlePreferenceError('diff width');
+      return;
+    }
+
+    const localPrefs = Object.assign({}, prefs);
+    if (this.path === COMMIT_MSG_PATH) {
+      // override line_length for commit msg the same way as
+      // in gr-diff
+      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+    }
+
+    let builder = null;
+    if (this.isImageDiff) {
+      builder = new GrDiffBuilderImage(
+          diff,
+          localPrefs,
+          this.diffElement,
+          this.baseImage,
+          this.revisionImage);
+    } else if (diff.binary) {
+      // If the diff is binary, but not an image.
+      return new GrDiffBuilderBinary(
+          diff,
+          localPrefs,
+          this.diffElement);
+    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      builder = new GrDiffBuilderSideBySide(
+          diff,
+          localPrefs,
+          this.diffElement,
+          this._layers
+      );
+    } else if (this.viewMode === DiffViewMode.UNIFIED) {
+      builder = new GrDiffBuilderUnified(
+          diff,
+          localPrefs,
+          this.diffElement,
+          this._layers);
+    }
+    if (!builder) {
+      throw Error('Unsupported diff view mode: ' + this.viewMode);
+    }
+    return builder;
+  }
+
+  _clearDiffContent() {
+    this.diffElement.innerHTML = null;
+  }
+
+  _groupsChanged(changeRecord) {
+    if (!changeRecord) { return; }
+    for (const splice of changeRecord.indexSplices) {
+      let group;
+      for (let i = 0; i < splice.addedCount; i++) {
+        group = splice.object[splice.index + i];
+        this._builder.groups.push(group);
+        this._builder.emitGroup(group);
+      }
+    }
+  }
+
+  _createIntralineLayer() {
+    return {
+      // Take a DIV.contentText element and a line object with intraline
+      // differences to highlight and apply them to the element as
+      // annotations.
+      annotate(contentEl, lineNumberEl, line) {
+        const HL_CLASS = 'style-scope gr-diff intraline';
+        for (const highlight of line.highlights) {
+          // The start and end indices could be the same if a highlight is
+          // meant to start at the end of a line and continue onto the
+          // next one. Ignore it.
+          if (highlight.startIndex === highlight.endIndex) { continue; }
+
+          // If endIndex isn't present, continue to the end of the line.
+          const endIndex = highlight.endIndex === undefined ?
+            line.text.length :
+            highlight.endIndex;
+
+          GrAnnotation.annotateElement(
+              contentEl,
+              highlight.startIndex,
+              endIndex - highlight.startIndex,
+              HL_CLASS);
+        }
+      },
+    };
+  }
+
+  _createTabIndicatorLayer() {
+    const show = () => this._showTabs;
+    return {
+      annotate(contentEl, lineNumberEl, line) {
+        // If visible tabs are disabled, do nothing.
+        if (!show()) { return; }
+
+        // Find and annotate the locations of tabs.
+        const split = line.text.split('\t');
+        if (!split) { return; }
+        for (let i = 0, pos = 0; i < split.length - 1; i++) {
+          // Skip forward by the length of the content
+          pos += split[i].length;
+
+          GrAnnotation.annotateElement(contentEl, pos, 1,
+              'style-scope gr-diff tab-indicator');
+
+          // Skip forward by one tab character.
+          pos++;
+        }
+      },
+    };
+  }
+
+  _createTrailingWhitespaceLayer() {
+    const show = function() {
+      return this._showTrailingWhitespace;
+    }.bind(this);
+
+    return {
+      annotate(contentEl, lineNumberEl, line) {
+        if (!show()) { return; }
+
+        const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+        if (match) {
+          // Normalize string positions in case there is unicode before or
+          // within the match.
+          const index = GrAnnotation.getStringLength(
+              line.text.substr(0, match.index));
+          const length = GrAnnotation.getStringLength(match[0]);
+          GrAnnotation.annotateElement(contentEl, index, length,
+              'style-scope gr-diff trailing-whitespace');
+        }
+      },
+    };
+  }
+
+  setBlame(blame) {
+    if (!this._builder || !blame) { return; }
+    this._builder.setBlame(blame);
+  }
+}
+
+customElements.define(GrDiffBuilderElement.is, GrDiffBuilderElement);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
new file mode 100644
index 0000000..4d6b890
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <div class="contentWrapper">
+    <slot></slot>
+  </div>
+  <gr-ranged-comment-layer
+    id="rangeLayer"
+    comment-ranges="[[commentRanges]]"
+  ></gr-ranged-comment-layer>
+  <gr-coverage-layer
+    id="coverageLayerLeft"
+    coverage-ranges="[[_leftCoverageRanges]]"
+    side="left"
+  ></gr-coverage-layer>
+  <gr-coverage-layer
+    id="coverageLayerRight"
+    coverage-ranges="[[_rightCoverageRanges]]"
+    side="right"
+  ></gr-coverage-layer>
+  <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
new file mode 100644
index 0000000..e5847c4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
@@ -0,0 +1,1233 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
+<title>gr-diff-builder</title>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+
+<test-fixture id="basic">
+  <template is="dom-template">
+    <gr-diff-builder>
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+  </template>
+</test-fixture>
+
+<test-fixture id="div-with-text">
+  <template>
+    <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+  </template>
+</test-fixture>
+
+<test-fixture id="mock-diff">
+  <template>
+    <gr-diff-builder view-mode="SIDE_BY_SIDE">
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+  </template>
+</test-fixture>
+
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-diff/gr-diff-group.js';
+import './gr-diff-builder.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
+import './gr-diff-builder-element.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {GrDiffBuilder} from './gr-diff-builder.js';
+
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+
+suite('gr-diff-builder tests', () => {
+  let prefs;
+  let element;
+  let builder;
+  let sandbox;
+  const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getProjectConfig() { return Promise.resolve({}); },
+    });
+    sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns('/r');
+    prefs = {
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    builder = new GrDiffBuilder({content: []}, prefs);
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('_createElement classStr applies all classes', () => {
+    const node = builder._createElement('div', 'test classes');
+    assert.isTrue(node.classList.contains('gr-diff'));
+    assert.isTrue(node.classList.contains('test'));
+    assert.isTrue(node.classList.contains('classes'));
+  });
+
+  test('context control buttons', () => {
+    // Create 10 lines.
+    const lines = [];
+    for (let i = 0; i < 10; i++) {
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = i + 1;
+      line.afterNumber = i + 1;
+      line.text = 'lorem upsum';
+      lines.push(line);
+    }
+
+    const contextLine = {
+      contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
+    };
+
+    const section = {};
+    // Does not include +10 buttons when there are fewer than 11 lines.
+    let td = builder._createContextControl(section, contextLine);
+    let buttons = td.querySelectorAll('gr-button.showContext');
+
+    assert.equal(buttons.length, 1);
+    assert.equal(dom(buttons[0]).textContent, 'Show 10 common lines');
+
+    // Add another line.
+    const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    line.text = 'lorem upsum';
+    line.beforeNumber = 11;
+    line.afterNumber = 11;
+    contextLine.contextGroups[0].addLine(line);
+
+    // Includes +10 buttons when there are at least 11 lines.
+    td = builder._createContextControl(section, contextLine);
+    buttons = td.querySelectorAll('gr-button.showContext');
+
+    assert.equal(buttons.length, 3);
+    assert.equal(dom(buttons[0]).textContent, '+10 above');
+    assert.equal(dom(buttons[1]).textContent, 'Show 11 common lines');
+    assert.equal(dom(buttons[2]).textContent, '+10 below');
+  });
+
+  test('newlines 1', () => {
+    let text = 'abcdef';
+
+    assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
+    text = 'a'.repeat(20);
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        'a'.repeat(10) +
+        LINE_FEED_HTML +
+        'a'.repeat(10));
+  });
+
+  test('newlines 2', () => {
+    const text = '<span class="thumbsup">👍</span>';
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        '&lt;span clas' +
+        LINE_FEED_HTML +
+        's="thumbsu' +
+        LINE_FEED_HTML +
+        'p"&gt;👍&lt;/span' +
+        LINE_FEED_HTML +
+        '&gt;');
+  });
+
+  test('newlines 3', () => {
+    const text = '01234\t56789';
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        '01234' + builder._getTabWrapper(3).outerHTML + '56' +
+        LINE_FEED_HTML +
+        '789');
+  });
+
+  test('newlines 4', () => {
+    const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
+    assert.equal(builder._formatText(text, 4, 20).innerHTML,
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+        LINE_FEED_HTML +
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+        LINE_FEED_HTML +
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
+  });
+
+  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);
+
+    const line = {text, highlights: []};
+    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    assert.equal(result, text);
+  });
+
+  test('line_length applied if line_wrapping is false', () => {
+    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
+    const text = 'a'.repeat(51);
+
+    const line = {text, highlights: []};
+    const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
+    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
+      .forEach(mode => {
+        test(`line_length used for regular files under ${mode}`, () => {
+          element.path = '/a.txt';
+          element.viewMode = mode;
+          builder = element._getDiffBuilder(
+              {}, {tab_size: 4, line_length: 50}
+          );
+          assert.equal(builder._prefs.line_length, 50);
+        });
+
+        test(`line_length ignored for commit msg under ${mode}`, () => {
+          element.path = '/COMMIT_MSG';
+          element.viewMode = mode;
+          builder = element._getDiffBuilder(
+              {}, {tab_size: 4, line_length: 50}
+          );
+          assert.equal(builder._prefs.line_length, 72);
+        });
+      });
+
+  test('_createTextEl linewrap with tabs', () => {
+    const text = '\t'.repeat(7) + '!';
+    const line = {text, highlights: []};
+    const el = builder._createTextEl(undefined, line);
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 2, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
+        newlineEl);
+  });
+
+  test('text length with tabs and unicode', () => {
+    function expectTextLength(text, tabSize, expected) {
+      // Formatting to |expected| columns should not introduce line breaks.
+      const result = builder._formatText(text, tabSize, expected);
+      assert.isNotOk(result.querySelector('.contentText > .br'),
+          `  Expected the result of: \n` +
+          `      _formatText(${text}', ${tabSize}, ${expected})\n` +
+          `  to not contain a br. But the actual result HTML was:\n` +
+          `      '${result.innerHTML}'\nwhereupon`);
+
+      // Increasing the line limit should produce the same markup.
+      assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
+          result.innerHTML);
+      assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
+          result.innerHTML);
+
+      // Decreasing the line limit should introduce line breaks.
+      if (expected > 0) {
+        const tooSmall = builder._formatText(text, tabSize, expected - 1);
+        assert.isOk(tooSmall.querySelector('.contentText > .br'),
+            `  Expected the result of: \n` +
+            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
+            `  to contain a br. But the actual result HTML was:\n` +
+            `      '${tooSmall.innerHTML}'\nwhereupon`);
+      }
+    }
+    expectTextLength('12345', 4, 5);
+    expectTextLength('\t\t12', 4, 10);
+    expectTextLength('abc💢123', 4, 7);
+    expectTextLength('abc\t', 8, 8);
+    expectTextLength('abc\t\t', 10, 20);
+    expectTextLength('', 10, 0);
+    expectTextLength('', 10, 0);
+    // 17 Thai combining chars.
+    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+    expectTextLength('abc\tde', 10, 12);
+    expectTextLength('abc\tde\t', 10, 20);
+    expectTextLength('\t\t\t\t\t', 20, 100);
+  });
+
+  test('tab wrapper insertion', () => {
+    const html = 'abc\tdef';
+    const tabSize = builder._prefs.tab_size;
+    const wrapper = builder._getTabWrapper(tabSize - 3);
+    assert.ok(wrapper);
+    assert.equal(wrapper.innerText, '\t');
+    assert.equal(
+        builder._formatText(html, tabSize, Infinity).innerHTML,
+        'abc' + wrapper.outerHTML + 'def');
+  });
+
+  test('tab wrapper style', () => {
+    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;
+      expect(html).to.match(pattern);
+      assert.equal(html.match(pattern)[1], size);
+    }
+  });
+
+  test('_handlePreferenceError called with invalid preference', () => {
+    sandbox.stub(element, '_handlePreferenceError');
+    const prefs = {tab_size: 0};
+    element._getDiffBuilder(element.diff, prefs);
+    assert.isTrue(element._handlePreferenceError.lastCall
+        .calledWithExactly('tab size'));
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    element.addEventListener('show-alert', errorStub);
+    assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
+    assert.equal(errorStub.lastCall.args[0].detail.message,
+        `The value of the 'tab size' user preference is invalid. ` +
+      `Fix in diff preferences`);
+  });
+
+  suite('_isTotal', () => {
+    test('is total for add', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
+      }
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('is total for remove', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
+      }
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('not total for empty', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('not total for non-delta', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
+      }
+      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+    });
+  });
+
+  suite('intraline differences', () => {
+    let el;
+    let str;
+    let annotateElementSpy;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str, start, end) {
+      return Array.from(str).slice(start, end)
+          .join('');
+    }
+
+    setup(() => {
+      el = fixture('div-with-text');
+      str = el.textContent;
+      annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      layer = document.createElement('gr-diff-builder')
+          ._createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      const line = {
+        text: str,
+        highlights: [],
+      };
+
+      layer.annotate(el, lineNumberEl, line);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6, endIndex: 12},
+          {startIndex: 18, endIndex: 22},
+        ],
+      };
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 28},
+        ],
+      };
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 28, endIndex: 28},
+        ],
+      };
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6, endIndex: 12},
+        ],
+      };
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6},
+        ],
+      };
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let element;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element = fixture('basic');
+      element._showTabs = true;
+      layer = element._createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element._showTabs = false;
+
+      const str = '\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let element;
+    let initialLayersCount;
+    let withLayerCount;
+    setup(() => {
+      const layers = [];
+      element = fixture('basic');
+      element.layers = layers;
+      element._showTrailingWhitespace = true;
+      element._setupAnnotationLayers();
+      initialLayersCount = element._layers.length;
+    });
+
+    test('no layers', () => {
+      element._setupAnnotationLayers();
+      assert.equal(element._layers.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers = [{}, {}];
+      setup(() => {
+        element = fixture('basic');
+        element.layers = layers;
+        element._showTrailingWhitespace = true;
+        element._setupAnnotationLayers();
+        withLayerCount = element._layers.length;
+      });
+      test('with layers', () => {
+        element._setupAnnotationLayers();
+        assert.equal(element._layers.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length,
+            withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let element;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element = fixture('basic');
+      element._showTrailingWhitespace = true;
+      layer = element._createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element._showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sandbox.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub;
+    let keyLocations;
+    let prefs;
+    let content;
+
+    setup(() => {
+      element = fixture('basic');
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sandbox.stub(element.$.processor, 'process')
+          .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+    });
+
+    test('text', () => {
+      element.diff = {content};
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isFalse(processStub.lastCall.args[1]);
+      });
+    });
+
+    test('image', () => {
+      element.diff = {content, binary: true};
+      element.isImageDiff = true;
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isTrue(processStub.lastCall.args[1]);
+      });
+    });
+
+    test('binary', () => {
+      element.diff = {content, binary: true};
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isTrue(processStub.lastCall.args[1]);
+      });
+    });
+  });
+
+  suite('rendering', () => {
+    let content;
+    let outputEl;
+    let keyLocations;
+
+    setup(done => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      element = fixture('basic');
+      outputEl = element.queryEffectiveChildren('#diffTable');
+      keyLocations = {left: {}, right: {}};
+      sandbox.stub(element, '_getDiffBuilder', () => {
+        const builder = new GrDiffBuilder({content}, prefs, outputEl);
+        sandbox.stub(builder, 'addColumns');
+        builder.buildSectionElement = function(group) {
+          const section = document.createElement('stub');
+          section.textContent = group.lines
+              .reduce((acc, line) => acc + line.text, '');
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {content};
+      element.render(keyLocations, prefs).then(done);
+    });
+
+    test('addColumns is called', done => {
+      element.render(keyLocations, {}).then(done);
+      assert.isTrue(element._builder.addColumns.called);
+    });
+
+    test('getSectionsByLineRange one line', () => {
+      const section = outputEl.querySelector('stub:nth-of-type(2)');
+      const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
+      assert.equal(sections.length, 1);
+      assert.strictEqual(sections[0], section);
+    });
+
+    test('getSectionsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector('stub:nth-of-type(2)'),
+        outputEl.querySelector('stub:nth-of-type(3)'),
+      ];
+      const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
+      assert.equal(sections.length, 2);
+      assert.strictEqual(sections[0], section[0]);
+      assert.strictEqual(sections[1], section[1]);
+    });
+
+    test('render-start and render-content are fired', done => {
+      const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
+      element.render(keyLocations, {}).then(() => {
+        const firedEventTypes = dispatchEventStub.getCalls()
+            .map(c => c.args[0].type);
+        assert.include(firedEventTypes, 'render-start');
+        assert.include(firedEventTypes, 'render-content');
+        done();
+      });
+    });
+
+    test('cancel', () => {
+      const processorCancelStub = sandbox.stub(element.$.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('mock-diff', () => {
+    let element;
+    let builder;
+    let diff;
+    let prefs;
+    let keyLocations;
+
+    setup(done => {
+      element = fixture('mock-diff');
+      diff = getMockDiffResponse();
+      element.diff = diff;
+
+      prefs = {
+        line_length: 80,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      keyLocations = {left: {}, right: {}};
+
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+        done();
+      });
+    });
+
+    test('aria-labels on added line numbers', () => {
+      const deltaLineNumberButton = element.diffElement.querySelectorAll(
+          '.lineNumButton.right')[5];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
+    });
+
+    test('aria-labels on removed line numbers', () => {
+      const deltaLineNumberButton = element.diffElement.querySelectorAll(
+          '.lineNumButton.left')[10];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(
+          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
+    });
+
+    test('getContentByLine', () => {
+      let actual;
+
+      actual = builder.getContentByLine(2, 'left');
+      assert.equal(actual.textContent, diff.content[0].ab[1]);
+
+      actual = builder.getContentByLine(2, 'right');
+      assert.equal(actual.textContent, diff.content[0].ab[1]);
+
+      actual = builder.getContentByLine(5, 'left');
+      assert.equal(actual.textContent, diff.content[2].ab[0]);
+
+      actual = builder.getContentByLine(5, 'right');
+      assert.equal(actual.textContent, diff.content[1].b[0]);
+    });
+
+    test('getContentByLineEl works both with button and td', () => {
+      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
+
+      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
+      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
+      const contentLeft = diffRow.querySelectorAll('.contentText')[0];
+
+      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
+      const lineNumButtonRight = lineNumTdRight.querySelector('button');
+      const contentRight = diffRow.querySelectorAll('.contentText')[1];
+
+      assert.equal(element.getContentByLineEl(lineNumTdLeft), contentLeft);
+      assert.equal(element.getContentByLineEl(lineNumButtonLeft), contentLeft);
+      assert.equal(element.getContentByLineEl(lineNumTdRight), contentRight);
+      assert.equal(
+          element.getContentByLineEl(lineNumButtonRight), contentRight);
+    });
+
+    test('findLinesByRange', () => {
+      const lines = [];
+      const elems = [];
+      const start = 6;
+      const end = 10;
+      const count = end - start + 1;
+
+      builder.findLinesByRange(start, end, 'right', lines, elems);
+
+      assert.equal(lines.length, count);
+      assert.equal(elems.length, count);
+
+      for (let i = 0; i < 5; i++) {
+        assert.instanceOf(lines[i], GrDiffLine);
+        assert.equal(lines[i].afterNumber, start + i);
+        assert.instanceOf(elems[i], HTMLElement);
+        assert.equal(lines[i].text, elems[i].textContent);
+      }
+    });
+
+    test('_renderContentByRange', () => {
+      const spy = sandbox.spy(builder, '_createTextEl');
+      const start = 9;
+      const end = 14;
+      const count = end - start + 1;
+
+      builder._renderContentByRange(start, end, 'left');
+
+      assert.equal(spy.callCount, count);
+      spy.getCalls().forEach((call, i) => {
+        assert.equal(call.args[1].beforeNumber, start + i);
+      });
+    });
+
+    test('_renderContentByRange notexistent elements', () => {
+      const spy = sandbox.spy(builder, '_createTextEl');
+
+      sandbox.stub(builder, 'findLinesByRange',
+          (s, e, d, lines, elements) => {
+            // Add a line and a corresponding element.
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+            const tr = document.createElement('tr');
+            const td = document.createElement('td');
+            const el = document.createElement('div');
+            tr.appendChild(td);
+            td.appendChild(el);
+            elements.push(el);
+
+            // Add 2 lines without corresponding elements.
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+          });
+
+      builder._renderContentByRange(1, 10, 'left');
+      // Should be called only once because only one line had a corresponding
+      // element.
+      assert.equal(spy.callCount, 1);
+    });
+
+    test('_getLineNumberEl side-by-side left', () => {
+      const contentEl = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('left'));
+    });
+
+    test('_getLineNumberEl side-by-side right', () => {
+      const contentEl = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('right'));
+    });
+
+    test('_getLineNumberEl unified left', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const contentEl = builder.getContentByLine(5, 'left',
+            element.$.diffTable);
+        const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl.classList.contains('left'));
+        done();
+      });
+    });
+
+    test('_getLineNumberEl unified right', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const contentEl = builder.getContentByLine(5, 'right',
+            element.$.diffTable);
+        const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl.classList.contains('right'));
+        done();
+      });
+    });
+
+    test('_getNextContentOnSide side-by-side left', () => {
+      const startElem = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const expectedStartString = diff.content[2].ab[0];
+      const expectedNextString = diff.content[2].ab[1];
+      assert.equal(startElem.textContent, expectedStartString);
+
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'left');
+      assert.equal(nextElem.textContent, expectedNextString);
+    });
+
+    test('_getNextContentOnSide side-by-side right', () => {
+      const startElem = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const expectedStartString = diff.content[1].b[0];
+      const expectedNextString = diff.content[1].b[1];
+      assert.equal(startElem.textContent, expectedStartString);
+
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'right');
+      assert.equal(nextElem.textContent, expectedNextString);
+    });
+
+    test('_getNextContentOnSide unified left', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const startElem = builder.getContentByLine(5, 'left',
+            element.$.diffTable);
+        const expectedStartString = diff.content[2].ab[0];
+        const expectedNextString = diff.content[2].ab[1];
+        assert.equal(startElem.textContent, expectedStartString);
+
+        const nextElem = builder._getNextContentOnSide(startElem,
+            'left');
+        assert.equal(nextElem.textContent, expectedNextString);
+
+        done();
+      });
+    });
+
+    test('_getNextContentOnSide unified right', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const startElem = builder.getContentByLine(5, 'right',
+            element.$.diffTable);
+        const expectedStartString = diff.content[1].b[0];
+        const expectedNextString = diff.content[1].b[1];
+        assert.equal(startElem.textContent, expectedStartString);
+
+        const nextElem = builder._getNextContentOnSide(startElem,
+            'right');
+        assert.equal(nextElem.textContent, expectedNextString);
+
+        done();
+      });
+    });
+
+    test('escaping HTML', () => {
+      let input = '<script>alert("XSS");<' + '/script>';
+      let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
+      let result = builder._formatText(input, 1, Infinity).innerHTML;
+      assert.equal(result, expected);
+
+      input = '& < > " \' / `';
+      expected = '&amp; &lt; &gt; " \' / `';
+      result = builder._formatText(input, 1, Infinity).innerHTML;
+      assert.equal(result, expected);
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame;
+
+    setup(() => {
+      mockBlame = [
+        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
+        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sandbox.stub(builder, '_getBlameByLineNum')
+          .returns(null);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('_getBlameCommitForBaseLine', () => {
+      builder.setBlame(mockBlame);
+      assert.isOk(builder._getBlameCommitForBaseLine(1));
+      assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
+
+      assert.isOk(builder._getBlameCommitForBaseLine(11));
+      assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
+
+      assert.isOk(builder._getBlameCommitForBaseLine(32));
+      assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
+
+      assert.isNull(builder._getBlameCommitForBaseLine(33));
+    });
+
+    test('_getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isNull(builder._getBlameCommitForBaseLine(1));
+      assert.isNull(builder._getBlameCommitForBaseLine(11));
+      assert.isNull(builder._getBlameCommitForBaseLine(31));
+    });
+
+    test('_createBlameCell', () => {
+      const mocbBlameCell = document.createElement('span');
+      const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
+          .returns(mocbBlameCell);
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder._createBlameCell(line);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      assert.equal(result.firstChild, mocbBlameCell);
+    });
+
+    test('_getBlameForBaseLine', () => {
+      const mockCommit = {
+        time: 1576105200,
+        id: 1234567890,
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [1],
+      };
+      const blameNode = builder._getBlameForBaseLine(1, mockCommit);
+
+      const authors = blameNode.getElementsByClassName('blameAuthor');
+      assert.equal(authors.length, 1);
+      assert.equal(authors[0].innerText, ' Clark');
+
+      const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
+      flush();
+      const cards = blameNode.getElementsByClassName('blameHoverCard');
+      assert.equal(cards.length, 1);
+      assert.equal(cards[0].innerHTML,
+          `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
+        + '<br><br>Testing Commit'
+      );
+
+      const url = blameNode.getElementsByClassName('blameDate');
+      assert.equal(url[0].getAttribute('href'), '/r/q/1234567890');
+    });
+  });
+});
+</script>
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..1fc0d4f 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
@@ -14,169 +14,165 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffBuilderSideBySide) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrDiffBuilderImage) { return; }
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
 
-  // MIME types for images we allow showing. Do not include SVG, it can contain
-  // arbitrary JavaScript.
-  const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
+// MIME types for images we allow showing. Do not include SVG, it can contain
+// arbitrary JavaScript.
+const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
 
-  function GrDiffBuilderImage(diff, prefs, outputEl, baseImage, revisionImage) {
-    GrDiffBuilderSideBySide.call(this, diff, prefs, outputEl, []);
-    this._baseImage = baseImage;
-    this._revisionImage = revisionImage;
+/** @constructor */
+export function GrDiffBuilderImage(diff, prefs, outputEl, baseImage,
+    revisionImage) {
+  GrDiffBuilderSideBySide.call(this, diff, prefs, outputEl, []);
+  this._baseImage = baseImage;
+  this._revisionImage = revisionImage;
+}
+
+GrDiffBuilderImage.prototype = Object.create(
+    GrDiffBuilderSideBySide.prototype);
+GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
+
+GrDiffBuilderImage.prototype.renderDiff = function() {
+  const section = this._createElement('tbody', 'image-diff');
+
+  this._emitImagePair(section);
+  this._emitImageLabels(section);
+
+  this._outputEl.appendChild(section);
+  this._outputEl.appendChild(this._createEndpoint());
+};
+
+GrDiffBuilderImage.prototype._createEndpoint = function() {
+  const tbody = this._createElement('tbody');
+  const tr = this._createElement('tr');
+  const td = this._createElement('td');
+
+  // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
+  // column limit.
+  td.setAttribute('colspan', '4');
+  const endpoint = this._createElement('gr-endpoint-decorator');
+  const endpointDomApi = Polymer.dom(endpoint);
+  endpointDomApi.setAttribute('name', 'image-diff');
+  endpointDomApi.appendChild(
+      this._createEndpointParam('baseImage', this._baseImage));
+  endpointDomApi.appendChild(
+      this._createEndpointParam('revisionImage', this._revisionImage));
+  td.appendChild(endpoint);
+  tr.appendChild(td);
+  tbody.appendChild(tr);
+  return tbody;
+};
+
+GrDiffBuilderImage.prototype._createEndpointParam = function(name, value) {
+  const endpointParam = this._createElement('gr-endpoint-param');
+  endpointParam.setAttribute('name', name);
+  endpointParam.value = value;
+  return endpointParam;
+};
+
+GrDiffBuilderImage.prototype._emitImagePair = function(section) {
+  const tr = this._createElement('tr');
+
+  tr.appendChild(this._createElement('td', 'left lineNum blank'));
+  tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
+
+  tr.appendChild(this._createElement('td', 'right lineNum blank'));
+  tr.appendChild(this._createImageCell(
+      this._revisionImage, 'right', section));
+
+  section.appendChild(tr);
+};
+
+GrDiffBuilderImage.prototype._createImageCell = function(image, className,
+    section) {
+  const td = this._createElement('td', className);
+  if (image && IMAGE_MIME_PATTERN.test(image.type)) {
+    const imageEl = this._createElement('img');
+    imageEl.onload = function() {
+      image._height = imageEl.naturalHeight;
+      image._width = imageEl.naturalWidth;
+      this._updateImageLabel(section, className, image);
+    }.bind(this);
+    imageEl.setAttribute('src', `data:${image.type};base64, ${image.body}`);
+    imageEl.addEventListener('error', () => {
+      imageEl.remove();
+      td.textContent = '[Image failed to load]';
+    });
+    td.appendChild(imageEl);
+  }
+  return td;
+};
+
+GrDiffBuilderImage.prototype._updateImageLabel = function(section, className,
+    image) {
+  const label = Polymer.dom(section)
+      .querySelector('.' + className + ' span.label');
+  this._setLabelText(label, image);
+};
+
+GrDiffBuilderImage.prototype._setLabelText = function(label, image) {
+  label.textContent = this._getImageLabel(image);
+};
+
+GrDiffBuilderImage.prototype._emitImageLabels = function(section) {
+  const tr = this._createElement('tr');
+
+  let addNamesInLabel = false;
+
+  if (this._baseImage && this._revisionImage &&
+      this._baseImage._name !== this._revisionImage._name) {
+    addNamesInLabel = true;
   }
 
-  GrDiffBuilderImage.prototype = Object.create(
-      GrDiffBuilderSideBySide.prototype);
-  GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
+  tr.appendChild(this._createElement('td', 'left lineNum blank'));
+  let td = this._createElement('td', 'left');
+  let label = this._createElement('label');
+  let nameSpan;
+  let labelSpan = this._createElement('span', 'label');
 
-  GrDiffBuilderImage.prototype.renderDiff = function() {
-    const section = this._createElement('tbody', 'image-diff');
+  if (addNamesInLabel) {
+    nameSpan = this._createElement('span', 'name');
+    nameSpan.textContent = this._baseImage._name;
+    label.appendChild(nameSpan);
+    label.appendChild(this._createElement('br'));
+  }
 
-    this._emitImagePair(section);
-    this._emitImageLabels(section);
+  this._setLabelText(labelSpan, this._baseImage, addNamesInLabel);
 
-    this._outputEl.appendChild(section);
-    this._outputEl.appendChild(this._createEndpoint());
-  };
+  label.appendChild(labelSpan);
+  td.appendChild(label);
+  tr.appendChild(td);
 
-  GrDiffBuilderImage.prototype._createEndpoint = function() {
-    const tbody = this._createElement('tbody');
-    const tr = this._createElement('tr');
-    const td = this._createElement('td');
+  tr.appendChild(this._createElement('td', 'right lineNum blank'));
+  td = this._createElement('td', 'right');
+  label = this._createElement('label');
+  labelSpan = this._createElement('span', 'label');
 
-    // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
-    // column limit.
-    td.setAttribute('colspan', '4');
-    const endpoint = this._createElement('gr-endpoint-decorator');
-    const endpointDomApi = Polymer.dom(endpoint);
-    endpointDomApi.setAttribute('name', 'image-diff');
-    endpointDomApi.appendChild(
-        this._createEndpointParam('baseImage', this._baseImage));
-    endpointDomApi.appendChild(
-        this._createEndpointParam('revisionImage', this._revisionImage));
-    td.appendChild(endpoint);
-    tr.appendChild(td);
-    tbody.appendChild(tr);
-    return tbody;
-  };
+  if (addNamesInLabel) {
+    nameSpan = this._createElement('span', 'name');
+    nameSpan.textContent = this._revisionImage._name;
+    label.appendChild(nameSpan);
+    label.appendChild(this._createElement('br'));
+  }
 
-  GrDiffBuilderImage.prototype._createEndpointParam = function(name, value) {
-    const endpointParam = this._createElement('gr-endpoint-param');
-    endpointParam.setAttribute('name', name);
-    endpointParam.value = value;
-    return endpointParam;
-  };
+  this._setLabelText(labelSpan, this._revisionImage, addNamesInLabel);
 
-  GrDiffBuilderImage.prototype._emitImagePair = function(section) {
-    const tr = this._createElement('tr');
+  label.appendChild(labelSpan);
+  td.appendChild(label);
+  tr.appendChild(td);
 
-    tr.appendChild(this._createElement('td', 'left lineNum blank'));
-    tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
+  section.appendChild(tr);
+};
 
-    tr.appendChild(this._createElement('td', 'right lineNum blank'));
-    tr.appendChild(this._createImageCell(
-        this._revisionImage, 'right', section));
-
-    section.appendChild(tr);
-  };
-
-  GrDiffBuilderImage.prototype._createImageCell = function(image, className,
-      section) {
-    const td = this._createElement('td', className);
-    if (image && IMAGE_MIME_PATTERN.test(image.type)) {
-      const imageEl = this._createElement('img');
-      imageEl.onload = function() {
-        image._height = imageEl.naturalHeight;
-        image._width = imageEl.naturalWidth;
-        this._updateImageLabel(section, className, image);
-      }.bind(this);
-      imageEl.setAttribute('src', `data:${image.type};base64, ${image.body}`);
-      imageEl.addEventListener('error', () => {
-        imageEl.remove();
-        td.textContent = '[Image failed to load]';
-      });
-      td.appendChild(imageEl);
+GrDiffBuilderImage.prototype._getImageLabel = function(image) {
+  if (image) {
+    const type = image.type || image._expectedType;
+    if (image._width && image._height) {
+      return image._width + '×' + image._height + ' ' + type;
+    } else {
+      return type;
     }
-    return td;
-  };
-
-  GrDiffBuilderImage.prototype._updateImageLabel = function(section, className,
-      image) {
-    const label = Polymer.dom(section)
-        .querySelector('.' + className + ' span.label');
-    this._setLabelText(label, image);
-  };
-
-  GrDiffBuilderImage.prototype._setLabelText = function(label, image) {
-    label.textContent = this._getImageLabel(image);
-  };
-
-  GrDiffBuilderImage.prototype._emitImageLabels = function(section) {
-    const tr = this._createElement('tr');
-
-    let addNamesInLabel = false;
-
-    if (this._baseImage && this._revisionImage &&
-        this._baseImage._name !== this._revisionImage._name) {
-      addNamesInLabel = true;
-    }
-
-    tr.appendChild(this._createElement('td', 'left lineNum blank'));
-    let td = this._createElement('td', 'left');
-    let label = this._createElement('label');
-    let nameSpan;
-    let labelSpan = this._createElement('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = this._createElement('span', 'name');
-      nameSpan.textContent = this._baseImage._name;
-      label.appendChild(nameSpan);
-      label.appendChild(this._createElement('br'));
-    }
-
-    this._setLabelText(labelSpan, this._baseImage, addNamesInLabel);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    tr.appendChild(this._createElement('td', 'right lineNum blank'));
-    td = this._createElement('td', 'right');
-    label = this._createElement('label');
-    labelSpan = this._createElement('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = this._createElement('span', 'name');
-      nameSpan.textContent = this._revisionImage._name;
-      label.appendChild(nameSpan);
-      label.appendChild(this._createElement('br'));
-    }
-
-    this._setLabelText(labelSpan, this._revisionImage, addNamesInLabel);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    section.appendChild(tr);
-  };
-
-  GrDiffBuilderImage.prototype._getImageLabel = function(image) {
-    if (image) {
-      const type = image.type || image._expectedType;
-      if (image._width && image._height) {
-        return image._width + '×' + image._height + ' ' + type;
-      } else {
-        return type;
-      }
-    }
-    return 'No image';
-  };
-
-  window.GrDiffBuilderImage = GrDiffBuilderImage;
-})(window, GrDiffBuilderSideBySide);
+  }
+  return 'No image';
+};
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..8b73936 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
@@ -14,106 +14,100 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffBuilder) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrDiffBuilderSideBySide) { return; }
+import {GrDiffBuilder} from './gr-diff-builder.js';
 
-  function GrDiffBuilderSideBySide(diff, prefs, outputEl, layers) {
-    GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
+/** @constructor */
+export function GrDiffBuilderSideBySide(diff, prefs, outputEl, layers) {
+  GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
+}
+GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
+GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
+
+GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
+  const sectionEl = this._createElement('tbody', 'section');
+  sectionEl.classList.add(group.type);
+  if (this._isTotal(group)) {
+    sectionEl.classList.add('total');
   }
-  GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
-  GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
+  if (group.dueToRebase) {
+    sectionEl.classList.add('dueToRebase');
+  }
+  if (group.ignoredWhitespaceOnly) {
+    sectionEl.classList.add('ignoredWhitespaceOnly');
+  }
+  const pairs = group.getSideBySidePairs();
+  for (let i = 0; i < pairs.length; i++) {
+    sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
+        pairs[i].right));
+  }
+  return sectionEl;
+};
 
-  GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
-    const sectionEl = this._createElement('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    const pairs = group.getSideBySidePairs();
-    for (let i = 0; i < pairs.length; i++) {
-      sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
-          pairs[i].right));
-    }
-    return sectionEl;
-  };
+GrDiffBuilderSideBySide.prototype.addColumns = function(outputEl, fontSize) {
+  const width = fontSize * 4;
+  const colgroup = document.createElement('colgroup');
 
-  GrDiffBuilderSideBySide.prototype.addColumns = function(outputEl, fontSize) {
-    const width = fontSize * 4;
-    const colgroup = document.createElement('colgroup');
+  // Add the blame column.
+  let col = this._createElement('col', 'blame');
+  colgroup.appendChild(col);
 
-    // Add the blame column.
-    let col = this._createElement('col', 'blame');
-    colgroup.appendChild(col);
+  // Add left-side line number.
+  col = document.createElement('col');
+  col.setAttribute('width', width);
+  colgroup.appendChild(col);
 
-    // Add left-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', width);
-    colgroup.appendChild(col);
+  // Add left-side content.
+  colgroup.appendChild(document.createElement('col'));
 
-    // Add left-side content.
-    colgroup.appendChild(document.createElement('col'));
+  // Add right-side line number.
+  col = document.createElement('col');
+  col.setAttribute('width', width);
+  colgroup.appendChild(col);
 
-    // Add right-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', width);
-    colgroup.appendChild(col);
+  // Add right-side content.
+  colgroup.appendChild(document.createElement('col'));
 
-    // Add right-side content.
-    colgroup.appendChild(document.createElement('col'));
+  outputEl.appendChild(colgroup);
+};
 
-    outputEl.appendChild(colgroup);
-  };
+GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
+    rightLine) {
+  const row = this._createElement('tr');
+  row.classList.add('diff-row', 'side-by-side');
+  row.setAttribute('left-type', leftLine.type);
+  row.setAttribute('right-type', rightLine.type);
+  row.tabIndex = -1;
 
-  GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
-      rightLine) {
-    const row = this._createElement('tr');
-    row.classList.add('diff-row', 'side-by-side');
-    row.setAttribute('left-type', leftLine.type);
-    row.setAttribute('right-type', rightLine.type);
-    row.tabIndex = -1;
+  row.appendChild(this._createBlameCell(leftLine));
 
-    row.appendChild(this._createBlameCell(leftLine));
+  this._appendPair(section, row, leftLine, leftLine.beforeNumber,
+      GrDiffBuilder.Side.LEFT);
+  this._appendPair(section, row, rightLine, rightLine.afterNumber,
+      GrDiffBuilder.Side.RIGHT);
+  return row;
+};
 
-    this._appendPair(section, row, leftLine, leftLine.beforeNumber,
-        GrDiffBuilder.Side.LEFT);
-    this._appendPair(section, row, rightLine, rightLine.afterNumber,
-        GrDiffBuilder.Side.RIGHT);
-    return row;
-  };
+GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
+    lineNumber, side) {
+  const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
+  row.appendChild(lineNumberEl);
+  const action = this._createContextControl(section, line);
+  if (action) {
+    row.appendChild(action);
+  } else {
+    const textEl = this._createTextEl(lineNumberEl, line, side);
+    row.appendChild(textEl);
+  }
+};
 
-  GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
-      lineNumber, side) {
-    const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
-    lineNumberEl.classList.add(side);
-    row.appendChild(lineNumberEl);
-    const action = this._createContextControl(section, line);
-    if (action) {
-      row.appendChild(action);
-    } else {
-      const textEl = this._createTextEl(lineNumberEl, line, side);
-      row.appendChild(textEl);
-    }
-  };
-
-  GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
-      content, side) {
-    let tr = content.parentElement.parentElement;
-    while (tr = tr.nextSibling) {
-      content = tr.querySelector(
-          'td.content .contentText[data-side="' + side + '"]');
-      if (content) { return content; }
-    }
-    return null;
-  };
-
-  window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
-})(window, GrDiffBuilder);
+GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
+    content, side) {
+  let tr = content.parentElement.parentElement;
+  while (tr = tr.nextSibling) {
+    content = tr.querySelector(
+        'td.content .contentText[data-side="' + side + '"]');
+    if (content) { return content; }
+  }
+  return null;
+};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 144cc56..8163176 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -14,104 +14,96 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffBuilder) {
-  'use strict';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffBuilder} from './gr-diff-builder.js';
 
-  // Prevent redefinition.
-  if (window.GrDiffBuilderUnified) { return; }
+export function GrDiffBuilderUnified(diff, prefs, outputEl, layers) {
+  GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
+}
+GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
+GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
 
-  function GrDiffBuilderUnified(diff, prefs, outputEl, layers) {
-    GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
+GrDiffBuilderUnified.prototype.buildSectionElement = function(group) {
+  const sectionEl = this._createElement('tbody', 'section');
+  sectionEl.classList.add(group.type);
+  if (this._isTotal(group)) {
+    sectionEl.classList.add('total');
   }
-  GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
-  GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
+  if (group.dueToRebase) {
+    sectionEl.classList.add('dueToRebase');
+  }
+  if (group.ignoredWhitespaceOnly) {
+    sectionEl.classList.add('ignoredWhitespaceOnly');
+  }
 
-  GrDiffBuilderUnified.prototype.buildSectionElement = function(group) {
-    const sectionEl = this._createElement('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
-      sectionEl.classList.add('total');
+  for (let i = 0; i < group.lines.length; ++i) {
+    const line = group.lines[i];
+    // If only whitespace has changed and the settings ask for whitespace to
+    // be ignored, only render the right-side line in unified diff mode.
+    if (group.ignoredWhitespaceOnly && line.type == GrDiffLine.Type.REMOVE) {
+      continue;
     }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
+    sectionEl.appendChild(this._createRow(sectionEl, line));
+  }
+  return sectionEl;
+};
+
+GrDiffBuilderUnified.prototype.addColumns = function(outputEl, fontSize) {
+  const width = fontSize * 4;
+  const colgroup = document.createElement('colgroup');
+
+  // Add the blame column.
+  let col = this._createElement('col', 'blame');
+  colgroup.appendChild(col);
+
+  // Add left-side line number.
+  col = document.createElement('col');
+  col.setAttribute('width', width);
+  colgroup.appendChild(col);
+
+  // Add right-side line number.
+  col = document.createElement('col');
+  col.setAttribute('width', width);
+  colgroup.appendChild(col);
+
+  // Add the content.
+  colgroup.appendChild(document.createElement('col'));
+
+  outputEl.appendChild(colgroup);
+};
+
+GrDiffBuilderUnified.prototype._createRow = function(section, line) {
+  const row = this._createElement('tr', line.type);
+  row.classList.add('diff-row', 'unified');
+  row.tabIndex = -1;
+  row.appendChild(this._createBlameCell(line));
+
+  let lineNumberEl = this._createLineEl(line, line.beforeNumber,
+      GrDiffLine.Type.REMOVE, 'left');
+  row.appendChild(lineNumberEl);
+  lineNumberEl = this._createLineEl(line, line.afterNumber,
+      GrDiffLine.Type.ADD, 'right');
+  row.appendChild(lineNumberEl);
+
+  const action = this._createContextControl(section, line);
+  if (action) {
+    row.appendChild(action);
+  } else {
+    const textEl = this._createTextEl(lineNumberEl, line);
+    row.appendChild(textEl);
+  }
+  return row;
+};
+
+GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
+    content, side) {
+  let tr = content.parentElement.parentElement;
+  while (tr = tr.nextSibling) {
+    if (tr.classList.contains('both') || (
+      (side === 'left' && tr.classList.contains('remove')) ||
+        (side === 'right' && tr.classList.contains('add')))) {
+      return tr.querySelector('.contentText');
     }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-
-    for (let i = 0; i < group.lines.length; ++i) {
-      const line = group.lines[i];
-      // If only whitespace has changed and the settings ask for whitespace to
-      // be ignored, only render the right-side line in unified diff mode.
-      if (group.ignoredWhitespaceOnly && line.type == GrDiffLine.Type.REMOVE) {
-        continue;
-      }
-      sectionEl.appendChild(this._createRow(sectionEl, line));
-    }
-    return sectionEl;
-  };
-
-  GrDiffBuilderUnified.prototype.addColumns = function(outputEl, fontSize) {
-    const width = fontSize * 4;
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = this._createElement('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', width);
-    colgroup.appendChild(col);
-
-    // Add right-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', width);
-    colgroup.appendChild(col);
-
-    // Add the content.
-    colgroup.appendChild(document.createElement('col'));
-
-    outputEl.appendChild(colgroup);
-  };
-
-  GrDiffBuilderUnified.prototype._createRow = function(section, line) {
-    const row = this._createElement('tr', line.type);
-    row.classList.add('diff-row', 'unified');
-    row.tabIndex = -1;
-    row.appendChild(this._createBlameCell(line));
-
-    let lineNumberEl = this._createLineEl(line, line.beforeNumber,
-        GrDiffLine.Type.REMOVE);
-    lineNumberEl.classList.add('left');
-    row.appendChild(lineNumberEl);
-    lineNumberEl = this._createLineEl(line, line.afterNumber,
-        GrDiffLine.Type.ADD);
-    lineNumberEl.classList.add('right');
-    row.appendChild(lineNumberEl);
-
-    const action = this._createContextControl(section, line);
-    if (action) {
-      row.appendChild(action);
-    } else {
-      const textEl = this._createTextEl(lineNumberEl, line);
-      row.appendChild(textEl);
-    }
-    return row;
-  };
-
-  GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
-      content, side) {
-    let tr = content.parentElement.parentElement;
-    while (tr = tr.nextSibling) {
-      if (tr.classList.contains('both') || (
-        (side === 'left' && tr.classList.contains('remove')) ||
-          (side === 'right' && tr.classList.contains('add')))) {
-        return tr.querySelector('.contentText');
-      }
-    }
-    return null;
-  };
-
-  window.GrDiffBuilderUnified = GrDiffBuilderUnified;
-})(window, GrDiffBuilder);
+  }
+  return null;
+};
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..2d26667 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
@@ -17,189 +17,189 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../gr-diff/gr-diff-group.js"></script>
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-<script src="gr-diff-builder.js"></script>
-<script src="gr-diff-builder-unified.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script>
-  suite('GrDiffBuilderUnified tests', () => {
-    let prefs;
-    let outputEl;
-    let diffBuilder;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-diff/gr-diff-group.js';
+import './gr-diff-builder.js';
+import './gr-diff-builder-unified.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
 
-    setup(()=> {
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      outputEl = document.createElement('div');
-      diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs;
+  let outputEl;
+  let diffBuilder;
+
+  setup(()=> {
+    prefs = {
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines;
+    let group;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
     });
 
-    suite('buildSectionElement for BOTH group', () => {
-      let lines;
-      let group;
-
-      setup(() => {
-        lines = [
-          new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
-        ];
-        lines[0].text = 'def hello_world():';
-        lines[1].text = '  print "Hello World";';
-        lines[2].text = '  return True';
-
-        group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
-      });
-
-      test('creates the section', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('section'));
-        assert.isTrue(sectionEl.classList.contains('both'));
-      });
-
-      test('creates each unchanged row once', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-        assert.equal(rowEls.length, 3);
-
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.left').textContent,
-            lines[0].beforeNumber);
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.right').textContent,
-            lines[0].afterNumber);
-        assert.equal(
-            rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.left').textContent,
-            lines[1].beforeNumber);
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.right').textContent,
-            lines[1].afterNumber);
-        assert.equal(
-            rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-        assert.equal(
-            rowEls[2].querySelector('.lineNum.left').textContent,
-            lines[2].beforeNumber);
-        assert.equal(
-            rowEls[2].querySelector('.lineNum.right').textContent,
-            lines[2].afterNumber);
-        assert.equal(
-            rowEls[2].querySelector('.content').textContent, lines[2].text);
-      });
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
     });
 
-    suite('buildSectionElement for DELTA group', () => {
-      let lines;
-      let group;
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
 
-      setup(() => {
-        lines = [
-          new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
-          new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
-          new GrDiffLine(GrDiffLine.Type.ADD, 2),
-          new GrDiffLine(GrDiffLine.Type.ADD, 3),
-        ];
-        lines[0].text = 'def hello_world():';
-        lines[1].text = '  print "Hello World"';
-        lines[2].text = 'def hello_universe()';
-        lines[3].text = '  print "Hello Universe"';
+      assert.equal(rowEls.length, 3);
 
-        group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
-      });
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.left').textContent,
+          lines[0].beforeNumber);
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.right').textContent,
+          lines[0].afterNumber);
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[0].text);
 
-      test('creates the section', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('section'));
-        assert.isTrue(sectionEl.classList.contains('delta'));
-      });
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.left').textContent,
+          lines[1].beforeNumber);
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.right').textContent,
+          lines[1].afterNumber);
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[1].text);
 
-      test('creates the section with class if ignoredWhitespaceOnly', () => {
-        group.ignoredWhitespaceOnly = true;
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-      });
-
-      test('creates the section with class if dueToRebase', () => {
-        group.dueToRebase = true;
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-      });
-
-      test('creates first the removed and then the added rows', () => {
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-        assert.equal(rowEls.length, 4);
-
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.left').textContent,
-            lines[0].beforeNumber);
-        assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-        assert.equal(
-            rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.left').textContent,
-            lines[1].beforeNumber);
-        assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-        assert.equal(
-            rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-        assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[2].querySelector('.lineNum.right').textContent,
-            lines[2].afterNumber);
-        assert.equal(
-            rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-        assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[3].querySelector('.lineNum.right').textContent,
-            lines[3].afterNumber);
-        assert.equal(
-            rowEls[3].querySelector('.content').textContent, lines[3].text);
-      });
-
-      test('creates only the added rows if only ignored whitespace', () => {
-        group.ignoredWhitespaceOnly = true;
-        const sectionEl = diffBuilder.buildSectionElement(group);
-        const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-        assert.equal(rowEls.length, 2);
-
-        assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[0].querySelector('.lineNum.right').textContent,
-            lines[2].afterNumber);
-        assert.equal(
-            rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-        assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-        assert.equal(
-            rowEls[1].querySelector('.lineNum.right').textContent,
-            lines[3].afterNumber);
-        assert.equal(
-            rowEls[1].querySelector('.content').textContent, lines[3].text);
-      });
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.left').textContent,
+          lines[2].beforeNumber);
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[2].querySelector('.content').textContent, lines[2].text);
     });
   });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines;
+    let group;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
+        new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
+        new GrDiffLine(GrDiffLine.Type.ADD, 2),
+        new GrDiffLine(GrDiffLine.Type.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+
+      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group.ignoredWhitespaceOnly = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group.dueToRebase = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.left').textContent,
+          lines[0].beforeNumber);
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[0].text);
+
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.left').textContent,
+          lines[1].beforeNumber);
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[1].text);
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[2].querySelector('.content').textContent, lines[2].text);
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[3].querySelector('.lineNum.right').textContent,
+          lines[3].afterNumber);
+      assert.equal(
+          rowEls[3].querySelector('.content').textContent, lines[3].text);
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group.ignoredWhitespaceOnly = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[2].text);
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.right').textContent,
+          lines[3].afterNumber);
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[3].text);
+    });
+  });
+});
 </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
deleted file mode 100644
index 40fbe3c..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ /dev/null
@@ -1,422 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-coverage-layer/gr-coverage-layer.html">
-<link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
-<link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
-
-<dom-module id="gr-diff-builder">
-  <template>
-    <div class="contentWrapper">
-      <slot></slot>
-    </div>
-    <gr-ranged-comment-layer
-        id="rangeLayer"
-        comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
-    <gr-coverage-layer
-        id="coverageLayerLeft"
-        coverage-ranges="[[_leftCoverageRanges]]"
-        side="left"></gr-coverage-layer>
-    <gr-coverage-layer
-        id="coverageLayerRight"
-        coverage-ranges="[[_rightCoverageRanges]]"
-        side="right"></gr-coverage-layer>
-    <gr-diff-processor
-        id="processor"
-        groups="{{_groups}}"></gr-diff-processor>
-  </template>
-  <script src="../../../scripts/util.js"></script>
-  <script src="../gr-diff/gr-diff-line.js"></script>
-  <script src="../gr-diff/gr-diff-group.js"></script>
-  <script src="../gr-diff-highlight/gr-annotation.js"></script>
-  <script src="gr-diff-builder.js"></script>
-  <script src="gr-diff-builder-side-by-side.js"></script>
-  <script src="gr-diff-builder-unified.js"></script>
-  <script src="gr-diff-builder-image.js"></script>
-  <script src="gr-diff-builder-binary.js"></script>
-  <script>
-    (function() {
-      'use strict';
-
-      const DiffViewMode = {
-        SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-        UNIFIED: 'UNIFIED_DIFF',
-      };
-
-      const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-      Polymer({
-        is: 'gr-diff-builder',
-
-        /**
-         * Fired when the diff begins rendering.
-         *
-         * @event render-start
-         */
-
-        /**
-         * Fired when the diff finishes rendering text content.
-         *
-         * @event render-content
-         */
-
-        properties: {
-          diff: Object,
-          changeNum: String,
-          patchNum: String,
-          viewMode: String,
-          isImageDiff: Boolean,
-          baseImage: Object,
-          revisionImage: Object,
-          parentIndex: Number,
-          path: String,
-          projectName: String,
-
-          _builder: Object,
-          _groups: Array,
-          _layers: Array,
-          _showTabs: Boolean,
-          /** @type {!Array<!Gerrit.HoveredRange>} */
-          commentRanges: {
-            type: Array,
-            value: () => [],
-          },
-          /** @type {!Array<!Gerrit.CoverageRange>} */
-          coverageRanges: {
-            type: Array,
-            value: () => [],
-          },
-          _leftCoverageRanges: {
-            type: Array,
-            computed: '_computeLeftCoverageRanges(coverageRanges)',
-          },
-          _rightCoverageRanges: {
-            type: Array,
-            computed: '_computeRightCoverageRanges(coverageRanges)',
-          },
-          /**
-           * The promise last returned from `render()` while the asynchronous
-           * rendering is running - `null` otherwise. Provides a `cancel()`
-           * method that rejects it with `{isCancelled: true}`.
-           *
-           * @type {?Object}
-           */
-          _cancelableRenderPromise: Object,
-          layers: {
-            type: Array,
-            value: [],
-          },
-        },
-
-        behaviors: [
-          Gerrit.FireBehavior,
-        ],
-
-        get diffElement() {
-          return this.queryEffectiveChildren('#diffTable');
-        },
-
-        observers: [
-          '_groupsChanged(_groups.splices)',
-        ],
-
-        _computeLeftCoverageRanges(coverageRanges) {
-          return coverageRanges.filter(range => range && range.side === 'left');
-        },
-
-        _computeRightCoverageRanges(coverageRanges) {
-          return coverageRanges.filter(range => range && range.side === 'right');
-        },
-
-        render(keyLocations, prefs) {
-          // Setting up annotation layers must happen after plugins are
-          // installed, and |render| satisfies the requirement, however,
-          // |attached| doesn't because in the diff view page, the element is
-          // attached before plugins are installed.
-          this._setupAnnotationLayers();
-
-          this._showTabs = !!prefs.show_tabs;
-          this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
-
-          // Stop the processor if it's running.
-          this.cancel();
-
-          this._builder = this._getDiffBuilder(this.diff, prefs);
-
-          this.$.processor.context = prefs.context;
-          this.$.processor.keyLocations = keyLocations;
-
-          this._clearDiffContent();
-          this._builder.addColumns(this.diffElement, prefs.font_size);
-
-          const isBinary = !!(this.isImageDiff || this.diff.binary);
-
-          this.dispatchEvent(new CustomEvent(
-              'render-start', {bubbles: true, composed: true}));
-          this._cancelableRenderPromise = util.makeCancelable(
-              this.$.processor.process(this.diff.content, isBinary)
-                  .then(() => {
-                    if (this.isImageDiff) {
-                      this._builder.renderDiff();
-                    }
-                    this.dispatchEvent(new CustomEvent('render-content',
-                        {bubbles: true, composed: true}));
-                  }));
-          return this._cancelableRenderPromise
-              .finally(() => { this._cancelableRenderPromise = null; })
-              // Mocca testing does not like uncaught rejections, so we catch
-              // the cancels which are expected and should not throw errors in
-              // tests.
-              .catch(e => { if (!e.isCanceled) return Promise.reject(e); });
-        },
-
-        _setupAnnotationLayers() {
-          const layers = [
-            this._createTrailingWhitespaceLayer(),
-            this._createIntralineLayer(),
-            this._createTabIndicatorLayer(),
-            this.$.rangeLayer,
-            this.$.coverageLayerLeft,
-            this.$.coverageLayerRight,
-          ];
-
-          if (this.layers) {
-            layers.push(...this.layers);
-          }
-          this._layers = layers;
-        },
-
-        getLineElByChild(node) {
-          while (node) {
-            if (node instanceof Element) {
-              if (node.classList.contains('lineNum')) {
-                return node;
-              }
-              if (node.classList.contains('section')) {
-                return null;
-              }
-            }
-            node = node.previousSibling || node.parentElement;
-          }
-          return null;
-        },
-
-        getLineNumberByChild(node) {
-          const lineEl = this.getLineElByChild(node);
-          return lineEl ?
-            parseInt(lineEl.getAttribute('data-value'), 10) :
-            null;
-        },
-
-        getContentByLine(lineNumber, opt_side, opt_root) {
-          return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
-        },
-
-        getContentByLineEl(lineEl) {
-          const root = Polymer.dom(lineEl.parentElement);
-          const side = this.getSideByLineEl(lineEl);
-          const line = lineEl.getAttribute('data-value');
-          return this.getContentByLine(line, side, root);
-        },
-
-        getLineElByNumber(lineNumber, opt_side) {
-          const sideSelector = opt_side ? ('.' + opt_side) : '';
-          return this.diffElement.querySelector(
-              '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
-        },
-
-        getContentsByLineRange(startLine, endLine, opt_side) {
-          const result = [];
-          this._builder.findLinesByRange(startLine, endLine, opt_side, null,
-              result);
-          return result;
-        },
-
-        getSideByLineEl(lineEl) {
-          return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
-            GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
-        },
-
-        emitGroup(group, sectionEl) {
-          this._builder.emitGroup(group, sectionEl);
-        },
-
-        showContext(newGroups, sectionEl) {
-          const groups = this._builder.groups;
-
-          const contextIndex = groups.findIndex(group =>
-            group.element === sectionEl
-          );
-          groups.splice(contextIndex, 1, ...newGroups);
-
-          for (const newGroup of newGroups) {
-            this._builder.emitGroup(newGroup, sectionEl);
-          }
-          sectionEl.parentNode.removeChild(sectionEl);
-
-          this.async(() => this.fire('render-content'), 1);
-        },
-
-        cancel() {
-          this.$.processor.cancel();
-          if (this._cancelableRenderPromise) {
-            this._cancelableRenderPromise.cancel();
-            this._cancelableRenderPromise = null;
-          }
-        },
-
-        _handlePreferenceError(pref) {
-          const message = `The value of the '${pref}' user preference is ` +
-              `invalid. Fix in diff preferences`;
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {
-              message,
-            }, bubbles: true, composed: true}));
-          throw Error(`Invalid preference value: ${pref}`);
-        },
-
-        _getDiffBuilder(diff, prefs) {
-          if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
-            this._handlePreferenceError('tab size');
-            return;
-          }
-
-          if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
-            this._handlePreferenceError('diff width');
-            return;
-          }
-
-          let builder = null;
-          if (this.isImageDiff) {
-            builder = new GrDiffBuilderImage(diff, prefs, this.diffElement,
-                this.baseImage, this.revisionImage);
-          } else if (diff.binary) {
-            // If the diff is binary, but not an image.
-            return new GrDiffBuilderBinary(diff, prefs, this.diffElement);
-          } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            builder = new GrDiffBuilderSideBySide(diff, prefs, this.diffElement,
-                this._layers);
-          } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            builder = new GrDiffBuilderUnified(diff, prefs, this.diffElement,
-                this._layers);
-          }
-          if (!builder) {
-            throw Error('Unsupported diff view mode: ' + this.viewMode);
-          }
-          return builder;
-        },
-
-        _clearDiffContent() {
-          this.diffElement.innerHTML = null;
-        },
-
-        _groupsChanged(changeRecord) {
-          if (!changeRecord) { return; }
-          for (const splice of changeRecord.indexSplices) {
-            let group;
-            for (let i = 0; i < splice.addedCount; i++) {
-              group = splice.object[splice.index + i];
-              this._builder.groups.push(group);
-              this._builder.emitGroup(group);
-            }
-          }
-        },
-
-        _createIntralineLayer() {
-          return {
-            // Take a DIV.contentText element and a line object with intraline
-            // differences to highlight and apply them to the element as
-            // annotations.
-            annotate(contentEl, lineNumberEl, line) {
-              const HL_CLASS = 'style-scope gr-diff intraline';
-              for (const highlight of line.highlights) {
-                // The start and end indices could be the same if a highlight is
-                // meant to start at the end of a line and continue onto the
-                // next one. Ignore it.
-                if (highlight.startIndex === highlight.endIndex) { continue; }
-
-                // If endIndex isn't present, continue to the end of the line.
-                const endIndex = highlight.endIndex === undefined ?
-                  line.text.length :
-                  highlight.endIndex;
-
-                GrAnnotation.annotateElement(
-                    contentEl,
-                    highlight.startIndex,
-                    endIndex - highlight.startIndex,
-                    HL_CLASS);
-              }
-            },
-          };
-        },
-
-        _createTabIndicatorLayer() {
-          const show = () => this._showTabs;
-          return {
-            annotate(contentEl, lineNumberEl, line) {
-              // If visible tabs are disabled, do nothing.
-              if (!show()) { return; }
-
-              // Find and annotate the locations of tabs.
-              const split = line.text.split('\t');
-              if (!split) { return; }
-              for (let i = 0, pos = 0; i < split.length - 1; i++) {
-                // Skip forward by the length of the content
-                pos += split[i].length;
-
-                GrAnnotation.annotateElement(contentEl, pos, 1,
-                    'style-scope gr-diff tab-indicator');
-
-                // Skip forward by one tab character.
-                pos++;
-              }
-            },
-          };
-        },
-
-        _createTrailingWhitespaceLayer() {
-          const show = function() {
-            return this._showTrailingWhitespace;
-          }.bind(this);
-
-          return {
-            annotate(contentEl, lineNumberEl, line) {
-              if (!show()) { return; }
-
-              const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
-              if (match) {
-                // Normalize string positions in case there is unicode before or
-                // within the match.
-                const index = GrAnnotation.getStringLength(
-                    line.text.substr(0, match.index));
-                const length = GrAnnotation.getStringLength(match[0]);
-                GrAnnotation.annotateElement(contentEl, index, length,
-                    'style-scope gr-diff trailing-whitespace');
-              }
-            },
-          };
-        },
-
-        setBlame(blame) {
-          if (!this._builder || !blame) { return; }
-          this._builder.setBlame(blame);
-        },
-      });
-    })();
-  </script>
-</dom-module>
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 54303f6..42fd6ea 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
@@ -14,583 +14,624 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffGroup, GrDiffLine) {
-  'use strict';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
 
-  // Prevent redefinition.
-  if (window.GrDiffBuilder) { return; }
+/**
+ * In JS, unicode code points above 0xFFFF occupy two elements of a string.
+ * For example '𐀏'.length is 2. An occurence of such a code point is called a
+ * surrogate pair.
+ *
+ * This regex segments a string along tabs ('\t') and surrogate pairs, since
+ * these are two cases where '1 char' does not automatically imply '1 column'.
+ *
+ * TODO: For human languages whose orthographies use combining marks, this
+ * approach won't correctly identify the grapheme boundaries. In those cases,
+ * a grapheme consists of multiple code points that should count as only one
+ * character against the column limit. Getting that correct (if it's desired)
+ * is probably beyond the limits of a regex, but there are nonstandard APIs to
+ * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
+ *
+ * Further reading:
+ *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
+ *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
+ *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
+ */
+const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  /**
-   * In JS, unicode code points above 0xFFFF occupy two elements of a string.
-   * For example '𐀏'.length is 2. An occurence of such a code point is called a
-   * surrogate pair.
-   *
-   * This regex segments a string along tabs ('\t') and surrogate pairs, since
-   * these are two cases where '1 char' does not automatically imply '1 column'.
-   *
-   * TODO: For human languages whose orthographies use combining marks, this
-   * approach won't correctly identify the grapheme boundaries. In those cases,
-   * a grapheme consists of multiple code points that should count as only one
-   * character against the column limit. Getting that correct (if it's desired)
-   * is probably beyond the limits of a regex, but there are nonstandard APIs to
-   * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
-   *
-   * Further reading:
-   *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
-   *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
-   *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
-   */
-  const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+export function GrDiffBuilder(diff, prefs, outputEl, layers) {
+  this._diff = diff;
+  this._prefs = prefs;
+  this._outputEl = outputEl;
+  this.groups = [];
+  this._blameInfo = null;
 
-  function GrDiffBuilder(diff, prefs, outputEl, layers) {
-    this._diff = diff;
-    this._prefs = prefs;
-    this._outputEl = outputEl;
-    this.groups = [];
-    this._blameInfo = null;
+  this.layers = layers || [];
 
-    this.layers = layers || [];
+  if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+    throw Error('Invalid tab size from preferences.');
+  }
 
-    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
-      throw Error('Invalid tab size from preferences.');
+  if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+    throw Error('Invalid line length from preferences.');
+  }
+
+  for (const layer of this.layers) {
+    if (layer.addListener) {
+      layer.addListener(this._handleLayerUpdate.bind(this));
+    }
+  }
+}
+
+GrDiffBuilder.GroupType = {
+  ADDED: 'b',
+  BOTH: 'ab',
+  REMOVED: 'a',
+};
+
+GrDiffBuilder.Highlights = {
+  ADDED: 'edit_b',
+  REMOVED: 'edit_a',
+};
+
+GrDiffBuilder.Side = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
+
+GrDiffBuilder.ContextButtonType = {
+  ABOVE: 'above',
+  BELOW: 'below',
+  ALL: 'all',
+};
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Abstract method
+ *
+ * @param {string} outputEl
+ * @param {number} fontSize
+ */
+GrDiffBuilder.prototype.addColumns = function() {
+  throw Error('Subclasses must implement addColumns');
+};
+
+/**
+ * Abstract method
+ *
+ * @param {Object} group
+ */
+GrDiffBuilder.prototype.buildSectionElement = function() {
+  throw Error('Subclasses must implement buildSectionElement');
+};
+
+GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
+  const element = this.buildSectionElement(group);
+  this._outputEl.insertBefore(element, opt_beforeSection);
+  group.element = element;
+};
+
+GrDiffBuilder.prototype.getGroupsByLineRange = function(
+    startLine, endLine, opt_side) {
+  const groups = [];
+  for (let i = 0; i < this.groups.length; i++) {
+    const group = this.groups[i];
+    if (group.lines.length === 0) {
+      continue;
+    }
+    let groupStartLine = 0;
+    let groupEndLine = 0;
+    if (opt_side) {
+      groupStartLine = group.lineRange[opt_side].start;
+      groupEndLine = group.lineRange[opt_side].end;
     }
 
-    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
-      throw Error('Invalid line length from preferences.');
+    if (groupStartLine === 0) { // Line was removed or added.
+      groupStartLine = groupEndLine;
     }
+    if (groupEndLine === 0) { // Line was removed or added.
+      groupEndLine = groupStartLine;
+    }
+    if (startLine <= groupEndLine && endLine >= groupStartLine) {
+      groups.push(group);
+    }
+  }
+  return groups;
+};
 
-    for (const layer of this.layers) {
-      if (layer.addListener) {
-        layer.addListener(this._handleLayerUpdate.bind(this));
+GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
+    opt_root) {
+  const root = Polymer.dom(opt_root || this._outputEl);
+  const sideSelector = opt_side ? ('.' + opt_side) : '';
+  return root.querySelector('td.lineNum[data-value="' + lineNumber +
+      '"]' + sideSelector + ' ~ td.content .contentText');
+};
+
+/**
+ * Find line elements or line objects by a range of line numbers and a side.
+ *
+ * @param {number} start The first line number
+ * @param {number} end The last line number
+ * @param {string} opt_side The side of the range. Either 'left' or 'right'.
+ * @param {!Array<GrDiffLine>} out_lines The output list of line objects. Use
+ *     null if not desired.
+ * @param  {!Array<HTMLElement>} out_elements The output list of line elements.
+ *     Use null if not desired.
+ */
+GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
+    out_lines, out_elements) {
+  const groups = this.getGroupsByLineRange(start, end, opt_side);
+  for (const group of groups) {
+    let content = null;
+    for (const line of group.lines) {
+      if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
+          (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
+        continue;
+      }
+      const lineNumber = opt_side === 'left' ?
+        line.beforeNumber : line.afterNumber;
+      if (lineNumber < start || lineNumber > end) { continue; }
+
+      if (out_lines) { out_lines.push(line); }
+      if (out_elements) {
+        if (content) {
+          content = this._getNextContentOnSide(content, opt_side);
+        } else {
+          content = this.getContentByLine(lineNumber, opt_side,
+              group.element);
+        }
+        if (content) { out_elements.push(content); }
+      }
+    }
+  }
+};
+
+/**
+ * Re-renders the DIV.contentText elements for the given side and range of
+ * diff content.
+ */
+GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
+  const lines = [];
+  const elements = [];
+  let line;
+  let el;
+  this.findLinesByRange(start, end, side, lines, elements);
+  for (let i = 0; i < lines.length; i++) {
+    line = lines[i];
+    el = elements[i];
+    if (!el) {
+      // Cannot re-render an element if it does not exist. This can happen
+      // if lines are collapsed and not visible on the page yet.
+      continue;
+    }
+    const lineNumberEl = this._getLineNumberEl(el, side);
+    el.parentElement.replaceChild(
+        this._createTextEl(lineNumberEl, line, side).firstChild,
+        el);
+  }
+};
+
+GrDiffBuilder.prototype.getSectionsByLineRange = function(
+    startLine, endLine, opt_side) {
+  return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
+      group => group.element);
+};
+
+GrDiffBuilder.prototype._createContextControl = function(section, line) {
+  if (!line.contextGroups) return null;
+
+  const numLines =
+      line.contextGroups[line.contextGroups.length - 1].lineRange.left.end -
+      line.contextGroups[0].lineRange.left.start + 1;
+
+  if (numLines === 0) return null;
+
+  const td = this._createElement('td');
+  const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+
+  if (showPartialLinks) {
+    td.appendChild(this._createContextButton(
+        GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
+  }
+
+  td.appendChild(this._createContextButton(
+      GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
+
+  if (showPartialLinks) {
+    td.appendChild(this._createContextButton(
+        GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
+  }
+
+  return td;
+};
+
+GrDiffBuilder.prototype._createContextButton = function(type, section, line,
+    numLines) {
+  const context = PARTIAL_CONTEXT_AMOUNT;
+
+  const button = this._createElement('gr-button', 'showContext');
+  button.setAttribute('link', true);
+  button.setAttribute('no-uppercase', true);
+
+  let text;
+  let groups = []; // The groups that replace this one if tapped.
+  if (type === GrDiffBuilder.ContextButtonType.ALL) {
+    const icon = this._createElement('iron-icon', 'showContext');
+    icon.setAttribute('icon', 'gr-icons:unfold-more');
+    Polymer.dom(button).appendChild(icon);
+
+    text = 'Show ' + numLines + ' common line';
+    if (numLines > 1) { text += 's'; }
+    groups.push(...line.contextGroups);
+  } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
+    text = '+' + context + ' above';
+    groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+        context, numLines);
+  } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
+    text = '+' + context + ' below';
+    groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+        0, numLines - context);
+  }
+  const textSpan = this._createElement('span', 'showContext');
+  Polymer.dom(textSpan).textContent = text;
+  Polymer.dom(button).appendChild(textSpan);
+
+  button.addEventListener('tap', e => {
+    e.detail = {
+      groups,
+      section,
+      numLines,
+    };
+    // Let it bubble up the DOM tree.
+  });
+
+  return button;
+};
+
+GrDiffBuilder.prototype._createLineEl = function(
+    line, number, type, side) {
+  const td = this._createElement('td');
+  if (line.type === GrDiffLine.Type.BLANK) {
+    td.classList.add(side);
+    return td;
+  }
+  if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
+    td.classList.add('contextLineNum');
+    return td;
+  }
+
+  if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
+    const button = this._createElement('button');
+    button.tabIndex = -1;
+    td.appendChild(button);
+
+    // Both td and button need a number of classes/attributes for various
+    // selectors to work.
+    this._decorateLineEl(td, number, side);
+    td.classList.add('lineNum');
+    this._decorateLineEl(button, number, side);
+    button.classList.add('lineNumButton');
+
+    button.textContent = number === 'FILE' ? 'File' : number;
+
+    // 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) {
+        button.setAttribute('aria-label', `${number} removed`);
+      } else if (line.type === GrDiffLine.Type.ADD) {
+        button.setAttribute('aria-label', `${number} added`);
       }
     }
   }
 
-  GrDiffBuilder.GroupType = {
-    ADDED: 'b',
-    BOTH: 'ab',
-    REMOVED: 'a',
-  };
+  return td;
+};
 
-  GrDiffBuilder.Highlights = {
-    ADDED: 'edit_b',
-    REMOVED: 'edit_a',
-  };
+GrDiffBuilder.prototype._decorateLineEl = function(el, number, side) {
+  el.classList.add(side);
+  el.dataset.value = number;
+};
 
-  GrDiffBuilder.Side = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+GrDiffBuilder.prototype._createTextEl = function(
+    lineNumberEl, line, opt_side) {
+  const td = this._createElement('td');
+  if (line.type !== GrDiffLine.Type.BLANK) {
+    td.classList.add('content');
+  }
 
-  GrDiffBuilder.ContextButtonType = {
-    ABOVE: 'above',
-    BELOW: 'below',
-    ALL: 'all',
-  };
+  // If intraline info is not available, the entire line will be
+  // considered as changed and marked as dark red / green color
+  if (!line.hasIntralineInfo) {
+    td.classList.add('no-intraline-info');
+  }
+  td.classList.add(line.type);
 
-  const PARTIAL_CONTEXT_AMOUNT = 10;
+  const lineLimit =
+      !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
 
-  /**
-   * Abstract method
-   *
-   * @param {string} outputEl
-   * @param {number} fontSize
-   */
-  GrDiffBuilder.prototype.addColumns = function() {
-    throw Error('Subclasses must implement addColumns');
-  };
+  const contentText =
+      this._formatText(line.text, this._prefs.tab_size, lineLimit);
+  if (opt_side) {
+    contentText.setAttribute('data-side', opt_side);
+  }
 
-  /**
-   * Abstract method
-   *
-   * @param {Object} group
-   */
-  GrDiffBuilder.prototype.buildSectionElement = function() {
-    throw Error('Subclasses must implement buildSectionElement');
-  };
-
-  GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
-    const element = this.buildSectionElement(group);
-    this._outputEl.insertBefore(element, opt_beforeSection);
-    group.element = element;
-  };
-
-  GrDiffBuilder.prototype.getGroupsByLineRange = function(
-      startLine, endLine, opt_side) {
-    const groups = [];
-    for (let i = 0; i < this.groups.length; i++) {
-      const group = this.groups[i];
-      if (group.lines.length === 0) {
-        continue;
-      }
-      let groupStartLine = 0;
-      let groupEndLine = 0;
-      if (opt_side) {
-        groupStartLine = group.lineRange[opt_side].start;
-        groupEndLine = group.lineRange[opt_side].end;
-      }
-
-      if (groupStartLine === 0) { // Line was removed or added.
-        groupStartLine = groupEndLine;
-      }
-      if (groupEndLine === 0) { // Line was removed or added.
-        groupEndLine = groupStartLine;
-      }
-      if (startLine <= groupEndLine && endLine >= groupStartLine) {
-        groups.push(group);
-      }
-    }
-    return groups;
-  };
-
-  GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
-      opt_root) {
-    const root = Polymer.dom(opt_root || this._outputEl);
-    const sideSelector = opt_side ? ('.' + opt_side) : '';
-    return root.querySelector('td.lineNum[data-value="' + lineNumber +
-        '"]' + sideSelector + ' ~ td.content .contentText');
-  };
-
-  /**
-   * Find line elements or line objects by a range of line numbers and a side.
-   *
-   * @param {number} start The first line number
-   * @param {number} end The last line number
-   * @param {string} opt_side The side of the range. Either 'left' or 'right'.
-   * @param {!Array<GrDiffLine>} out_lines The output list of line objects. Use
-   *     null if not desired.
-   * @param  {!Array<HTMLElement>} out_elements The output list of line elements.
-   *     Use null if not desired.
-   */
-  GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
-      out_lines, out_elements) {
-    const groups = this.getGroupsByLineRange(start, end, opt_side);
-    for (const group of groups) {
-      let content = null;
-      for (const line of group.lines) {
-        if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
-            (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
-          continue;
-        }
-        const lineNumber = opt_side === 'left' ?
-          line.beforeNumber : line.afterNumber;
-        if (lineNumber < start || lineNumber > end) { continue; }
-
-        if (out_lines) { out_lines.push(line); }
-        if (out_elements) {
-          if (content) {
-            content = this._getNextContentOnSide(content, opt_side);
-          } else {
-            content = this.getContentByLine(lineNumber, opt_side,
-                group.element);
-          }
-          if (content) { out_elements.push(content); }
-        }
-      }
-    }
-  };
-
-  /**
-   * Re-renders the DIV.contentText elements for the given side and range of
-   * diff content.
-   */
-  GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
-    const lines = [];
-    const elements = [];
-    let line;
-    let el;
-    this.findLinesByRange(start, end, side, lines, elements);
-    for (let i = 0; i < lines.length; i++) {
-      line = lines[i];
-      el = elements[i];
-      if (!el) {
-        // Cannot re-render an element if it does not exist. This can happen
-        // if lines are collapsed and not visible on the page yet.
-        continue;
-      }
-      const lineNumberEl = this._getLineNumberEl(el, side);
-      el.parentElement.replaceChild(
-          this._createTextEl(lineNumberEl, line, side).firstChild,
-          el);
-    }
-  };
-
-  GrDiffBuilder.prototype.getSectionsByLineRange = function(
-      startLine, endLine, opt_side) {
-    return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
-        group => { return group.element; });
-  };
-
-  GrDiffBuilder.prototype._createContextControl = function(section, line) {
-    if (!line.contextGroups) return null;
-
-    const numLines =
-        line.contextGroups[line.contextGroups.length - 1].lineRange.left.end -
-        line.contextGroups[0].lineRange.left.start + 1;
-
-    if (numLines === 0) return null;
-
-    const td = this._createElement('td');
-    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-
-    if (showPartialLinks) {
-      td.appendChild(this._createContextButton(
-          GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
-      td.appendChild(document.createTextNode(' - '));
-    }
-
-    td.appendChild(this._createContextButton(
-        GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
-
-    if (showPartialLinks) {
-      td.appendChild(document.createTextNode(' - '));
-      td.appendChild(this._createContextButton(
-          GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
-    }
-
-    return td;
-  };
-
-  GrDiffBuilder.prototype._createContextButton = function(type, section, line,
-      numLines) {
-    const context = PARTIAL_CONTEXT_AMOUNT;
-
-    const button = this._createElement('gr-button', 'showContext');
-    button.setAttribute('link', true);
-    button.setAttribute('no-uppercase', true);
-
-    let text;
-    let groups = []; // The groups that replace this one if tapped.
-
-    if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      text = 'Show ' + numLines + ' common line';
-      if (numLines > 1) { text += 's'; }
-      groups.push(...line.contextGroups);
-    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
-      text = '+' + context + '↑';
-      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
-          context, numLines);
-    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
-      text = '+' + context + '↓';
-      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
-          0, numLines - context);
-    }
-
-    Polymer.dom(button).textContent = text;
-
-    button.addEventListener('tap', e => {
-      e.detail = {
-        groups,
-        section,
-      };
-      // Let it bubble up the DOM tree.
-    });
-
-    return button;
-  };
-
-  GrDiffBuilder.prototype._createLineEl = function(
-      line, number, type, opt_class) {
-    const td = this._createElement('td');
-    if (opt_class) {
-      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`);
-    }
-
-    if (line.type === GrDiffLine.Type.BLANK) {
-      return td;
-    } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
-      td.classList.add('contextLineNum');
-    } else if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
-      td.classList.add('lineNum');
-      td.setAttribute('data-value', number);
-      td.textContent = number === 'FILE' ? 'File' : number;
-    }
-    return td;
-  };
-
-  GrDiffBuilder.prototype._createTextEl = function(
-      lineNumberEl, line, opt_side) {
-    const td = this._createElement('td');
-    if (line.type !== GrDiffLine.Type.BLANK) {
-      td.classList.add('content');
-    }
-
-    // If intraline info is not available, the entire line will be
-    // considered as changed and marked as dark red / green color
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    td.classList.add(line.type);
-
-    const lineLimit =
-        !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
-
-    const contentText =
-        this._formatText(line.text, this._prefs.tab_size, lineLimit);
-    if (opt_side) {
-      contentText.setAttribute('data-side', opt_side);
-    }
-
-    for (const layer of this.layers) {
+  for (const layer of this.layers) {
+    if (typeof layer.annotate == 'function') {
       layer.annotate(contentText, lineNumberEl, line);
     }
+  }
 
-    td.appendChild(contentText);
+  td.appendChild(contentText);
 
-    return td;
-  };
+  return td;
+};
 
-  /**
-   * Returns a 'div' element containing the supplied |text| as its innerText,
-   * with '\t' characters expanded to a width determined by |tabSize|, and the
-   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
-   * desired.
-   *
-   * @param {string} text The text to be formatted.
-   * @param {number} tabSize The width of each tab stop.
-   * @param {number} lineLimit The column after which to wrap lines.
-   * @return {HTMLElement}
-   */
-  GrDiffBuilder.prototype._formatText = function(text, tabSize, lineLimit) {
-    const contentText = this._createElement('div', 'contentText');
+/**
+ * Returns a 'div' element containing the supplied |text| as its innerText,
+ * with '\t' characters expanded to a width determined by |tabSize|, and the
+ * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+ * desired.
+ *
+ * @param {string} text The text to be formatted.
+ * @param {number} tabSize The width of each tab stop.
+ * @param {number} lineLimit The column after which to wrap lines.
+ * @return {HTMLElement}
+ */
+GrDiffBuilder.prototype._formatText = function(text, tabSize, lineLimit) {
+  const contentText = this._createElement('div', 'contentText');
 
-    let columnPos = 0;
-    let textOffset = 0;
-    for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
-      if (segment) {
-        // |segment| contains only normal characters. If |segment| doesn't fit
-        // entirely on the current line, append chunks of |segment| followed by
-        // line breaks.
-        let rowStart = 0;
-        let rowEnd = lineLimit - columnPos;
-        while (rowEnd < segment.length) {
-          contentText.appendChild(
-              document.createTextNode(segment.substring(rowStart, rowEnd)));
+  let columnPos = 0;
+  let textOffset = 0;
+  for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
+    if (segment) {
+      // |segment| contains only normal characters. If |segment| doesn't fit
+      // entirely on the current line, append chunks of |segment| followed by
+      // line breaks.
+      let rowStart = 0;
+      let rowEnd = lineLimit - columnPos;
+      while (rowEnd < segment.length) {
+        contentText.appendChild(
+            document.createTextNode(segment.substring(rowStart, rowEnd)));
+        contentText.appendChild(this._createElement('span', 'br'));
+        columnPos = 0;
+        rowStart = rowEnd;
+        rowEnd += lineLimit;
+      }
+      // Append the last part of |segment|, which fits on the current line.
+      contentText.appendChild(
+          document.createTextNode(segment.substring(rowStart)));
+      columnPos += (segment.length - rowStart);
+      textOffset += segment.length;
+    }
+    if (textOffset < text.length) {
+      // Handle the special character at |textOffset|.
+      if (text.startsWith('\t', textOffset)) {
+        // Append a single '\t' character.
+        let effectiveTabSize = tabSize - (columnPos % tabSize);
+        if (columnPos + effectiveTabSize > lineLimit) {
           contentText.appendChild(this._createElement('span', 'br'));
           columnPos = 0;
-          rowStart = rowEnd;
-          rowEnd += lineLimit;
+          effectiveTabSize = tabSize;
         }
-        // Append the last part of |segment|, which fits on the current line.
-        contentText.appendChild(
-            document.createTextNode(segment.substring(rowStart)));
-        columnPos += (segment.length - rowStart);
-        textOffset += segment.length;
-      }
-      if (textOffset < text.length) {
-        // Handle the special character at |textOffset|.
-        if (text.startsWith('\t', textOffset)) {
-          // Append a single '\t' character.
-          let effectiveTabSize = tabSize - (columnPos % tabSize);
-          if (columnPos + effectiveTabSize > lineLimit) {
-            contentText.appendChild(this._createElement('span', 'br'));
-            columnPos = 0;
-            effectiveTabSize = tabSize;
-          }
-          contentText.appendChild(this._getTabWrapper(effectiveTabSize));
-          columnPos += effectiveTabSize;
-          textOffset++;
-        } else {
-          // Append a single surrogate pair.
-          if (columnPos >= lineLimit) {
-            contentText.appendChild(this._createElement('span', 'br'));
-            columnPos = 0;
-          }
-          contentText.appendChild(document.createTextNode(
-              text.substring(textOffset, textOffset + 2)));
-          textOffset += 2;
-          columnPos += 1;
+        contentText.appendChild(this._getTabWrapper(effectiveTabSize));
+        columnPos += effectiveTabSize;
+        textOffset++;
+      } else {
+        // Append a single surrogate pair.
+        if (columnPos >= lineLimit) {
+          contentText.appendChild(this._createElement('span', 'br'));
+          columnPos = 0;
         }
+        contentText.appendChild(document.createTextNode(
+            text.substring(textOffset, textOffset + 2)));
+        textOffset += 2;
+        columnPos += 1;
       }
     }
-    return contentText;
-  };
+  }
+  return contentText;
+};
 
-  /**
-   * Returns a <span> element holding a '\t' character, that will visually
-   * occupy |tabSize| many columns.
-   *
-   * @param {number} tabSize The effective size of this tab stop.
-   * @return {HTMLElement}
-   */
-  GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
-    // Force this to be a number to prevent arbitrary injection.
-    const result = this._createElement('span', 'tab');
-    result.style['tab-size'] = tabSize;
-    result.style['-moz-tab-size'] = tabSize;
-    result.innerText = '\t';
-    return result;
-  };
+/**
+ * Returns a <span> element holding a '\t' character, that will visually
+ * occupy |tabSize| many columns.
+ *
+ * @param {number} tabSize The effective size of this tab stop.
+ * @return {HTMLElement}
+ */
+GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
+  // Force this to be a number to prevent arbitrary injection.
+  const result = this._createElement('span', 'tab');
+  result.style['tab-size'] = tabSize;
+  result.style['-moz-tab-size'] = tabSize;
+  result.innerText = '\t';
+  return result;
+};
 
-  GrDiffBuilder.prototype._createElement = function(tagName, classStr) {
-    const el = document.createElement(tagName);
-    // When Shady DOM is being used, these classes are added to account for
-    // Polymer's polyfill behavior. In order to guarantee sufficient
-    // specificity within the CSS rules, these are added to every element.
-    // Since the Polymer DOM utility functions (which would do this
-    // automatically) are not being used for performance reasons, this is
-    // done manually.
-    el.classList.add('style-scope', 'gr-diff');
-    if (classStr) {
-      for (const className of classStr.split(' ')) {
-        el.classList.add(className);
-      }
+GrDiffBuilder.prototype._createElement = function(tagName, classStr) {
+  const el = document.createElement(tagName);
+  // When Shady DOM is being used, these classes are added to account for
+  // Polymer's polyfill behavior. In order to guarantee sufficient
+  // specificity within the CSS rules, these are added to every element.
+  // Since the Polymer DOM utility functions (which would do this
+  // automatically) are not being used for performance reasons, this is
+  // done manually.
+  el.classList.add('style-scope', 'gr-diff');
+  if (classStr) {
+    for (const className of classStr.split(' ')) {
+      el.classList.add(className);
     }
-    return el;
-  };
+  }
+  return el;
+};
 
-  GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) {
-    this._renderContentByRange(start, end, side);
-  };
+GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) {
+  this._renderContentByRange(start, end, side);
+};
 
-  /**
-   * Finds the next DIV.contentText element following the given element, and on
-   * the same side. Will only search within a group.
-   *
-   * @param {HTMLElement} content
-   * @param {string} side Either 'left' or 'right'
-   * @return {HTMLElement}
-   */
-  GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) {
-    throw Error('Subclasses must implement _getNextContentOnSide');
-  };
+/**
+ * Finds the next DIV.contentText element following the given element, and on
+ * the same side. Will only search within a group.
+ *
+ * @param {HTMLElement} content
+ * @param {string} side Either 'left' or 'right'
+ * @return {HTMLElement}
+ */
+GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) {
+  throw Error('Subclasses must implement _getNextContentOnSide');
+};
 
-  /**
-   * Determines whether the given group is either totally an addition or totally
-   * a removal.
-   *
-   * @param {!Object} group (GrDiffGroup)
-   * @return {boolean}
-   */
-  GrDiffBuilder.prototype._isTotal = function(group) {
-    return group.type === GrDiffGroup.Type.DELTA &&
-        (!group.adds.length || !group.removes.length) &&
-        !(!group.adds.length && !group.removes.length);
-  };
+/**
+ * Determines whether the given group is either totally an addition or totally
+ * a removal.
+ *
+ * @param {!Object} group (GrDiffGroup)
+ * @return {boolean}
+ */
+GrDiffBuilder.prototype._isTotal = function(group) {
+  return group.type === GrDiffGroup.Type.DELTA &&
+      (!group.adds.length || !group.removes.length) &&
+      !(!group.adds.length && !group.removes.length);
+};
 
-  /**
-   * Set the blame information for the diff. For any already-rendered line,
-   * re-render its blame cell content.
-   *
-   * @param {Object} blame
-   */
-  GrDiffBuilder.prototype.setBlame = function(blame) {
-    this._blameInfo = blame;
+/**
+ * Set the blame information for the diff. For any already-rendered line,
+ * re-render its blame cell content.
+ *
+ * @param {Object} blame
+ */
+GrDiffBuilder.prototype.setBlame = function(blame) {
+  this._blameInfo = blame;
 
-    // TODO(wyatta): make this loop asynchronous.
-    for (const commit of blame) {
-      for (const range of commit.ranges) {
-        for (let i = range.start; i <= range.end; i++) {
-          // TODO(wyatta): this query is expensive, but, when traversing a
-          // range, the lines are consecutive, and given the previous blame
-          // cell, the next one can be reached cheaply.
-          const el = this._getBlameByLineNum(i);
-          if (!el) { continue; }
-          // Remove the element's children (if any).
-          while (el.hasChildNodes()) {
-            el.removeChild(el.lastChild);
-          }
-          const blame = this._getBlameForBaseLine(i, commit);
-          el.appendChild(blame);
+  // TODO(wyatta): make this loop asynchronous.
+  for (const commit of blame) {
+    for (const range of commit.ranges) {
+      for (let i = range.start; i <= range.end; i++) {
+        // TODO(wyatta): this query is expensive, but, when traversing a
+        // range, the lines are consecutive, and given the previous blame
+        // cell, the next one can be reached cheaply.
+        const el = this._getBlameByLineNum(i);
+        if (!el) { continue; }
+        // Remove the element's children (if any).
+        while (el.hasChildNodes()) {
+          el.removeChild(el.lastChild);
         }
+        const blame = this._getBlameForBaseLine(i, commit);
+        el.appendChild(blame);
       }
     }
-  };
+  }
+};
 
-  /**
-   * Find the blame cell for a given line number.
-   *
-   * @param {number} lineNum
-   * @return {HTMLTableDataCellElement}
-   */
-  GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) {
-    const root = Polymer.dom(this._outputEl);
-    return root.querySelector(`td.blame[data-line-number="${lineNum}"]`);
-  };
+/**
+ * Find the blame cell for a given line number.
+ *
+ * @param {number} lineNum
+ * @return {HTMLTableDataCellElement}
+ */
+GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) {
+  const root = Polymer.dom(this._outputEl);
+  return root.querySelector(`td.blame[data-line-number="${lineNum}"]`);
+};
 
-  /**
-   * Given a base line number, return the commit containing that line in the
-   * current set of blame information. If no blame information has been
-   * provided, null is returned.
-   *
-   * @param {number} lineNum
-   * @return {Object} The commit information.
-   */
-  GrDiffBuilder.prototype._getBlameCommitForBaseLine = function(lineNum) {
-    if (!this._blameInfo) { return null; }
+/**
+ * Given a base line number, return the commit containing that line in the
+ * current set of blame information. If no blame information has been
+ * provided, null is returned.
+ *
+ * @param {number} lineNum
+ * @return {Object} The commit information.
+ */
+GrDiffBuilder.prototype._getBlameCommitForBaseLine = function(lineNum) {
+  if (!this._blameInfo) { return null; }
 
-    for (const blameCommit of this._blameInfo) {
-      for (const range of blameCommit.ranges) {
-        if (range.start <= lineNum && range.end >= lineNum) {
-          return blameCommit;
-        }
+  for (const blameCommit of this._blameInfo) {
+    for (const range of blameCommit.ranges) {
+      if (range.start <= lineNum && range.end >= lineNum) {
+        return blameCommit;
       }
     }
-    return null;
-  };
+  }
+  return null;
+};
 
-  /**
-   * Given the number of a base line, get the content for the blame cell of that
-   * line. If there is no blame information for that line, returns null.
-   *
-   * @param {number} lineNum
-   * @param {Object=} opt_commit Optionally provide the commit object, so that
-   *     it does not need to be searched.
-   * @return {HTMLSpanElement}
-   */
-  GrDiffBuilder.prototype._getBlameForBaseLine = function(lineNum, opt_commit) {
-    const commit = opt_commit || this._getBlameCommitForBaseLine(lineNum);
-    if (!commit) { return null; }
+/**
+ * Given the number of a base line, get the content for the blame cell of that
+ * line. If there is no blame information for that line, returns null.
+ *
+ * @param {number} lineNum
+ * @param {Object=} opt_commit Optionally provide the commit object, so that
+ *     it does not need to be searched.
+ * @return {HTMLSpanElement}
+ */
+GrDiffBuilder.prototype._getBlameForBaseLine = function(lineNum, opt_commit) {
+  const commit = opt_commit || this._getBlameCommitForBaseLine(lineNum);
+  if (!commit) { return null; }
 
-    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+  const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
 
-    const date = (new Date(commit.time * 1000)).toLocaleDateString();
-    const blameNode = this._createElement('span',
-        isStartOfRange ? 'startOfRange' : '');
-    const shaNode = this._createElement('span', 'sha');
-    shaNode.innerText = commit.id.substr(0, 7);
-    blameNode.appendChild(shaNode);
-    blameNode.append(` on ${date} by ${commit.author}`);
-    return blameNode;
-  };
+  const date = (new Date(commit.time * 1000)).toLocaleDateString();
+  const blameNode = this._createElement('span',
+      isStartOfRange ? 'startOfRange' : '');
 
-  /**
-   * Create a blame cell for the given base line. Blame information will be
-   * included in the cell if available.
-   *
-   * @param {GrDiffLine} line
-   * @return {HTMLTableDataCellElement}
-   */
-  GrDiffBuilder.prototype._createBlameCell = function(line) {
-    const blameTd = this._createElement('td', 'blame');
-    blameTd.setAttribute('data-line-number', line.beforeNumber);
-    if (line.beforeNumber) {
-      const content = this._getBlameForBaseLine(line.beforeNumber);
-      if (content) {
-        blameTd.appendChild(content);
-      }
+  const shaNode = this._createElement('a', 'blameDate');
+  shaNode.innerText = `${date}`;
+  shaNode.setAttribute('href',
+      `${BaseUrlBehavior.getBaseUrl()}/q/${commit.id}`);
+  blameNode.appendChild(shaNode);
+
+  const shortName = commit.author.split(' ')[0];
+  const authorNode = this._createElement('span', 'blameAuthor');
+  authorNode.innerText = ` ${shortName}`;
+  blameNode.appendChild(authorNode);
+
+  const hoverCardFragment = this._createElement('span', 'blameHoverCard');
+  hoverCardFragment.innerText =
+    `Commit ${commit.id}
+Author: ${commit.author}
+Date: ${date}
+
+${commit.commit_msg}`;
+  const hovercard = this._createElement('gr-hovercard');
+  hovercard.appendChild(hoverCardFragment);
+  blameNode.appendChild(hovercard);
+
+  return blameNode;
+};
+
+/**
+ * Create a blame cell for the given base line. Blame information will be
+ * included in the cell if available.
+ *
+ * @param {GrDiffLine} line
+ * @return {HTMLTableDataCellElement}
+ */
+GrDiffBuilder.prototype._createBlameCell = function(line) {
+  const blameTd = this._createElement('td', 'blame');
+  blameTd.setAttribute('data-line-number', line.beforeNumber);
+  if (line.beforeNumber) {
+    const content = this._getBlameForBaseLine(line.beforeNumber);
+    if (content) {
+      blameTd.appendChild(content);
     }
-    return blameTd;
-  };
+  }
+  return blameTd;
+};
 
-  /**
-   * Finds the line number element given the content element by walking up the
-   * DOM tree to the diff row and then querying for a .lineNum element on the
-   * requested side.
-   *
-   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
-   */
-  GrDiffBuilder.prototype._getLineNumberEl = function(content, side) {
-    let row = content;
-    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
-    return row ? row.querySelector('.lineNum.' + side) : null;
-  };
-
-  window.GrDiffBuilder = GrDiffBuilder;
-})(window, GrDiffGroup, GrDiffLine);
+/**
+ * Finds the line number element given the content element by walking up the
+ * DOM tree to the diff row and then querying for a .lineNum element on the
+ * requested side.
+ *
+ * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
+ */
+GrDiffBuilder.prototype._getLineNumberEl = function(content, side) {
+  let row = content;
+  while (row && !row.classList.contains('diff-row')) row = row.parentElement;
+  return row ? row.querySelector('.lineNum.' + side) : null;
+};
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
deleted file mode 100644
index 42414b7..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ /dev/null
@@ -1,1143 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-<script src="../gr-diff/gr-diff-line.js"></script>
-<script src="../gr-diff/gr-diff-group.js"></script>
-<script src="../gr-diff-highlight/gr-annotation.js"></script>
-<script src="gr-diff-builder.js"></script>
-
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-diff-builder.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template is="dom-template">
-    <gr-diff-builder>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-  </template>
-</test-fixture>
-
-<test-fixture id="div-with-text">
-  <template>
-    <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-  </template>
-</test-fixture>
-
-<test-fixture id="mock-diff">
-  <template>
-    <gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-diff-builder tests', () => {
-    let prefs;
-    let element;
-    let builder;
-    let sandbox;
-    const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      builder = new GrDiffBuilder({content: []}, prefs);
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('_createElement classStr applies all classes', () => {
-      const node = builder._createElement('div', 'test classes');
-      assert.isTrue(node.classList.contains('gr-diff'));
-      assert.isTrue(node.classList.contains('test'));
-      assert.isTrue(node.classList.contains('classes'));
-    });
-
-    test('context control buttons', () => {
-      // Create 10 lines.
-      const lines = [];
-      for (let i = 0; i < 10; i++) {
-        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.beforeNumber = i + 1;
-        line.afterNumber = i + 1;
-        line.text = 'lorem upsum';
-        lines.push(line);
-      }
-
-      const contextLine = {
-        contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
-      };
-
-      const section = {};
-      // Does not include +10 buttons when there are fewer than 11 lines.
-      let td = builder._createContextControl(section, contextLine);
-      let buttons = td.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 1);
-      assert.equal(Polymer.dom(buttons[0]).textContent, 'Show 10 common lines');
-
-      // Add another line.
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.text = 'lorem upsum';
-      line.beforeNumber = 11;
-      line.afterNumber = 11;
-      contextLine.contextGroups[0].addLine(line);
-
-      // Includes +10 buttons when there are at least 11 lines.
-      td = builder._createContextControl(section, contextLine);
-      buttons = td.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 3);
-      assert.equal(Polymer.dom(buttons[0]).textContent, '+10↑');
-      assert.equal(Polymer.dom(buttons[1]).textContent, 'Show 11 common lines');
-      assert.equal(Polymer.dom(buttons[2]).textContent, '+10↓');
-    });
-
-    test('newlines 1', () => {
-      let text = 'abcdef';
-
-      assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
-      text = 'a'.repeat(20);
-      assert.equal(builder._formatText(text, 4, 10).innerHTML,
-          'a'.repeat(10) +
-          LINE_FEED_HTML +
-          'a'.repeat(10));
-    });
-
-    test('newlines 2', () => {
-      const text = '<span class="thumbsup">👍</span>';
-      assert.equal(builder._formatText(text, 4, 10).innerHTML,
-          '&lt;span clas' +
-          LINE_FEED_HTML +
-          's="thumbsu' +
-          LINE_FEED_HTML +
-          'p"&gt;👍&lt;/span' +
-          LINE_FEED_HTML +
-          '&gt;');
-    });
-
-    test('newlines 3', () => {
-      const text = '01234\t56789';
-      assert.equal(builder._formatText(text, 4, 10).innerHTML,
-          '01234' + builder._getTabWrapper(3).outerHTML + '56' +
-          LINE_FEED_HTML +
-          '789');
-    });
-
-    test('newlines 4', () => {
-      const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
-      assert.equal(builder._formatText(text, 4, 20).innerHTML,
-          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-          LINE_FEED_HTML +
-          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-          LINE_FEED_HTML +
-          '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
-    });
-
-
-    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);
-
-      const line = {text, highlights: []};
-      const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-      assert.equal(result, text);
-    });
-
-    test('line_length applied if line_wrapping is false', () => {
-      builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-      const text = 'a'.repeat(51);
-
-      const line = {text, highlights: []};
-      const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
-      const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-      assert.equal(result, expected);
-    });
-
-    test('_createTextEl linewrap with tabs', () => {
-      const text = '\t'.repeat(7) + '!';
-      const line = {text, highlights: []};
-      const el = builder._createTextEl(undefined, line);
-      assert.equal(el.innerText, text);
-      // With line length 10 and tab size 2, there should be a line break
-      // after every two tabs.
-      const newlineEl = el.querySelector('.contentText > .br');
-      assert.isOk(newlineEl);
-      assert.equal(
-          el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-          newlineEl);
-    });
-
-    test('text length with tabs and unicode', () => {
-      function expectTextLength(text, tabSize, expected) {
-        // Formatting to |expected| columns should not introduce line breaks.
-        const result = builder._formatText(text, tabSize, expected);
-        assert.isNotOk(result.querySelector('.contentText > .br'),
-            `  Expected the result of: \n` +
-            `      _formatText(${text}', ${tabSize}, ${expected})\n` +
-            `  to not contain a br. But the actual result HTML was:\n` +
-            `      '${result.innerHTML}'\nwhereupon`);
-
-        // Increasing the line limit should produce the same markup.
-        assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
-            result.innerHTML);
-        assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
-            result.innerHTML);
-
-        // Decreasing the line limit should introduce line breaks.
-        if (expected > 0) {
-          const tooSmall = builder._formatText(text, tabSize, expected - 1);
-          assert.isOk(tooSmall.querySelector('.contentText > .br'),
-              `  Expected the result of: \n` +
-              `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
-              `  to contain a br. But the actual result HTML was:\n` +
-              `      '${tooSmall.innerHTML}'\nwhereupon`);
-        }
-      }
-      expectTextLength('12345', 4, 5);
-      expectTextLength('\t\t12', 4, 10);
-      expectTextLength('abc💢123', 4, 7);
-      expectTextLength('abc\t', 8, 8);
-      expectTextLength('abc\t\t', 10, 20);
-      expectTextLength('', 10, 0);
-      expectTextLength('', 10, 0);
-      // 17 Thai combining chars.
-      expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
-      expectTextLength('abc\tde', 10, 12);
-      expectTextLength('abc\tde\t', 10, 20);
-      expectTextLength('\t\t\t\t\t', 20, 100);
-    });
-
-    test('tab wrapper insertion', () => {
-      const html = 'abc\tdef';
-      const tabSize = builder._prefs.tab_size;
-      const wrapper = builder._getTabWrapper(tabSize - 3);
-      assert.ok(wrapper);
-      assert.equal(wrapper.innerText, '\t');
-      assert.equal(
-          builder._formatText(html, tabSize, Infinity).innerHTML,
-          'abc' + wrapper.outerHTML + 'def');
-    });
-
-    test('tab wrapper style', () => {
-      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;
-        expect(html).to.match(pattern);
-        assert.equal(html.match(pattern)[1], size);
-      }
-    });
-
-    test('_handlePreferenceError called with invalid preference', () => {
-      sandbox.stub(element, '_handlePreferenceError');
-      const prefs = {tab_size: 0};
-      element._getDiffBuilder(element.diff, prefs);
-      assert.isTrue(element._handlePreferenceError.lastCall
-          .calledWithExactly('tab size'));
-    });
-
-    test('_handlePreferenceError triggers alert and javascript error', () => {
-      const errorStub = sinon.stub();
-      element.addEventListener('show-alert', errorStub);
-      assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
-      assert.equal(errorStub.lastCall.args[0].detail.message,
-          `The value of the 'tab size' user preference is invalid. ` +
-        `Fix in diff preferences`);
-    });
-
-    suite('_isTotal', () => {
-      test('is total for add', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (let idx = 0; idx < 10; idx++) {
-          group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
-        }
-        assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-      });
-
-      test('is total for remove', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (let idx = 0; idx < 10; idx++) {
-          group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
-        }
-        assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-      });
-
-      test('not total for empty', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
-        assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-      });
-
-      test('not total for non-delta', () => {
-        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (let idx = 0; idx < 10; idx++) {
-          group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
-        }
-        assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-      });
-    });
-
-    suite('intraline differences', () => {
-      let el;
-      let str;
-      let annotateElementSpy;
-      let layer;
-      const lineNumberEl = document.createElement('td');
-
-      function slice(str, start, end) {
-        return Array.from(str).slice(start, end).join('');
-      }
-
-      setup(() => {
-        el = fixture('div-with-text');
-        str = el.textContent;
-        annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-        layer = document.createElement('gr-diff-builder')
-            ._createIntralineLayer();
-      });
-
-      test('annotate no highlights', () => {
-        const line = {
-          text: str,
-          highlights: [],
-        };
-
-        layer.annotate(el, lineNumberEl, line);
-
-        // The content is unchanged.
-        assert.isFalse(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 1);
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(str, el.childNodes[0].textContent);
-      });
-
-      test('annotate with highlights', () => {
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 6, endIndex: 12},
-            {startIndex: 18, endIndex: 22},
-          ],
-        };
-        const str0 = slice(str, 0, 6);
-        const str1 = slice(str, 6, 12);
-        const str2 = slice(str, 12, 18);
-        const str3 = slice(str, 18, 22);
-        const str4 = slice(str, 22);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 5);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-
-        assert.instanceOf(el.childNodes[2], Text);
-        assert.equal(el.childNodes[2].textContent, str2);
-
-        assert.notInstanceOf(el.childNodes[3], Text);
-        assert.equal(el.childNodes[3].textContent, str3);
-
-        assert.instanceOf(el.childNodes[4], Text);
-        assert.equal(el.childNodes[4].textContent, str4);
-      });
-
-      test('annotate without endIndex', () => {
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 28},
-          ],
-        };
-
-        const str0 = slice(str, 0, 28);
-        const str1 = slice(str, 28);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 2);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-      });
-
-      test('annotate ignores empty highlights', () => {
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 28, endIndex: 28},
-          ],
-        };
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 1);
-      });
-
-      test('annotate handles unicode', () => {
-        // Put some unicode into the string:
-        str = str.replace(/\s/g, '💢');
-        el.textContent = str;
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 6, endIndex: 12},
-          ],
-        };
-
-        const str0 = slice(str, 0, 6);
-        const str1 = slice(str, 6, 12);
-        const str2 = slice(str, 12);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 3);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-
-        assert.instanceOf(el.childNodes[2], Text);
-        assert.equal(el.childNodes[2].textContent, str2);
-      });
-
-      test('annotate handles unicode w/o endIndex', () => {
-        // Put some unicode into the string:
-        str = str.replace(/\s/g, '💢');
-        el.textContent = str;
-
-        const line = {
-          text: str,
-          highlights: [
-            {startIndex: 6},
-          ],
-        };
-
-        const str0 = slice(str, 0, 6);
-        const str1 = slice(str, 6);
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementSpy.called);
-        assert.equal(el.childNodes.length, 2);
-
-        assert.instanceOf(el.childNodes[0], Text);
-        assert.equal(el.childNodes[0].textContent, str0);
-
-        assert.notInstanceOf(el.childNodes[1], Text);
-        assert.equal(el.childNodes[1].textContent, str1);
-      });
-    });
-
-    suite('tab indicators', () => {
-      let element;
-      let layer;
-      const lineNumberEl = document.createElement('td');
-
-      setup(() => {
-        element = fixture('basic');
-        element._showTabs = true;
-        layer = element._createTabIndicatorLayer();
-      });
-
-      test('does nothing with empty line', () => {
-        const line = {text: ''};
-        const el = document.createElement('div');
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('does nothing with no tabs', () => {
-        const str = 'lorem ipsum no tabs';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('annotates tab at beginning', () => {
-        const str = '\tlorem upsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.equal(annotateElementStub.callCount, 1);
-        const args = annotateElementStub.getCalls()[0].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 0, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-      });
-
-      test('does not annotate when disabled', () => {
-        element._showTabs = false;
-
-        const str = '\tlorem upsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('annotates multiple in beginning', () => {
-        const str = '\t\tlorem upsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.equal(annotateElementStub.callCount, 2);
-
-        let args = annotateElementStub.getCalls()[0].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 0, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-
-        args = annotateElementStub.getCalls()[1].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 1, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-      });
-
-      test('annotates intermediate tabs', () => {
-        const str = 'lorem\tupsum';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-
-        layer.annotate(el, lineNumberEl, line);
-
-        assert.equal(annotateElementStub.callCount, 1);
-        const args = annotateElementStub.getCalls()[0].args;
-        assert.equal(args[0], el);
-        assert.equal(args[1], 5, 'offset of tab indicator');
-        assert.equal(args[2], 1, 'length of tab indicator');
-        assert.include(args[3], 'tab-indicator');
-      });
-    });
-
-    suite('layers', () => {
-      let element;
-      let initialLayersCount;
-      let withLayerCount;
-      setup(() => {
-        const layers = [];
-        element = fixture('basic');
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        initialLayersCount = element._layers.length;
-      });
-
-      test('no layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, initialLayersCount);
-      });
-
-      suite('with layers', () => {
-        const layers = [{}, {}];
-        setup(() => {
-          element = fixture('basic');
-          element.layers = layers;
-          element._showTrailingWhitespace = true;
-          element._setupAnnotationLayers();
-          withLayerCount = element._layers.length;
-        });
-        test('with layers', () => {
-          element._setupAnnotationLayers();
-          assert.equal(element._layers.length, withLayerCount);
-          assert.equal(initialLayersCount + layers.length,
-              withLayerCount);
-        });
-      });
-    });
-
-    suite('trailing whitespace', () => {
-      let element;
-      let layer;
-      const lineNumberEl = document.createElement('td');
-
-      setup(() => {
-        element = fixture('basic');
-        element._showTrailingWhitespace = true;
-        layer = element._createTrailingWhitespaceLayer();
-      });
-
-      test('does nothing with empty line', () => {
-        const line = {text: ''};
-        const el = document.createElement('div');
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('does nothing with no trailing whitespace', () => {
-        const str = 'lorem ipsum blah blah';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('annotates trailing spaces', () => {
-        const str = 'lorem ipsum   ';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 11);
-        assert.equal(annotateElementStub.lastCall.args[2], 3);
-      });
-
-      test('annotates trailing tabs', () => {
-        const str = 'lorem ipsum\t\t\t';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 11);
-        assert.equal(annotateElementStub.lastCall.args[2], 3);
-      });
-
-      test('annotates mixed trailing whitespace', () => {
-        const str = 'lorem ipsum\t \t';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 11);
-        assert.equal(annotateElementStub.lastCall.args[2], 3);
-      });
-
-      test('unicode preceding trailing whitespace', () => {
-        const str = '💢\t';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isTrue(annotateElementStub.called);
-        assert.equal(annotateElementStub.lastCall.args[1], 1);
-        assert.equal(annotateElementStub.lastCall.args[2], 1);
-      });
-
-      test('does not annotate when disabled', () => {
-        element._showTrailingWhitespace = false;
-        const str = 'lorem upsum\t \t ';
-        const line = {text: str};
-        const el = document.createElement('div');
-        el.textContent = str;
-        const annotateElementStub =
-            sandbox.stub(GrAnnotation, 'annotateElement');
-        layer.annotate(el, lineNumberEl, line);
-        assert.isFalse(annotateElementStub.called);
-      });
-    });
-
-    suite('rendering text, images and binary files', () => {
-      let processStub;
-      let keyLocations;
-      let prefs;
-      let content;
-
-      setup(() => {
-        element = fixture('basic');
-        element.viewMode = 'SIDE_BY_SIDE';
-        processStub = sandbox.stub(element.$.processor, 'process')
-            .returns(Promise.resolve());
-        keyLocations = {left: {}, right: {}};
-        prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-          syntax_highlighting: true,
-        };
-        content = [{
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        }, {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        }];
-      });
-
-      test('text', () => {
-        element.diff = {content};
-        return element.render(keyLocations, prefs).then(() => {
-          assert.isTrue(processStub.calledOnce);
-          assert.isFalse(processStub.lastCall.args[1]);
-        });
-      });
-
-      test('image', () => {
-        element.diff = {content, binary: true};
-        element.isImageDiff = true;
-        return element.render(keyLocations, prefs).then(() => {
-          assert.isTrue(processStub.calledOnce);
-          assert.isTrue(processStub.lastCall.args[1]);
-        });
-      });
-
-      test('binary', () => {
-        element.diff = {content, binary: true};
-        return element.render(keyLocations, prefs).then(() => {
-          assert.isTrue(processStub.calledOnce);
-          assert.isTrue(processStub.lastCall.args[1]);
-        });
-      });
-    });
-
-    suite('rendering', () => {
-      let content;
-      let outputEl;
-      let keyLocations;
-
-      setup(done => {
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-          syntax_highlighting: true,
-        };
-        content = [
-          {
-            a: ['all work and no play make andybons a dull boy'],
-            b: ['elgoog elgoog elgoog'],
-          },
-          {
-            ab: [
-              'Non eram nescius, Brute, cum, quae summis ingeniis ',
-              'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-            ],
-          },
-        ];
-        element = fixture('basic');
-        outputEl = element.queryEffectiveChildren('#diffTable');
-        keyLocations = {left: {}, right: {}};
-        sandbox.stub(element, '_getDiffBuilder', () => {
-          const builder = new GrDiffBuilder({content}, prefs, outputEl);
-          sandbox.stub(builder, 'addColumns');
-          builder.buildSectionElement = function(group) {
-            const section = document.createElement('stub');
-            section.textContent = group.lines.reduce((acc, line) => {
-              return acc + line.text;
-            }, '');
-            return section;
-          };
-          return builder;
-        });
-        element.diff = {content};
-        element.render(keyLocations, prefs).then(done);
-      });
-
-      test('addColumns is called', done => {
-        element.render(keyLocations, {}).then(done);
-        assert.isTrue(element._builder.addColumns.called);
-      });
-
-      test('getSectionsByLineRange one line', () => {
-        const section = outputEl.querySelector('stub:nth-of-type(2)');
-        const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
-        assert.equal(sections.length, 1);
-        assert.strictEqual(sections[0], section);
-      });
-
-      test('getSectionsByLineRange over diff', () => {
-        const section = [
-          outputEl.querySelector('stub:nth-of-type(2)'),
-          outputEl.querySelector('stub:nth-of-type(3)'),
-        ];
-        const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
-        assert.equal(sections.length, 2);
-        assert.strictEqual(sections[0], section[0]);
-        assert.strictEqual(sections[1], section[1]);
-      });
-
-      test('render-start and render-content are fired', done => {
-        const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
-        element.render(keyLocations, {}).then(() => {
-          const firedEventTypes = dispatchEventStub.getCalls()
-              .map(c => { return c.args[0].type; });
-          assert.include(firedEventTypes, 'render-start');
-          assert.include(firedEventTypes, 'render-content');
-          done();
-        });
-      });
-
-      test('cancel', () => {
-        const processorCancelStub = sandbox.stub(element.$.processor, 'cancel');
-        element.cancel();
-        assert.isTrue(processorCancelStub.called);
-      });
-    });
-
-    suite('mock-diff', () => {
-      let element;
-      let builder;
-      let diff;
-      let prefs;
-      let keyLocations;
-
-      setup(done => {
-        element = fixture('mock-diff');
-        diff = document.createElement('mock-diff-response').diffResponse;
-        element.diff = diff;
-
-        prefs = {
-          line_length: 80,
-          show_tabs: true,
-          tab_size: 4,
-        };
-        keyLocations = {left: {}, right: {}};
-
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-          done();
-        });
-      });
-
-      test('getContentByLine', () => {
-        let actual;
-
-        actual = builder.getContentByLine(2, 'left');
-        assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-        actual = builder.getContentByLine(2, 'right');
-        assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-        actual = builder.getContentByLine(5, 'left');
-        assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-        actual = builder.getContentByLine(5, 'right');
-        assert.equal(actual.textContent, diff.content[1].b[0]);
-      });
-
-      test('findLinesByRange', () => {
-        const lines = [];
-        const elems = [];
-        const start = 6;
-        const end = 10;
-        const count = end - start + 1;
-
-        builder.findLinesByRange(start, end, 'right', lines, elems);
-
-        assert.equal(lines.length, count);
-        assert.equal(elems.length, count);
-
-        for (let i = 0; i < 5; i++) {
-          assert.instanceOf(lines[i], GrDiffLine);
-          assert.equal(lines[i].afterNumber, start + i);
-          assert.instanceOf(elems[i], HTMLElement);
-          assert.equal(lines[i].text, elems[i].textContent);
-        }
-      });
-
-      test('_renderContentByRange', () => {
-        const spy = sandbox.spy(builder, '_createTextEl');
-        const start = 9;
-        const end = 14;
-        const count = end - start + 1;
-
-        builder._renderContentByRange(start, end, 'left');
-
-        assert.equal(spy.callCount, count);
-        spy.getCalls().forEach((call, i) => {
-          assert.equal(call.args[1].beforeNumber, start + i);
-        });
-      });
-
-      test('_renderContentByRange notexistent elements', () => {
-        const spy = sandbox.spy(builder, '_createTextEl');
-
-        sandbox.stub(builder, 'findLinesByRange',
-            (s, e, d, lines, elements) => {
-              // Add a line and a corresponding element.
-              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-              const tr = document.createElement('tr');
-              const td = document.createElement('td');
-              const el = document.createElement('div');
-              tr.appendChild(td);
-              td.appendChild(el);
-              elements.push(el);
-
-              // Add 2 lines without corresponding elements.
-              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-              lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-            });
-
-        builder._renderContentByRange(1, 10, 'left');
-        // Should be called only once because only one line had a corresponding
-        // element.
-        assert.equal(spy.callCount, 1);
-      });
-
-      test('_getLineNumberEl side-by-side left', () => {
-        const contentEl = builder.getContentByLine(5, 'left',
-            element.$.diffTable);
-        const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl.classList.contains('left'));
-      });
-
-      test('_getLineNumberEl side-by-side right', () => {
-        const contentEl = builder.getContentByLine(5, 'right',
-            element.$.diffTable);
-        const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl.classList.contains('right'));
-      });
-
-      test('_getLineNumberEl unified left', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-
-          const contentEl = builder.getContentByLine(5, 'left',
-              element.$.diffTable);
-          const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-          assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-          assert.isTrue(lineNumberEl.classList.contains('left'));
-          done();
-        });
-      });
-
-      test('_getLineNumberEl unified right', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-
-          const contentEl = builder.getContentByLine(5, 'right',
-              element.$.diffTable);
-          const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-          assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-          assert.isTrue(lineNumberEl.classList.contains('right'));
-          done();
-        });
-      });
-
-      test('_getNextContentOnSide side-by-side left', () => {
-        const startElem = builder.getContentByLine(5, 'left',
-            element.$.diffTable);
-        const expectedStartString = diff.content[2].ab[0];
-        const expectedNextString = diff.content[2].ab[1];
-        assert.equal(startElem.textContent, expectedStartString);
-
-        const nextElem = builder._getNextContentOnSide(startElem,
-            'left');
-        assert.equal(nextElem.textContent, expectedNextString);
-      });
-
-      test('_getNextContentOnSide side-by-side right', () => {
-        const startElem = builder.getContentByLine(5, 'right',
-            element.$.diffTable);
-        const expectedStartString = diff.content[1].b[0];
-        const expectedNextString = diff.content[1].b[1];
-        assert.equal(startElem.textContent, expectedStartString);
-
-        const nextElem = builder._getNextContentOnSide(startElem,
-            'right');
-        assert.equal(nextElem.textContent, expectedNextString);
-      });
-
-      test('_getNextContentOnSide unified left', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-
-          const startElem = builder.getContentByLine(5, 'left',
-              element.$.diffTable);
-          const expectedStartString = diff.content[2].ab[0];
-          const expectedNextString = diff.content[2].ab[1];
-          assert.equal(startElem.textContent, expectedStartString);
-
-          const nextElem = builder._getNextContentOnSide(startElem,
-              'left');
-          assert.equal(nextElem.textContent, expectedNextString);
-
-          done();
-        });
-      });
-
-      test('_getNextContentOnSide unified right', done => {
-        // Re-render as unified:
-        element.viewMode = 'UNIFIED_DIFF';
-        element.render(keyLocations, prefs).then(() => {
-          builder = element._builder;
-
-          const startElem = builder.getContentByLine(5, 'right',
-              element.$.diffTable);
-          const expectedStartString = diff.content[1].b[0];
-          const expectedNextString = diff.content[1].b[1];
-          assert.equal(startElem.textContent, expectedStartString);
-
-          const nextElem = builder._getNextContentOnSide(startElem,
-              'right');
-          assert.equal(nextElem.textContent, expectedNextString);
-
-          done();
-        });
-      });
-
-      test('escaping HTML', () => {
-        let input = '<script>alert("XSS");<' + '/script>';
-        let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
-        let result = builder._formatText(input, 1, Infinity).innerHTML;
-        assert.equal(result, expected);
-
-        input = '& < > " \' / `';
-        expected = '&amp; &lt; &gt; " \' / `';
-        result = builder._formatText(input, 1, Infinity).innerHTML;
-        assert.equal(result, expected);
-      });
-    });
-
-    suite('blame', () => {
-      let mockBlame;
-
-      setup(() => {
-        mockBlame = [
-          {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-          {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-        ];
-      });
-
-      test('setBlame attempts to render each blamed line', () => {
-        const getBlameStub = sandbox.stub(builder, '_getBlameByLineNum')
-            .returns(null);
-        builder.setBlame(mockBlame);
-        assert.equal(getBlameStub.callCount, 32);
-      });
-
-      test('_getBlameCommitForBaseLine', () => {
-        builder.setBlame(mockBlame);
-        assert.isOk(builder._getBlameCommitForBaseLine(1));
-        assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
-
-        assert.isOk(builder._getBlameCommitForBaseLine(11));
-        assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
-
-        assert.isOk(builder._getBlameCommitForBaseLine(32));
-        assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
-
-        assert.isNull(builder._getBlameCommitForBaseLine(33));
-      });
-
-      test('_getBlameCommitForBaseLine w/o blame returns null', () => {
-        assert.isNull(builder._getBlameCommitForBaseLine(1));
-        assert.isNull(builder._getBlameCommitForBaseLine(11));
-        assert.isNull(builder._getBlameCommitForBaseLine(31));
-      });
-
-      test('_createBlameCell', () => {
-        const mocbBlameCell = document.createElement('span');
-        const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
-            .returns(mocbBlameCell);
-        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.beforeNumber = 3;
-        line.afterNumber = 5;
-
-        const result = builder._createBlameCell(line);
-
-        assert.isTrue(getBlameStub.calledWithExactly(3));
-        assert.equal(result.getAttribute('data-line-number'), '3');
-        assert.equal(result.firstChild, mocbBlameCell);
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
deleted file mode 100644
index 99d0498..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ /dev/null
@@ -1,31 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-
-<dom-module id="gr-diff-cursor">
-  <template>
-    <gr-cursor-manager
-        id="cursorManager"
-        scroll-behavior="[[_scrollBehavior]]"
-        cursor-target-class="target-row"
-        focus-on-move="[[_focusOnMove]]"
-        target="{{diffRow}}"></gr-cursor-manager>
-  </template>
-  <script src="gr-diff-cursor.js"></script>
-</dom-module>
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..0b9ae5b 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
@@ -14,34 +14,46 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const DiffSides = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-cursor_html.js';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const DiffSides = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
 
-  const ScrollBehavior = {
-    KEEP_VISIBLE: 'keep-visible',
-    NEVER: 'never',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  const LEFT_SIDE_CLASS = 'target-side-left';
-  const RIGHT_SIDE_CLASS = 'target-side-right';
+const ScrollBehavior = {
+  KEEP_VISIBLE: 'keep-visible',
+  NEVER: 'never',
+};
 
-  Polymer({
-    is: 'gr-diff-cursor',
+const LEFT_SIDE_CLASS = 'target-side-left';
+const RIGHT_SIDE_CLASS = 'target-side-right';
 
-    properties: {
-      /**
-       * Either DiffSides.LEFT or DiffSides.RIGHT.
-       */
+/** @extends Polymer.Element */
+class GrDiffCursor extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-cursor'; }
+
+  static get properties() {
+    return {
+    /**
+     * Either DiffSides.LEFT or DiffSides.RIGHT.
+     */
       side: {
         type: String,
         value: DiffSides.RIGHT,
@@ -92,340 +104,436 @@
       },
 
       _listeningForScroll: Boolean,
-    },
 
-    observers: [
+      /**
+       * gr-diff-view has gr-fixed-panel on top. The panel can
+       * intersect a main element and partially hides a content of
+       * the main element. To correctly calculates visibility of an
+       * element, the cursor must know how much height occuped by a fixed
+       * panel.
+       * The scrollTopMargin defines margin occuped by fixed panel.
+       */
+      scrollTopMargin: {
+        type: Number,
+        value: 0,
+      },
+    };
+  }
+
+  static get observers() {
+    return [
       '_updateSideClass(side)',
       '_diffsChanged(diffs.splices)',
-    ],
+    ];
+  }
 
-    attached() {
-      // Catch when users are scrolling as the view loads.
-      this.listen(window, 'scroll', '_handleWindowScroll');
-    },
+  constructor() {
+    super();
+    this._boundHandleWindowScroll = () => this._handleWindowScroll();
+    this._boundHandleDiffRenderStart = () => this._handleDiffRenderStart();
+    this._boundHandleDiffRenderContent = () => this._handleDiffRenderContent();
+    this._boundHandleDiffLineSelected = e => this._handleDiffLineSelected(e);
+  }
 
-    detached() {
-      this.unlisten(window, 'scroll', '_handleWindowScroll');
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    afterNextRender(this, () => {
+      /*
+      This represents the diff cursor is ready for interaction coming from
+      client components. It is more then Polymer "ready" lifecycle, as no
+      "ready" events are automatically fired by Polymer, it means
+      the cursor is completely interactable - in this case attached and
+      painted on the page. We name it "ready" instead of "rendered" as the
+      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
+      element with an actual lifecycle. This will be triggered only once
+      per element.
+      */
+      this.dispatchEvent(new CustomEvent('ready', {
+        composed: true, bubbles: false,
+      }));
+    });
+  }
 
-    moveLeft() {
-      this.side = DiffSides.LEFT;
-      if (this._isTargetBlank()) {
-        this.moveUp();
-      }
-    },
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    // Catch when users are scrolling as the view loads.
+    window.addEventListener('scroll', this._boundHandleWindowScroll);
+  }
 
-    moveRight() {
-      this.side = DiffSides.RIGHT;
-      if (this._isTargetBlank()) {
-        this.moveUp();
-      }
-    },
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener('scroll', this._boundHandleWindowScroll);
+  }
 
-    moveDown() {
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.cursorManager.next(this._rowHasSide.bind(this));
-      } else {
-        this.$.cursorManager.next();
-      }
-    },
+  moveLeft() {
+    this.side = DiffSides.LEFT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
 
-    moveUp() {
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.cursorManager.previous(this._rowHasSide.bind(this));
-      } else {
-        this.$.cursorManager.previous();
-      }
-    },
+  moveRight() {
+    this.side = DiffSides.RIGHT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
 
-    moveToNextChunk(opt_clipToTop) {
-      this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-          target => {
-            return target.parentNode.scrollHeight;
-          }, opt_clipToTop);
-      this._fixSide();
-    },
+  moveDown() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.next(this._rowHasSide.bind(this));
+    } else {
+      this.$.cursorManager.next();
+    }
+  }
 
-    moveToPreviousChunk() {
-      this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
-      this._fixSide();
-    },
+  moveUp() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.previous(this._rowHasSide.bind(this));
+    } else {
+      this.$.cursorManager.previous();
+    }
+  }
 
-    moveToNextCommentThread() {
-      this.$.cursorManager.next(this._rowHasThread.bind(this));
-      this._fixSide();
-    },
+  moveToVisibleArea() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.cursorManager.moveToVisibleArea(
+          this._rowHasSide.bind(this));
+    } else {
+      this.$.cursorManager.moveToVisibleArea();
+    }
+  }
 
-    moveToPreviousCommentThread() {
-      this.$.cursorManager.previous(this._rowHasThread.bind(this));
-      this._fixSide();
-    },
+  moveToNextChunk(opt_clipToTop) {
+    this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
+        target => target.parentNode.scrollHeight, opt_clipToTop);
+    this._fixSide();
+  }
 
-    /**
-     * @param {number} number
-     * @param {string} side
-     * @param {string=} opt_path
-     */
-    moveToLineNumber(number, side, opt_path) {
-      const row = this._findRowByNumberAndFile(number, side, opt_path);
-      if (row) {
-        this.side = side;
-        this.$.cursorManager.setCursor(row);
-      }
-    },
+  moveToPreviousChunk() {
+    this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
+    this._fixSide();
+  }
 
-    /**
-     * Get the line number element targeted by the cursor row and side.
-     *
-     * @return {?Element|undefined}
-     */
-    getTargetLineElement() {
-      let lineElSelector = '.lineNum';
+  moveToNextCommentThread() {
+    this.$.cursorManager.next(this._rowHasThread.bind(this));
+    this._fixSide();
+  }
 
-      if (!this.diffRow) {
-        return;
-      }
+  moveToPreviousCommentThread() {
+    this.$.cursorManager.previous(this._rowHasThread.bind(this));
+    this._fixSide();
+  }
 
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
-      }
+  /**
+   * @param {number} number
+   * @param {string} side
+   * @param {string=} opt_path
+   */
+  moveToLineNumber(number, side, opt_path) {
+    const row = this._findRowByNumberAndFile(number, side, opt_path);
+    if (row) {
+      this.side = side;
+      this.$.cursorManager.setCursor(row);
+    }
+  }
 
-      return this.diffRow.querySelector(lineElSelector);
-    },
+  /**
+   * Get the line number element targeted by the cursor row and side.
+   *
+   * @return {?Element|undefined}
+   */
+  getTargetLineElement() {
+    let lineElSelector = '.lineNum';
 
-    getTargetDiffElement() {
-      if (!this.diffRow) return null;
+    if (!this.diffRow) {
+      return;
+    }
 
-      const hostOwner = Polymer.dom(/** @type {Node} */ (this.diffRow))
-          .getOwnerRoot();
-      if (hostOwner && hostOwner.host &&
-          hostOwner.host.tagName === 'GR-DIFF') {
-        return hostOwner.host;
-      }
-      return null;
-    },
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
+    }
 
-    moveToFirstChunk() {
-      this.$.cursorManager.moveToStart();
-      this.moveToNextChunk(true);
-    },
+    return this.diffRow.querySelector(lineElSelector);
+  }
 
-    reInitCursor() {
-      this._updateStops();
+  getTargetDiffElement() {
+    if (!this.diffRow) return null;
+
+    const hostOwner = dom( (this.diffRow))
+        .getOwnerRoot();
+    if (hostOwner && hostOwner.host &&
+        hostOwner.host.tagName === 'GR-DIFF') {
+      return hostOwner.host;
+    }
+    return null;
+  }
+
+  moveToFirstChunk() {
+    this.$.cursorManager.moveToStart();
+    this.moveToNextChunk(true);
+  }
+
+  moveToLastChunk() {
+    this.$.cursorManager.moveToEnd();
+    this.moveToPreviousChunk();
+  }
+
+  /**
+   * Move the cursor either to initialLineNumber or the first chunk and
+   * reset scroll behavior.
+   *
+   * This may grab the focus from the app.
+   *
+   * If you do not want to move the cursor or grab focus, and just want to
+   * reset the scroll behavior, use reInit() instead.
+   */
+  reInitCursor() {
+    if (!this.diffRow) {
+      // does not scroll during init unless requested
+      const scrollingBehaviorForInit = this.initialLineNumber ?
+        ScrollBehavior.KEEP_VISIBLE :
+        ScrollBehavior.NEVER;
+      this._scrollBehavior = scrollingBehaviorForInit;
       if (this.initialLineNumber) {
         this.moveToLineNumber(this.initialLineNumber, this.side);
         this.initialLineNumber = null;
       } else {
         this.moveToFirstChunk();
       }
-    },
+    }
+    this.reInit();
+  }
 
-    _handleWindowScroll() {
-      if (this._listeningForScroll) {
-        this._scrollBehavior = ScrollBehavior.NEVER;
-        this._focusOnMove = false;
-        this._listeningForScroll = false;
+  reInit() {
+    this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
+  }
+
+  _handleWindowScroll() {
+    if (this._preventAutoScrollOnManualScroll) {
+      this._scrollBehavior = ScrollBehavior.NEVER;
+      this._focusOnMove = false;
+      this._preventAutoScrollOnManualScroll = false;
+    }
+  }
+
+  handleDiffUpdate() {
+    this._updateStops();
+    this.reInitCursor();
+  }
+
+  _handleDiffRenderStart() {
+    this._preventAutoScrollOnManualScroll = true;
+  }
+
+  _handleDiffRenderContent() {
+    this._updateStops();
+    // When done rendering, turn focus on move and automatic scrolling back on
+    this._focusOnMove = true;
+    this._preventAutoScrollOnManualScroll = false;
+  }
+
+  _handleDiffLineSelected(event) {
+    this.moveToLineNumber(
+        event.detail.number, event.detail.side, event.detail.path);
+  }
+
+  createCommentInPlace() {
+    const diffWithRangeSelected = this.diffs
+        .find(diff => diff.isRangeSelected());
+    if (diffWithRangeSelected) {
+      diffWithRangeSelected.createRangeComment();
+    } else {
+      const line = this.getTargetLineElement();
+      if (line) {
+        this.getTargetDiffElement().addDraftAtLine(line);
       }
-    },
+    }
+  }
 
-    handleDiffUpdate() {
-      this._updateStops();
-      if (!this.diffRow) {
-        // does not scroll during init unless requested
-        const scrollingBehaviorForInit = this.initialLineNumber ?
-          ScrollBehavior.KEEP_VISIBLE :
-          ScrollBehavior.NEVER;
-        this._scrollBehavior = scrollingBehaviorForInit;
-        this.reInitCursor();
+  /**
+   * Get an object describing the location of the cursor. Such as
+   * {leftSide: false, number: 123} for line 123 of the revision, or
+   * {leftSide: true, number: 321} for line 321 of the base patch.
+   * Returns null if an address is not available.
+   *
+   * @return {?Object}
+   */
+  getAddress() {
+    if (!this.diffRow) { return null; }
+
+    // Get the line-number cell targeted by the cursor. If the mode is unified
+    // then prefer the revision cell if available.
+    let cell;
+    if (this._getViewMode() === DiffViewMode.UNIFIED) {
+      cell = this.diffRow.querySelector('.lineNum.right');
+      if (!cell) {
+        cell = this.diffRow.querySelector('.lineNum.left');
       }
-      this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
-      this._focusOnMove = true;
-      this._listeningForScroll = false;
-    },
+    } else {
+      cell = this.diffRow.querySelector('.lineNum.' + this.side);
+    }
+    if (!cell) { return null; }
 
-    _handleDiffRenderStart() {
-      this._listeningForScroll = true;
-    },
+    const number = cell.getAttribute('data-value');
+    if (!number || number === 'FILE') { return null; }
 
-    /**
-     * Get an object describing the location of the cursor. Such as
-     * {leftSide: false, number: 123} for line 123 of the revision, or
-     * {leftSide: true, number: 321} for line 321 of the base patch.
-     * Returns null if an address is not available.
-     *
-     * @return {?Object}
-     */
-    getAddress() {
-      if (!this.diffRow) { return null; }
+    return {
+      leftSide: cell.matches('.left'),
+      number: parseInt(number, 10),
+    };
+  }
 
-      // Get the line-number cell targeted by the cursor. If the mode is unified
-      // then prefer the revision cell if available.
-      let cell;
-      if (this._getViewMode() === DiffViewMode.UNIFIED) {
-        cell = this.diffRow.querySelector('.lineNum.right');
-        if (!cell) {
-          cell = this.diffRow.querySelector('.lineNum.left');
-        }
-      } else {
-        cell = this.diffRow.querySelector('.lineNum.' + this.side);
-      }
-      if (!cell) { return null; }
+  _getViewMode() {
+    if (!this.diffRow) {
+      return null;
+    }
 
-      const number = cell.getAttribute('data-value');
-      if (!number || number === 'FILE') { return null; }
+    if (this.diffRow.classList.contains('side-by-side')) {
+      return DiffViewMode.SIDE_BY_SIDE;
+    } else {
+      return DiffViewMode.UNIFIED;
+    }
+  }
 
-      return {
-        leftSide: cell.matches('.left'),
-        number: parseInt(number, 10),
-      };
-    },
+  _rowHasSide(row) {
+    const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
+        ' + .content';
+    return !!row.querySelector(selector);
+  }
 
-    _getViewMode() {
-      if (!this.diffRow) {
-        return null;
+  _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
+   * switch to the alternate side.
+   */
+  _fixSide() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+        this._isTargetBlank()) {
+      this.side = this.side === DiffSides.LEFT ?
+        DiffSides.RIGHT : DiffSides.LEFT;
+    }
+  }
+
+  _isTargetBlank() {
+    if (!this.diffRow) {
+      return false;
+    }
+
+    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) {
+      return;
+    }
+    this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
+        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};
+    if (this.diffRow) {
+      actions.left = this._isActionType(
+          this.diffRow.getAttribute('left-type'));
+      actions.right = this._isActionType(
+          this.diffRow.getAttribute('right-type'));
+    }
+    return actions;
+  }
+
+  _getStops() {
+    return this.diffs.reduce(
+        (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
+   * removed from the cursor.
+   *
+   * @private
+   */
+  _diffsChanged(changeRecord) {
+    if (!changeRecord) { return; }
+
+    this._updateStops();
+
+    let splice;
+    let i;
+    for (let spliceIdx = 0;
+      changeRecord.indexSplices &&
+          spliceIdx < changeRecord.indexSplices.length;
+      spliceIdx++) {
+      splice = changeRecord.indexSplices[spliceIdx];
+
+      for (i = splice.index; i < splice.index + splice.addedCount; i++) {
+        this.diffs[i].addEventListener(
+            'render-start', this._boundHandleDiffRenderStart);
+        this.diffs[i].addEventListener(
+            'render-content', this._boundHandleDiffRenderContent);
+        this.diffs[i].addEventListener(
+            'line-selected', this._boundHandleDiffLineSelected);
       }
 
-      if (this.diffRow.classList.contains('side-by-side')) {
-        return DiffViewMode.SIDE_BY_SIDE;
-      } else {
-        return DiffViewMode.UNIFIED;
+      for (i = 0; i < splice.removed && splice.removed.length; i++) {
+        splice.removed[i].removeEventListener(
+            'render-start', this._boundHandleDiffRenderStart);
+        splice.removed[i].removeEventListener(
+            'render-content', this._boundHandleDiffRenderContent);
+        splice.removed[i].removeEventListener(
+            'line-selected', this._boundHandleDiffLineSelected);
       }
-    },
+    }
+  }
 
-    _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
-     * switch to the alternate side.
-     */
-    _fixSide() {
-      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
-          this._isTargetBlank()) {
-        this.side = this.side === DiffSides.LEFT ?
-          DiffSides.RIGHT : DiffSides.LEFT;
+  _findRowByNumberAndFile(targetNumber, side, opt_path) {
+    let stops;
+    if (opt_path) {
+      const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
+      stops = diff.getCursorStops();
+    } else {
+      stops = this.$.cursorManager.stops;
+    }
+    let selector;
+    for (let i = 0; i < stops.length; i++) {
+      selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
+      if (stops[i].querySelector(selector)) {
+        return stops[i];
       }
-    },
+    }
+  }
+}
 
-    _isTargetBlank() {
-      if (!this.diffRow) {
-        return false;
-      }
-
-      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) {
-        return;
-      }
-      this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
-          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};
-      if (this.diffRow) {
-        actions.left = this._isActionType(
-            this.diffRow.getAttribute('left-type'));
-        actions.right = this._isActionType(
-            this.diffRow.getAttribute('right-type'));
-      }
-      return actions;
-    },
-
-    _getStops() {
-      return this.diffs.reduce(
-          (stops, diff) => {
-            return stops.concat(diff.getCursorStops());
-          }, []);
-    },
-
-    _updateStops() {
-      this.$.cursorManager.stops = this._getStops();
-    },
-
-    /**
-     * Setup and tear down on-render listeners for any diffs that are added or
-     * removed from the cursor.
-     *
-     * @private
-     */
-    _diffsChanged(changeRecord) {
-      if (!changeRecord) { return; }
-
-      this._updateStops();
-
-      let splice;
-      let i;
-      for (let spliceIdx = 0;
-        changeRecord.indexSplices &&
-            spliceIdx < changeRecord.indexSplices.length;
-        spliceIdx++) {
-        splice = changeRecord.indexSplices[spliceIdx];
-
-        for (i = splice.index;
-          i < splice.index + splice.addedCount;
-          i++) {
-          this.listen(this.diffs[i], 'render-start', '_handleDiffRenderStart');
-          this.listen(this.diffs[i], 'render-content', 'handleDiffUpdate');
-        }
-
-        for (i = 0;
-          i < splice.removed && splice.removed.length;
-          i++) {
-          this.unlisten(splice.removed[i],
-              'render-start', '_handleDiffRenderStart');
-          this.unlisten(splice.removed[i],
-              'render-content', 'handleDiffUpdate');
-        }
-      }
-    },
-
-    _findRowByNumberAndFile(targetNumber, side, opt_path) {
-      let stops;
-      if (opt_path) {
-        const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
-        stops = diff.getCursorStops();
-      } else {
-        stops = this.$.cursorManager.stops;
-      }
-      let selector;
-      for (let i = 0; i < stops.length; i++) {
-        selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
-        if (stops[i].querySelector(selector)) {
-          return stops[i];
-        }
-      }
-    },
-  });
-})();
+customElements.define(GrDiffCursor.is, GrDiffCursor);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
new file mode 100644
index 0000000..1ac47f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-cursor-manager
+    id="cursorManager"
+    scroll-behavior="[[_scrollBehavior]]"
+    cursor-target-class="target-row"
+    focus-on-move="[[_focusOnMove]]"
+    target="{{diffRow}}"
+    scroll-top-margin="[[scrollTopMargin]]"
+  ></gr-cursor-manager>
+`;
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..77e5179 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
@@ -17,78 +17,158 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../gr-diff/gr-diff.html">
-<link rel="import" href="./gr-diff-cursor.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
-    <mock-diff-response></mock-diff-response>
     <gr-diff></gr-diff>
     <gr-diff-cursor></gr-diff-cursor>
     <gr-rest-api-interface></gr-rest-api-interface>
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-cursor tests', () => {
-    let sandbox;
-    let cursorElement;
-    let diffElement;
-    let mockDiffResponse;
+<test-fixture id="empty">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
 
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-diff/gr-diff.js';
+import './gr-diff-cursor.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-diff-cursor tests', () => {
+  let sandbox;
+  let cursorElement;
+  let diffElement;
+
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+
+    const fixtureElems = fixture('basic');
+    diffElement = fixtureElems[0];
+    cursorElement = fixtureElems[1];
+    const restAPI = fixtureElems[2];
+
+    // Register the diff with the cursor.
+    cursorElement.push('diffs', diffElement);
+
+    diffElement.loggedIn = false;
+    diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
+    diffElement.comments = {
+      left: [],
+      right: [],
+      meta: {patchRange: undefined},
+    };
+    const setupDone = () => {
+      cursorElement._updateStops();
+      cursorElement.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      done();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    restAPI.getDiffPreferences().then(prefs => {
+      diffElement.prefs = prefs;
+      diffElement.diff = getMockDiffResponse();
+    });
+  });
+
+  teardown(() => sandbox.restore());
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursorElement.diffRow);
+
+    const firstDeltaRow = diffElement.shadowRoot
+        .querySelector('.section.delta .diff-row');
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+    cursorElement.moveDown();
+
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+    cursorElement.moveUp();
+
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+  });
+
+  test('moveToLastChunk', () => {
+    const chunks = Array.from(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', () => {
+    assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+
+    cursorElement._handleDiffRenderStart();
+    assert.isTrue(cursorElement._focusOnMove);
+
+    cursorElement._handleWindowScroll();
+    assert.equal(cursorElement._scrollBehavior, 'never');
+    assert.isFalse(cursorElement._focusOnMove);
+
+    cursorElement._handleDiffRenderContent();
+    assert.isTrue(cursorElement._focusOnMove);
+
+    cursorElement.reInitCursor();
+    assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+
+    cursorElement._handleDiffLineSelected(
+        new CustomEvent('line-selected', {
+          detail: {number: '123', side: 'right', path: 'some/file'},
+        }));
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], '123');
+    assert.equal(moveToNumStub.lastCall.args[1], 'right');
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
     setup(done => {
-      sandbox = sinon.sandbox.create();
-
-      const fixtureElems = fixture('basic');
-      mockDiffResponse = fixtureElems[0];
-      diffElement = fixtureElems[1];
-      cursorElement = fixtureElems[2];
-      const restAPI = fixtureElems[3];
-
-      // Register the diff with the cursor.
-      cursorElement.push('diffs', diffElement);
-
-      diffElement.loggedIn = false;
-      diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
-      diffElement.comments = {
-        left: [],
-        right: [],
-        meta: {patchRange: undefined},
-      };
-      const setupDone = () => {
-        cursorElement._updateStops();
-        cursorElement.moveToFirstChunk();
-        diffElement.removeEventListener('render', setupDone);
+      // We must allow the diff to re-render after setting the viewMode.
+      const renderHandler = function() {
+        diffElement.removeEventListener('render', renderHandler);
+        cursorElement.reInitCursor();
         done();
       };
-      diffElement.addEventListener('render', setupDone);
-
-      restAPI.getDiffPreferences().then(prefs => {
-        diffElement.prefs = prefs;
-        diffElement.diff = mockDiffResponse.diffResponse;
-      });
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.viewMode = 'UNIFIED_DIFF';
     });
 
-    teardown(() => sandbox.restore());
-
-    test('diff cursor functionality (side-by-side)', () => {
+    test('diff cursor functionality (unified)', () => {
       // The cursor has been initialized to the first delta.
       assert.isOk(cursorElement.diffRow);
 
-      const firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      let firstDeltaRow = diffElement.shadowRoot
+          .querySelector('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      firstDeltaRow = diffElement.shadowRoot
+          .querySelector('.section.delta .diff-row');
       assert.equal(cursorElement.diffRow, firstDeltaRow);
 
       cursorElement.moveDown();
@@ -101,209 +181,250 @@
       assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
+  });
 
-    test('cursor scroll behavior', () => {
-      cursorElement._handleDiffRenderStart();
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const firstDeltaSection = diffElement.shadowRoot
+        .querySelector('.section.delta');
+    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursorElement.side, 'right');
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    const firstIndex = cursorElement.$.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursorElement.moveLeft();
+
+    assert.equal(cursorElement.side, 'left');
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
+    assert.equal(cursorElement.diffRow.parentElement,
+        firstDeltaSection.previousSibling);
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursorElement.moveDown();
+
+    assert.equal(cursorElement.side, 'left');
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
+    assert.equal(cursorElement.diffRow.parentElement,
+        firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = dom(diffElement.root).querySelectorAll(
+        '.section.delta');
+    const indexOfChunk = function(chunk) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursorElement.side, 'right');
+
+    // Move to the next chunk.
+    cursorElement.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically mvoed over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursorElement.side, 'left');
+  });
+
+  test('initialLineNumber not provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+    const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk',
+        () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
+
+    function renderHandler() {
+      diffElement.removeEventListener('render', renderHandler);
+      cursorElement.reInitCursor();
+      assert.isFalse(moveToNumStub.called);
+      assert.isTrue(moveToChunkStub.called);
+      assert.equal(scrollBehaviorDuringMove, 'never');
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-      assert.isTrue(cursorElement._focusOnMove);
+      done();
+    }
+    diffElement.addEventListener('render', renderHandler);
+    diffElement._diffChanged(getMockDiffResponse());
+  });
 
-      cursorElement._handleWindowScroll();
-      assert.equal(cursorElement._scrollBehavior, 'never');
-      assert.isFalse(cursorElement._focusOnMove);
-
-      cursorElement.handleDiffUpdate();
+  test('initialLineNumber provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber',
+        () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
+    const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
+    function renderHandler() {
+      diffElement.removeEventListener('render', renderHandler);
+      cursorElement.reInitCursor();
+      assert.isFalse(moveToChunkStub.called);
+      assert.isTrue(moveToNumStub.called);
+      assert.equal(moveToNumStub.lastCall.args[0], 10);
+      assert.equal(moveToNumStub.lastCall.args[1], 'right');
+      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-      assert.isTrue(cursorElement._focusOnMove);
+      done();
+    }
+    diffElement.addEventListener('render', renderHandler);
+    cursorElement.initialLineNumber = 10;
+    cursorElement.side = 'right';
+
+    diffElement._diffChanged(getMockDiffResponse());
+  });
+
+  test('getTargetDiffElement', () => {
+    cursorElement.initialLineNumber = 1;
+    assert.isTrue(!!cursorElement.diffRow);
+    assert.equal(
+        cursorElement.getTargetDiffElement(),
+        diffElement
+    );
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
     });
 
-    suite('unified diff', () => {
-      setup(done => {
-        // We must allow the diff to re-render after setting the viewMode.
-        const renderHandler = function() {
-          diffElement.removeEventListener('render', renderHandler);
-          cursorElement.reInitCursor();
-          done();
-        };
-        diffElement.addEventListener('render', renderHandler);
-        diffElement.viewMode = 'UNIFIED_DIFF';
+    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('diff cursor functionality (unified)', () => {
-        // The cursor has been initialized to the first delta.
-        assert.isOk(cursorElement.diffRow);
-
-        let firstDeltaRow = diffElement.$$('.section.delta .diff-row');
-        assert.equal(cursorElement.diffRow, firstDeltaRow);
-
-        firstDeltaRow = diffElement.$$('.section.delta .diff-row');
-        assert.equal(cursorElement.diffRow, firstDeltaRow);
-
-        cursorElement.moveDown();
-
-        assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-        assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
-
-        cursorElement.moveUp();
-
-        assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-        assert.equal(cursorElement.diffRow, firstDeltaRow);
+    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('cursor side functionality', () => {
-      // The side only applies to side-by-side mode, which should be the default
-      // mode.
-      assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-      const firstDeltaSection = diffElement.$$('.section.delta');
-      const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-      // Because the first delta in this diff is on the right, it should be set
-      // to the right side.
-      assert.equal(cursorElement.side, 'right');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
-      const firstIndex = cursorElement.$.cursorManager.index;
-
-      // Move the side to the left. Because this delta only has a right side, we
-      // should be moved up to the previous line where there is content on the
-      // right. The previous row is part of the previous section.
-      cursorElement.moveLeft();
-
-      assert.equal(cursorElement.side, 'left');
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
-      assert.equal(cursorElement.diffRow.parentElement,
-          firstDeltaSection.previousSibling);
-
-      // If we move down, we should skip everything in the first delta because
-      // we are on the left side and the first delta has no content on the left.
-      cursorElement.moveDown();
-
-      assert.equal(cursorElement.side, 'left');
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
-      assert.equal(cursorElement.diffRow.parentElement,
-          firstDeltaSection.nextSibling);
-    });
-
-    test('chunk skip functionality', () => {
-      const chunks = Polymer.dom(diffElement.root).querySelectorAll(
-          '.section.delta');
-      const indexOfChunk = function(chunk) {
-        return Array.prototype.indexOf.call(chunks, chunk);
+    test('createCommentInPlace creates comment for range if selected', done => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
       };
-
-      // We should be initialized to the first chunk. Since this chunk only has
-      // content on the right side, our side should be right.
-      let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, 0);
-      assert.equal(cursorElement.side, 'right');
-
-      // Move to the next chunk.
-      cursorElement.moveToNextChunk();
-
-      // Since this chunk only has content on the left side. we should have been
-      // automatically mvoed over.
-      const previousIndex = currentIndex;
-      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, previousIndex + 1);
-      assert.equal(cursorElement.side, 'left');
-    });
-
-    test('initialLineNumber not provided', done => {
-      let scrollBehaviorDuringMove;
-      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
-      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk',
-          () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
-
-      function renderHandler() {
-        diffElement.removeEventListener('render', renderHandler);
-        assert.isFalse(moveToNumStub.called);
-        assert.isTrue(moveToChunkStub.called);
-        assert.equal(scrollBehaviorDuringMove, 'never');
-        assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-        done();
-      }
-      diffElement.addEventListener('render', renderHandler);
-      diffElement._diffChanged(mockDiffResponse.diffResponse);
-    });
-
-    test('initialLineNumber provided', done => {
-      let scrollBehaviorDuringMove;
-      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber',
-          () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
-      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
-      function renderHandler() {
-        diffElement.removeEventListener('render', renderHandler);
-        assert.isFalse(moveToChunkStub.called);
-        assert.isTrue(moveToNumStub.called);
-        assert.equal(moveToNumStub.lastCall.args[0], 10);
-        assert.equal(moveToNumStub.lastCall.args[1], 'right');
-        assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-        assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-        done();
-      }
-      diffElement.addEventListener('render', renderHandler);
-      cursorElement.initialLineNumber = 10;
-      cursorElement.side = 'right';
-
-      diffElement._diffChanged(mockDiffResponse.diffResponse);
-    });
-
-    test('getTargetDiffElement', () => {
-      cursorElement.initialLineNumber = 1;
-      assert.isTrue(!!cursorElement.diffRow);
-      assert.equal(
-          cursorElement.getTargetDiffElement(),
-          diffElement
-      );
-    });
-
-    test('getAddress', () => {
-      // It should initialize to the first chunk: line 5 of the revision.
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: false, number: 5});
-
-      // Revision line 4 is up.
-      cursorElement.moveUp();
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: false, number: 4});
-
-      // Base line 4 is left.
-      cursorElement.moveLeft();
-      assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
-
-      // Moving to the next chunk takes it back to the start.
-      cursorElement.moveToNextChunk();
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: false, number: 5});
-
-      // The following chunk is a removal starting on line 10 of the base.
-      cursorElement.moveToNextChunk();
-      assert.deepEqual(cursorElement.getAddress(),
-          {leftSide: true, number: 10});
-
-      // Should be null if there is no selection.
-      cursorElement.$.cursorManager.unsetCursor();
-      assert.isNotOk(cursorElement.getAddress());
-    });
-
-    test('_findRowByNumberAndFile', () => {
-      // Get the first ab row after the first chunk.
-      const row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
-
-      // It should be line 8 on the right, but line 5 on the left.
-      assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
-      assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
-    });
-
-    test('expand context updates stops', done => {
-      sandbox.spy(cursorElement, 'handleDiffUpdate');
-      MockInteractions.tap(diffElement.$$('.showContext'));
-      flush(() => {
-        assert.isTrue(cursorElement.handleDiffUpdate.called);
+      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(),
+        {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursorElement.moveUp();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursorElement.moveLeft();
+    assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursorElement.moveToNextChunk();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursorElement.moveToNextChunk();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursorElement.$.cursorManager.unsetCursor();
+    assert.isNotOk(cursorElement.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const row = dom(diffElement.root).querySelectorAll('tr')[8];
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
+    assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+  });
+
+  test('expand context updates stops', done => {
+    sandbox.spy(cursorElement, '_updateStops');
+    MockInteractions.tap(diffElement.shadowRoot
+        .querySelector('.showContext'));
+    flush(() => {
+      assert.isTrue(cursorElement._updateStops.called);
+      done();
+    });
+  });
+
+  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..4006d13 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
@@ -14,202 +14,263 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrAnnotation) { return; }
+// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
+const ANNOTATION_TAG = 'HL';
 
-  // TODO(wyatta): refactor this to be <MARK> rather than <HL>.
-  const ANNOTATION_TAG = 'HL';
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+export const GrAnnotation = {
 
-  const GrAnnotation = {
+  /**
+   * The DOM API textContent.length calculation is broken when the text
+   * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+   *
+   * @param  {!Text} node text node.
+   * @return {number} The length of the text.
+   */
+  getLength(node) {
+    return this.getStringLength(node.textContent);
+  },
 
-    /**
-     * The DOM API textContent.length calculation is broken when the text
-     * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
-     *
-     * @param  {!Text} node text node.
-     * @return {number} The length of the text.
-     */
-    getLength(node) {
-      return this.getStringLength(node.textContent);
-    },
+  getStringLength(str) {
+    return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+  },
 
-    getStringLength(str) {
-      return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
-    },
+  /**
+   * 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;
 
-    /**
-     * Surrounds the element's text at specified range in an ANNOTATION_TAG
-     * element. If the element has child elements, the range is split and
-     * applied as deeply as possible.
-     */
-    annotateElement(parent, offset, length, cssClass) {
-      const nodes = [].slice.apply(parent.childNodes);
-      let nodeLength;
-      let subLength;
+    if (parent instanceof Element) {
+      childNodes = Array.from(parent.childNodes);
+    } else if (parent instanceof Text) {
+      childNodes = [parent];
+      parent = parent.parentNode;
+    } else {
+      return;
+    }
 
-      for (const node of nodes) {
-        nodeLength = this.getLength(node);
-
-        // If the current node is completely before the offset.
-        if (nodeLength <= offset) {
-          offset -= nodeLength;
-          continue;
-        }
-
-        // Sublength is the annotation length for the current node.
-        subLength = Math.min(length, nodeLength - offset);
-
-        if (node instanceof Text) {
-          this._annotateText(node, offset, subLength, cssClass);
-        } else if (node instanceof HTMLElement) {
-          this.annotateElement(node, offset, subLength, cssClass);
-        }
-
-        // If there is still more to annotate, then shift the indices, otherwise
-        // work is done, so break the loop.
-        if (subLength < length) {
-          length -= subLength;
-          offset = 0;
-        } else {
-          break;
-        }
+    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;
       }
-    },
 
-    /**
-     * Wraps node in annotation tag with cssClass, replacing the node in DOM.
-     *
-     * @return {!Element} Wrapped node.
-     */
-    wrapInHighlight(node, cssClass) {
-      let hl;
-      if (node.tagName === ANNOTATION_TAG) {
-        hl = node;
-        hl.classList.add(cssClass);
+      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.
+   */
+  annotateElement(parent, offset, length, cssClass) {
+    const nodes = [].slice.apply(parent.childNodes);
+    let nodeLength;
+    let subLength;
+
+    for (const node of nodes) {
+      nodeLength = this.getLength(node);
+
+      // If the current node is completely before the offset.
+      if (nodeLength <= offset) {
+        offset -= nodeLength;
+        continue;
+      }
+
+      // Sublength is the annotation length for the current node.
+      subLength = Math.min(length, nodeLength - offset);
+
+      if (node instanceof Text) {
+        this._annotateText(node, offset, subLength, cssClass);
+      } else if (node instanceof HTMLElement) {
+        this.annotateElement(node, offset, subLength, cssClass);
+      }
+
+      // If there is still more to annotate, then shift the indices, otherwise
+      // work is done, so break the loop.
+      if (subLength < length) {
+        length -= subLength;
+        offset = 0;
       } else {
-        hl = document.createElement(ANNOTATION_TAG);
-        hl.className = cssClass;
-        Polymer.dom(node.parentElement).replaceChild(hl, node);
-        Polymer.dom(hl).appendChild(node);
+        break;
       }
-      return hl;
-    },
+    }
+  },
 
-    /**
-     * Splits Text Node and wraps it in hl with cssClass.
-     * Wraps trailing part after split, tailing one if opt_firstPart is true.
-     *
-     * @param {!Node} node
-     * @param {number} offset
-     * @param {string} cssClass
-     * @param {boolean=} opt_firstPart
-     */
-    splitAndWrapInHighlight(node, offset, cssClass, opt_firstPart) {
-      if (this.getLength(node) === offset || offset === 0) {
-        return this.wrapInHighlight(node, cssClass);
+  /**
+   * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+   *
+   * @return {!Element} Wrapped node.
+   */
+  wrapInHighlight(node, cssClass) {
+    let hl;
+    if (node.tagName === ANNOTATION_TAG) {
+      hl = node;
+      hl.classList.add(cssClass);
+    } else {
+      hl = document.createElement(ANNOTATION_TAG);
+      hl.className = cssClass;
+      Polymer.dom(node.parentElement).replaceChild(hl, node);
+      Polymer.dom(hl).appendChild(node);
+    }
+    return hl;
+  },
+
+  /**
+   * Splits Text Node and wraps it in hl with cssClass.
+   * Wraps trailing part after split, tailing one if opt_firstPart is true.
+   *
+   * @param {!Node} node
+   * @param {number} offset
+   * @param {string} cssClass
+   * @param {boolean=} opt_firstPart
+   */
+  splitAndWrapInHighlight(node, offset, cssClass, opt_firstPart) {
+    if (this.getLength(node) === offset || offset === 0) {
+      return this.wrapInHighlight(node, cssClass);
+    } else {
+      if (opt_firstPart) {
+        this.splitNode(node, offset);
+        // Node points to first part of the Text, second one is sibling.
       } else {
-        if (opt_firstPart) {
-          this.splitNode(node, offset);
-          // Node points to first part of the Text, second one is sibling.
-        } else {
-          node = this.splitNode(node, offset);
-        }
-        return this.wrapInHighlight(node, cssClass);
+        node = this.splitNode(node, offset);
       }
-    },
+      return this.wrapInHighlight(node, cssClass);
+    }
+  },
 
-    /**
-     * Splits Node at offset.
-     * If Node is Element, it's cloned and the node at offset is split too.
-     *
-     * @param {!Node} node
-     * @param {number} offset
-     * @return {!Node} Trailing Node.
-     */
-    splitNode(element, offset) {
-      if (element instanceof Text) {
-        return this.splitTextNode(element, offset);
+  /**
+   * Splits Node at offset.
+   * If Node is Element, it's cloned and the node at offset is split too.
+   *
+   * @param {!Node} node
+   * @param {number} offset
+   * @return {!Node} Trailing Node.
+   */
+  splitNode(element, offset) {
+    if (element instanceof Text) {
+      return this.splitTextNode(element, offset);
+    }
+    const tail = element.cloneNode(false);
+    element.parentElement.insertBefore(tail, element.nextSibling);
+    // Skip nodes before offset.
+    let node = element.firstChild;
+    while (node &&
+        this.getLength(node) <= offset ||
+        this.getLength(node) === 0) {
+      offset -= this.getLength(node);
+      node = node.nextSibling;
+    }
+    if (this.getLength(node) > offset) {
+      tail.appendChild(this.splitNode(node, offset));
+    }
+    while (node.nextSibling) {
+      tail.appendChild(node.nextSibling);
+    }
+    return tail;
+  },
+
+  /**
+   * Node.prototype.splitText Unicode-valid alternative.
+   *
+   * DOM Api for splitText() is broken for Unicode:
+   * https://mathiasbynens.be/notes/javascript-unicode
+   *
+   * @param {!Text} node
+   * @param {number} offset
+   * @return {!Text} Trailing Text Node.
+   */
+  splitTextNode(node, offset) {
+    if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
+      // TODO (viktard): Polyfill Array.from for IE10.
+      const head = Array.from(node.textContent);
+      const tail = head.splice(offset);
+      const parent = node.parentNode;
+
+      // Split the content of the original node.
+      node.textContent = head.join('');
+
+      const tailNode = document.createTextNode(tail.join(''));
+      if (parent) {
+        parent.insertBefore(tailNode, node.nextSibling);
       }
-      const tail = element.cloneNode(false);
-      element.parentElement.insertBefore(tail, element.nextSibling);
-      // Skip nodes before offset.
-      let node = element.firstChild;
-      while (node &&
-          this.getLength(node) <= offset ||
-          this.getLength(node) === 0) {
-        offset -= this.getLength(node);
-        node = node.nextSibling;
-      }
-      if (this.getLength(node) > offset) {
-        tail.appendChild(this.splitNode(node, offset));
-      }
-      while (node.nextSibling) {
-        tail.appendChild(node.nextSibling);
-      }
-      return tail;
-    },
+      return tailNode;
+    } else {
+      return node.splitText(offset);
+    }
+  },
 
-    /**
-     * Node.prototype.splitText Unicode-valid alternative.
-     *
-     * DOM Api for splitText() is broken for Unicode:
-     * https://mathiasbynens.be/notes/javascript-unicode
-     *
-     * @param {!Text} node
-     * @param {number} offset
-     * @return {!Text} Trailing Text Node.
-     */
-    splitTextNode(node, offset) {
-      if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
-        // TODO (viktard): Polyfill Array.from for IE10.
-        const head = Array.from(node.textContent);
-        const tail = head.splice(offset);
-        const parent = node.parentNode;
+  _annotateText(node, offset, length, cssClass) {
+    const nodeLength = this.getLength(node);
 
-        // Split the content of the original node.
-        node.textContent = head.join('');
+    // There are four cases:
+    //  1) Entire node is highlighted.
+    //  2) Highlight is at the start.
+    //  3) Highlight is at the end.
+    //  4) Highlight is in the middle.
 
-        const tailNode = document.createTextNode(tail.join(''));
-        if (parent) {
-          parent.insertBefore(tailNode, node.nextSibling);
-        }
-        return tailNode;
-      } else {
-        return node.splitText(offset);
-      }
-    },
+    if (offset === 0 && nodeLength === length) {
+      // Case 1.
+      this.wrapInHighlight(node, cssClass);
+    } else if (offset === 0) {
+      // Case 2.
+      this.splitAndWrapInHighlight(node, length, cssClass, true);
+    } else if (offset + length === nodeLength) {
+      // Case 3
+      this.splitAndWrapInHighlight(node, offset, cssClass, false);
+    } else {
+      // Case 4
+      this.splitAndWrapInHighlight(this.splitTextNode(node, offset), length,
+          cssClass, true);
+    }
+  },
+};
 
-    _annotateText(node, offset, length, cssClass) {
-      const nodeLength = this.getLength(node);
-
-      // There are four cases:
-      //  1) Entire node is highlighted.
-      //  2) Highlight is at the start.
-      //  3) Highlight is at the end.
-      //  4) Highlight is in the middle.
-
-      if (offset === 0 && nodeLength === length) {
-        // Case 1.
-        this.wrapInHighlight(node, cssClass);
-      } else if (offset === 0) {
-        // Case 2.
-        this.splitAndWrapInHighlight(node, length, cssClass, true);
-      } else if (offset + length === nodeLength) {
-        // Case 3
-        this.splitAndWrapInHighlight(node, offset, cssClass, false);
-      } else {
-        // Case 4
-        this.splitAndWrapInHighlight(this.splitTextNode(node, offset), length,
-            cssClass, true);
-      }
-    },
-  };
-
-  window.GrAnnotation = GrAnnotation;
-})(window);
+/**
+ * Data used to construct an element.
+ *
+ * @typedef {{
+ *   tagName: string,
+ *   attributes: (!Object<string, *>|undefined)
+ * }}
+ */
+GrAnnotation.ElementSpec;
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..2bda950 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="gr-annotation.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,161 +31,268 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('annotation', () => {
-    let str;
-    let parent;
-    let textNode;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {GrAnnotation} from './gr-annotation.js';
+import {sanitizeDOMValue, setSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
+suite('annotation', () => {
+  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');
+
+    assert.equal(parent.childNodes.length, 1);
+    assert.instanceOf(parent.childNodes[0], HTMLElement);
+    assert.equal(parent.childNodes[0].className, 'foobar');
+    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+    assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
+  });
+
+  test('_annotateText Case 2', () => {
+    const length = 12;
+    const substr = str.substr(0, length);
+    const remainder = str.substr(length);
+
+    GrAnnotation._annotateText(textNode, 0, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 2);
+
+    assert.instanceOf(parent.childNodes[0], HTMLElement);
+    assert.equal(parent.childNodes[0].className, 'foobar');
+    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+    assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
+
+    assert.instanceOf(parent.childNodes[1], Text);
+    assert.equal(parent.childNodes[1].textContent, remainder);
+  });
+
+  test('_annotateText Case 3', () => {
+    const index = 12;
+    const length = str.length - index;
+    const remainder = str.substr(0, index);
+    const substr = str.substr(index);
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 2);
+
+    assert.instanceOf(parent.childNodes[0], Text);
+    assert.equal(parent.childNodes[0].textContent, remainder);
+
+    assert.instanceOf(parent.childNodes[1], HTMLElement);
+    assert.equal(parent.childNodes[1].className, 'foobar');
+    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+  });
+
+  test('_annotateText Case 4', () => {
+    const index = str.indexOf('dolor');
+    const length = 'dolor '.length;
+
+    const remainderPre = str.substr(0, index);
+    const substr = str.substr(index, length);
+    const remainderPost = str.substr(index + length);
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 3);
+
+    assert.instanceOf(parent.childNodes[0], Text);
+    assert.equal(parent.childNodes[0].textContent, remainderPre);
+
+    assert.instanceOf(parent.childNodes[1], HTMLElement);
+    assert.equal(parent.childNodes[1].className, 'foobar');
+    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+
+    assert.instanceOf(parent.childNodes[2], Text);
+    assert.equal(parent.childNodes[2].textContent, remainderPost);
+  });
+
+  test('_annotateElement design doc example', () => {
+    const layers = [
+      'amet, ',
+      'inceptos ',
+      'amet, ',
+      'et, suspendisse ince',
+    ];
+
+    // Apply the layers successively.
+    layers.forEach((layer, i) => {
+      GrAnnotation.annotateElement(
+          parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
+    });
+
+    assert.equal(parent.textContent, str);
+
+    // Layer 1:
+    const layer1 = parent.querySelectorAll('.layer-1');
+    assert.equal(layer1.length, 1);
+    assert.equal(layer1[0].textContent, layers[0]);
+    assert.equal(layer1[0].parentElement, parent);
+
+    // Layer 2:
+    const layer2 = parent.querySelectorAll('.layer-2');
+    assert.equal(layer2.length, 1);
+    assert.equal(layer2[0].textContent, layers[1]);
+    assert.equal(layer2[0].parentElement, parent);
+
+    // Layer 3:
+    const layer3 = parent.querySelectorAll('.layer-3');
+    assert.equal(layer3.length, 1);
+    assert.equal(layer3[0].textContent, layers[2]);
+    assert.equal(layer3[0].parentElement, layer1[0]);
+
+    // Layer 4:
+    const layer4 = parent.querySelectorAll('.layer-4');
+    assert.equal(layer4.length, 3);
+
+    assert.equal(layer4[0].textContent, 'et, ');
+    assert.equal(layer4[0].parentElement, layer3[0]);
+
+    assert.equal(layer4[1].textContent, 'suspendisse ');
+    assert.equal(layer4[1].parentElement, parent);
+
+    assert.equal(layer4[2].textContent, 'ince');
+    assert.equal(layer4[2].parentElement, layer2[0]);
+
+    assert.equal(layer4[0].textContent +
+        layer4[1].textContent +
+        layer4[2].textContent,
+    layers[3]);
+  });
+
+  test('splitTextNode', () => {
+    const helloString = 'hello';
+    const asciiString = 'ASCII';
+    const unicodeString = 'Unic💢de';
+
+    let node;
+    let tail;
+
+    // Non-unicode path:
+    node = document.createTextNode(helloString + asciiString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, asciiString);
+
+    // Unicdoe path:
+    node = document.createTextNode(helloString + unicodeString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, unicodeString);
+  });
+
+  suite('annotateWithElement', () => {
+    const fullText = '01234567890123456789';
+    let mockSanitize;
+    let originalSanitizeDOMValue;
 
     setup(() => {
-      parent = fixture('basic');
-      textNode = parent.childNodes[0];
-      str = textNode.textContent;
+      originalSanitizeDOMValue = sanitizeDOMValue;
+      assert.isDefined(originalSanitizeDOMValue);
+      mockSanitize = sandbox.spy(originalSanitizeDOMValue);
+      setSanitizeDOMValue(mockSanitize);
     });
 
-    test('_annotateText Case 1', () => {
-      GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
-
-      assert.equal(parent.childNodes.length, 1);
-      assert.instanceOf(parent.childNodes[0], HTMLElement);
-      assert.equal(parent.childNodes[0].className, 'foobar');
-      assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-      assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
+    teardown(() => {
+      setSanitizeDOMValue(originalSanitizeDOMValue);
     });
 
-    test('_annotateText Case 2', () => {
-      const length = 12;
-      const substr = str.substr(0, length);
-      const remainder = str.substr(length);
+    test('annotates when fully contained', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper'});
 
-      GrAnnotation._annotateText(textNode, 0, length, 'foobar');
-
-      assert.equal(parent.childNodes.length, 2);
-
-      assert.instanceOf(parent.childNodes[0], HTMLElement);
-      assert.equal(parent.childNodes[0].className, 'foobar');
-      assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-      assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
-
-      assert.instanceOf(parent.childNodes[1], Text);
-      assert.equal(parent.childNodes[1].textContent, remainder);
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
     });
 
-    test('_annotateText Case 3', () => {
-      const index = 12;
-      const length = str.length - index;
-      const remainder = str.substr(0, index);
-      const substr = str.substr(index);
+    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'});
 
-      GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-      assert.equal(parent.childNodes.length, 2);
-
-      assert.instanceOf(parent.childNodes[0], Text);
-      assert.equal(parent.childNodes[0].textContent, remainder);
-
-      assert.instanceOf(parent.childNodes[1], HTMLElement);
-      assert.equal(parent.childNodes[1].className, 'foobar');
-      assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-      assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+      assert.equal(
+          container.innerHTML,
+          '0' +
+          '<test-wrapper>' +
+          '1234' +
+          '<hl class="testclass">567890</hl>' +
+          '</test-wrapper>' +
+          '<hl class="testclass">1234</hl>' +
+          '56789');
     });
 
-    test('_annotateText Case 4', () => {
-      const index = str.indexOf('dolor');
-      const length = 'dolor '.length;
+    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'});
 
-      const remainderPre = str.substr(0, index);
-      const substr = str.substr(index, length);
-      const remainderPost = str.substr(index + length);
-
-      GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-      assert.equal(parent.childNodes.length, 3);
-
-      assert.instanceOf(parent.childNodes[0], Text);
-      assert.equal(parent.childNodes[0].textContent, remainderPre);
-
-      assert.instanceOf(parent.childNodes[1], HTMLElement);
-      assert.equal(parent.childNodes[1].className, 'foobar');
-      assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-      assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-
-      assert.instanceOf(parent.childNodes[2], Text);
-      assert.equal(parent.childNodes[2].textContent, remainderPost);
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
     });
 
-    test('_annotateElement design doc example', () => {
-      const layers = [
-        'amet, ',
-        'inceptos ',
-        'amet, ',
-        'et, suspendisse ince',
-      ];
+    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'});
 
-      // Apply the layers successively.
-      layers.forEach((layer, i) => {
-        GrAnnotation.annotateElement(
-            parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
-      });
-
-      assert.equal(parent.textContent, str);
-
-      // Layer 1:
-      const layer1 = parent.querySelectorAll('.layer-1');
-      assert.equal(layer1.length, 1);
-      assert.equal(layer1[0].textContent, layers[0]);
-      assert.equal(layer1[0].parentElement, parent);
-
-      // Layer 2:
-      const layer2 = parent.querySelectorAll('.layer-2');
-      assert.equal(layer2.length, 1);
-      assert.equal(layer2[0].textContent, layers[1]);
-      assert.equal(layer2[0].parentElement, parent);
-
-      // Layer 3:
-      const layer3 = parent.querySelectorAll('.layer-3');
-      assert.equal(layer3.length, 1);
-      assert.equal(layer3[0].textContent, layers[2]);
-      assert.equal(layer3[0].parentElement, layer1[0]);
-
-      // Layer 4:
-      const layer4 = parent.querySelectorAll('.layer-4');
-      assert.equal(layer4.length, 3);
-
-      assert.equal(layer4[0].textContent, 'et, ');
-      assert.equal(layer4[0].parentElement, layer3[0]);
-
-      assert.equal(layer4[1].textContent, 'suspendisse ');
-      assert.equal(layer4[1].parentElement, parent);
-
-      assert.equal(layer4[2].textContent, 'ince');
-      assert.equal(layer4[2].parentElement, layer2[0]);
-
-      assert.equal(layer4[0].textContent +
-          layer4[1].textContent +
-          layer4[2].textContent,
-      layers[3]);
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
     });
 
-    test('splitTextNode', () => {
-      const helloString = 'hello';
-      const asciiString = 'ASCII';
-      const unicodeString = 'Unic💢de';
-
-      let node;
-      let tail;
-
-      // Non-unicode path:
-      node = document.createTextNode(helloString + asciiString);
-      tail = GrAnnotation.splitTextNode(node, helloString.length);
-      assert(node.textContent, helloString);
-      assert(tail.textContent, asciiString);
-
-      // Unicdoe path:
-      node = document.createTextNode(helloString + unicodeString);
-      tail = GrAnnotation.splitTextNode(node, helloString.length);
-      assert(node.textContent, helloString);
-      assert(tail.textContent, unicodeString);
+    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.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
deleted file mode 100644
index 3b17190..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
-
-<dom-module id="gr-diff-highlight">
-  <template>
-    <style include="shared-styles">
-      :host {
-        position: relative;
-      }
-      gr-selection-action-box {
-        /**
-         * Needs z-index to apear above wrapped content, since it's inseted
-         * into DOM before it.
-         */
-        z-index: 10;
-      }
-    </style>
-    <div class="contentWrapper">
-      <slot></slot>
-    </div>
-  </template>
-  <script src="gr-annotation.js"></script>
-  <script src="gr-range-normalizer.js"></script>
-  <script src="gr-diff-highlight.js"></script>
-</dom-module>
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 b159b51..00da805 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
@@ -14,14 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-diff-highlight',
+import '../../../styles/shared-styles.js';
+import '../gr-selection-action-box/gr-selection-action-box.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-highlight_html.js';
+import {GrAnnotation} from './gr-annotation.js';
+import {GrRangeNormalizer} from './gr-range-normalizer.js';
 
-    properties: {
-      /** @type {!Array<!Gerrit.HoveredRange>} */
+/**
+ * @extends Polymer.Element
+ */
+class GrDiffHighlight extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-highlight'; }
+
+  static get properties() {
+    return {
+    /** @type {!Array<!Gerrit.HoveredRange>} */
       commentRanges: {
         type: Array,
         notify: true,
@@ -33,442 +49,500 @@
        * @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) {
-        this._cachedDiffBuilder =
-            Polymer.dom(this).querySelector('gr-diff-builder');
+  get diffBuilder() {
+    if (!this._cachedDiffBuilder) {
+      this._cachedDiffBuilder =
+          dom(this).querySelector('gr-diff-builder');
+    }
+    return this._cachedDiffBuilder;
+  }
+
+  /**
+   * Determines side/line/range for a DOM selection and shows a tooltip.
+   *
+   * With native shadow DOM, gr-diff-highlight cannot access a selection that
+   * references the DOM elements making up the diff because they are in the
+   * shadow DOM the gr-diff element. For this reason, we listen to the
+   * selectionchange event and retrieve the selection in gr-diff, and then
+   * call this method to process the Selection.
+   *
+   * @param {Selection} selection A DOM Selection living in the shadow DOM of
+   *     the diff element.
+   * @param {boolean} isMouseUp If true, this is called due to a mouseup
+   *     event, in which case we might want to immediately create a comment,
+   *     because isMouseUp === true combined with an existing selection must
+   *     mean that this is the end of a double-click.
+   */
+  handleSelectionChange(selection, isMouseUp) {
+    // Debounce is not just nice for waiting until the selection has settled,
+    // it is also vital for being able to click on the action box before it is
+    // removed.
+    // If you wait longer than 50 ms, then you don't properly catch a very
+    // quick 'c' press after the selection change. If you wait less than 10
+    // ms, then you will have about 50 _handleSelection calls when doing a
+    // simple drag for select.
+    this.debounce(
+        'selectionChange', () => this._handleSelection(selection, isMouseUp),
+        10);
+  }
+
+  _getThreadEl(e) {
+    const path = dom(e).path || [];
+    for (const pathEl of path) {
+      if (pathEl.classList.contains('comment-thread')) return pathEl;
+    }
+    return null;
+  }
+
+  _toggleRangeElHighlight(threadEl, highlightRange = false) {
+    // We don't want to re-create the line just for highlighting the range which
+    // is creating annoying bugs: @see Issue 12934
+    // As gr-ranged-comment-layer now does not notify the layer re-render and
+    // lack of access to the thread or the lineEl from the ranged-comment-layer,
+    // need to update range class for styles here.
+    const currentLine = threadEl.assignedSlot.parentElement.previousSibling;
+    if (currentLine && currentLine.querySelector) {
+      if (highlightRange) {
+        const rangeNode = currentLine.querySelector('.range');
+        if (rangeNode) {
+          rangeNode.classList.add('rangeHighlight');
+          rangeNode.classList.remove('range');
+        }
+      } else {
+        const rangeNode = currentLine.querySelector('.rangeHighlight');
+        if (rangeNode) {
+          rangeNode.classList.remove('rangeHighlight');
+          rangeNode.classList.add('range');
+        }
       }
-      return this._cachedDiffBuilder;
-    },
+    }
+  }
 
+  _handleCommentThreadMouseenter(e) {
+    const threadEl = this._getThreadEl(e);
+    const index = this._indexForThreadEl(threadEl);
 
-    isRangeSelected() {
-      return !!this.$$('gr-selection-action-box');
-    },
+    if (index !== undefined) {
+      this.set(['commentRanges', index, 'hovering'], true);
+    }
 
-    /**
-     * Determines side/line/range for a DOM selection and shows a tooltip.
-     *
-     * With native shadow DOM, gr-diff-highlight cannot access a selection that
-     * references the DOM elements making up the diff because they are in the
-     * shadow DOM the gr-diff element. For this reason, we listen to the
-     * selectionchange event and retrieve the selection in gr-diff, and then
-     * call this method to process the Selection.
-     *
-     * @param {Selection} selection A DOM Selection living in the shadow DOM of
-     *     the diff element.
-     * @param {boolean} isMouseUp If true, this is called due to a mouseup
-     *     event, in which case we might want to immediately create a comment,
-     *     because isMouseUp === true combined with an existing selection must
-     *     mean that this is the end of a double-click.
-     */
-    handleSelectionChange(selection, isMouseUp) {
-      // Debounce is not just nice for waiting until the selection has settled,
-      // it is also vital for being able to click on the action box before it is
-      // removed.
-      // If you wait longer than 50 ms, then you don't properly catch a very
-      // quick 'c' press after the selection change. If you wait less than 10
-      // ms, then you will have about 50 _handleSelection calls when doing a
-      // simple drag for select.
-      this.debounce(
-          'selectionChange', () => this._handleSelection(selection, isMouseUp),
-          10);
-    },
+    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  }
 
-    _getThreadEl(e) {
-      const path = Polymer.dom(e).path || [];
-      for (const pathEl of path) {
-        if (pathEl.classList.contains('comment-thread')) return pathEl;
+  _handleCommentThreadMouseleave(e) {
+    const threadEl = this._getThreadEl(e);
+    const index = this._indexForThreadEl(threadEl);
+
+    if (index !== undefined) {
+      this.set(['commentRanges', index, 'hovering'], false);
+    }
+
+    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  }
+
+  _indexForThreadEl(threadEl) {
+    const side = threadEl.getAttribute('comment-side');
+    const range = JSON.parse(threadEl.getAttribute('range'));
+
+    if (!range) return undefined;
+
+    return this._indexOfCommentRange(side, range);
+  }
+
+  _indexOfCommentRange(side, range) {
+    function rangesEqual(a, b) {
+      if (!a && !b) {
+        return true;
       }
+      if (!a || !b) {
+        return false;
+      }
+      return a.start_line === b.start_line &&
+          a.start_character === b.start_character &&
+          a.end_line === b.end_line &&
+          a.end_character === b.end_character;
+    }
+
+    return this.commentRanges.findIndex(commentRange =>
+      commentRange.side === side && rangesEqual(commentRange.range, range));
+  }
+
+  /**
+   * Get current normalized selection.
+   * Merges multiple ranges, accounts for triple click, accounts for
+   * syntax highligh, convert native DOM Range objects to Gerrit concepts
+   * (line, side, etc).
+   *
+   * @param {Selection} selection
+   * @return {({
+   *   start: {
+   *     node: Node,
+   *     side: string,
+   *     line: Number,
+   *     column: Number
+   *   },
+   *   end: {
+   *     node: Node,
+   *     side: string,
+   *     line: Number,
+   *     column: Number
+   *   }
+   * })|null|!Object}
+   */
+  _getNormalizedRange(selection) {
+    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+       we can get is a single Range */
+    if (selection instanceof Range) {
+      return this._normalizeRange(selection);
+    }
+    const rangeCount = selection.rangeCount;
+    if (rangeCount === 0) {
       return null;
-    },
-
-    _handleCommentThreadMouseenter(e) {
-      const threadEl = this._getThreadEl(e);
-      const index = this._indexForThreadEl(threadEl);
-
-      if (index !== undefined) {
-        this.set(['commentRanges', index, 'hovering'], true);
-      }
-    },
-
-    _handleCommentThreadMouseleave(e) {
-      const threadEl = this._getThreadEl(e);
-      const index = this._indexForThreadEl(threadEl);
-
-      if (index !== undefined) {
-        this.set(['commentRanges', index, 'hovering'], false);
-      }
-    },
-
-    _indexForThreadEl(threadEl) {
-      const side = threadEl.getAttribute('comment-side');
-      const range = JSON.parse(threadEl.getAttribute('range'));
-
-      if (!range) return undefined;
-
-      return this._indexOfCommentRange(side, range);
-    },
-
-    _indexOfCommentRange(side, range) {
-      function rangesEqual(a, b) {
-        if (!a && !b) {
-          return true;
-        }
-        if (!a || !b) {
-          return false;
-        }
-        return a.start_line === b.start_line &&
-            a.start_character === b.start_character &&
-            a.end_line === b.end_line &&
-            a.end_character === b.end_character;
-      }
-
-      return this.commentRanges.findIndex(commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range));
-    },
-
-    /**
-     * Get current normalized selection.
-     * Merges multiple ranges, accounts for triple click, accounts for
-     * syntax highligh, convert native DOM Range objects to Gerrit concepts
-     * (line, side, etc).
-     *
-     * @param {Selection} selection
-     * @return {({
-     *   start: {
-     *     node: Node,
-     *     side: string,
-     *     line: Number,
-     *     column: Number
-     *   },
-     *   end: {
-     *     node: Node,
-     *     side: string,
-     *     line: Number,
-     *     column: Number
-     *   }
-     * })|null|!Object}
-     */
-    _getNormalizedRange(selection) {
-      /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
-         we can get is a single Range */
-      if (selection instanceof Range) {
-        return this._normalizeRange(selection);
-      }
-      const rangeCount = selection.rangeCount;
-      if (rangeCount === 0) {
-        return null;
-      } else if (rangeCount === 1) {
-        return this._normalizeRange(selection.getRangeAt(0));
-      } else {
-        const startRange = this._normalizeRange(selection.getRangeAt(0));
-        const endRange = this._normalizeRange(
-            selection.getRangeAt(rangeCount - 1));
-        return {
-          start: startRange.start,
-          end: endRange.end,
-        };
-      }
-    },
-
-    /**
-     * Normalize a specific DOM Range.
-     *
-     * @return {!Object} fixed normalized range
-     */
-    _normalizeRange(domRange) {
-      const range = GrRangeNormalizer.normalize(domRange);
-      return this._fixTripleClickSelection({
-        start: this._normalizeSelectionSide(
-            range.startContainer, range.startOffset),
-        end: this._normalizeSelectionSide(
-            range.endContainer, range.endOffset),
-      }, domRange);
-    },
-
-    /**
-     * Adjust triple click selection for the whole line.
-     * A triple click always results in:
-     * - start.column == end.column == 0
-     * - end.line == start.line + 1
-     *
-     * @param {!Object} range Normalized range, ie column/line numbers
-     * @param {!Range} domRange DOM Range object
-     * @return {!Object} fixed normalized range
-     */
-    _fixTripleClickSelection(range, domRange) {
-      if (!range.start) {
-        // Selection outside of current diff.
-        return range;
-      }
-      const start = range.start;
-      const end = range.end;
-      // Happens when triple click in side-by-side mode with other side empty.
-      const endsAtOtherEmptySide = !end &&
-          domRange.endOffset === 0 &&
-          domRange.endContainer.nodeName === 'TD' &&
-          (domRange.endContainer.classList.contains('left') ||
-           domRange.endContainer.classList.contains('right'));
-      const endsAtBeginningOfNextLine = end &&
-          start.column === 0 &&
-          end.column === 0 &&
-          end.line === start.line + 1;
-      const content = domRange.cloneContents().querySelector('.contentText');
-      const lineLength = content && this._getLength(content) || 0;
-      if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
-        // Move the selection to the end of the previous line.
-        range.end = {
-          node: start.node,
-          column: lineLength,
-          side: start.side,
-          line: start.line,
-        };
-      }
-      return range;
-    },
-
-    /**
-     * Convert DOM Range selection to concrete numbers (line, column, side).
-     * Moves range end if it's not inside td.content.
-     * Returns null if selection end is not valid (outside of diff).
-     *
-     * @param {Node} node td.content child
-     * @param {number} offset offset within node
-     * @return {({
-     *   node: Node,
-     *   side: string,
-     *   line: Number,
-     *   column: Number
-     * }|undefined)}
-     */
-    _normalizeSelectionSide(node, offset) {
-      let column;
-      if (!this.contains(node)) {
-        return;
-      }
-      const lineEl = this.diffBuilder.getLineElByChild(node);
-      if (!lineEl) {
-        return;
-      }
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      if (!side) {
-        return;
-      }
-      const line = this.diffBuilder.getLineNumberByChild(lineEl);
-      if (!line) {
-        return;
-      }
-      const contentText = this.diffBuilder.getContentByLineEl(lineEl);
-      if (!contentText) {
-        return;
-      }
-      const contentTd = contentText.parentElement;
-      if (!contentTd.contains(node)) {
-        node = contentText;
-        column = 0;
-      } else {
-        const thread = contentTd.querySelector('.comment-thread');
-        if (thread && thread.contains(node)) {
-          column = this._getLength(contentText);
-          node = contentText;
-        } else {
-          column = this._convertOffsetToColumn(node, offset);
-        }
-      }
-
+    } else if (rangeCount === 1) {
+      return this._normalizeRange(selection.getRangeAt(0));
+    } else {
+      const startRange = this._normalizeRange(selection.getRangeAt(0));
+      const endRange = this._normalizeRange(
+          selection.getRangeAt(rangeCount - 1));
       return {
-        node,
-        side,
-        line,
-        column,
+        start: startRange.start,
+        end: endRange.end,
       };
-    },
+    }
+  }
 
-    /**
-     * The only line in which add a comment tooltip is cut off is the first
-     * line. Even if there is a collapsed section, The first visible line is
-     * in the position where the second line would have been, if not for the
-     * collapsed section, so don't need to worry about this case for
-     * positioning the tooltip.
-     */
-    _positionActionBox(actionBox, startLine, range) {
-      if (startLine > 1) {
-        actionBox.placeAbove(range);
-        return;
-      }
-      actionBox.positionBelow = true;
-      actionBox.placeBelow(range);
-    },
+  /**
+   * Normalize a specific DOM Range.
+   *
+   * @return {!Object} fixed normalized range
+   */
+  _normalizeRange(domRange) {
+    const range = GrRangeNormalizer.normalize(domRange);
+    return this._fixTripleClickSelection({
+      start: this._normalizeSelectionSide(
+          range.startContainer, range.startOffset),
+      end: this._normalizeSelectionSide(
+          range.endContainer, range.endOffset),
+    }, domRange);
+  }
 
-    _isRangeValid(range) {
-      if (!range || !range.start || !range.end) {
-        return false;
-      }
-      const start = range.start;
-      const end = range.end;
-      if (start.side !== end.side ||
-          end.line < start.line ||
-          (start.line === end.line && start.column === end.column)) {
-        return false;
-      }
-      return true;
-    },
+  /**
+   * Adjust triple click selection for the whole line.
+   * A triple click always results in:
+   * - start.column == end.column == 0
+   * - end.line == start.line + 1
+   *
+   * @param {!Object} range Normalized range, ie column/line numbers
+   * @param {!Range} domRange DOM Range object
+   * @return {!Object} fixed normalized range
+   */
+  _fixTripleClickSelection(range, domRange) {
+    if (!range.start) {
+      // Selection outside of current diff.
+      return range;
+    }
+    const start = range.start;
+    const end = range.end;
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide = !end &&
+        domRange.endOffset === 0 &&
+        domRange.endContainer.nodeName === 'TD' &&
+        (domRange.endContainer.classList.contains('left') ||
+         domRange.endContainer.classList.contains('right'));
+    const endsAtBeginningOfNextLine = end &&
+        start.column === 0 &&
+        end.column === 0 &&
+        end.line === start.line + 1;
+    const content = domRange.cloneContents().querySelector('.contentText');
+    const lineLength = content && this._getLength(content) || 0;
+    if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
+      // Move the selection to the end of the previous line.
+      range.end = {
+        node: start.node,
+        column: lineLength,
+        side: start.side,
+        line: start.line,
+      };
+    }
+    return range;
+  }
 
-    _handleSelection(selection, isMouseUp) {
-      /* On Safari, the selection events may return a null range that should
-         be ignored */
-      if (!selection) {
-        return;
+  /**
+   * Convert DOM Range selection to concrete numbers (line, column, side).
+   * Moves range end if it's not inside td.content.
+   * Returns null if selection end is not valid (outside of diff).
+   *
+   * @param {Node} node td.content child
+   * @param {number} offset offset within node
+   * @return {({
+   *   node: Node,
+   *   side: string,
+   *   line: Number,
+   *   column: Number
+   * }|undefined)}
+   */
+  _normalizeSelectionSide(node, offset) {
+    let column;
+    if (!this.contains(node)) {
+      return;
+    }
+    const lineEl = this.diffBuilder.getLineElByChild(node);
+    if (!lineEl) {
+      return;
+    }
+    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    if (!side) {
+      return;
+    }
+    const line = this.diffBuilder.getLineNumberByChild(lineEl);
+    if (!line) {
+      return;
+    }
+    const contentText = this.diffBuilder.getContentByLineEl(lineEl);
+    if (!contentText) {
+      return;
+    }
+    const contentTd = contentText.parentElement;
+    if (!contentTd.contains(node)) {
+      node = contentText;
+      column = 0;
+    } else {
+      const thread = contentTd.querySelector('.comment-thread');
+      if (thread && thread.contains(node)) {
+        column = this._getLength(contentText);
+        node = contentText;
+      } else {
+        column = this._convertOffsetToColumn(node, offset);
       }
-      const normalizedRange = this._getNormalizedRange(selection);
-      if (!this._isRangeValid(normalizedRange)) {
-        this._removeActionBox();
-        return;
-      }
-      /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
-         we can get is a single Range */
-      const domRange = selection instanceof Range ? selection:selection.getRangeAt(0);
-      const start = normalizedRange.start;
-      const end = normalizedRange.end;
+    }
 
-      // TODO (viktard): Drop empty first and last lines from selection.
+    return {
+      node,
+      side,
+      line,
+      column,
+    };
+  }
 
-      // If the selection is from the end of one line to the start of the next
-      // line, then this must have been a double-click, or you have started
-      // dragging. Showing the action box is bad in the former case and not very
-      // useful in the latter, so never do that.
-      // If this was a mouse-up event, we create a comment immediately if
-      // the selection is from the end of a line to the start of the next line.
-      // In a perfect world we would only do this for double-click, but it is
-      // extremely rare that a user would drag from the end of one line to the
-      // start of the next and release the mouse, so we don't bother.
-      // TODO(brohlfs): This does not work, if the double-click is before a new
-      // diff chunk (start will be equal to end), and neither before an "expand
-      // the diff context" block (end line will match the first line of the new
-      // section and thus be greater than start line + 1).
-      if (start.line === end.line - 1 && end.column === 0) {
-        // Rather than trying to find the line contents (for comparing
-        // start.column with the content length), we just check if the selection
-        // 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: {
-            start_line: start.line,
-            start_character: 0,
-            end_line: start.line,
-            end_character: start.column,
-          }});
-        }
-        return;
-      }
+  /**
+   * The only line in which add a comment tooltip is cut off is the first
+   * line. Even if there is a collapsed section, The first visible line is
+   * in the position where the second line would have been, if not for the
+   * collapsed section, so don't need to worry about this case for
+   * positioning the tooltip.
+   */
+  _positionActionBox(actionBox, startLine, range) {
+    if (startLine > 1) {
+      actionBox.placeAbove(range);
+      return;
+    }
+    actionBox.positionBelow = true;
+    actionBox.placeBelow(range);
+  }
 
-      let actionBox = this.$$('gr-selection-action-box');
-      if (!actionBox) {
-        actionBox = document.createElement('gr-selection-action-box');
-        const root = Polymer.dom(this.root);
-        root.insertBefore(actionBox, root.firstElementChild);
+  _isRangeValid(range) {
+    if (!range || !range.start || !range.end) {
+      return false;
+    }
+    const start = range.start;
+    const end = range.end;
+    if (start.side !== end.side ||
+        end.line < start.line ||
+        (start.line === end.line && start.column === end.column)) {
+      return false;
+    }
+    return true;
+  }
+
+  _handleSelection(selection, isMouseUp) {
+    /* On Safari, the selection events may return a null range that should
+       be ignored */
+    if (!selection) {
+      return;
+    }
+    const normalizedRange = this._getNormalizedRange(selection);
+    if (!this._isRangeValid(normalizedRange)) {
+      this._removeActionBox();
+      return;
+    }
+    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+       we can get is a single Range */
+    const domRange = selection instanceof Range ?
+      selection :
+      selection.getRangeAt(0);
+    const start = normalizedRange.start;
+    const end = normalizedRange.end;
+
+    // TODO (viktard): Drop empty first and last lines from selection.
+
+    // If the selection is from the end of one line to the start of the next
+    // line, then this must have been a double-click, or you have started
+    // dragging. Showing the action box is bad in the former case and not very
+    // useful in the latter, so never do that.
+    // If this was a mouse-up event, we create a comment immediately if
+    // the selection is from the end of a line to the start of the next line.
+    // In a perfect world we would only do this for double-click, but it is
+    // extremely rare that a user would drag from the end of one line to the
+    // start of the next and release the mouse, so we don't bother.
+    // TODO(brohlfs): This does not work, if the double-click is before a new
+    // diff chunk (start will be equal to end), and neither before an "expand
+    // the diff context" block (end line will match the first line of the new
+    // section and thus be greater than start line + 1).
+    if (start.line === end.line - 1 && end.column === 0) {
+      // Rather than trying to find the line contents (for comparing
+      // start.column with the content length), we just check if the selection
+      // 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._fireCreateRangeComment(start.side, {
+          start_line: start.line,
+          start_character: 0,
+          end_line: start.line,
+          end_character: start.column,
+        });
       }
-      actionBox.range = {
+      return;
+    }
+
+    let actionBox = this.shadowRoot.querySelector('gr-selection-action-box');
+    if (!actionBox) {
+      actionBox = document.createElement('gr-selection-action-box');
+      const root = dom(this.root);
+      root.insertBefore(actionBox, root.firstElementChild);
+    }
+    this.selectedRange = {
+      range: {
         start_line: start.line,
         start_character: start.column,
         end_line: end.line,
         end_character: end.column,
-      };
-      actionBox.side = start.side;
-      if (start.line === end.line) {
-        this._positionActionBox(actionBox, start.line, domRange);
-      } else if (start.node instanceof Text) {
-        if (start.column) {
-          this._positionActionBox(actionBox, start.line,
-              start.node.splitText(start.column));
-        }
-        start.node.parentElement.normalize(); // Undo splitText from above.
-      } else if (start.node.classList.contains('content') &&
-          start.node.firstChild) {
-        this._positionActionBox(actionBox, start.line, start.node.firstChild);
-      } else {
-        this._positionActionBox(actionBox, start.line, start.node);
+      },
+      side: start.side,
+    };
+    if (start.line === end.line) {
+      this._positionActionBox(actionBox, start.line, domRange);
+    } else if (start.node instanceof Text) {
+      if (start.column) {
+        this._positionActionBox(actionBox, start.line,
+            start.node.splitText(start.column));
       }
-    },
+      start.node.parentElement.normalize(); // Undo splitText from above.
+    } else if (start.node.classList.contains('content') &&
+        start.node.firstChild) {
+      this._positionActionBox(actionBox, start.line, start.node.firstChild);
+    } else {
+      this._positionActionBox(actionBox, start.line, start.node);
+    }
+  }
 
-    _createRangeComment(e) {
-      this._removeActionBox();
-    },
+  _fireCreateRangeComment(side, range) {
+    this.dispatchEvent(new CustomEvent('create-range-comment', {
+      detail: {side, range},
+      composed: true, bubbles: true,
+    }));
+    this._removeActionBox();
+  }
 
-    _removeActionBox() {
-      const actionBox = this.$$('gr-selection-action-box');
-      if (actionBox) {
-        Polymer.dom(this.root).removeChild(actionBox);
-      }
-    },
+  _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);
+  }
 
-    _convertOffsetToColumn(el, offset) {
-      if (el instanceof Element && el.classList.contains('content')) {
-        return offset;
-      }
-      while (el.previousSibling ||
-          !el.parentElement.classList.contains('content')) {
-        if (el.previousSibling) {
-          el = el.previousSibling;
-          offset += this._getLength(el);
-        } else {
-          el = el.parentElement;
-        }
-      }
+  _removeActionBox() {
+    this.selectedRange = undefined;
+    const actionBox = this.shadowRoot
+        .querySelector('gr-selection-action-box');
+    if (actionBox) {
+      dom(this.root).removeChild(actionBox);
+    }
+  }
+
+  _convertOffsetToColumn(el, offset) {
+    if (el instanceof Element && el.classList.contains('content')) {
       return offset;
-    },
-
-    /**
-     * Traverse Element from right to left, call callback for each node.
-     * Stops if callback returns true.
-     *
-     * @param {!Element} startNode
-     * @param {function(Node):boolean} callback
-     * @param {Object=} opt_flags If flags.left is true, traverse left.
-     */
-    _traverseContentSiblings(startNode, callback, opt_flags) {
-      const travelLeft = opt_flags && opt_flags.left;
-      let node = startNode;
-      while (node) {
-        if (node instanceof Element &&
-            node.tagName !== 'HL' &&
-            node.tagName !== 'SPAN') {
-          break;
-        }
-        const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
-        if (callback(node)) {
-          break;
-        }
-        node = nextNode;
-      }
-    },
-
-    /**
-     * Get length of a node. If the node is a content node, then only give the
-     * length of its .contentText child.
-     *
-     * @param {?Element} node this is sometimes passed as null.
-     * @return {number}
-     */
-    _getLength(node) {
-      if (node instanceof Element && node.classList.contains('content')) {
-        return this._getLength(node.querySelector('.contentText'));
+    }
+    while (el.previousSibling ||
+        !el.parentElement.classList.contains('content')) {
+      if (el.previousSibling) {
+        el = el.previousSibling;
+        offset += this._getLength(el);
       } else {
-        return GrAnnotation.getLength(node);
+        el = el.parentElement;
       }
-    },
-  });
-})();
+    }
+    return offset;
+  }
+
+  /**
+   * Traverse Element from right to left, call callback for each node.
+   * Stops if callback returns true.
+   *
+   * @param {!Element} startNode
+   * @param {function(Node):boolean} callback
+   * @param {Object=} opt_flags If flags.left is true, traverse left.
+   */
+  _traverseContentSiblings(startNode, callback, opt_flags) {
+    const travelLeft = opt_flags && opt_flags.left;
+    let node = startNode;
+    while (node) {
+      if (node instanceof Element &&
+          node.tagName !== 'HL' &&
+          node.tagName !== 'SPAN') {
+        break;
+      }
+      const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
+      if (callback(node)) {
+        break;
+      }
+      node = nextNode;
+    }
+  }
+
+  /**
+   * Get length of a node. If the node is a content node, then only give the
+   * length of its .contentText child.
+   *
+   * @param {?Element} node this is sometimes passed as null.
+   * @return {number}
+   */
+  _getLength(node) {
+    if (node instanceof Element && node.classList.contains('content')) {
+      return this._getLength(node.querySelector('.contentText'));
+    } else {
+      return GrAnnotation.getLength(node);
+    }
+  }
+}
+
+customElements.define(GrDiffHighlight.is, GrDiffHighlight);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
new file mode 100644
index 0000000..08b21499
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      position: relative;
+    }
+    gr-selection-action-box {
+      /**
+         * Needs z-index to apear above wrapped content, since it's inseted
+         * into DOM before it.
+         */
+      z-index: 10;
+    }
+  </style>
+  <div class="contentWrapper">
+    <slot></slot>
+  </div>
+`;
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..86f1505 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-highlight.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -60,8 +57,7 @@
           </tr>
         </tbody>
 
-
-        <tbody class="section both">
+<tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
             <td class="left lineNum" data-value="138"></td>
             <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
@@ -146,477 +142,489 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-highlight', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-highlight.js';
+import {GrRangeNormalizer} from './gr-range-normalizer.js';
+
+suite('gr-diff-highlight', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic')[1];
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('comment events', () => {
+    let builder;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic')[1];
+      builder = {
+        getContentsByLineRange: sandbox.stub().returns([]),
+        getLineElByChild: sandbox.stub().returns({}),
+        getSideByLineEl: sandbox.stub().returns('other-side'),
+      };
+      element._cachedDiffBuilder = builder;
+    });
+
+    test('comment-thread-mouseenter from line comments is ignored', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right'}];
+
+      sandbox.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseenter', {bubbles: true, composed: true}));
+      assert.isFalse(element.set.called);
+    });
+
+    test('comment-thread-mouseenter from ranged comment causes set', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      threadEl.setAttribute('range', JSON.stringify({
+        start_line: 3,
+        start_character: 4,
+        end_line: 5,
+        end_character: 6,
+      }));
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right', range: {
+        start_line: 3,
+        start_character: 4,
+        end_line: 5,
+        end_character: 6,
+      }}];
+
+      sandbox.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseenter', {bubbles: true, composed: true}));
+      assert.isTrue(element.set.called);
+      const args = element.set.lastCall.args;
+      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
+      assert.deepEqual(args[1], true);
+    });
+
+    test('comment-thread-mouseleave from line comments is ignored', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right'}];
+
+      sandbox.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseleave', {bubbles: true, composed: true}));
+      assert.isFalse(element.set.called);
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      sandbox.stub(element, '_removeActionBox');
+      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);
+    });
+  });
+
+  suite('selection', () => {
+    let diff;
+    let builder;
+    let contentStubs;
+
+    const stubContent = (line, side, opt_child) => {
+      const contentTd = diff.querySelector(
+          `.${side}.lineNum[data-value="${line}"] ~ .content`);
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl = diff.querySelector(
+          `.${side}.lineNum[data-value="${line}"]`);
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
+      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
+      builder.getContentByLine.withArgs(line, side).returns(contentText);
+      builder.getSideByLineEl.withArgs(lineEl).returns(side);
+      return contentText;
+    };
+
+    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
+      const selection = window.getSelection();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element._handleSelection(selection);
+    };
+
+    const getLineElByChild = node => {
+      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
+      return stubs && stubs.lineEl;
+    };
+
+    setup(() => {
+      contentStubs = [];
+      stub('gr-selection-action-box', {
+        placeAbove: sandbox.stub(),
+        placeBelow: sandbox.stub(),
+      });
+      diff = element.querySelector('#diffTable');
+      builder = {
+        getContentByLine: sandbox.stub(),
+        getContentByLineEl: sandbox.stub(),
+        getLineElByChild,
+        getLineNumberByChild: sandbox.stub(),
+        getSideByLineEl: sandbox.stub(),
+      };
+      element._cachedDiffBuilder = builder;
     });
 
     teardown(() => {
-      sandbox.restore();
+      contentStubs = null;
+      window.getSelection().removeAllRanges();
     });
 
-    suite('comment events', () => {
-      let builder;
+    test('single first line', () => {
+      const content = stubContent(1, 'right');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      assert.isTrue(actionBox.positionBelow);
+    });
 
-      setup(() => {
-        builder = {
-          getContentsByLineRange: sandbox.stub().returns([]),
-          getLineElByChild: sandbox.stub().returns({}),
-          getSideByLineEl: sandbox.stub().returns('other-side'),
-        };
-        element._cachedDiffBuilder = builder;
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, 'right');
+      const endContent = stubContent(2, 'right');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(
+          startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', () => {
+      const content = stubContent(138, 'left');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
       });
+      assert.equal(side, 'left');
+      assert.notOk(actionBox.positionBelow);
+    });
 
-      test('comment-thread-mouseenter from line comments is ignored', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        element.appendChild(threadEl);
-        element.commentRanges = [{side: 'right'}];
-
-        sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(new CustomEvent(
-            'comment-thread-mouseenter', {bubbles: true, composed: true}));
-        assert.isFalse(element.set.called);
+    test('multiline', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      sandbox.spy(element, '_positionActionBox');
+      emulateSelection(
+          startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
       });
+      assert.equal(side, 'right');
+      assert.notOk(actionBox.positionBelow);
+    });
 
-      test('comment-thread-mouseenter from ranged comment causes set', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        threadEl.setAttribute('range', JSON.stringify({
-          start_line: 3,
-          start_character: 4,
-          end_line: 5,
-          end_character: 6,
-        }));
-        element.appendChild(threadEl);
-        element.commentRanges = [{side: 'right', range: {
-          start_line: 3,
-          start_character: 4,
-          end_line: 5,
-          end_character: 6,
-        }}];
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
 
-        sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(new CustomEvent(
-            'comment-thread-mouseenter', {bubbles: true, composed: true}));
-        assert.isTrue(element.set.called);
-        const args = element.set.lastCall.args;
-        assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-        assert.deepEqual(args[1], true);
-      });
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
 
-      test('comment-thread-mouseleave from line comments is ignored', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        element.appendChild(threadEl);
-        element.commentRanges = [{side: 'right'}];
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
 
-        sandbox.stub(element, 'set');
-        threadEl.dispatchEvent(new CustomEvent(
-            'comment-thread-mouseleave', {bubbles: true, composed: true}));
-        assert.isFalse(element.set.called);
-      });
-
-      test('on create-range-comment action box is removed', () => {
-        sandbox.stub(element, '_removeActionBox');
-        element.fire('create-range-comment', {
-          comment: {
-            range: {},
-          },
-        });
-        assert.isTrue(element._removeActionBox.called);
+      const getRangeAtStub = sandbox.stub();
+      getRangeAtStub
+          .onFirstCall().returns(startRange)
+          .onSecondCall()
+          .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sandbox.stub(),
+      };
+      element._handleSelection(selection);
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
       });
     });
 
-    suite('selection', () => {
-      let diff;
-      let builder;
-      let contentStubs;
-
-      const stubContent = (line, side, opt_child) => {
-        const contentTd = diff.querySelector(
-            `.${side}.lineNum[data-value="${line}"] ~ .content`);
-        const contentText = contentTd.querySelector('.contentText');
-        const lineEl = diff.querySelector(
-            `.${side}.lineNum[data-value="${line}"]`);
-        contentStubs.push({
-          lineEl,
-          contentTd,
-          contentText,
-        });
-        builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
-        builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-        builder.getContentByLine.withArgs(line, side).returns(contentText);
-        builder.getSideByLineEl.withArgs(lineEl).returns(side);
-        return contentText;
-      };
-
-      const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-        const selection = window.getSelection();
-        const range = document.createRange();
-        range.setStart(startNode, startOffset);
-        range.setEnd(endNode, endOffset);
-        selection.addRange(range);
-        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;
-      };
-
-      setup(() => {
-        contentStubs = [];
-        stub('gr-selection-action-box', {
-          placeAbove: sandbox.stub(),
-          placeBelow: sandbox.stub(),
-        });
-        diff = element.querySelector('#diffTable');
-        builder = {
-          getContentByLine: sandbox.stub(),
-          getContentByLineEl: sandbox.stub(),
-          getLineElByChild,
-          getLineNumberByChild: sandbox.stub(),
-          getSideByLineEl: sandbox.stub(),
-        };
-        element._cachedDiffBuilder = builder;
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
       });
+      assert.equal(side, 'right');
+    });
 
-      teardown(() => {
-        contentStubs = null;
-        window.getSelection().removeAllRanges();
+    test('collapsed', () => {
+      const content = stubContent(138, 'left');
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      assert.isOk(window.getSelection().getRangeAt(0).startContainer);
+      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);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('single first line', () => {
-        const content = stubContent(1, 'right');
-        sandbox.spy(element, '_positionActionBox');
-        emulateSelection(content.firstChild, 5, content.firstChild, 12);
-        const actionBox = element.$$('gr-selection-action-box');
-        assert.isTrue(actionBox.positionBelow);
+    test('ends inside hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelector('.bar');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
       });
+    });
 
-      test('multiline starting on first line', () => {
-        const startContent = stubContent(1, 'right');
-        const endContent = stubContent(2, 'right');
-        sandbox.spy(element, '_positionActionBox');
-        emulateSelection(
-            startContent.firstChild, 10, endContent.lastChild, 7);
-        const actionBox = element.$$('gr-selection-action-box');
-        assert.isTrue(actionBox.positionBelow);
+    test('multiple hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelectorAll('hl')[4];
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('single line', () => {
-        const content = stubContent(138, 'left');
-        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(), {
-          start_line: 138,
-          start_character: 5,
-          end_line: 138,
-          end_character: 12,
-        });
-        assert.equal(getActionSide(), 'left');
-        assert.notOk(actionBox.positionBelow);
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, 'left');
+      const contentTd = contentText.parentElement;
+
+      emulateSelection(contentTd.previousElementSibling, 0,
+          contentText.firstChild, 2);
+      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.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.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, 'left');
+      const comment = startContent.parentElement.querySelector(
+          '.comment-thread');
+      const endContent = stubContent(141, 'left');
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('multiline', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
-        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(), {
-          start_line: 119,
-          start_character: 10,
-          end_line: 120,
-          end_character: 36,
-        });
-        assert.equal(getActionSide(), 'right');
-        assert.notOk(actionBox.positionBelow);
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, 'left');
+      const comment = content.parentElement.querySelector(
+          '.comment-thread');
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
       });
+      assert.equal(side, 'left');
+    });
 
-      test('multiple ranges aka firefox implementation', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
+    test('starts in context element', () => {
+      const contextControl =
+          diff.querySelector('.contextControl').querySelector('gr-button');
+      const content = stubContent(146, 'right');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
 
-        const startRange = document.createRange();
-        startRange.setStart(startContent.firstChild, 10);
-        startRange.setEnd(startContent.firstChild, 11);
+    test('ends in context element', () => {
+      const contextControl =
+          diff.querySelector('.contextControl').querySelector('gr-button');
+      const content = stubContent(141, 'left');
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
 
-        const endRange = document.createRange();
-        endRange.setStart(endContent.lastChild, 6);
-        endRange.setEnd(endContent.lastChild, 7);
-
-        const getRangeAtStub = sandbox.stub();
-        getRangeAtStub
-            .onFirstCall().returns(startRange)
-            .onSecondCall().returns(endRange);
-        const selection = {
-          rangeCount: 2,
-          getRangeAt: getRangeAtStub,
-          removeAllRanges: sandbox.stub(),
-        };
-        element._handleSelection(selection);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
-          start_line: 119,
-          start_character: 10,
-          end_line: 120,
-          end_character: 36,
-        });
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, 'right');
+      const endContent = stubContent(146, 'right');
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
       });
+      assert.equal(side, 'right');
+    });
 
-      test('multiline grow end highlight over tabs', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
-        emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
-          start_line: 119,
-          start_character: 10,
-          end_line: 120,
-          end_character: 2,
-        });
-        assert.equal(getActionSide(), 'right');
+    test('ends at a tab', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(
+          content.firstChild, 1, content.querySelector('span'), 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
       });
+      assert.equal(side, 'left');
+    });
 
-      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());
+    test('starts at a tab', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(
+          content.querySelectorAll('hl')[3], 0,
+          content.querySelectorAll('span')[1].nextSibling, 1);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
       });
+      assert.equal(side, 'left');
+    });
 
-      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(), {
-          start_line: 140,
-          start_character: 8,
-          end_line: 140,
-          end_character: 23,
-        });
-        assert.equal(getActionSide(), 'left');
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, 'left');
+      const spy = sinon.spy(element, '_normalizeRange');
+      emulateSelection(
+          content.querySelectorAll('hl')[3], 0,
+          content.querySelectorAll('span')[1], 0);
+      const spyCall = spy.getCall(0);
+      const range = window.getSelection().getRangeAt(0);
+      assert.notDeepEqual(spyCall.returnValue, range);
+    });
+
+    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
+      let content = stubContent(140, 'left');
+      let child = content.lastChild.lastChild;
+      let result = GrRangeNormalizer._getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, 'right');
+      child = content.lastChild;
+      result = GrRangeNormalizer._getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('_fixTripleClickSelection', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element._getLength(startContent),
       });
+      assert.equal(side, 'right');
+    });
 
-      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(), {
-          start_line: 140,
-          start_character: 18,
-          end_line: 140,
-          end_character: 27,
-        });
+    test('_fixTripleClickSelection empty line', () => {
+      const startContent = stubContent(146, 'right');
+      const endContent = stubContent(165, 'left');
+      emulateSelection(startContent.firstChild, 0,
+          endContent.parentElement.previousElementSibling, 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 146,
+        start_character: 0,
+        end_line: 146,
+        end_character: 84,
       });
-
-      test('multiple hl', () => {
-        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(), {
-          start_line: 140,
-          start_character: 2,
-          end_line: 140,
-          end_character: 61,
-        });
-        assert.equal(getActionSide(), 'left');
-      });
-
-      test('starts outside of diff', () => {
-        const contentText = stubContent(140, 'left');
-        const contentTd = contentText.parentElement;
-
-        emulateSelection(contentTd.previousElementSibling, 0,
-            contentText.firstChild, 2);
-        assert.isFalse(element.isRangeSelected());
-      });
-
-      test('ends outside of diff', () => {
-        const content = stubContent(140, 'left');
-        emulateSelection(content.nextElementSibling.firstChild, 2,
-            content.firstChild, 2);
-        assert.isFalse(element.isRangeSelected());
-      });
-
-      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());
-      });
-
-      test('starts in comment thread element', () => {
-        const startContent = stubContent(140, 'left');
-        const comment = startContent.parentElement.querySelector(
-            '.comment-thread');
-        const endContent = stubContent(141, 'left');
-        emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
-          start_line: 140,
-          start_character: 83,
-          end_line: 141,
-          end_character: 4,
-        });
-        assert.equal(getActionSide(), 'left');
-      });
-
-      test('ends in comment thread element', () => {
-        const content = stubContent(140, 'left');
-        const comment = content.parentElement.querySelector(
-            '.comment-thread');
-        emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
-          start_line: 140,
-          start_character: 4,
-          end_line: 140,
-          end_character: 83,
-        });
-        assert.equal(getActionSide(), 'left');
-      });
-
-      test('starts in context element', () => {
-        const contextControl =
-            diff.querySelector('.contextControl').querySelector('gr-button');
-        const content = stubContent(146, 'right');
-        emulateSelection(contextControl, 0, content.firstChild, 7);
-        // TODO (viktard): Select nearest line.
-        assert.isFalse(element.isRangeSelected());
-      });
-
-      test('ends in context element', () => {
-        const contextControl =
-            diff.querySelector('.contextControl').querySelector('gr-button');
-        const content = stubContent(141, 'left');
-        emulateSelection(content.firstChild, 2, contextControl, 1);
-        // TODO (viktard): Select nearest line.
-        assert.isFalse(element.isRangeSelected());
-      });
-
-      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(), {
-          start_line: 130,
-          start_character: 3,
-          end_line: 146,
-          end_character: 14,
-        });
-        assert.equal(getActionSide(), '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(), {
-          start_line: 140,
-          start_character: 1,
-          end_line: 140,
-          end_character: 51,
-        });
-        assert.equal(getActionSide(), 'left');
-      });
-
-      test('starts at a tab', () => {
-        const content = stubContent(140, 'left');
-        emulateSelection(
-            content.querySelectorAll('hl')[3], 0,
-            content.querySelectorAll('span')[1].nextSibling, 1);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
-          start_line: 140,
-          start_character: 51,
-          end_line: 140,
-          end_character: 71,
-        });
-        assert.equal(getActionSide(), 'left');
-      });
-
-      test('properly accounts for syntax highlighting', () => {
-        const content = stubContent(140, 'left');
-        const spy = sinon.spy(element, '_normalizeRange');
-        emulateSelection(
-            content.querySelectorAll('hl')[3], 0,
-            content.querySelectorAll('span')[1], 0);
-        const spyCall = spy.getCall(0);
-        const range = window.getSelection().getRangeAt(0);
-        assert.notDeepEqual(spyCall.returnValue, range);
-      });
-
-      test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-        let content = stubContent(140, 'left');
-        let child = content.lastChild.lastChild;
-        let result = GrRangeNormalizer._getTextOffset(content, child);
-        assert.equal(result, 75);
-        content = stubContent(146, 'right');
-        child = content.lastChild;
-        result = GrRangeNormalizer._getTextOffset(content, child);
-        assert.equal(result, 0);
-      });
-
-      test('_fixTripleClickSelection', () => {
-        const startContent = stubContent(119, 'right');
-        const endContent = stubContent(120, 'right');
-        emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
-          start_line: 119,
-          start_character: 0,
-          end_line: 119,
-          end_character: element._getLength(startContent),
-        });
-        assert.equal(getActionSide(), 'right');
-      });
-
-      test('_fixTripleClickSelection empty line', () => {
-        const startContent = stubContent(146, 'right');
-        const endContent = stubContent(165, 'left');
-        emulateSelection(startContent.firstChild, 0,
-            endContent.parentElement.previousElementSibling, 0);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
-          start_line: 146,
-          start_character: 0,
-          end_line: 146,
-          end_character: 84,
-        });
-        assert.equal(getActionSide(), 'right');
-      });
+      assert.equal(side, 'right');
     });
   });
+});
 </script>
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..5d04bd7 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
@@ -14,99 +14,91 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrRangeNormalizer) { return; }
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+export const GrRangeNormalizer = {
+  /**
+   * Remap DOM range to whole lines of a diff if necessary. If the start or
+   * end containers are DOM elements that are singular pieces of syntax
+   * highlighting, the containers are remapped to the .contentText divs that
+   * contain the entire line of code.
+   *
+   * @param {!Object} range - the standard DOM selector range.
+   * @return {!Object} A modified version of the range that correctly accounts
+   *     for syntax highlighting.
+   */
+  normalize(range) {
+    const startContainer = this._getContentTextParent(range.startContainer);
+    const startOffset = range.startOffset +
+        this._getTextOffset(startContainer, range.startContainer);
+    const endContainer = this._getContentTextParent(range.endContainer);
+    const endOffset = range.endOffset + this._getTextOffset(endContainer,
+        range.endContainer);
+    return {
+      startContainer,
+      startOffset,
+      endContainer,
+      endOffset,
+    };
+  },
 
-  const GrRangeNormalizer = {
-    /**
-     * Remap DOM range to whole lines of a diff if necessary. If the start or
-     * end containers are DOM elements that are singular pieces of syntax
-     * highlighting, the containers are remapped to the .contentText divs that
-     * contain the entire line of code.
-     *
-     * @param {!Object} range - the standard DOM selector range.
-     * @return {!Object} A modified version of the range that correctly accounts
-     *     for syntax highlighting.
-     */
-    normalize(range) {
-      const startContainer = this._getContentTextParent(range.startContainer);
-      const startOffset = range.startOffset +
-          this._getTextOffset(startContainer, range.startContainer);
-      const endContainer = this._getContentTextParent(range.endContainer);
-      const endOffset = range.endOffset + this._getTextOffset(endContainer,
-          range.endContainer);
-      return {
-        startContainer,
-        startOffset,
-        endContainer,
-        endOffset,
-      };
-    },
-
-    _getContentTextParent(target) {
-      let element = target;
-      if (element.nodeName === '#text') {
-        element = element.parentElement;
+  _getContentTextParent(target) {
+    let element = target;
+    if (element.nodeName === '#text') {
+      element = element.parentElement;
+    }
+    while (element && !element.classList.contains('contentText')) {
+      if (element.parentElement === null) {
+        return target;
       }
-      while (element && !element.classList.contains('contentText')) {
-        if (element.parentElement === null) {
-          return target;
-        }
-        element = element.parentElement;
+      element = element.parentElement;
+    }
+    return element;
+  },
+
+  /**
+   * Gets the character offset of the child within the parent.
+   * Performs a synchronous in-order traversal from top to bottom of the node
+   * element, counting the length of the syntax until child is found.
+   *
+   * @param {!Element} node The root DOM element to be searched through.
+   * @param {!Element} child The child element being searched for.
+   * @return {number}
+   */
+  _getTextOffset(node, child) {
+    let count = 0;
+    let stack = [node];
+    while (stack.length) {
+      const n = stack.pop();
+      if (n === child) {
+        break;
       }
-      return element;
-    },
-
-    /**
-     * Gets the character offset of the child within the parent.
-     * Performs a synchronous in-order traversal from top to bottom of the node
-     * element, counting the length of the syntax until child is found.
-     *
-     * @param {!Element} node The root DOM element to be searched through.
-     * @param {!Element} child The child element being searched for.
-     * @return {number}
-     */
-    _getTextOffset(node, child) {
-      let count = 0;
-      let stack = [node];
-      while (stack.length) {
-        const n = stack.pop();
-        if (n === child) {
-          break;
+      if (n && n.childNodes && n.childNodes.length !== 0) {
+        const arr = [];
+        for (const childNode of n.childNodes) {
+          arr.push(childNode);
         }
-        if (n && n.childNodes && n.childNodes.length !== 0) {
-          const arr = [];
-          for (const childNode of n.childNodes) {
-            arr.push(childNode);
-          }
-          arr.reverse();
-          stack = stack.concat(arr);
-        } else {
-          count += this._getLength(n);
-        }
+        arr.reverse();
+        stack = stack.concat(arr);
+      } else {
+        count += this._getLength(n);
       }
-      return count;
-    },
+    }
+    return count;
+  },
 
-    /**
-     * The DOM API textContent.length calculation is broken when the text
-     * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
-     *
-     * @param {text} node A text node.
-     * @return {number} The length of the text.
-     */
-    _getLength(node) {
-      return node
-        ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
-        : 0;
-    },
-  };
-
-  window.GrRangeNormalizer = GrRangeNormalizer;
-})(window);
+  /**
+   * The DOM API textContent.length calculation is broken when the text
+   * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+   *
+   * @param {text} node A text node.
+   * @return {number} The length of the text.
+   */
+  _getLength(node) {
+    return node ?
+      node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length :
+      0;
+  },
+};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
deleted file mode 100644
index 7d60f13..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
+++ /dev/null
@@ -1,65 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../gr-diff/gr-diff.html">
-<link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
-
-<dom-module id="gr-diff-host">
-  <template>
-    <gr-diff
-        id="diff"
-        change-num="[[changeNum]]"
-        no-auto-render=[[noAutoRender]]
-        patch-range="[[patchRange]]"
-        path="[[path]]"
-        prefs="[[prefs]]"
-        project-name="[[projectName]]"
-        display-line="[[displayLine]]"
-        is-image-diff="[[isImageDiff]]"
-        commit-range="[[commitRange]]"
-        hidden$="[[hidden]]"
-        no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
-        line-wrapping="[[lineWrapping]]"
-        view-mode="[[viewMode]]"
-        line-of-interest="[[lineOfInterest]]"
-        logged-in="[[_loggedIn]]"
-        loading="[[_loading]]"
-        error-message="[[_errorMessage]]"
-        base-image="[[_baseImage]]"
-        revision-image=[[_revisionImage]]
-        coverage-ranges="[[_coverageRanges]]"
-        blame="[[_blame]]"
-        layers="[[_layers]]"
-        diff="[[diff]]">
-    </gr-diff>
-    <gr-syntax-layer
-        id="syntaxLayer"
-        enabled="[[_syntaxHighlightingEnabled]]"
-        diff="[[diff]]"></gr-syntax-layer>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting" category="diff"></gr-reporting>
-  </template>
-  <script src="gr-diff-host.js"></script>
-</dom-module>
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 2dd19a0..2ed69b6 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
@@ -14,88 +14,107 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-comment-thread/gr-comment-thread.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import '../gr-diff/gr-diff.js';
+import '../gr-syntax-layer/gr-syntax-layer.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-host_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js';
+import {util} from '../../../scripts/util.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js';
 
-  const EVENT_AGAINST_PARENT = 'diff-against-parent';
-  const EVENT_ZERO_REBASE = 'rebase-percent-zero';
-  const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const EVENT_AGAINST_PARENT = 'diff-against-parent';
+const EVENT_ZERO_REBASE = 'rebase-percent-zero';
+const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
-  /** @enum {string} */
-  const TimingLabel = {
-    TOTAL: 'Diff Total Render',
-    CONTENT: 'Diff Content Render',
-    SYNTAX: 'Diff Syntax Render',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  // Disable syntax highlighting if the overall diff is too large.
-  const SYNTAX_MAX_DIFF_LENGTH = 20000;
+/** @enum {string} */
+const TimingLabel = {
+  TOTAL: 'Diff Total Render',
+  CONTENT: 'Diff Content Render',
+  SYNTAX: 'Diff Syntax Render',
+};
 
-  // If any line of the diff is more than the character limit, then disable
-  // syntax highlighting for the entire file.
-  const SYNTAX_MAX_LINE_LENGTH = 500;
+// Disable syntax highlighting if the overall diff is too large.
+const SYNTAX_MAX_DIFF_LENGTH = 20000;
 
-  // 120 lines is good enough threshold for full-sized window viewport
-  const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+const SYNTAX_MAX_LINE_LENGTH = 500;
 
-  const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
+// 120 lines is good enough threshold for full-sized window viewport
+const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
 
+const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
+
+/**
+ * @param {Object} diff
+ * @return {boolean}
+ */
+function isImageDiff(diff) {
+  if (!diff) { return false; }
+
+  const isA = diff.meta_a &&
+      diff.meta_a.content_type.startsWith('image/');
+  const isB = diff.meta_b &&
+      diff.meta_b.content_type.startsWith('image/');
+
+  return !!(diff.binary && (isA || isB));
+}
+
+/**
+ * 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
+ */
+class GrDiffHost extends mixinBehaviors( [
+  PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-host'; }
   /**
-   * @param {Object} diff
-   * @return {boolean}
-   */
-  function isImageDiff(diff) {
-    if (!diff) { return false; }
-
-    const isA = diff.meta_a &&
-        diff.meta_a.content_type.startsWith('image/');
-    const isB = diff.meta_b &&
-        diff.meta_b.content_type.startsWith('image/');
-
-    return !!(diff.binary && (isA || isB));
-  }
-
-  /** @enum {string} */
-  Gerrit.DiffSide = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
-
-  /**
-   * Wrapper around gr-diff.
+   * Fired when the user selects a line.
    *
-   * Webcomponent fetching diffs and related data from restAPI and passing them
-   * to the presentational gr-diff for rendering.
+   * @event line-selected
    */
-  Polymer({
-    is: 'gr-diff-host',
 
-    /**
-     * Fired when the user selects a line.
-     *
-     * @event line-selected
-     */
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
 
-    /**
-     * Fired if being logged in is required.
-     *
-     * @event show-auth-required
-     */
+  /**
+   * Fired when a comment is saved or discarded
+   *
+   * @event diff-comments-modified
+   */
 
-    /**
-     * Fired when a comment is saved or discarded
-     *
-     * @event diff-comments-modified
-     */
-
-    properties: {
+  static get properties() {
+    return {
       changeNum: String,
       noAutoRender: {
         type: Boolean,
@@ -222,777 +241,886 @@
       _syntaxHighlightingEnabled: {
         type: Boolean,
         computed:
-          '_isSyntaxHighlightingEnabled(prefs.*, diff)',
+        '_isSyntaxHighlightingEnabled(prefs.*, diff)',
       },
 
       _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: [
+  static get observers() {
+    return [
       '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
-          ' noRenderOnPrefsChange)',
+        ' noRenderOnPrefsChange)',
       '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
-    ],
+    ];
+  }
 
-    ready() {
-      if (this._canReload()) {
-        this.reload();
-      }
-    },
+  /** @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));
+  }
 
-    attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    if (this._canReload()) {
+      this.reload();
+    }
+  }
 
-    /**
-     * @param {boolean=} haveParamsChanged ends reporting events that started
-     * on location change.
-     * @return {!Promise}
-     **/
-    reload(haveParamsChanged) {
-      this._loading = true;
-      this._errorMessage = null;
-      const whitespaceLevel = this._getIgnoreWhitespace();
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+  }
 
-      const layers = [this.$.syntaxLayer];
-      // Get layers from plugins (if any).
-      for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-          this.path, this.changeNum, this.patchNum)) {
-        layers.push(pluginLayer);
-      }
-      this._layers = layers;
+  /**
+   * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
+   * signal to report metrics event that started on location change.
+   * @return {!Promise}
+   **/
+  reload(shouldReportMetric) {
+    this._loading = true;
+    this._errorMessage = null;
+    const whitespaceLevel = this._getIgnoreWhitespace();
 
-      if (haveParamsChanged) {
-        // We listen on render viewport only on DiffPage (on paramsChanged)
-        this._listenToViewportRender();
-      }
+    const layers = [this.$.syntaxLayer];
+    // Get layers from plugins (if any).
+    for (const pluginLayer of this.$.jsAPI.getDiffLayers(
+        this.path, this.changeNum, this.patchNum)) {
+      layers.push(pluginLayer);
+    }
+    this._layers = layers;
 
-      this._coverageRanges = [];
-      this._getCoverageData();
-      const diffRequest = this._getDiff()
-          .then(diff => {
-            this._loadedWhitespaceLevel = whitespaceLevel;
-            this._reportDiff(diff);
-            return diff;
-          })
-          .catch(e => {
-            this._handleGetDiffError(e);
-            return null;
-          });
+    if (shouldReportMetric) {
+      // We listen on render viewport only on DiffPage (on paramsChanged)
+      this._listenToViewportRender();
+    }
 
-      const assetRequest = diffRequest.then(diff => {
-        // If the diff is null, then it's failed to load.
-        if (!diff) { return null; }
+    this._coverageRanges = [];
+    this._getCoverageData();
+    const diffRequest = this._getDiff()
+        .then(diff => {
+          this._loadedWhitespaceLevel = whitespaceLevel;
+          this._reportDiff(diff);
+          return diff;
+        })
+        .catch(e => {
+          this._handleGetDiffError(e);
+          return null;
+        });
 
-        return this._loadDiffAssets(diff);
-      });
+    const assetRequest = diffRequest.then(diff => {
+      // If the diff is null, then it's failed to load.
+      if (!diff) { return null; }
 
-      // 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];
-            if (!diff) {
-              return Promise.resolve();
-            }
-            this.filesWeblinks = this._getFilesWeblinks(diff);
-            return new Promise(resolve => {
-              const callback = event => {
-                const needsSyntaxHighlighting = event.detail
-                      && event.detail.contentRendered;
-                if (needsSyntaxHighlighting) {
-                  this.$.reporting.time(TimingLabel.SYNTAX);
-                  this.$.syntaxLayer.process().then(() => {
-                    this.$.reporting.timeEnd(TimingLabel.SYNTAX);
-                    this.$.reporting.timeEnd(TimingLabel.TOTAL);
-                    resolve();
-                  });
-                } else {
+      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];
+          if (!diff) {
+            return Promise.resolve();
+          }
+          this.filesWeblinks = this._getFilesWeblinks(diff);
+          return new Promise(resolve => {
+            const callback = event => {
+              const needsSyntaxHighlighting = event.detail &&
+                    event.detail.contentRendered;
+              if (needsSyntaxHighlighting) {
+                this.$.reporting.time(TimingLabel.SYNTAX);
+                this.$.syntaxLayer.process().then(() => {
+                  this.$.reporting.timeEnd(TimingLabel.SYNTAX);
                   this.$.reporting.timeEnd(TimingLabel.TOTAL);
                   resolve();
-                }
-                this.removeEventListener('render', callback);
-              };
-              this.addEventListener('render', callback);
-              this.diff = diff;
-            });
-          })
-          .catch(err => {
-            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);
+              } else {
+                this.$.reporting.timeEnd(TimingLabel.TOTAL);
+                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;
           });
-    },
+        })
+        .catch(err => {
+          console.warn('Error encountered loading diff:', err);
+        })
+        .then(() => { this._loading = false; });
+  }
 
-    _getFilesWeblinks(diff) {
-      if (!this.commitRange) {
-        return {};
-      }
-      return {
-        meta_a: Gerrit.Nav.getFileWebLinks(
-            this.projectName, this.commitRange.baseCommit, this.path,
-            {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
-        meta_b: Gerrit.Nav.getFileWebLinks(
-            this.projectName, this.commitRange.commit, this.path,
-            {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
-      };
-    },
+  _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;
+                }
 
-    /** Cancel any remaining diff builder rendering work. */
-    cancel() {
-      this.$.diff.cancel();
-    },
+                const existingCoverageRanges = this._coverageRanges;
+                this._coverageRanges = coverageRanges;
 
-    /** @return {!Array<!HTMLElement>} */
-    getCursorStops() {
-      return this.$.diff.getCursorStops();
-    },
+                // 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);
+                });
 
-    /** @return {boolean} */
-    isRangeSelected() {
-      return this.$.diff.isRangeSelected();
-    },
-
-    toggleLeftDiff() {
-      this.$.diff.toggleLeftDiff();
-    },
-
-    /**
-     * Load and display blame information for the base of the diff.
-     *
-     * @return {Promise} A promise that resolves when blame finishes rendering.
-     */
-    loadBlame() {
-      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
-          this.path, true)
-          .then(blame => {
-            if (!blame.length) {
-              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
-              return Promise.reject(MSG_EMPTY_BLAME);
-            }
-
-            this._blame = blame;
-          });
-    },
-
-    /** Unload blame information for the diff. */
-    clearBlame() {
-      this._blame = null;
-    },
-
-    /**
-     * The thread elements in this diff, in no particular order.
-     *
-     * @return {!Array<!HTMLElement>}
-     */
-    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() {
-      // Wrap the diff request in a new promise so that the error handler
-      // rejects the promise, allowing the error to be handled in the .catch.
-      return new Promise((resolve, reject) => {
-        this.$.restAPI.getDiff(
-            this.changeNum,
-            this.patchRange.basePatchNum,
-            this.patchRange.patchNum,
-            this.path,
-            this._getIgnoreWhitespace(),
-            reject)
-            .then(resolve);
-      });
-    },
-
-    _handleGetDiffError(response) {
-      // Loading the diff may respond with 409 if the file is too large. In this
-      // case, use a toast error..
-      if (response.status === 409) {
-        this.fire('server-error', {response});
-        return;
-      }
-
-      if (this.showLoadFailure) {
-        this._errorMessage = [
-          'Encountered error when loading the diff:',
-          response.status,
-          response.statusText,
-        ].join(' ');
-        return;
-      }
-
-      this.fire('page-error', {response});
-    },
-
-    /**
-     * Report info about the diff response.
-     */
-    _reportDiff(diff) {
-      if (!diff || !diff.content) {
-        return;
-      }
-
-      // Count the delta lines stemming from normal deltas, and from
-      // due_to_rebase deltas.
-      let nonRebaseDelta = 0;
-      let rebaseDelta = 0;
-      diff.content.forEach(chunk => {
-        if (chunk.ab) { return; }
-        const deltaSize = Math.max(
-            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
-        if (chunk.due_to_rebase) {
-          rebaseDelta += deltaSize;
-        } else {
-          nonRebaseDelta += deltaSize;
-        }
-      });
-
-      // Find the percent of the delta from due_to_rebase chunks rounded to two
-      // digits. Diffs with no delta are considered 0%.
-      const totalDelta = rebaseDelta + nonRebaseDelta;
-      const percentRebaseDelta = !totalDelta ? 0 :
-        Math.round(100 * rebaseDelta / totalDelta);
-
-      // Report the due_to_rebase percentage in the "diff" category when
-      // applicable.
-      if (this.patchRange.basePatchNum === 'PARENT') {
-        this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
-      } else if (percentRebaseDelta === 0) {
-        this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
-      } else {
-        this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
-            percentRebaseDelta);
-      }
-    },
-
-    /**
-     * @param {Object} diff
-     * @return {!Promise}
-     */
-    _loadDiffAssets(diff) {
-      if (isImageDiff(diff)) {
-        return this._getImages(diff).then(images => {
-          this._baseImage = images.baseImage;
-          this._revisionImage = images.revisionImage;
+                // 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);
         });
-      } else {
-        this._baseImage = null;
-        this._revisionImage = null;
-        return Promise.resolve();
-      }
-    },
+  }
 
-    /**
-     * @param {Object} diff
-     * @return {boolean}
-     */
-    _computeIsImageDiff(diff) {
-      return isImageDiff(diff);
-    },
+  _getFilesWeblinks(diff) {
+    if (!this.commitRange) {
+      return {};
+    }
+    return {
+      meta_a: GerritNav.getFileWebLinks(
+          this.projectName, this.commitRange.baseCommit, this.path,
+          {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
+      meta_b: GerritNav.getFileWebLinks(
+          this.projectName, this.commitRange.commit, this.path,
+          {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
+    };
+  }
 
-    _commentsChanged(newComments) {
-      const allComments = [];
-      for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
-        // This is needed by the threading.
-        for (const comment of newComments[side]) {
-          comment.__commentSide = side;
-        }
-        allComments.push(...newComments[side]);
-      }
-      // Currently, the only way this is ever changed here is when the initial
-      // comments are loaded, so it's okay performance wise to clear the threads
-      // and recreate them. If this changes in future, we might want to reuse
-      // some DOM nodes here.
-      this._clearThreads();
-      const threads = this._createThreads(allComments);
-      for (const thread of threads) {
-        const threadEl = this._createThreadElement(thread);
-        this._attachThreadElement(threadEl);
-      }
-    },
+  /** Cancel any remaining diff builder rendering work. */
+  cancel() {
+    this.$.diff.cancel();
+  }
 
-    /**
-     * @param {!Array<!Object>} comments
-     * @return {!Array<!Object>} Threads for the given comments.
-     */
-    _createThreads(comments) {
-      const sortedComments = comments.slice(0).sort((a, b) => {
-        if (b.__draft && !a.__draft ) { return 0; }
-        if (a.__draft && !b.__draft ) { return 1; }
-        return util.parseDate(a.updated) - util.parseDate(b.updated);
-      });
+  /** @return {!Array<!HTMLElement>} */
+  getCursorStops() {
+    return this.$.diff.getCursorStops();
+  }
 
-      const threads = [];
-      for (const comment of sortedComments) {
-        // If the comment is in reply to another comment, find that comment's
-        // thread and append to it.
-        if (comment.in_reply_to) {
-          const thread = threads.find(thread =>
-            thread.comments.some(c => c.id === comment.in_reply_to));
-          if (thread) {
-            thread.comments.push(comment);
-            continue;
+  /** @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.
+   *
+   * @return {Promise} A promise that resolves when blame finishes rendering.
+   */
+  loadBlame() {
+    return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
+        this.path, true)
+        .then(blame => {
+          if (!blame.length) {
+            this.dispatchEvent(new CustomEvent('show-alert', {
+              detail: {message: MSG_EMPTY_BLAME},
+              composed: true, bubbles: true,
+            }));
+            return Promise.reject(MSG_EMPTY_BLAME);
           }
-        }
 
-        // Otherwise, this comment starts its own thread.
-        const newThread = {
-          start_datetime: comment.updated,
-          comments: [comment],
-          commentSide: comment.__commentSide,
-          patchNum: comment.patch_set,
-          rootId: comment.id || comment.__draftID,
-          lineNum: comment.line,
-          isOnParent: comment.side === 'PARENT',
-        };
-        if (comment.range) {
-          newThread.range = Object.assign({}, comment.range);
-        }
-        threads.push(newThread);
-      }
-      return threads;
-    },
-
-    /**
-     * @param {Object} blame
-     * @return {boolean}
-     */
-    _computeIsBlameLoaded(blame) {
-      return !!blame;
-    },
-
-    /**
-     * @param {Object} diff
-     * @return {!Promise}
-     */
-    _getImages(diff) {
-      return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
-          this.patchRange);
-    },
-
-    /** @param {CustomEvent} e */
-    _handleCreateComment(e) {
-      const {lineNum, side, patchNum, isOnParent, range} = e.detail;
-      const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
-          isOnParent);
-      threadEl.addOrEditDraft(lineNum, range);
-
-      this.$.reporting.recordDraftInteraction();
-    },
-
-    /**
-     * Gets or creates a comment thread at a given location.
-     * May provide a range, to get/create a range comment.
-     *
-     * @param {string} patchNum
-     * @param {?number} lineNum
-     * @param {string} commentSide
-     * @param {Gerrit.Range|undefined} range
-     * @param {boolean} isOnParent
-     * @return {!Object}
-     */
-    _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
-      let threadEl = this._getThreadEl(lineNum, commentSide, range);
-      if (!threadEl) {
-        threadEl = this._createThreadElement({
-          comments: [],
-          commentSide,
-          patchNum,
-          lineNum,
-          range,
-          isOnParent,
+          this._blame = blame;
         });
-        this._attachThreadElement(threadEl);
-      }
-      return threadEl;
-    },
+  }
 
-    _attachThreadElement(threadEl) {
-      Polymer.dom(this.$.diff).appendChild(threadEl);
-    },
+  /** Unload blame information for the diff. */
+  clearBlame() {
+    this._blame = null;
+  }
 
-    _clearThreads() {
-      for (const threadEl of this.getThreadEls()) {
-        const parent = Polymer.dom(threadEl).parentNode;
-        Polymer.dom(parent).removeChild(threadEl);
-      }
-    },
+  /**
+   * The thread elements in this diff, in no particular order.
+   *
+   * @return {!Array<!HTMLElement>}
+   */
+  getThreadEls() {
+    return Array.from(
+        dom(this.$.diff).querySelectorAll('.comment-thread'));
+  }
 
-    _createThreadElement(thread) {
-      const threadEl = document.createElement('gr-comment-thread');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
-      threadEl.comments = thread.comments;
-      threadEl.commentSide = thread.commentSide;
-      threadEl.isOnParent = !!thread.isOnParent;
-      threadEl.parentIndex = this._parentIndex;
-      threadEl.changeNum = this.changeNum;
-      threadEl.patchNum = thread.patchNum;
-      threadEl.lineNum = thread.lineNum;
-      const rootIdChangedListener = changeEvent => {
-        thread.rootId = changeEvent.detail.value;
-      };
-      threadEl.addEventListener('root-id-changed', rootIdChangedListener);
-      threadEl.path = this.path;
-      threadEl.projectName = this.projectName;
-      threadEl.range = thread.range;
-      const threadDiscardListener = e => {
-        const threadEl = /** @type {!Node} */ (e.currentTarget);
+  /** @param {HTMLElement} el */
+  addDraftAtLine(el) {
+    this.$.diff.addDraftAtLine(el);
+  }
 
-        const parent = Polymer.dom(threadEl).parentNode;
-        Polymer.dom(parent).removeChild(threadEl);
+  clearDiffContent() {
+    this.$.diff.clearDiffContent();
+  }
 
-        threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
-        threadEl.removeEventListener('thread-discard', threadDiscardListener);
-      };
-      threadEl.addEventListener('thread-discard', threadDiscardListener);
-      return threadEl;
-    },
+  expandAllContext() {
+    this.$.diff.expandAllContext();
+  }
 
-    /**
-     * Gets a comment thread element at a given location.
-     * May provide a range, to get a range comment.
-     *
-     * @param {?number} lineNum
-     * @param {string} commentSide
-     * @param {!Gerrit.Range=} range
-     * @return {?Node}
-     */
-    _getThreadEl(lineNum, commentSide, range = undefined) {
-      let line;
-      if (commentSide === GrDiffBuilder.Side.LEFT) {
-        line = {beforeNumber: lineNum};
-      } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
-        line = {afterNumber: lineNum};
+  /** @return {!Promise} */
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  /** @return {boolean}} */
+  _canReload() {
+    return !!this.changeNum && !!this.patchRange && !!this.path &&
+        !this.noAutoRender;
+  }
+
+  /** @return {!Promise<!Object>} */
+  _getDiff() {
+    // Wrap the diff request in a new promise so that the error handler
+    // rejects the promise, allowing the error to be handled in the .catch.
+    return new Promise((resolve, reject) => {
+      this.$.restAPI.getDiff(
+          this.changeNum,
+          this.patchRange.basePatchNum,
+          this.patchRange.patchNum,
+          this.path,
+          this._getIgnoreWhitespace(),
+          reject)
+          .then(resolve);
+    });
+  }
+
+  _handleGetDiffError(response) {
+    // Loading the diff may respond with 409 if the file is too large. In this
+    // case, use a toast error..
+    if (response.status === 409) {
+      this.dispatchEvent(new CustomEvent('server-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+
+    if (this.showLoadFailure) {
+      this._errorMessage = [
+        'Encountered error when loading the diff:',
+        response.status,
+        response.statusText,
+      ].join(' ');
+      return;
+    }
+
+    this.dispatchEvent(new CustomEvent('page-error', {
+      detail: {response},
+      composed: true, bubbles: true,
+    }));
+  }
+
+  /**
+   * Report info about the diff response.
+   */
+  _reportDiff(diff) {
+    if (!diff || !diff.content) {
+      return;
+    }
+
+    // Count the delta lines stemming from normal deltas, and from
+    // due_to_rebase deltas.
+    let nonRebaseDelta = 0;
+    let rebaseDelta = 0;
+    diff.content.forEach(chunk => {
+      if (chunk.ab) { return; }
+      const deltaSize = Math.max(
+          chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
+      if (chunk.due_to_rebase) {
+        rebaseDelta += deltaSize;
       } else {
-        throw new Error(`Unknown side: ${commentSide}`);
+        nonRebaseDelta += deltaSize;
       }
-      function matchesRange(threadEl) {
-        const threadRange = /** @type {!Gerrit.Range} */(
-          JSON.parse(threadEl.getAttribute('range')));
-        return Gerrit.rangesEqual(threadRange, range);
-      }
+    });
 
-      const filteredThreadEls = this._filterThreadElsForLocation(
-          this.getThreadEls(), line, commentSide).filter(matchesRange);
-      return filteredThreadEls.length ? filteredThreadEls[0] : null;
-    },
+    // Find the percent of the delta from due_to_rebase chunks rounded to two
+    // digits. Diffs with no delta are considered 0%.
+    const totalDelta = rebaseDelta + nonRebaseDelta;
+    const percentRebaseDelta = !totalDelta ? 0 :
+      Math.round(100 * rebaseDelta / totalDelta);
 
-    /**
-     * @param {!Array<!HTMLElement>} threadEls
-     * @param {!{beforeNumber: (number|string|undefined|null),
-     *           afterNumber: (number|string|undefined|null)}}
-     *     lineInfo
-     * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT) for
-     *     which to return the threads.
-     * @return {!Array<!HTMLElement>} The thread elements matching the given
-     *     location.
-     */
-    _filterThreadElsForLocation(threadEls, lineInfo, side) {
-      function matchesLeftLine(threadEl) {
-        return threadEl.getAttribute('comment-side') ==
-            Gerrit.DiffSide.LEFT &&
-            threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
-      }
-      function matchesRightLine(threadEl) {
-        return threadEl.getAttribute('comment-side') ==
-            Gerrit.DiffSide.RIGHT &&
-            threadEl.getAttribute('line-num') == lineInfo.afterNumber;
-      }
-      function matchesFileComment(threadEl) {
-        return threadEl.getAttribute('comment-side') == side &&
-              // line/range comments have 1-based line set, if line is falsy it's
-              // a file comment
-              !threadEl.getAttribute('line-num');
-      }
+    // Report the due_to_rebase percentage in the "diff" category when
+    // applicable.
+    if (this.patchRange.basePatchNum === 'PARENT') {
+      this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
+    } else if (percentRebaseDelta === 0) {
+      this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
+    } else {
+      this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
+          {percentRebaseDelta});
+    }
+  }
 
-      // Select the appropriate matchers for the desired side and line
-      // If side is BOTH, we want both the left and right matcher.
-      const matchers = [];
-      if (side !== Gerrit.DiffSide.RIGHT) {
-        matchers.push(matchesLeftLine);
-      }
-      if (side !== Gerrit.DiffSide.LEFT) {
-        matchers.push(matchesRightLine);
-      }
-      if (lineInfo.afterNumber === 'FILE' ||
-          lineInfo.beforeNumber === 'FILE') {
-        matchers.push(matchesFileComment);
-      }
-      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,
-        noRenderOnPrefsChange) {
-      // Polymer 2: check for undefined
-      if ([
-        preferredWhitespaceLevel,
-        loadedWhitespaceLevel,
-        noRenderOnPrefsChange,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
-          !noRenderOnPrefsChange) {
-        this.reload();
-      }
-    },
-
-    _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
-      // Polymer 2: check for undefined
-      if ([
-        noRenderOnPrefsChange,
-        prefsChangeRecord,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
-        return;
-      }
-
-      if (!noRenderOnPrefsChange) {
-        this.reload();
-      }
-    },
-
-    /**
-     * @param {Object} patchRangeRecord
-     * @return {number|null}
-     */
-    _computeParentIndex(patchRangeRecord) {
-      return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
-        this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
-    },
-
-    _handleCommentSave(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      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:
-     * https://github.com/Polymer/polymer/pull/4776
-     * but for not supressing annotations.
-     *
-     * @suppress {checkTypes}
-     */
-    _handleCommentUpdate(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      let idx = this._findCommentIndex(comment, side);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) { // Update draft or comment.
-        this.set(['comments', side, idx], comment);
-      } 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);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) {
-        this.splice('comments.' + side, idx, 1);
-      }
-    },
-
-    /** @return {number} */
-    _findCommentIndex(comment, side) {
-      if (!comment.id || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(item => item.id === comment.id);
-    },
-
-    /** @return {number} */
-    _findDraftIndex(comment, side) {
-      if (!comment.__draftID || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(
-          item => item.__draftID === comment.__draftID);
-    },
-
-    _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
-      if (!preferenceChangeRecord ||
-          !preferenceChangeRecord.base ||
-          !preferenceChangeRecord.base.syntax_highlighting ||
-          !diff) {
-        return false;
-      }
-      return !this._anyLineTooLong(diff) &&
-          this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
-    },
-
-    /**
-     * @return {boolean} whether any of the lines in diff are longer
-     * than SYNTAX_MAX_LINE_LENGTH.
-     */
-    _anyLineTooLong(diff) {
-      if (!diff) return false;
-      return diff.content.some(section => {
-        const lines = section.ab ?
-          section.ab :
-          (section.a || []).concat(section.b || []);
-        return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+  /**
+   * @param {Object} diff
+   * @return {!Promise}
+   */
+  _loadDiffAssets(diff) {
+    if (isImageDiff(diff)) {
+      return this._getImages(diff).then(images => {
+        this._baseImage = images.baseImage;
+        this._revisionImage = images.revisionImage;
       });
-    },
+    } else {
+      this._baseImage = null;
+      this._revisionImage = null;
+      return Promise.resolve();
+    }
+  }
 
-    _listenToViewportRender() {
-      const renderUpdateListener = start => {
-        if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
-          this.$.reporting.diffViewDisplayed();
-          this.$.syntaxLayer.removeListener(renderUpdateListener);
+  /**
+   * @param {Object} diff
+   * @return {boolean}
+   */
+  _computeIsImageDiff(diff) {
+    return isImageDiff(diff);
+  }
+
+  _commentsChanged(newComments) {
+    const allComments = [];
+    for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
+      // This is needed by the threading.
+      for (const comment of newComments[side]) {
+        comment.__commentSide = side;
+      }
+      allComments.push(...newComments[side]);
+    }
+    // Currently, the only way this is ever changed here is when the initial
+    // comments are loaded, so it's okay performance wise to clear the threads
+    // and recreate them. If this changes in future, we might want to reuse
+    // some DOM nodes here.
+    this._clearThreads();
+    const threads = this._createThreads(allComments);
+    for (const thread of threads) {
+      const threadEl = this._createThreadElement(thread);
+      this._attachThreadElement(threadEl);
+    }
+  }
+
+  _sortComments(comments) {
+    return comments.slice(0).sort((a, b) => {
+      if (b.__draft && !a.__draft ) { return -1; }
+      if (a.__draft && !b.__draft ) { return 1; }
+      return util.parseDate(a.updated) - util.parseDate(b.updated);
+    });
+  }
+
+  /**
+   * @param {!Array<!Object>} comments
+   * @return {!Array<!Object>} Threads for the given comments.
+   */
+  _createThreads(comments) {
+    const sortedComments = this._sortComments(comments);
+    const threads = [];
+    for (const comment of sortedComments) {
+      // If the comment is in reply to another comment, find that comment's
+      // thread and append to it.
+      if (comment.in_reply_to) {
+        const thread = threads.find(thread =>
+          thread.comments.some(c => c.id === comment.in_reply_to));
+        if (thread) {
+          thread.comments.push(comment);
+          continue;
         }
+      }
+
+      // Otherwise, this comment starts its own thread.
+      const newThread = {
+        start_datetime: comment.updated,
+        comments: [comment],
+        commentSide: comment.__commentSide,
+        patchNum: comment.patch_set,
+        rootId: comment.id || comment.__draftID,
+        lineNum: comment.line,
+        isOnParent: comment.side === 'PARENT',
       };
+      if (comment.range) {
+        newThread.range = Object.assign({}, comment.range);
+      }
+      threads.push(newThread);
+    }
+    return threads;
+  }
 
-      this.$.syntaxLayer.addListener(renderUpdateListener);
-    },
+  /**
+   * @param {Object} blame
+   * @return {boolean}
+   */
+  _computeIsBlameLoaded(blame) {
+    return !!blame;
+  }
 
-    _handleRenderStart() {
-      this.$.reporting.time(TimingLabel.TOTAL);
-      this.$.reporting.time(TimingLabel.CONTENT);
-    },
+  /**
+   * @param {Object} diff
+   * @return {!Promise}
+   */
+  _getImages(diff) {
+    return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
+        this.patchRange);
+  }
 
-    _handleRenderContent() {
-      this.$.reporting.timeEnd(TimingLabel.CONTENT);
-      this.$.reporting.diffViewContentDisplayed();
-    },
+  /** @param {CustomEvent} e */
+  _handleCreateComment(e) {
+    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+    const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
+        isOnParent);
+    threadEl.addOrEditDraft(lineNum, range);
 
-    _handleNormalizeRange(event) {
-      this.$.reporting.reportInteraction('normalize-range',
-          `Modified invalid comment range on l. ${event.detail.lineNum}` +
-          ` of the ${event.detail.side} side`);
-    },
-  });
-})();
+    this.$.reporting.recordDraftInteraction();
+  }
+
+  /**
+   * Gets or creates a comment thread at a given location.
+   * May provide a range, to get/create a range comment.
+   *
+   * @param {string} patchNum
+   * @param {?number} lineNum
+   * @param {string} commentSide
+   * @param {Gerrit.Range|undefined} range
+   * @param {boolean} isOnParent
+   * @return {!Object}
+   */
+  _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
+    let threadEl = this._getThreadEl(lineNum, commentSide, range);
+    if (!threadEl) {
+      threadEl = this._createThreadElement({
+        comments: [],
+        commentSide,
+        patchNum,
+        lineNum,
+        range,
+        isOnParent,
+      });
+      this._attachThreadElement(threadEl);
+    }
+    return threadEl;
+  }
+
+  _attachThreadElement(threadEl) {
+    dom(this.$.diff).appendChild(threadEl);
+  }
+
+  _clearThreads() {
+    for (const threadEl of this.getThreadEls()) {
+      const parent = dom(threadEl).parentNode;
+      dom(parent).removeChild(threadEl);
+    }
+  }
+
+  _createThreadElement(thread) {
+    const threadEl = document.createElement('gr-comment-thread');
+    threadEl.className = 'comment-thread';
+    threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
+    threadEl.comments = thread.comments;
+    threadEl.commentSide = thread.commentSide;
+    threadEl.isOnParent = !!thread.isOnParent;
+    threadEl.parentIndex = this._parentIndex;
+    threadEl.changeNum = this.changeNum;
+    threadEl.patchNum = thread.patchNum;
+    threadEl.lineNum = thread.lineNum;
+    const rootIdChangedListener = changeEvent => {
+      thread.rootId = changeEvent.detail.value;
+    };
+    threadEl.addEventListener('root-id-changed', rootIdChangedListener);
+    threadEl.path = this.path;
+    threadEl.projectName = this.projectName;
+    threadEl.range = thread.range;
+    const threadDiscardListener = e => {
+      const threadEl = /** @type {!Node} */ (e.currentTarget);
+
+      const parent = dom(threadEl).parentNode;
+      dom(parent).removeChild(threadEl);
+
+      threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
+      threadEl.removeEventListener('thread-discard', threadDiscardListener);
+    };
+    threadEl.addEventListener('thread-discard', threadDiscardListener);
+    return threadEl;
+  }
+
+  /**
+   * Gets a comment thread element at a given location.
+   * May provide a range, to get a range comment.
+   *
+   * @param {?number} lineNum
+   * @param {string} commentSide
+   * @param {!Gerrit.Range=} range
+   * @return {?Node}
+   */
+  _getThreadEl(lineNum, commentSide, range = undefined) {
+    let line;
+    if (commentSide === GrDiffBuilder.Side.LEFT) {
+      line = {beforeNumber: lineNum};
+    } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
+      line = {afterNumber: lineNum};
+    } else {
+      throw new Error(`Unknown side: ${commentSide}`);
+    }
+    function matchesRange(threadEl) {
+      const threadRange = /** @type {!Gerrit.Range} */(
+        JSON.parse(threadEl.getAttribute('range')));
+      return rangesEqual(threadRange, range);
+    }
+
+    const filteredThreadEls = this._filterThreadElsForLocation(
+        this.getThreadEls(), line, commentSide).filter(matchesRange);
+    return filteredThreadEls.length ? filteredThreadEls[0] : null;
+  }
+
+  /**
+   * @param {!Array<!HTMLElement>} threadEls
+   * @param {!{beforeNumber: (number|string|undefined|null),
+   *           afterNumber: (number|string|undefined|null)}}
+   *     lineInfo
+   * @param {!DiffSide=} side The side (LEFT, RIGHT) for
+   *     which to return the threads.
+   * @return {!Array<!HTMLElement>} The thread elements matching the given
+   *     location.
+   */
+  _filterThreadElsForLocation(threadEls, lineInfo, side) {
+    function matchesLeftLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          DiffSide.LEFT &&
+          threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
+    }
+    function matchesRightLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          DiffSide.RIGHT &&
+          threadEl.getAttribute('line-num') == lineInfo.afterNumber;
+    }
+    function matchesFileComment(threadEl) {
+      return threadEl.getAttribute('comment-side') == side &&
+            // line/range comments have 1-based line set, if line is falsy it's
+            // a file comment
+            !threadEl.getAttribute('line-num');
+    }
+
+    // Select the appropriate matchers for the desired side and line
+    // If side is BOTH, we want both the left and right matcher.
+    const matchers = [];
+    if (side !== DiffSide.RIGHT) {
+      matchers.push(matchesLeftLine);
+    }
+    if (side !== DiffSide.LEFT) {
+      matchers.push(matchesRightLine);
+    }
+    if (lineInfo.afterNumber === 'FILE' ||
+        lineInfo.beforeNumber === 'FILE') {
+      matchers.push(matchesFileComment);
+    }
+    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,
+      noRenderOnPrefsChange) {
+    // Polymer 2: check for undefined
+    if ([
+      preferredWhitespaceLevel,
+      loadedWhitespaceLevel,
+      noRenderOnPrefsChange,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
+        !noRenderOnPrefsChange) {
+      this.reload();
+    }
+  }
+
+  _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
+    // Polymer 2: check for undefined
+    if ([
+      noRenderOnPrefsChange,
+      prefsChangeRecord,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
+      return;
+    }
+
+    if (!noRenderOnPrefsChange) {
+      this.reload();
+    }
+  }
+
+  /**
+   * @param {Object} patchRangeRecord
+   * @return {number|null}
+   */
+  _computeParentIndex(patchRangeRecord) {
+    return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
+      this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
+  }
+
+  _handleCommentSave(e) {
+    const comment = e.detail.comment;
+    const side = e.detail.comment.__commentSide;
+    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:
+   * https://github.com/Polymer/polymer/pull/4776
+   * but for not supressing annotations.
+   *
+   * @suppress {checkTypes}
+   */
+  _handleCommentUpdate(e) {
+    const comment = e.detail.comment;
+    const side = e.detail.comment.__commentSide;
+    let idx = this._findCommentIndex(comment, side);
+    if (idx === -1) {
+      idx = this._findDraftIndex(comment, side);
+    }
+    if (idx !== -1) { // Update draft or comment.
+      this.set(['comments', side, idx], comment);
+    } 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);
+    if (idx === -1) {
+      idx = this._findDraftIndex(comment, side);
+    }
+    if (idx !== -1) {
+      this.splice('comments.' + side, idx, 1);
+    }
+  }
+
+  /** @return {number} */
+  _findCommentIndex(comment, side) {
+    if (!comment.id || !this.comments[side]) {
+      return -1;
+    }
+    return this.comments[side].findIndex(item => item.id === comment.id);
+  }
+
+  /** @return {number} */
+  _findDraftIndex(comment, side) {
+    if (!comment.__draftID || !this.comments[side]) {
+      return -1;
+    }
+    return this.comments[side].findIndex(
+        item => item.__draftID === comment.__draftID);
+  }
+
+  _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
+    if (!preferenceChangeRecord ||
+        !preferenceChangeRecord.base ||
+        !preferenceChangeRecord.base.syntax_highlighting ||
+        !diff) {
+      return false;
+    }
+    return !this._anyLineTooLong(diff) &&
+        this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
+  }
+
+  /**
+   * @return {boolean} whether any of the lines in diff are longer
+   * than SYNTAX_MAX_LINE_LENGTH.
+   */
+  _anyLineTooLong(diff) {
+    if (!diff) return false;
+    return diff.content.some(section => {
+      const lines = section.ab ?
+        section.ab :
+        (section.a || []).concat(section.b || []);
+      return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+    });
+  }
+
+  _listenToViewportRender() {
+    const renderUpdateListener = start => {
+      if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
+        this.$.reporting.diffViewDisplayed();
+        this.$.syntaxLayer.removeListener(renderUpdateListener);
+      }
+    };
+
+    this.$.syntaxLayer.addListener(renderUpdateListener);
+  }
+
+  _handleRenderStart() {
+    this.$.reporting.time(TimingLabel.TOTAL);
+    this.$.reporting.time(TimingLabel.CONTENT);
+  }
+
+  _handleRenderContent() {
+    this.$.reporting.timeEnd(TimingLabel.CONTENT);
+  }
+
+  _handleNormalizeRange(event) {
+    this.$.reporting.reportInteraction('normalize-range',
+        {
+          side: event.detail.side,
+          lineNum: event.detail.lineNum,
+        });
+  }
+
+  _handleDiffContextExpanded(event) {
+    this.$.reporting.reportInteraction(
+        'diff-context-expanded', {numLines: event.detail.numLines}
+    );
+  }
+
+  /**
+   * Find the last chunk for the given side.
+   *
+   * @param {!Object} diff
+   * @param {boolean} leftSide true if checking the base of the diff,
+   *     false if testing the revision.
+   * @return {Object|null} returns the chunk object or null if there was
+   *     no chunk for that side.
+   */
+  _lastChunkForSide(diff, leftSide) {
+    if (!diff.content.length) { return null; }
+
+    let chunkIndex = diff.content.length;
+    let chunk;
+
+    // Walk backwards until we find a chunk for the given side.
+    do {
+      chunkIndex--;
+      chunk = diff.content[chunkIndex];
+    } while (
+    // We haven't reached the beginning.
+      chunkIndex >= 0 &&
+
+        // The chunk doesn't have both sides.
+        !chunk.ab &&
+
+        // The chunk doesn't have the given side.
+        ((leftSide && (!chunk.a || !chunk.a.length)) ||
+         (!leftSide && (!chunk.b || !chunk.b.length))));
+
+    // If we reached the beginning of the diff and failed to find a chunk
+    // with the given side, return null.
+    if (chunkIndex === -1) { return null; }
+
+    return chunk;
+  }
+
+  /**
+   * Check whether the specified side of the diff has a trailing newline.
+   *
+   * @param {!Object} diff
+   * @param {boolean} leftSide true if checking the base of the diff,
+   *     false if testing the revision.
+   * @return {boolean|null} Return true if the side has a trailing newline.
+   *     Return false if it doesn't. Return null if not applicable (for
+   *     example, if the diff has no content on the specified side).
+   */
+  _hasTrailingNewlines(diff, leftSide) {
+    const chunk = this._lastChunkForSide(diff, leftSide);
+    if (!chunk) { return null; }
+    let lines;
+    if (chunk.ab) {
+      lines = chunk.ab;
+    } else {
+      lines = leftSide ? chunk.a : chunk.b;
+    }
+    return lines[lines.length - 1] === '';
+  }
+
+  _showNewlineWarningLeft(diff) {
+    return this._hasTrailingNewlines(diff, true) === false;
+  }
+
+  _showNewlineWarningRight(diff) {
+    return this._hasTrailingNewlines(diff, false) === false;
+  }
+}
+
+customElements.define(GrDiffHost.is, GrDiffHost);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
new file mode 100644
index 0000000..4e425dc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-diff
+    id="diff"
+    change-num="[[changeNum]]"
+    no-auto-render="[[noAutoRender]]"
+    patch-range="[[patchRange]]"
+    path="[[path]]"
+    prefs="[[prefs]]"
+    project-name="[[projectName]]"
+    display-line="[[displayLine]]"
+    is-image-diff="[[isImageDiff]]"
+    commit-range="[[commitRange]]"
+    hidden$="[[hidden]]"
+    no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
+    line-wrapping="[[lineWrapping]]"
+    view-mode="[[viewMode]]"
+    line-of-interest="[[lineOfInterest]]"
+    logged-in="[[_loggedIn]]"
+    loading="[[_loading]]"
+    error-message="[[_errorMessage]]"
+    base-image="[[_baseImage]]"
+    revision-image="[[_revisionImage]]"
+    coverage-ranges="[[_coverageRanges]]"
+    blame="[[_blame]]"
+    layers="[[_layers]]"
+    diff="[[diff]]"
+    show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
+    show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
+  >
+  </gr-diff>
+  <gr-syntax-layer
+    id="syntaxLayer"
+    enabled="[[_syntaxHighlightingEnabled]]"
+    diff="[[diff]]"
+  ></gr-syntax-layer>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-reporting id="reporting" category="diff"></gr-reporting>
+`;
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 94b2f7d..0cb2b5e 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-diff-host.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,127 +31,53 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-host tests', () => {
-    let element;
-    let sandbox;
-    let getLoggedIn;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-host.js';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {DiffSide} from '../gr-diff/gr-diff-utils.js';
 
+suite('gr-diff-host tests', () => {
+  let element;
+  let sandbox;
+  let getLoggedIn;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    getLoggedIn = false;
+    stub('gr-rest-api-interface', {
+      async getLoggedIn() { return getLoggedIn; },
+    });
+    stub('gr-reporting', {
+      time: sandbox.stub(),
+      timeEnd: sandbox.stub(),
+    });
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('plugin layers', () => {
+    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      getLoggedIn = false;
-      stub('gr-rest-api-interface', {
-        async getLoggedIn() { return getLoggedIn; },
-      });
-      stub('gr-reporting', {
-        time: sandbox.stub(),
-        timeEnd: sandbox.stub(),
+      stub('gr-js-api-interface', {
+        getDiffLayers() { return pluginLayers; },
       });
       element = fixture('basic');
     });
-
-    teardown(() => {
-      sandbox.restore();
+    test('plugin layers requested', () => {
+      element.patchRange = {};
+      element.reload();
+      assert(element.$.jsAPI.getDiffLayers.called);
     });
+  });
 
-
-    suite('plugin layers', () => {
-      const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
-      setup(() => {
-        stub('gr-js-api-interface', {
-          getDiffLayers() { return pluginLayers; },
-        });
-        element = fixture('basic');
-      });
-      test('plugin layers requested', () => {
-        element.patchRange = {};
-        element.reload();
-        assert(element.$.jsAPI.getDiffLayers.called);
-      });
-    });
-
-    suite('handle comment-update', () => {
-      setup(() => {
-        sandbox.stub(element, '_commentsChanged');
-        element.comments = {
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        };
-      });
-
-      test('creating a draft', () => {
-        const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-          __commentSide: 'left'};
-        element.fire('comment-update', {comment});
-        assert.include(element.comments.left, comment);
-      });
-
-      test('discarding a draft', () => {
-        const draftID = 'tempID';
-        const id = 'savedID';
-        const comment = {
-          __draft: true,
-          __draftID: draftID,
-          side: 'PARENT',
-          __commentSide: 'left',
-        };
-        const diffCommentsModifiedStub = sandbox.stub();
-        element.addEventListener('diff-comments-modified',
-            diffCommentsModifiedStub);
-        element.comments.left.push(comment);
-        comment.id = id;
-        element.fire('comment-discard', {comment});
-        const drafts = element.comments.left.filter(item => {
-          return item.__draftID === draftID;
-        });
-        assert.equal(drafts.length, 0);
-        assert.isTrue(diffCommentsModifiedStub.called);
-      });
-
-      test('saving a draft', () => {
-        const draftID = 'tempID';
-        const id = 'savedID';
-        const comment = {
-          __draft: true,
-          __draftID: draftID,
-          side: 'PARENT',
-          __commentSide: 'left',
-        };
-        const diffCommentsModifiedStub = sandbox.stub();
-        element.addEventListener('diff-comments-modified',
-            diffCommentsModifiedStub);
-        element.comments.left.push(comment);
-        comment.id = id;
-        element.fire('comment-save', {comment});
-        const drafts = element.comments.left.filter(item => {
-          return item.__draftID === draftID;
-        });
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].id, id);
-        assert.isTrue(diffCommentsModifiedStub.called);
-      });
-    });
-
-    test('remove comment', () => {
+  suite('handle comment-update', () => {
+    setup(() => {
       sandbox.stub(element, '_commentsChanged');
       element.comments = {
         meta: {
@@ -180,1320 +102,1543 @@
           {id: 'd2', __draft: true, __commentSide: 'right'},
         ],
       };
-
-      element._removeComment({});
-      // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-      // to believe that one object deepEquals another even when they do :-/.
-      assert.equal(JSON.stringify(element.comments), JSON.stringify({
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      }));
-
-      element._removeComment({id: 'bc2', side: 'PARENT',
-        __commentSide: 'left'});
-      assert.deepEqual(element.comments, {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      });
-
-      element._removeComment({id: 'd2', __commentSide: 'right'});
-      assert.deepEqual(element.comments, {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-        ],
-      });
     });
 
-    test('thread-discard handling', () => {
-      const threads = [
-        {comments: [{id: 4711}]},
-        {comments: [{id: 42}]},
-      ];
-      element._parentIndex = 1;
-      element.changeNum = '2';
-      element.path = 'some/path';
-      element.projectName = 'Some project';
-      const threadEls = threads.map(
-          thread => {
-            const threadEl = element._createThreadElement(thread);
-            // Polymer 2 doesn't fire ready events and doesn't execute
-            // observers if element is not added to the Dom.
-            // See https://github.com/Polymer/old-docs-site/issues/2322
-            // and https://github.com/Polymer/polymer/issues/4526
-            element._attachThreadElement(threadEl);
-            return threadEl;
-          });
-      assert.equal(threadEls.length, 2);
-      assert.equal(threadEls[0].rootId, 4711);
-      assert.equal(threadEls[1].rootId, 42);
-      for (const threadEl of threadEls) {
-        Polymer.dom(element).appendChild(threadEl);
-      }
-
-      threadEls[0].dispatchEvent(
-          new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
-      const attachedThreads = element.queryAllEffectiveChildren(
-          'gr-comment-thread');
-      assert.equal(attachedThreads.length, 1);
-      assert.equal(attachedThreads[0].rootId, 42);
+    test('creating a draft', () => {
+      const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+        __commentSide: 'left'};
+      element.dispatchEvent(
+          new CustomEvent('comment-update', {
+            detail: {comment},
+            composed: true, bubbles: true,
+          }));
+      assert.include(element.comments.left, comment);
     });
 
-    suite('render reporting', () => {
-      test('starts total and content timer on render-start', done => {
-        element.dispatchEvent(
-            new CustomEvent('render-start', {bubbles: true, composed: true}));
-        assert.isTrue(element.$.reporting.time.calledWithExactly(
-            'Diff Total Render'));
-        assert.isTrue(element.$.reporting.time.calledWithExactly(
-            'Diff Content Render'));
-        done();
-      });
+    test('discarding a draft', () => {
+      const draftID = 'tempID';
+      const id = 'savedID';
+      const comment = {
+        __draft: true,
+        __draftID: draftID,
+        side: 'PARENT',
+        __commentSide: 'left',
+      };
+      const diffCommentsModifiedStub = sandbox.stub();
+      element.addEventListener('diff-comments-modified',
+          diffCommentsModifiedStub);
+      element.comments.left.push(comment);
+      comment.id = id;
+      element.dispatchEvent(
+          new CustomEvent('comment-discard', {
+            detail: {comment},
+            composed: true, bubbles: true,
+          }));
+      const drafts = element.comments.left
+          .filter(item => item.__draftID === draftID);
+      assert.equal(drafts.length, 0);
+      assert.isTrue(diffCommentsModifiedStub.called);
+    });
 
-      test('ends content timer on render-content', () => {
-        element.dispatchEvent(
-            new CustomEvent('render-content', {bubbles: true, composed: true}));
-        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-            'Diff Content Render'));
-      });
+    test('saving a draft', () => {
+      const draftID = 'tempID';
+      const id = 'savedID';
+      const comment = {
+        __draft: true,
+        __draftID: draftID,
+        side: 'PARENT',
+        __commentSide: 'left',
+      };
+      const diffCommentsModifiedStub = sandbox.stub();
+      element.addEventListener('diff-comments-modified',
+          diffCommentsModifiedStub);
+      element.comments.left.push(comment);
+      comment.id = id;
+      element.dispatchEvent(
+          new CustomEvent('comment-save', {
+            detail: {comment},
+            composed: true, bubbles: true,
+          }));
+      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);
+    });
+  });
 
-      test('ends total and syntax timer after syntax layer processing', done => {
-        let notifySyntaxProcessed;
-        sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-            resolve => {
-              notifySyntaxProcessed = resolve;
-            }));
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.patchRange = {};
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          return element.reload();
+  test('remove comment', () => {
+    sandbox.stub(element, '_commentsChanged');
+    element.comments = {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    };
+
+    element._removeComment({});
+    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
+    // to believe that one object deepEquals another even when they do :-/.
+    assert.equal(JSON.stringify(element.comments), JSON.stringify({
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    }));
+
+    element._removeComment({id: 'bc2', side: 'PARENT',
+      __commentSide: 'left'});
+    assert.deepEqual(element.comments, {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    });
+
+    element._removeComment({id: 'd2', __commentSide: 'right'});
+    assert.deepEqual(element.comments, {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+      ],
+    });
+  });
+
+  test('thread-discard handling', () => {
+    const threads = [
+      {comments: [{id: 4711}]},
+      {comments: [{id: 42}]},
+    ];
+    element._parentIndex = 1;
+    element.changeNum = '2';
+    element.path = 'some/path';
+    element.projectName = 'Some project';
+    const threadEls = threads.map(
+        thread => {
+          const threadEl = element._createThreadElement(thread);
+          // Polymer 2 doesn't fire ready events and doesn't execute
+          // observers if element is not added to the Dom.
+          // See https://github.com/Polymer/old-docs-site/issues/2322
+          // and https://github.com/Polymer/polymer/issues/4526
+          element._attachThreadElement(threadEl);
+          return threadEl;
         });
-        // Multiple cascading microtasks are scheduled.
-        setTimeout(() => {
-          notifySyntaxProcessed();
-          // Assert after the notification task is processed.
-          Promise.resolve().then(() => {
-            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-                'Diff Total Render'));
-            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-                'Diff Syntax Render'));
-            assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-                'StartupDiffViewOnlyContent'));
-            done();
-          });
-        });
-      });
+    assert.equal(threadEls.length, 2);
+    assert.equal(threadEls[0].rootId, 4711);
+    assert.equal(threadEls[1].rootId, 42);
+    for (const threadEl of threadEls) {
+      dom(element).appendChild(threadEl);
+    }
 
-      test('ends total timer w/ no syntax layer processing', done => {
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.patchRange = {};
-        element.reload();
-        // Multiple cascading microtasks are scheduled.
-        setTimeout(() => {
-          assert.isTrue(element.$.reporting.timeEnd.calledOnce);
+    threadEls[0].dispatchEvent(
+        new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
+    const attachedThreads = element.queryAllEffectiveChildren(
+        'gr-comment-thread');
+    assert.equal(attachedThreads.length, 1);
+    assert.equal(attachedThreads[0].rootId, 42);
+  });
+
+  suite('render reporting', () => {
+    test('starts total and content timer on render-start', done => {
+      element.dispatchEvent(
+          new CustomEvent('render-start', {bubbles: true, composed: true}));
+      assert.isTrue(element.$.reporting.time.calledWithExactly(
+          'Diff Total Render'));
+      assert.isTrue(element.$.reporting.time.calledWithExactly(
+          'Diff Content Render'));
+      done();
+    });
+
+    test('ends content timer on render-content', () => {
+      element.dispatchEvent(
+          new CustomEvent('render-content', {bubbles: true, composed: true}));
+      assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+          'Diff Content Render'));
+    });
+
+    test('ends total and syntax timer after syntax layer processing', done => {
+      let notifySyntaxProcessed;
+      sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.$.restAPI.getDiffPreferences().then(prefs => {
+        element.prefs = prefs;
+        return element.reload(true);
+      });
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        notifySyntaxProcessed();
+        // Assert after the notification task is processed.
+        Promise.resolve().then(() => {
           assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
               'Diff Total Render'));
+          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+              'Diff Syntax Render'));
+          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+              'StartupDiffViewOnlyContent'));
           done();
         });
       });
+    });
 
-      test('completes reload promise after syntax layer processing', done => {
-        let notifySyntaxProcessed;
-        sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-            resolve => {
-              notifySyntaxProcessed = resolve;
-            }));
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.patchRange = {};
-        let reloadComplete = false;
+    test('ends total timer w/ no syntax layer processing', done => {
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.reload();
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        assert.isTrue(element.$.reporting.timeEnd.calledOnce);
+        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
+            'Diff Total Render'));
+        done();
+      });
+    });
+
+    test('completes reload promise after syntax layer processing', done => {
+      let notifySyntaxProcessed;
+      sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      let reloadComplete = false;
+      element.$.restAPI.getDiffPreferences()
+          .then(prefs => {
+            element.prefs = prefs;
+            return element.reload();
+          })
+          .then(() => {
+            reloadComplete = true;
+          });
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        assert.isFalse(reloadComplete);
+        notifySyntaxProcessed();
+        // Assert after the notification task is processed.
+        setTimeout(() => {
+          assert.isTrue(reloadComplete);
+          done();
+        });
+      });
+    });
+  });
+
+  test('reload() cancels before network resolves', () => {
+    const cancelStub = sandbox.stub(element.$.diff, 'cancel');
+
+    // Stub the network calls into requests that never resolve.
+    sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+    element.patchRange = {};
+
+    element.reload();
+    assert.isTrue(cancelStub.called);
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      getLoggedIn = false;
+      element = fixture('basic');
+    });
+
+    test('reload() loads files weblinks', () => {
+      const weblinksStub = sandbox.stub(GerritNav, '_generateWeblinks')
+          .returns({name: 'stubb', url: '#s'});
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
+        content: [],
+      }));
+      element.projectName = 'test-project';
+      element.path = 'test-path';
+      element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
+      element.patchRange = {};
+      return element.reload().then(() => {
+        assert.isTrue(weblinksStub.calledTwice);
+        assert.isTrue(weblinksStub.firstCall.calledWith({
+          commit: 'test-base',
+          file: 'test-path',
+          options: {
+            weblinks: undefined,
+          },
+          repo: 'test-project',
+          type: GerritNav.WeblinkType.FILE}));
+        assert.isTrue(weblinksStub.secondCall.calledWith({
+          commit: 'test-commit',
+          file: 'test-path',
+          options: {
+            weblinks: undefined,
+          },
+          repo: 'test-project',
+          type: GerritNav.WeblinkType.FILE}));
+        assert.deepEqual(element.filesWeblinks, {
+          meta_a: [{name: 'stubb', url: '#s'}],
+          meta_b: [{name: 'stubb', url: '#s'}],
+        });
+      });
+    });
+
+    test('_getDiff handles null diff responses', done => {
+      stub('gr-rest-api-interface', {
+        getDiff() { return Promise.resolve(null); },
+      });
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+      element._getDiff().then(done);
+    });
+
+    test('reload resolves on error', () => {
+      const onErrStub = sandbox.stub(element, '_handleGetDiffError');
+      const error = {ok: false, status: 500};
+      sandbox.stub(element.$.restAPI, 'getDiff',
+          (changeNum, basePatchNum, patchNum, path, onErr) => {
+            onErr(error);
+          });
+      element.patchRange = {};
+      return element.reload().then(() => {
+        assert.isTrue(onErrStub.calledOnce);
+      });
+    });
+
+    suite('_handleGetDiffError', () => {
+      let serverErrorStub;
+      let pageErrorStub;
+
+      setup(() => {
+        serverErrorStub = sinon.stub();
+        element.addEventListener('server-error', serverErrorStub);
+        pageErrorStub = sinon.stub();
+        element.addEventListener('page-error', pageErrorStub);
+      });
+
+      test('page error on HTTP-409', () => {
+        element._handleGetDiffError({status: 409});
+        assert.isTrue(serverErrorStub.calledOnce);
+        assert.isFalse(pageErrorStub.called);
+        assert.isNotOk(element._errorMessage);
+      });
+
+      test('server error on non-HTTP-409', () => {
+        element._handleGetDiffError({status: 500});
+        assert.isFalse(serverErrorStub.called);
+        assert.isTrue(pageErrorStub.calledOnce);
+        assert.isNotOk(element._errorMessage);
+      });
+
+      test('error message if showLoadFailure', () => {
+        element.showLoadFailure = true;
+        element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+        assert.isFalse(serverErrorStub.called);
+        assert.isFalse(pageErrorStub.called);
+        assert.equal(element._errorMessage,
+            'Encountered error when loading the diff: 500 Failure!');
+      });
+    });
+
+    suite('image diffs', () => {
+      let mockFile1;
+      let mockFile2;
+      setup(() => {
+        mockFile1 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+        sandbox.stub(element.$.restAPI,
+            'getB64FileContents',
+            (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
+                opt_parentIndex === 1 ? mockFile1 :
+                  mockFile2)
+        );
+
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.comments = {
+          left: [],
+          right: [],
+          meta: {patchRange: element.patchRange},
+        };
+      });
+
+      test('renders image diffs with same file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diff.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diff.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isNotOk(rightLabelName);
+          assert.isNotOk(leftLabelName);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
         element.$.restAPI.getDiffPreferences().then(prefs => {
           element.prefs = prefs;
-          return element.reload();
-        }).then(() => {
-          reloadComplete = true;
-        });
-        // Multiple cascading microtasks are scheduled.
-        setTimeout(() => {
-          assert.isFalse(reloadComplete);
-          notifySyntaxProcessed();
-          // Assert after the notification task is processed.
-          setTimeout(() => {
-            assert.isTrue(reloadComplete);
-            done();
-          });
-        });
-      });
-    });
-
-    test('reload() cancels before network resolves', () => {
-      const cancelStub = sandbox.stub(element.$.diff, 'cancel');
-
-      // Stub the network calls into requests that never resolve.
-      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
-      element.patchRange = {};
-
-      element.reload();
-      assert.isTrue(cancelStub.called);
-    });
-
-    suite('not logged in', () => {
-      setup(() => {
-        getLoggedIn = false;
-        element = fixture('basic');
-      });
-
-      test('reload() loads files weblinks', () => {
-        const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-            .returns({name: 'stubb', url: '#s'});
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
-          content: [],
-        }));
-        element.projectName = 'test-project';
-        element.path = 'test-path';
-        element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-        element.patchRange = {};
-        return element.reload().then(() => {
-          assert.isTrue(weblinksStub.calledTwice);
-          assert.isTrue(weblinksStub.firstCall.calledWith({
-            commit: 'test-base',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.isTrue(weblinksStub.secondCall.calledWith({
-            commit: 'test-commit',
-            file: 'test-path',
-            options: {
-              weblinks: undefined,
-            },
-            repo: 'test-project',
-            type: Gerrit.Nav.WeblinkType.FILE}));
-          assert.deepEqual(element.filesWeblinks, {
-            meta_a: [{name: 'stubb', url: '#s'}],
-            meta_b: [{name: 'stubb', url: '#s'}],
-          });
+          element.reload();
         });
       });
 
-      test('_getDiff handles null diff responses', done => {
-        stub('gr-rest-api-interface', {
-          getDiff() { return Promise.resolve(null); },
-        });
-        element.changeNum = 123;
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        element.path = 'file.txt';
-        element._getDiff().then(done);
-      });
+      test('renders image diffs with a different file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
 
-      test('reload resolves on error', () => {
-        const onErrStub = sandbox.stub(element, '_handleGetDiffError');
-        const error = {ok: false, status: 500};
-        sandbox.stub(element.$.restAPI, 'getDiff',
-            (changeNum, basePatchNum, patchNum, path, onErr) => {
-              onErr(error);
-            });
-        element.patchRange = {};
-        return element.reload().then(() => {
-          assert.isTrue(onErrStub.calledOnce);
-        });
-      });
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
 
-      suite('_handleGetDiffError', () => {
-        let serverErrorStub;
-        let pageErrorStub;
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diff.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
 
-        setup(() => {
-          serverErrorStub = sinon.stub();
-          element.addEventListener('server-error', serverErrorStub);
-          pageErrorStub = sinon.stub();
-          element.addEventListener('page-error', pageErrorStub);
-        });
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diff.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
 
-        test('page error on HTTP-409', () => {
-          element._handleGetDiffError({status: 409});
-          assert.isTrue(serverErrorStub.calledOnce);
-          assert.isFalse(pageErrorStub.called);
-          assert.isNotOk(element._errorMessage);
-        });
+          assert.isOk(rightLabelName);
+          assert.isOk(leftLabelName);
+          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
 
-        test('server error on non-HTTP-409', () => {
-          element._handleGetDiffError({status: 500});
-          assert.isFalse(serverErrorStub.called);
-          assert.isTrue(pageErrorStub.calledOnce);
-          assert.isNotOk(element._errorMessage);
-        });
+          let leftLoaded = false;
+          let rightLoaded = false;
 
-        test('error message if showLoadFailure', () => {
-          element.showLoadFailure = true;
-          element._handleGetDiffError({status: 500, statusText: 'Failure!'});
-          assert.isFalse(serverErrorStub.called);
-          assert.isFalse(pageErrorStub.called);
-          assert.equal(element._errorMessage,
-              'Encountered error when loading the diff: 500 Failure!');
-        });
-      });
-
-      suite('image diffs', () => {
-        let mockFile1;
-        let mockFile2;
-        setup(() => {
-          mockFile1 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAAAAAA/w==',
-            type: 'image/bmp',
-          };
-          mockFile2 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAA/////w==',
-            type: 'image/bmp',
-          };
-          sandbox.stub(element.$.restAPI,
-              'getB64FileContents',
-              (changeId, patchNum, path, opt_parentIndex) => {
-                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
-                  mockFile2);
-              });
-
-          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.comments = {
-            left: [],
-            right: [],
-            meta: {patchRange: element.patchRange},
-          };
-        });
-
-        test('renders image diffs with same file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diff.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diff.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isNotOk(rightLabelName);
-            assert.isNotOk(leftLabelName);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-
-        test('renders image diffs with a different file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot2.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot2.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diff.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diff.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isOk(rightLabelName);
-            assert.isOk(leftLabelName);
-            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-
-        test('renders added image', done => {
-          const mockDiff = {
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'ADDED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 0000000..f9c2f2c 100644',
-              '--- /dev/null',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          element.addEventListener('render', () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-
-            assert.isNotOk(leftImage);
-            assert.isOk(rightImage);
-            done();
-          });
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-
-        test('renders removed image', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          element.addEventListener('render', () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            const rightImage =
-                element.$.diff.$.diffTable.querySelector('td.right img');
-
+          leftImage.addEventListener('load', () => {
             assert.isOk(leftImage);
-            assert.isNotOk(rightImage);
-            done();
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
           });
 
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
           });
-        });
+        };
 
-        test('does not render disallowed image type', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          mockFile1.type = 'image/jpeg-evil';
+        element.addEventListener('render', rendered);
 
-          sandbox.stub(element.$.restAPI, 'getDiff')
-              .returns(Promise.resolve(mockDiff));
-
-          element.addEventListener('render', () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-            const leftImage =
-                element.$.diff.$.diffTable.querySelector('td.left img');
-            assert.isNotOk(leftImage);
-            done();
-          });
-
-          element.$.restAPI.getDiffPreferences().then(prefs => {
-            element.prefs = prefs;
-            element.reload();
-          });
-        });
-      });
-    });
-
-    test('delegates cancel()', () => {
-      const stub = sandbox.stub(element.$.diff, 'cancel');
-      element.patchRange = {};
-      element.reload();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates getCursorStops()', () => {
-      const returnValue = [document.createElement('b')];
-      const stub = sandbox.stub(element.$.diff, 'getCursorStops')
-          .returns(returnValue);
-      assert.equal(element.getCursorStops(), returnValue);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates isRangeSelected()', () => {
-      const returnValue = true;
-      const stub = sandbox.stub(element.$.diff, 'isRangeSelected')
-          .returns(returnValue);
-      assert.equal(element.isRangeSelected(), returnValue);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates toggleLeftDiff()', () => {
-      const stub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
-      element.toggleLeftDiff();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    suite('blame', () => {
-      setup(() => {
-        element = fixture('basic');
-      });
-
-      test('clearBlame', () => {
-        element._blame = [];
-        const setBlameSpy = sandbox.spy(element.$.diff.$.diffBuilder, 'setBlame');
-        element.clearBlame();
-        assert.isNull(element._blame);
-        assert.isTrue(setBlameSpy.calledWithExactly(null));
-        assert.equal(element.isBlameLoaded, false);
-      });
-
-      test('loadBlame', () => {
-        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame().then(() => {
-          assert.isTrue(getBlameStub.calledWithExactly(
-              42, 5, 'foo/bar.baz', true));
-          assert.isFalse(showAlertStub.called);
-          assert.equal(element._blame, mockBlame);
-          assert.equal(element.isBlameLoaded, true);
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
         });
       });
 
-      test('loadBlame empty', () => {
-        const mockBlame = [];
-        const showAlertStub = sinon.stub();
-        element.addEventListener('show-alert', showAlertStub);
-        sandbox.stub(element.$.restAPI, 'getBlame')
-            .returns(Promise.resolve(mockBlame));
-        element.changeNum = 42;
-        element.patchRange = {patchNum: 5, basePatchNum: 4};
-        element.path = 'foo/bar.baz';
-        return element.loadBlame()
-            .then(() => {
-              assert.isTrue(false, 'Promise should not resolve');
-            })
-            .catch(() => {
-              assert.isTrue(showAlertStub.calledOnce);
-              assert.isNull(element._blame);
-              assert.equal(element.isBlameLoaded, false);
-            });
-      });
-    });
-
-    test('getThreadEls() returns .comment-threads', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      Polymer.dom(element.$.diff).appendChild(threadEl);
-      assert.deepEqual(element.getThreadEls(), [threadEl]);
-    });
-
-    test('delegates addDraftAtLine(el)', () => {
-      const param0 = document.createElement('b');
-      const stub = sandbox.stub(element.$.diff, 'addDraftAtLine');
-      element.addDraftAtLine(param0);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 1);
-      assert.equal(stub.lastCall.args[0], param0);
-    });
-
-    test('delegates clearDiffContent()', () => {
-      const stub = sandbox.stub(element.$.diff, 'clearDiffContent');
-      element.clearDiffContent();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('delegates expandAllContext()', () => {
-      const stub = sandbox.stub(element.$.diff, 'expandAllContext');
-      element.expandAllContext();
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
-    });
-
-    test('passes in changeNum', () => {
-      const value = '12345';
-      element.changeNum = value;
-      assert.equal(element.$.diff.changeNum, value);
-    });
-
-    test('passes in noAutoRender', () => {
-      const value = true;
-      element.noAutoRender = value;
-      assert.equal(element.$.diff.noAutoRender, value);
-    });
-
-    test('passes in patchRange', () => {
-      const value = {patchNum: 'foo', basePatchNum: 'bar'};
-      element.patchRange = value;
-      assert.equal(element.$.diff.patchRange, value);
-    });
-
-    test('passes in path', () => {
-      const value = 'some/file/path';
-      element.path = value;
-      assert.equal(element.$.diff.path, value);
-    });
-
-    test('passes in prefs', () => {
-      const value = {};
-      element.prefs = value;
-      assert.equal(element.$.diff.prefs, value);
-    });
-
-    test('passes in changeNum', () => {
-      const value = '12345';
-      element.changeNum = value;
-      assert.equal(element.$.diff.changeNum, value);
-    });
-
-    test('passes in projectName', () => {
-      const value = 'Gerrit';
-      element.projectName = value;
-      assert.equal(element.$.diff.projectName, value);
-    });
-
-    test('passes in displayLine', () => {
-      const value = true;
-      element.displayLine = value;
-      assert.equal(element.$.diff.displayLine, value);
-    });
-
-    test('passes in commitRange', () => {
-      const value = {};
-      element.commitRange = value;
-      assert.equal(element.$.diff.commitRange, value);
-    });
-
-    test('passes in hidden', () => {
-      const value = true;
-      element.hidden = value;
-      assert.equal(element.$.diff.hidden, value);
-      assert.isNotNull(element.getAttribute('hidden'));
-    });
-
-    test('passes in noRenderOnPrefsChange', () => {
-      const value = true;
-      element.noRenderOnPrefsChange = value;
-      assert.equal(element.$.diff.noRenderOnPrefsChange, value);
-    });
-
-    test('passes in lineWrapping', () => {
-      const value = true;
-      element.lineWrapping = value;
-      assert.equal(element.$.diff.lineWrapping, value);
-    });
-
-    test('passes in viewMode', () => {
-      const value = 'SIDE_BY_SIDE';
-      element.viewMode = value;
-      assert.equal(element.$.diff.viewMode, value);
-    });
-
-    test('passes in lineOfInterest', () => {
-      const value = {number: 123, leftSide: true};
-      element.lineOfInterest = value;
-      assert.equal(element.$.diff.lineOfInterest, value);
-    });
-
-    suite('_reportDiff', () => {
-      let reportStub;
-
-      setup(() => {
-        element = fixture('basic');
-        element.patchRange = {basePatchNum: 1};
-        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
-      });
-
-      test('null and content-less', () => {
-        element._reportDiff(null);
-        assert.isFalse(reportStub.called);
-
-        element._reportDiff({});
-        assert.isFalse(reportStub.called);
-      });
-
-      test('diff w/ no delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {ab: ['baz', 'foo']},
+      test('renders added image', done => {
+        const mockDiff = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
           ],
+          content: [{skip: 66}],
+          binary: true,
         };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+
+          assert.isNotOk(leftImage);
+          assert.isOk(rightImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
       });
 
-      test('diff w/ no rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo']},
-            {ab: ['foo', 'bar']},
+      test('renders removed image', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
           ],
+          content: [{skip: 66}],
+          binary: true,
         };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+
+          assert.isOk(leftImage);
+          assert.isNotOk(rightImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
       });
 
-      test('diff w/ some rebase delta', () => {
-        const diff = {
-          content: [
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo'], b: ['bar', 'baz']},
-            {ab: ['foo', 'bar']},
-            {b: ['baz', 'foo'], due_to_rebase: true},
-            {ab: ['foo', 'bar']},
-            {a: ['baz', 'foo']},
+      test('does not render disallowed image type', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
           ],
+          content: [{skip: 66}],
+          binary: true,
         };
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
-        assert.strictEqual(reportStub.lastCall.args[1], 50);
-      });
+        mockFile1.type = 'image/jpeg-evil';
 
-      test('diff w/ all rebase delta', () => {
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-          due_to_rebase: true,
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'rebase-percent-nonzero');
-        assert.strictEqual(reportStub.lastCall.args[1], 100);
-      });
+        sandbox.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
 
-      test('diff against parent event', () => {
-        element.patchRange.basePatchNum = 'PARENT';
-        const diff = {content: [{
-          a: ['foo', 'bar'],
-          b: ['baz', 'foo'],
-        }]};
-        element._reportDiff(diff);
-        assert.isTrue(reportStub.calledOnce);
-        assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-        assert.isUndefined(reportStub.lastCall.args[1]);
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          assert.isNotOk(leftImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+    });
+  });
+
+  test('delegates cancel()', () => {
+    const stub = sandbox.stub(element.$.diff, 'cancel');
+    element.patchRange = {};
+    element.reload();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates getCursorStops()', () => {
+    const returnValue = [document.createElement('b')];
+    const stub = sandbox.stub(element.$.diff, 'getCursorStops')
+        .returns(returnValue);
+    assert.equal(element.getCursorStops(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates isRangeSelected()', () => {
+    const returnValue = true;
+    const stub = sandbox.stub(element.$.diff, 'isRangeSelected')
+        .returns(returnValue);
+    assert.equal(element.isRangeSelected(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleLeftDiff()', () => {
+    const stub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+    element.toggleLeftDiff();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  suite('blame', () => {
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('clearBlame', () => {
+      element._blame = [];
+      const setBlameSpy = sandbox.spy(element.$.diff.$.diffBuilder, 'setBlame');
+      element.clearBlame();
+      assert.isNull(element._blame);
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.equal(element.isBlameLoaded, false);
+    });
+
+    test('loadBlame', () => {
+      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
+          .returns(Promise.resolve(mockBlame));
+      element.changeNum = 42;
+      element.patchRange = {patchNum: 5, basePatchNum: 4};
+      element.path = 'foo/bar.baz';
+      return element.loadBlame().then(() => {
+        assert.isTrue(getBlameStub.calledWithExactly(
+            42, 5, 'foo/bar.baz', true));
+        assert.isFalse(showAlertStub.called);
+        assert.equal(element._blame, mockBlame);
+        assert.equal(element.isBlameLoaded, true);
       });
     });
 
-    test('_createThreads', () => {
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          line: 1,
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-          line: 1,
-          in_reply_to: 'sallys_confession',
-        },
-        {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __commentSide: 'left',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-        },
-      ];
+    test('loadBlame empty', () => {
+      const mockBlame = [];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      sandbox.stub(element.$.restAPI, 'getBlame')
+          .returns(Promise.resolve(mockBlame));
+      element.changeNum = 42;
+      element.patchRange = {patchNum: 5, basePatchNum: 4};
+      element.path = 'foo/bar.baz';
+      return element.loadBlame()
+          .then(() => {
+            assert.isTrue(false, 'Promise should not resolve');
+          })
+          .catch(() => {
+            assert.isTrue(showAlertStub.calledOnce);
+            assert.isNull(element._blame);
+            assert.equal(element.isBlameLoaded, false);
+          });
+    });
+  });
 
-      const actualThreads = element._createThreads(comments);
+  test('getThreadEls() returns .comment-threads', () => {
+    const threadEl = document.createElement('div');
+    threadEl.className = 'comment-thread';
+    dom(element.$.diff).appendChild(threadEl);
+    assert.deepEqual(element.getThreadEls(), [threadEl]);
+  });
 
-      assert.equal(actualThreads.length, 2);
+  test('delegates addDraftAtLine(el)', () => {
+    const param0 = document.createElement('b');
+    const stub = sandbox.stub(element.$.diff, 'addDraftAtLine');
+    element.addDraftAtLine(param0);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 1);
+    assert.equal(stub.lastCall.args[0], param0);
+  });
 
-      assert.equal(
-          actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
-      assert.equal(actualThreads[0].commentSide, 'left');
-      assert.equal(actualThreads[0].comments.length, 2);
-      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
-      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
-      assert.equal(actualThreads[0].patchNum, undefined);
-      assert.equal(actualThreads[0].rootId, 'sallys_confession');
-      assert.equal(actualThreads[0].lineNum, 1);
+  test('delegates clearDiffContent()', () => {
+    const stub = sandbox.stub(element.$.diff, 'clearDiffContent');
+    element.clearDiffContent();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
 
-      assert.equal(
-          actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
-      assert.equal(actualThreads[1].commentSide, 'left');
-      assert.equal(actualThreads[1].comments.length, 1);
-      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-      assert.equal(actualThreads[1].patchNum, undefined);
-      assert.equal(actualThreads[1].rootId, 'new_draft');
-      assert.equal(actualThreads[1].lineNum, undefined);
+  test('delegates expandAllContext()', () => {
+    const stub = sandbox.stub(element.$.diff, 'expandAllContext');
+    element.expandAllContext();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('passes in changeNum', () => {
+    const value = '12345';
+    element.changeNum = value;
+    assert.equal(element.$.diff.changeNum, value);
+  });
+
+  test('passes in noAutoRender', () => {
+    const value = true;
+    element.noAutoRender = value;
+    assert.equal(element.$.diff.noAutoRender, value);
+  });
+
+  test('passes in patchRange', () => {
+    const value = {patchNum: 'foo', basePatchNum: 'bar'};
+    element.patchRange = value;
+    assert.equal(element.$.diff.patchRange, value);
+  });
+
+  test('passes in path', () => {
+    const value = 'some/file/path';
+    element.path = value;
+    assert.equal(element.$.diff.path, value);
+  });
+
+  test('passes in prefs', () => {
+    const value = {};
+    element.prefs = value;
+    assert.equal(element.$.diff.prefs, value);
+  });
+
+  test('passes in changeNum', () => {
+    const value = '12345';
+    element.changeNum = value;
+    assert.equal(element.$.diff.changeNum, value);
+  });
+
+  test('passes in projectName', () => {
+    const value = 'Gerrit';
+    element.projectName = value;
+    assert.equal(element.$.diff.projectName, value);
+  });
+
+  test('passes in displayLine', () => {
+    const value = true;
+    element.displayLine = value;
+    assert.equal(element.$.diff.displayLine, value);
+  });
+
+  test('passes in commitRange', () => {
+    const value = {};
+    element.commitRange = value;
+    assert.equal(element.$.diff.commitRange, value);
+  });
+
+  test('passes in hidden', () => {
+    const value = true;
+    element.hidden = value;
+    assert.equal(element.$.diff.hidden, value);
+    assert.isNotNull(element.getAttribute('hidden'));
+  });
+
+  test('passes in noRenderOnPrefsChange', () => {
+    const value = true;
+    element.noRenderOnPrefsChange = value;
+    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
+  });
+
+  test('passes in lineWrapping', () => {
+    const value = true;
+    element.lineWrapping = value;
+    assert.equal(element.$.diff.lineWrapping, value);
+  });
+
+  test('passes in viewMode', () => {
+    const value = 'SIDE_BY_SIDE';
+    element.viewMode = value;
+    assert.equal(element.$.diff.viewMode, value);
+  });
+
+  test('passes in lineOfInterest', () => {
+    const value = {number: 123, leftSide: true};
+    element.lineOfInterest = value;
+    assert.equal(element.$.diff.lineOfInterest, value);
+  });
+
+  suite('_reportDiff', () => {
+    let reportStub;
+
+    setup(() => {
+      element = fixture('basic');
+      element.patchRange = {basePatchNum: 1};
+      reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
     });
 
-    test('_createThreads inherits patchNum and range', () => {
-      const comments = [{
-        id: 'betsys_confession',
+    test('null and content-less', () => {
+      element._reportDiff(null);
+      assert.isFalse(reportStub.called);
+
+      element._reportDiff({});
+      assert.isFalse(reportStub.called);
+    });
+
+    test('diff w/ no delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {ab: ['baz', 'foo']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ no rebase delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ some rebase delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(reportStub.calledWith(
+          'rebase-percent-nonzero',
+          {percentRebaseDelta: 50}
+      ));
+    });
+
+    test('diff w/ all rebase delta', () => {
+      const diff = {content: [{
+        a: ['foo', 'bar'],
+        b: ['baz', 'foo'],
+        due_to_rebase: true,
+      }]};
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(reportStub.calledWith(
+          'rebase-percent-nonzero',
+          {percentRebaseDelta: 100}
+      ));
+    });
+
+    test('diff against parent event', () => {
+      element.patchRange.basePatchNum = 'PARENT';
+      const diff = {content: [{
+        a: ['foo', 'bar'],
+        b: ['baz', 'foo'],
+      }]};
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+  });
+
+  test('comments sorting', () => {
+    const comments = [
+      {
+        id: 'new_draft',
+        message: 'i do not like either of you',
+        __commentSide: 'left',
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+      {
+        id: 'sallys_confession',
         message: 'i like you, jack',
-        updated: '2015-12-24 15:00:10.396000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
-        },
-        patch_set: 5,
+        updated: '2015-12-23 15:00:20.396000000',
+        line: 1,
+        __commentSide: 'left',
+      }, {
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000',
         __commentSide: 'left',
         line: 1,
-      }];
+        in_reply_to: 'sallys_confession',
+      },
+    ];
+    const sortedComments = element._sortComments(comments);
+    assert.equal(sortedComments[0], comments[1]);
+    assert.equal(sortedComments[1], comments[2]);
+    assert.equal(sortedComments[2], comments[0]);
+  });
 
-      expectedThreads = [
-        {
-          start_datetime: '2015-12-24 15:00:10.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'betsys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:10.396000000',
-            range: {
-              start_line: 1,
-              start_character: 1,
-              end_line: 1,
-              end_character: 2,
-            },
-            patch_set: 5,
-            __commentSide: 'left',
-            line: 1,
-          }],
-          patchNum: 5,
-          rootId: 'betsys_confession',
+  test('_createThreads', () => {
+    const comments = [
+      {
+        id: 'sallys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000',
+        line: 1,
+        __commentSide: 'left',
+      }, {
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000',
+        __commentSide: 'left',
+        line: 1,
+        in_reply_to: 'sallys_confession',
+      },
+      {
+        id: 'new_draft',
+        message: 'i do not like either of you',
+        __commentSide: 'left',
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+    ];
+
+    const actualThreads = element._createThreads(comments);
+
+    assert.equal(actualThreads.length, 2);
+
+    assert.equal(
+        actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
+    assert.equal(actualThreads[0].commentSide, 'left');
+    assert.equal(actualThreads[0].comments.length, 2);
+    assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+    assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+    assert.equal(actualThreads[0].patchNum, undefined);
+    assert.equal(actualThreads[0].rootId, 'sallys_confession');
+    assert.equal(actualThreads[0].lineNum, 1);
+
+    assert.equal(
+        actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
+    assert.equal(actualThreads[1].commentSide, 'left');
+    assert.equal(actualThreads[1].comments.length, 1);
+    assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+    assert.equal(actualThreads[1].patchNum, undefined);
+    assert.equal(actualThreads[1].rootId, 'new_draft');
+    assert.equal(actualThreads[1].lineNum, undefined);
+  });
+
+  test('_createThreads inherits patchNum and range', () => {
+    const comments = [{
+      id: 'betsys_confession',
+      message: 'i like you, jack',
+      updated: '2015-12-24 15:00:10.396000000',
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 2,
+      },
+      patch_set: 5,
+      __commentSide: 'left',
+      line: 1,
+    }];
+
+    const expectedThreads = [
+      {
+        start_datetime: '2015-12-24 15:00:10.396000000',
+        commentSide: 'left',
+        comments: [{
+          id: 'betsys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:10.396000000',
           range: {
             start_line: 1,
             start_character: 1,
             end_line: 1,
             end_character: 2,
           },
-          lineNum: 1,
-          isOnParent: false,
+          patch_set: 5,
+          __commentSide: 'left',
+          line: 1,
+        }],
+        patchNum: 5,
+        rootId: 'betsys_confession',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
         },
-      ];
+        lineNum: 1,
+        isOnParent: false,
+      },
+    ];
 
-      assert.deepEqual(
-          element._createThreads(comments),
-          expectedThreads);
-    });
+    assert.deepEqual(
+        element._createThreads(comments),
+        expectedThreads);
+  });
 
-    test('_createThreads does not thread unrelated comments at same location',
-        () => {
-          const comments = [
-            {
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:01:20.396000000',
-              __commentSide: 'left',
-            },
-          ];
-          assert.equal(element._createThreads(comments).length, 2);
-        });
-
-    test('_createThreads derives isOnParent using  side from first comment',
-        () => {
-          const comments = [
-            {
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              // line: 1,
-              // __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:01:20.396000000',
-              // __commentSide: 'left',
-              // line: 1,
-              in_reply_to: 'sallys_confession',
-            },
-          ];
-
-          assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-          comments[0].side = 'REVISION';
-          assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-          comments[0].side = 'PARENT';
-          assert.equal(element._createThreads(comments)[0].isOnParent, true);
-        });
-
-    test('_getOrCreateThread', () => {
-      const commentSide = 'left';
-
-      assert.isOk(element._getOrCreateThread('2', 3,
-          commentSide, undefined, false));
-
-      let threads = Polymer.dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].commentSide, commentSide);
-      assert.equal(threads[0].range, undefined);
-      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,
-        start_character: 1,
-        end_line: 1,
-        end_character: 3,
-      };
-
-      assert.isOk(element._getOrCreateThread(
-          '3', 1, commentSide, range, true));
-
-      threads = Polymer.dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 2);
-      assert.equal(threads[1].commentSide, commentSide);
-      assert.equal(threads[1].range, range);
-      assert.equal(threads[1].isOnParent, true);
-      assert.equal(threads[1].patchNum, 3);
-    });
-
-    test('_filterThreadElsForLocation with no threads', () => {
-      const line = {beforeNumber: 3, afterNumber: 5};
-
-      const threads = [];
-      assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
-      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-          Gerrit.DiffSide.LEFT), []);
-      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-          Gerrit.DiffSide.RIGHT), []);
-    });
-
-    test('_filterThreadElsForLocation for line comments', () => {
-      const line = {beforeNumber: 3, afterNumber: 5};
-
-      const l3 = document.createElement('div');
-      l3.setAttribute('line-num', 3);
-      l3.setAttribute('comment-side', 'left');
-
-      const l5 = document.createElement('div');
-      l5.setAttribute('line-num', 5);
-      l5.setAttribute('comment-side', 'left');
-
-      const r3 = document.createElement('div');
-      r3.setAttribute('line-num', 3);
-      r3.setAttribute('comment-side', 'right');
-
-      const r5 = document.createElement('div');
-      r5.setAttribute('line-num', 5);
-      r5.setAttribute('comment-side', 'right');
-
-      const threadEls = [l3, l5, r3, r5];
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-          [l3, r5]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.LEFT), [l3]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.RIGHT), [r5]);
-    });
-
-    test('_filterThreadElsForLocation for file comments', () => {
-      const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-      const l = document.createElement('div');
-      l.setAttribute('comment-side', 'left');
-      l.setAttribute('line-num', 'FILE');
-
-      const r = document.createElement('div');
-      r.setAttribute('comment-side', 'right');
-      r.setAttribute('line-num', 'FILE');
-
-      const threadEls = [l, r];
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-          [l, r]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.BOTH), [l, r]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.LEFT), [l]);
-      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.RIGHT), [r]);
-    });
-
-    suite('syntax layer with syntax_highlighting on', () => {
-      setup(() => {
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-          syntax_highlighting: true,
-        };
-        element.patchRange = {};
-        element.prefs = prefs;
-      });
-
-      test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
-        element.reload();
-        assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
-      });
-
-      test('rendering normal-sized diff does not disable syntax', () => {
-        element.diff = {
-          content: [{
-            a: ['foo'],
-          }],
-        };
-        assert.isTrue(element.$.syntaxLayer.enabled);
-      });
-
-      test('rendering large diff disables syntax', () => {
-        // Before it renders, set the first diff line to 500 '*' characters.
-        element.diff = {
-          content: [{
-            a: [new Array(501).join('*')],
-          }],
-        };
-        assert.isFalse(element.$.syntaxLayer.enabled);
-      });
-
-      test('starts syntax layer processing on render event', done => {
-        sandbox.stub(element.$.syntaxLayer, 'process').returns(Promise.resolve());
-        sandbox.stub(element.$.restAPI, 'getDiff').returns(
-            Promise.resolve({content: []}));
-        element.reload();
-        setTimeout(() => {
-          element.dispatchEvent(
-              new CustomEvent('render', {bubbles: true, composed: true}));
-          assert.isTrue(element.$.syntaxLayer.process.called);
-          done();
-        });
-      });
-    });
-
-    suite('syntax layer with syntax_highlgihting off', () => {
-      setup(() => {
-        const prefs = {
-          line_length: 10,
-          show_tabs: true,
-          tab_size: 4,
-          context: -1,
-        };
-        element.diff = {
-          content: [{
-            a: ['foo'],
-          }],
-        };
-        element.patchRange = {};
-        element.prefs = prefs;
-      });
-
-      test('gr-diff-host provides syntax highlighting layer', () => {
-        element.reload();
-        assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
-      });
-
-      test('syntax layer should be disabled', () => {
-        assert.isFalse(element.$.syntaxLayer.enabled);
-      });
-
-      test('still disabled for large diff', () => {
-        // Before it renders, set the first diff line to 500 '*' characters.
-        element.diff = {
-          content: [{
-            a: [new Array(501).join('*')],
-          }],
-        };
-        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,
-                    },
-                  },
-                ]);
-              },
-            });
+  test('_createThreads does not thread unrelated comments at same location',
+      () => {
+        const comments = [
+          {
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:01:20.396000000',
+            __commentSide: 'left',
           },
-        });
-        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;
+        ];
+        assert.equal(element._createThreads(comments).length, 2);
       });
 
-      test('getCoverageAnnotationApi should be called', done => {
-        element.reload();
-        flush(() => {
-          assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
-          done();
-        });
+  test('_createThreads derives isOnParent using  side from first comment',
+      () => {
+        const comments = [
+          {
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            // line: 1,
+            // __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:01:20.396000000',
+            // __commentSide: 'left',
+            // line: 1,
+            in_reply_to: 'sallys_confession',
+          },
+        ];
+
+        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+        comments[0].side = 'REVISION';
+        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+        comments[0].side = 'PARENT';
+        assert.equal(element._createThreads(comments)[0].isOnParent, true);
       });
 
-      test('coverageRangeChanged should be called', done => {
-        element.reload();
-        flush(() => {
-          assert.equal(notifyStub.callCount, 2);
-          done();
-        });
+  test('_getOrCreateThread', () => {
+    const commentSide = 'left';
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, false));
+
+    let threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].range, undefined);
+    assert.equal(threads[0].isOnParent, false);
+    assert.equal(threads[0].patchNum, 2);
+
+    // Try to fetch a thread with a different range.
+    const range = {
+      start_line: 1,
+      start_character: 1,
+      end_line: 1,
+      end_character: 3,
+    };
+
+    assert.isOk(element._getOrCreateThread(
+        '3', 1, commentSide, range, true));
+
+    threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 2);
+    assert.equal(threads[1].commentSide, commentSide);
+    assert.equal(threads[1].range, range);
+    assert.equal(threads[1].isOnParent, true);
+    assert.equal(threads[1].patchNum, 3);
+  });
+
+  test('_filterThreadElsForLocation with no threads', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const threads = [];
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+        DiffSide.LEFT), []);
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+        DiffSide.RIGHT), []);
+  });
+
+  test('_filterThreadElsForLocation for line comments', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const l3 = document.createElement('div');
+    l3.setAttribute('line-num', 3);
+    l3.setAttribute('comment-side', 'left');
+
+    const l5 = document.createElement('div');
+    l5.setAttribute('line-num', 5);
+    l5.setAttribute('comment-side', 'left');
+
+    const r3 = document.createElement('div');
+    r3.setAttribute('line-num', 3);
+    r3.setAttribute('comment-side', 'right');
+
+    const r5 = document.createElement('div');
+    r5.setAttribute('line-num', 5);
+    r5.setAttribute('comment-side', 'right');
+
+    const threadEls = [l3, l5, r3, r5];
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+        [l3, r5]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.LEFT), [l3]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.RIGHT), [r5]);
+  });
+
+  test('_filterThreadElsForLocation for file comments', () => {
+    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+    const l = document.createElement('div');
+    l.setAttribute('comment-side', 'left');
+    l.setAttribute('line-num', 'FILE');
+
+    const r = document.createElement('div');
+    r.setAttribute('comment-side', 'right');
+    r.setAttribute('line-num', 'FILE');
+
+    const threadEls = [l, r];
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+        [l, r]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.BOTH), [l, r]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.LEFT), [l]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.RIGHT), [r]);
+  });
+
+  suite('syntax layer with syntax_highlighting on', () => {
+    setup(() => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
+      element.reload();
+      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    });
+
+    test('rendering normal-sized diff does not disable syntax', () => {
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      assert.isTrue(element.$.syntaxLayer.enabled);
+    });
+
+    test('rendering large diff disables syntax', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        content: [{
+          a: [new Array(501).join('*')],
+        }],
+      };
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
+
+    test('starts syntax layer processing on render event', done => {
+      sandbox.stub(element.$.syntaxLayer, 'process')
+          .returns(Promise.resolve());
+      sandbox.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.reload();
+      setTimeout(() => {
+        element.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
+        assert.isTrue(element.$.syntaxLayer.process.called);
+        done();
       });
     });
   });
+
+  suite('syntax layer with syntax_highlgihting off', () => {
+    setup(() => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', () => {
+      element.reload();
+      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    });
+
+    test('syntax layer should be disabled', () => {
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
+
+    test('still disabled for large diff', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        content: [{
+          a: [new Array(501).join('*')],
+        }],
+      };
+      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();
+      });
+    });
+  });
+
+  suite('trailing newlines', () => {
+    setup(() => {
+    });
+
+    suite('_lastChunkForSide', () => {
+      test('deltas', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar'], b: ['baz']},
+          {ab: ['foo', 'bar', 'baz']},
+          {b: ['foo']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
+
+        diff.content.push({a: ['foo'], b: ['bar']});
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
+      });
+
+      test('addition with a undefined', () => {
+        const diff = {content: [
+          {b: ['foo', 'bar', 'baz']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('addition with a empty', () => {
+        const diff = {content: [
+          {a: [], b: ['foo', 'bar', 'baz']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('deletion with b undefined', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar', 'baz']},
+        ]};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('deletion with b empty', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar', 'baz'], b: []},
+        ]};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('empty', () => {
+        const diff = {content: []};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+    });
+
+    suite('_hasTrailingNewlines', () => {
+      test('shared no trailing', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide')
+            .returns({ab: ['foo', 'bar']});
+        assert.isFalse(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('delta trailing in right', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide')
+            .returns({a: ['foo', 'bar'], b: ['baz', '']});
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('addition', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+          if (leftSide) { return null; }
+          return {b: ['foo', '']};
+        });
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isNull(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('deletion', () => {
+        const diff = undefined;
+        sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+          if (!leftSide) { return null; }
+          return {a: ['foo']};
+        });
+        assert.isNull(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
deleted file mode 100644
index 47cf771..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
+++ /dev/null
@@ -1,60 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-diff-mode-selector">
-  <template>
-    <style include="shared-styles">
-      :host {
-        /* Used to remove horizontal whitespace between the icons. */
-        display: flex;
-      }
-      gr-button.selected iron-icon {
-        color: var(--link-color);
-      }
-      iron-icon {
-        height: 1.3rem;
-        width: 1.3rem;
-      }
-    </style>
-    <gr-button
-        id="sideBySideBtn"
-        link
-        has-tooltip
-        class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
-        title="Side-by-side diff"
-        on-click="_handleSideBySideTap">
-      <iron-icon icon="gr-icons:side-by-side"></iron-icon>
-    </gr-button>
-    <gr-button
-        id="unifiedBtn"
-        link
-        has-tooltip
-        title="Unified diff"
-        class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
-        on-click="_handleUnifiedTap">
-      <iron-icon icon="gr-icons:unified"></iron-icon>
-    </gr-button>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-diff-mode-selector.js"></script>
-</dom-module>
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..acd9457 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
@@ -14,13 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-diff-mode-selector',
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-mode-selector_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrDiffModeSelector extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-mode-selector'; }
+
+  static get properties() {
+    return {
       mode: {
         type: String,
         notify: true,
@@ -44,28 +58,30 @@
           UNIFIED: 'UNIFIED_DIFF',
         },
       },
-    },
+    };
+  }
 
-    /**
-     * Set the mode. If save on change is enabled also update the preference.
-     */
-    setMode(newMode) {
-      if (this.saveOnChange && this.mode && this.mode !== newMode) {
-        this.$.restAPI.savePreferences({diff_view: newMode});
-      }
-      this.mode = newMode;
-    },
+  /**
+   * Set the mode. If save on change is enabled also update the preference.
+   */
+  setMode(newMode) {
+    if (this.saveOnChange && this.mode && this.mode !== newMode) {
+      this.$.restAPI.savePreferences({diff_view: newMode});
+    }
+    this.mode = newMode;
+  }
 
-    _computeSelectedClass(diffViewMode, buttonViewMode) {
-      return buttonViewMode === diffViewMode ? 'selected' : '';
-    },
+  _computeSelectedClass(diffViewMode, buttonViewMode) {
+    return buttonViewMode === diffViewMode ? 'selected' : '';
+  }
 
-    _handleSideBySideTap() {
-      this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
-    },
+  _handleSideBySideTap() {
+    this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
+  }
 
-    _handleUnifiedTap() {
-      this.setMode(this._VIEW_MODES.UNIFIED);
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
new file mode 100644
index 0000000..b5393ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      /* Used to remove horizontal whitespace between the icons. */
+      display: flex;
+    }
+    gr-button.selected iron-icon {
+      color: var(--link-color);
+    }
+    iron-icon {
+      height: 1.3rem;
+      width: 1.3rem;
+    }
+  </style>
+  <gr-button
+    id="sideBySideBtn"
+    link=""
+    has-tooltip=""
+    class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
+    title="Side-by-side diff"
+    on-click="_handleSideBySideTap"
+  >
+    <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+  </gr-button>
+  <gr-button
+    id="unifiedBtn"
+    link=""
+    has-tooltip=""
+    title="Unified diff"
+    class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
+    on-click="_handleUnifiedTap"
+  >
+    <iron-icon icon="gr-icons:unified"></iron-icon>
+  </gr-button>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..309f4ac 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
@@ -17,19 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-diff-mode-selector.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -37,51 +32,53 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-mode-selector tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-mode-selector.js';
+suite('gr-diff-mode-selector tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeSelectedClass', () => {
-      assert.equal(
-          element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
-          'selected');
-      assert.equal(
-          element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
-    });
-
-    test('setMode', () => {
-      const saveStub = sandbox.stub(element.$.restAPI, 'savePreferences');
-
-      // Setting the mode initially does not save prefs.
-      element.saveOnChange = true;
-      element.setMode('SIDE_BY_SIDE');
-      assert.isFalse(saveStub.called);
-
-      // Setting the mode to itself does not save prefs.
-      element.setMode('SIDE_BY_SIDE');
-      assert.isFalse(saveStub.called);
-
-      // Setting the mode to something else does not save prefs if saveOnChange
-      // is false.
-      element.saveOnChange = false;
-      element.setMode('UNIFIED_DIFF');
-      assert.isFalse(saveStub.called);
-
-      // Setting the mode to something else does not save prefs if saveOnChange
-      // is false.
-      element.saveOnChange = true;
-      element.setMode('SIDE_BY_SIDE');
-      assert.isTrue(saveStub.calledOnce);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeSelectedClass', () => {
+    assert.equal(
+        element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
+        'selected');
+    assert.equal(
+        element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
+  });
+
+  test('setMode', () => {
+    const saveStub = sandbox.stub(element.$.restAPI, 'savePreferences');
+
+    // Setting the mode initially does not save prefs.
+    element.saveOnChange = true;
+    element.setMode('SIDE_BY_SIDE');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to itself does not save prefs.
+    element.setMode('SIDE_BY_SIDE');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = false;
+    element.setMode('UNIFIED_DIFF');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = true;
+    element.setMode('SIDE_BY_SIDE');
+    assert.isTrue(saveStub.calledOnce);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html
deleted file mode 100644
index b0167b3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.html
+++ /dev/null
@@ -1,81 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.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-button/gr-button.html">
-<link rel="import" href="../../shared/gr-diff-preferences/gr-diff-preferences.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-
-<dom-module id="gr-diff-preferences-dialog">
-  <template>
-    <style include="shared-styles">
-      .diffHeader,
-      .diffActions {
-        padding: var(--spacing-l) var(--spacing-xl);
-      }
-      .diffHeader,
-      .diffActions {
-        background-color: var(--dialog-background-color);
-      }
-      .diffHeader {
-        border-bottom: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-      }
-      .diffActions {
-        border-top: 1px solid var(--border-color);
-        display: flex;
-        justify-content: flex-end;
-      }
-      .diffPrefsOverlay gr-button {
-        margin-left: var(--spacing-l);
-      }
-      div.edited:after {
-        color: var(--deemphasized-text-color);
-        content: ' *';
-      }
-      #diffPreferences {
-        display: flex;
-        padding: var(--spacing-s) var(--spacing-xl);
-      }
-    </style>
-    <gr-overlay id="diffPrefsOverlay" with-backdrop>
-      <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">Diff Preferences</div>
-      <gr-diff-preferences
-          id="diffPreferences"
-          diff-prefs="{{_editableDiffPrefs}}"
-          has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
-      <div class="diffActions">
-        <gr-button
-            id="cancelButton"
-            link
-            on-click="_handleCancelDiff">
-            Cancel
-        </gr-button>
-        <gr-button
-            id="saveButton"
-            link primary
-            on-click="_handleSaveDiffPreferences"
-            disabled$="[[!_diffPrefsChanged]]">
-            Save
-        </gr-button>
-      </div>
-    </gr-overlay>
-  </template>
-  <script src="gr-diff-preferences-dialog.js"></script>
-</dom-module>
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 9b723bd..8f48507 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
@@ -14,14 +14,29 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-diff-preferences-dialog',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-preferences-dialog_html.js';
 
-    properties: {
-      /** @type {?} */
+/**
+ * @extends Polymer.Element
+ */
+class GrDiffPreferencesDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-preferences-dialog'; }
+
+  static get properties() {
+    return {
+    /** @type {?} */
       diffPrefs: Object,
 
       /**
@@ -36,51 +51,51 @@
       _editableDiffPrefs: Object,
 
       _diffPrefsChanged: Boolean,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  getFocusStops() {
+    return {
+      start: this.$.diffPreferences.$.contextSelect,
+      end: this.$.saveButton,
+    };
+  }
 
-    getFocusStops() {
-      return {
-        start: this.$.diffPreferences.$.contextSelect,
-        end: this.$.saveButton,
-      };
-    },
+  resetFocus() {
+    this.$.diffPreferences.$.contextSelect.focus();
+  }
 
-    resetFocus() {
-      this.$.diffPreferences.$.contextSelect.focus();
-    },
+  _computeHeaderClass(changed) {
+    return changed ? 'edited' : '';
+  }
 
-    _computeHeaderClass(changed) {
-      return changed ? 'edited' : '';
-    },
+  _handleCancelDiff(e) {
+    e.stopPropagation();
+    this.$.diffPrefsOverlay.close();
+  }
 
-    _handleCancelDiff(e) {
-      e.stopPropagation();
+  open() {
+    // JSON.parse(JSON.stringify(...)) makes a deep clone of diffPrefs.
+    // It is known, that diffPrefs is obtained from an RestAPI call and
+    // it is safe to clone the object this way.
+    this._editableDiffPrefs = JSON.parse(JSON.stringify(this.diffPrefs));
+    this.$.diffPrefsOverlay.open().then(() => {
+      const focusStops = this.getFocusStops();
+      this.$.diffPrefsOverlay.setFocusStops(focusStops);
+      this.resetFocus();
+    });
+  }
+
+  _handleSaveDiffPreferences() {
+    this.diffPrefs = this._editableDiffPrefs;
+    this.$.diffPreferences.save().then(() => {
+      this.dispatchEvent(new CustomEvent('reload-diff-preference', {
+        composed: true, bubbles: false,
+      }));
+
       this.$.diffPrefsOverlay.close();
-    },
+    });
+  }
+}
 
-    open() {
-      // JSON.parse(JSON.stringify(...)) makes a deep clone of diffPrefs.
-      // It is known, that diffPrefs is obtained from an RestAPI call and
-      // it is safe to clone the object this way.
-      this._editableDiffPrefs = JSON.parse(JSON.stringify(this.diffPrefs));
-      this.$.diffPrefsOverlay.open().then(() => {
-        const focusStops = this.getFocusStops();
-        this.$.diffPrefsOverlay.setFocusStops(focusStops);
-        this.resetFocus();
-      });
-    },
-
-    _handleSaveDiffPreferences() {
-      this.diffPrefs = this._editableDiffPrefs;
-      this.$.diffPreferences.save().then(() => {
-        this.fire('reload-diff-preference', null, {bubbles: false});
-
-        this.$.diffPrefsOverlay.close();
-      });
-    },
-  });
-})();
+customElements.define(GrDiffPreferencesDialog.is, GrDiffPreferencesDialog);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
new file mode 100644
index 0000000..d65ba1f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .diffHeader,
+    .diffActions {
+      padding: var(--spacing-l) var(--spacing-xl);
+    }
+    .diffHeader,
+    .diffActions {
+      background-color: var(--dialog-background-color);
+    }
+    .diffHeader {
+      border-bottom: 1px solid var(--border-color);
+      font-weight: var(--font-weight-bold);
+    }
+    .diffActions {
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      justify-content: flex-end;
+    }
+    .diffPrefsOverlay gr-button {
+      margin-left: var(--spacing-l);
+    }
+    div.edited:after {
+      color: var(--deemphasized-text-color);
+      content: ' *';
+    }
+    #diffPreferences {
+      display: flex;
+      padding: var(--spacing-s) var(--spacing-xl);
+    }
+  </style>
+  <gr-overlay id="diffPrefsOverlay" with-backdrop="">
+    <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">
+      Diff Preferences
+    </div>
+    <gr-diff-preferences
+      id="diffPreferences"
+      diff-prefs="{{_editableDiffPrefs}}"
+      has-unsaved-changes="{{_diffPrefsChanged}}"
+    ></gr-diff-preferences>
+    <div class="diffActions">
+      <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
+        Cancel
+      </gr-button>
+      <gr-button
+        id="saveButton"
+        link=""
+        primary=""
+        on-click="_handleSaveDiffPreferences"
+        disabled$="[[!_diffPrefsChanged]]"
+      >
+        Save
+      </gr-button>
+    </div>
+  </gr-overlay>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html
index 156ec69..d3050af 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html
@@ -17,12 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <title>gr-diff-preferences-dialog</title>
 
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/components/web-component-tester/data/a11ySuite.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -30,34 +32,38 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-preferences-dialog', () => {
-    let element;
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-    test('changes applies only on save', async () => {
-      const originalDiffPrefs = {
-        line_wrapping: true,
-      };
-      element.diffPrefs = originalDiffPrefs;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-preferences-dialog.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 
-      element.open();
-      await flush();
-      assert.isTrue(element.$.diffPreferences.$.lineWrappingInput.checked);
-
-      MockInteractions.tap(element.$.diffPreferences.$.lineWrappingInput);
-      await flush();
-      assert.isFalse(element.$.diffPreferences.$.lineWrappingInput.checked);
-      assert.isTrue(element._diffPrefsChanged);
-      assert.isTrue(element.diffPrefs.line_wrapping);
-      assert.isTrue(originalDiffPrefs.line_wrapping);
-
-      MockInteractions.tap(element.$.saveButton);
-      await flush();
-      // Original prefs must remains unchanged, dialog must expose a new object
-      assert.isTrue(originalDiffPrefs.line_wrapping);
-      assert.isFalse(element.diffPrefs.line_wrapping);
-    });
+suite('gr-diff-preferences-dialog', () => {
+  let element;
+  setup(() => {
+    element = fixture('basic');
   });
+  test('changes applies only on save', async () => {
+    const originalDiffPrefs = {
+      line_wrapping: true,
+    };
+    element.diffPrefs = originalDiffPrefs;
+
+    element.open();
+    await flush();
+    assert.isTrue(element.$.diffPreferences.$.lineWrappingInput.checked);
+
+    MockInteractions.tap(element.$.diffPreferences.$.lineWrappingInput);
+    await flush();
+    assert.isFalse(element.$.diffPreferences.$.lineWrappingInput.checked);
+    assert.isTrue(element._diffPrefsChanged);
+    assert.isTrue(element.diffPrefs.line_wrapping);
+    assert.isTrue(originalDiffPrefs.line_wrapping);
+
+    MockInteractions.tap(element.$.saveButton);
+    await flush();
+    // Original prefs must remains unchanged, dialog must expose a new object
+    assert.isTrue(originalDiffPrefs.line_wrapping);
+    assert.isFalse(element.diffPrefs.line_wrapping);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
deleted file mode 100644
index 922ac87..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
+++ /dev/null
@@ -1,26 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-diff-processor">
-  <script src="../gr-diff/gr-diff-line.js"></script>
-  <script src="../gr-diff/gr-diff-group.js"></script>
-
-  <script src="../../../scripts/util.js"></script>
-  <script src="gr-diff-processor.js"></script>
-</dom-module>
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 e052a8f..62ddfee 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
@@ -14,60 +14,71 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const WHOLE_FILE = -1;
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {util} from '../../../scripts/util.js';
 
-  const DiffSide = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+const WHOLE_FILE = -1;
 
-  const DiffHighlights = {
-    ADDED: 'edit_b',
-    REMOVED: 'edit_a',
-  };
+const DiffSide = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
 
-  /**
-   * The maximum size for an addition or removal chunk before it is broken down
-   * into a series of chunks that are this size at most.
-   *
-   * Note: The value of 120 is chosen so that it is larger than the default
-   * _asyncThreshold of 64, but feel free to tune this constant to your
-   * performance needs.
-   */
-  const MAX_GROUP_SIZE = 120;
+const DiffHighlights = {
+  ADDED: 'edit_b',
+  REMOVED: 'edit_a',
+};
 
-  /**
-   * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
-   *
-   * Glossary:
-   * - "chunk": A single `DiffContent` as returned by the API.
-   * - "group": A single `GrDiffGroup` as used for rendering.
-   * - "common" chunk/group: A chunk/group that should be considered unchanged
-   *   for diffing purposes. This can mean its either actually unchanged, or it
-   *   has only whitespace changes.
-   * - "key location": A line number and side of the diff that should not be
-   *   collapsed e.g. because a comment is attached to it, or because it was
-   *   provided in the URL and thus should be visible
-   * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
-   *   or cannot be collapsed because it contains a key location
-   *
-   * Here a a number of tasks this processor performs:
-   *  - splitting large chunks to allow more granular async rendering
-   *  - adding a group for the "File" pseudo line that file-level comments can
-   *    be attached to
-   *  - replacing common parts of the diff that are outside the user's
-   *    context setting and do not have comments with a group representing the
-   *    "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.
-   */
-  Polymer({
-    is: 'gr-diff-processor',
+/**
+ * The maximum size for an addition or removal chunk before it is broken down
+ * into a series of chunks that are this size at most.
+ *
+ * Note: The value of 120 is chosen so that it is larger than the default
+ * _asyncThreshold of 64, but feel free to tune this constant to your
+ * performance needs.
+ */
+const MAX_GROUP_SIZE = 120;
 
-    properties: {
+/**
+ * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
+ *
+ * Glossary:
+ * - "chunk": A single `DiffContent` as returned by the API.
+ * - "group": A single `GrDiffGroup` as used for rendering.
+ * - "common" chunk/group: A chunk/group that should be considered unchanged
+ *   for diffing purposes. This can mean its either actually unchanged, or it
+ *   has only whitespace changes.
+ * - "key location": A line number and side of the diff that should not be
+ *   collapsed e.g. because a comment is attached to it, or because it was
+ *   provided in the URL and thus should be visible
+ * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+ *   or cannot be collapsed because it contains a key location
+ *
+ * Here a a number of tasks this processor performs:
+ *  - splitting large chunks to allow more granular async rendering
+ *  - adding a group for the "File" pseudo line that file-level comments can
+ *    be attached to
+ *  - replacing common parts of the diff that are outside the user's
+ *    context setting and do not have comments with a group representing the
+ *    "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
+ */
+class GrDiffProcessor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-diff-processor'; }
+
+  static get properties() {
+    return {
 
       /**
        * The amount of context around collapsed groups.
@@ -113,544 +124,548 @@
         value: null,
       },
       _isScrolling: Boolean,
-    },
+    };
+  }
 
-    attached() {
-      this.listen(window, 'scroll', '_handleWindowScroll');
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(window, 'scroll', '_handleWindowScroll');
+  }
 
-    detached() {
-      this.cancel();
-      this.unlisten(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);
-    },
+  _handleWindowScroll() {
+    this._isScrolling = true;
+    this.debounce('resetIsScrolling', () => {
+      this._isScrolling = false;
+    }, 50);
+  }
 
-    /**
-     * Asynchronously process the diff chunks into groups. As it processes, it
-     * will splice groups into the `groups` property of the component.
-     *
-     * @param {!Array<!Gerrit.DiffChunk>} chunks
-     * @param {boolean} isBinary
-     *
-     * @return {!Promise<!Array<!Object>>} A promise that resolves with an
-     *     array of GrDiffGroups when the diff is completely processed.
-     */
-    process(chunks, isBinary) {
-      // Cancel any still running process() calls, because they append to the
-      // same groups field.
-      this.cancel();
+  /**
+   * Asynchronously process the diff chunks into groups. As it processes, it
+   * will splice groups into the `groups` property of the component.
+   *
+   * @param {!Array<!Gerrit.DiffChunk>} chunks
+   * @param {boolean} isBinary
+   *
+   * @return {!Promise<!Array<!Object>>} A promise that resolves with an
+   *     array of GrDiffGroups when the diff is completely processed.
+   */
+  process(chunks, isBinary) {
+    // Cancel any still running process() calls, because they append to the
+    // same groups field.
+    this.cancel();
 
-      this.groups = [];
-      this.push('groups', this._makeFileComments());
+    this.groups = [];
+    this.push('groups', this._makeFileComments());
 
-      // If it's a binary diff, we won't be rendering hunks of text differences
-      // so finish processing.
-      if (isBinary) { return Promise.resolve(); }
+    // If it's a binary diff, we won't be rendering hunks of text differences
+    // so finish processing.
+    if (isBinary) { return Promise.resolve(); }
 
+    this._processPromise = util.makeCancelable(
+        new Promise(resolve => {
+          const state = {
+            lineNums: {left: 0, right: 0},
+            chunkIndex: 0,
+          };
 
-      this._processPromise = util.makeCancelable(
-          new Promise(resolve => {
-            const state = {
-              lineNums: {left: 0, right: 0},
-              chunkIndex: 0,
-            };
+          chunks = this._splitLargeChunks(chunks);
+          chunks = this._splitCommonChunksWithKeyLocations(chunks);
 
-            chunks = this._splitLargeChunks(chunks);
-            chunks = this._splitCommonChunksWithKeyLocations(chunks);
+          let currentBatch = 0;
+          const nextStep = () => {
+            if (this._isScrolling) {
+              this._nextStepHandle = this.async(nextStep, 100);
+              return;
+            }
+            // If we are done, resolve the promise.
+            if (state.chunkIndex >= chunks.length) {
+              resolve();
+              this._nextStepHandle = null;
+              return;
+            }
 
-            let currentBatch = 0;
-            const nextStep = () => {
-              if (this._isScrolling) {
-                this._nextStepHandle = this.async(nextStep, 100);
-                return;
-              }
-              // If we are done, resolve the promise.
-              if (state.chunkIndex >= chunks.length) {
-                resolve();
-                this._nextStepHandle = null;
-                return;
-              }
+            // Process the next chunk and incorporate the result.
+            const stateUpdate = this._processNext(state, chunks);
+            for (const group of stateUpdate.groups) {
+              this.push('groups', group);
+              currentBatch += group.lines.length;
+            }
+            state.lineNums.left += stateUpdate.lineDelta.left;
+            state.lineNums.right += stateUpdate.lineDelta.right;
 
-              // Process the next chunk and incorporate the result.
-              const stateUpdate = this._processNext(state, chunks);
-              for (const group of stateUpdate.groups) {
-                this.push('groups', group);
-                currentBatch += group.lines.length;
-              }
-              state.lineNums.left += stateUpdate.lineDelta.left;
-              state.lineNums.right += stateUpdate.lineDelta.right;
+            // Increment the index and recurse.
+            state.chunkIndex = stateUpdate.newChunkIndex;
+            if (currentBatch >= this._asyncThreshold) {
+              currentBatch = 0;
+              this._nextStepHandle = this.async(nextStep, 1);
+            } else {
+              nextStep.call(this);
+            }
+          };
 
-              // Increment the index and recurse.
-              state.chunkIndex = stateUpdate.newChunkIndex;
-              if (currentBatch >= this._asyncThreshold) {
-                currentBatch = 0;
-                this._nextStepHandle = this.async(nextStep, 1);
-              } else {
-                nextStep.call(this);
-              }
-            };
+          nextStep.call(this);
+        }));
+    return this._processPromise
+        .finally(() => { this._processPromise = null; });
+  }
 
-            nextStep.call(this);
-          }));
-      return this._processPromise
-          .finally(() => { this._processPromise = null; });
-    },
+  /**
+   * Cancel any jobs that are running.
+   */
+  cancel() {
+    if (this._nextStepHandle != null) {
+      this.cancelAsync(this._nextStepHandle);
+      this._nextStepHandle = null;
+    }
+    if (this._processPromise) {
+      this._processPromise.cancel();
+    }
+  }
 
-    /**
-     * Cancel any jobs that are running.
-     */
-    cancel() {
-      if (this._nextStepHandle != null) {
-        this.cancelAsync(this._nextStepHandle);
-        this._nextStepHandle = null;
-      }
-      if (this._processPromise) {
-        this._processPromise.cancel();
-      }
-    },
-
-    /**
-     * Process the next uncollapsible chunk, or the next collapsible chunks.
-     *
-     * @param {!Object} state
-     * @param {!Array<!Object>} chunks
-     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
-     */
-    _processNext(state, chunks) {
-      const firstUncollapsibleChunkIndex =
-          this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
-      if (firstUncollapsibleChunkIndex === state.chunkIndex) {
-        const chunk = chunks[state.chunkIndex];
-        return {
-          lineDelta: {
-            left: this._linesLeft(chunk).length,
-            right: this._linesRight(chunk).length,
-          },
-          groups: [this._chunkToGroup(
-              chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
-          newChunkIndex: state.chunkIndex + 1,
-        };
-      }
-
-      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;
-      while (chunkIndex < chunks.length &&
-          this._isCollapsibleChunk(chunks[chunkIndex])) {
-        chunkIndex++;
-      }
-      return chunkIndex;
-    },
-
-    _isCollapsibleChunk(chunk) {
-      return (chunk.ab || chunk.common) && !chunk.keyLocation;
-    },
-
-    /**
-     * Process a stretch of collapsible chunks.
-     *
-     * Outputs up to three groups:
-     *  1) Visible context before the hidden common code, unless it's the
-     *     very beginning of the file.
-     *  2) Context hidden behind a context bar, unless empty.
-     *  3) Visible context after the hidden common code, unless it's the very
-     *     end of the file.
-     *
-     * @param {!Object} state
-     * @param {!Array<Object>} chunks
-     * @param {number} firstUncollapsibleChunkIndex
-     * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
-     */
-    _processCollapsibleChunks(
-        state, chunks, firstUncollapsibleChunkIndex) {
-      const collapsibleChunks = chunks.slice(
-          state.chunkIndex, firstUncollapsibleChunkIndex);
-      const lineCount = collapsibleChunks.reduce(
-          (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
-
-      let groups = this._chunksToGroups(
-          collapsibleChunks,
-          state.lineNums.left + 1,
-          state.lineNums.right + 1);
-
-      if (this.context !== WHOLE_FILE) {
-        const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
-        const hiddenEnd = lineCount - (
-          firstUncollapsibleChunkIndex === chunks.length ?
-            0 : this.context);
-        groups = GrDiffGroup.hideInContextControl(
-            groups, hiddenStart, hiddenEnd);
-      }
-
+  /**
+   * Process the next uncollapsible chunk, or the next collapsible chunks.
+   *
+   * @param {!Object} state
+   * @param {!Array<!Object>} chunks
+   * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
+   */
+  _processNext(state, chunks) {
+    const firstUncollapsibleChunkIndex =
+        this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
+    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+      const chunk = chunks[state.chunkIndex];
       return {
         lineDelta: {
-          left: lineCount,
-          right: lineCount,
+          left: this._linesLeft(chunk).length,
+          right: this._linesRight(chunk).length,
         },
-        groups,
-        newChunkIndex: firstUncollapsibleChunkIndex,
+        groups: [this._chunkToGroup(
+            chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
+        newChunkIndex: state.chunkIndex + 1,
       };
-    },
+    }
 
-    _commonChunkLength(chunk) {
-      console.assert(chunk.ab || chunk.common);
-      console.assert(
-          !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;
-    },
+    return this._processCollapsibleChunks(
+        state, chunks, firstUncollapsibleChunkIndex);
+  }
 
-    /**
-     * @param {!Array<!Object>} chunks
-     * @param {number} offsetLeft
-     * @param {number} offsetRight
-     * @return {!Array<!Object>} (GrDiffGroup)
-     */
-    _chunksToGroups(chunks, offsetLeft, offsetRight) {
-      return chunks.map(chunk => {
-        const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
-        const chunkLength = this._commonChunkLength(chunk);
-        offsetLeft += chunkLength;
-        offsetRight += chunkLength;
-        return group;
-      });
-    },
+  _linesLeft(chunk) {
+    return chunk.ab || chunk.a || [];
+  }
 
-    /**
-     * @param {!Object} chunk
-     * @param {number} offsetLeft
-     * @param {number} offsetRight
-     * @return {!Object} (GrDiffGroup)
-     */
-    _chunkToGroup(chunk, offsetLeft, offsetRight) {
-      const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
-      const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
-      const group = new GrDiffGroup(type, lines);
-      group.keyLocation = chunk.keyLocation;
-      group.dueToRebase = chunk.due_to_rebase;
-      group.ignoredWhitespaceOnly = chunk.common;
+  _linesRight(chunk) {
+    return chunk.ab || chunk.b || [];
+  }
+
+  _firstUncollapsibleChunkIndex(chunks, offset) {
+    let chunkIndex = offset;
+    while (chunkIndex < chunks.length &&
+        this._isCollapsibleChunk(chunks[chunkIndex])) {
+      chunkIndex++;
+    }
+    return chunkIndex;
+  }
+
+  _isCollapsibleChunk(chunk) {
+    return (chunk.ab || chunk.common) && !chunk.keyLocation;
+  }
+
+  /**
+   * Process a stretch of collapsible chunks.
+   *
+   * Outputs up to three groups:
+   *  1) Visible context before the hidden common code, unless it's the
+   *     very beginning of the file.
+   *  2) Context hidden behind a context bar, unless empty.
+   *  3) Visible context after the hidden common code, unless it's the very
+   *     end of the file.
+   *
+   * @param {!Object} state
+   * @param {!Array<Object>} chunks
+   * @param {number} firstUncollapsibleChunkIndex
+   * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
+   */
+  _processCollapsibleChunks(
+      state, chunks, firstUncollapsibleChunkIndex) {
+    const collapsibleChunks = chunks.slice(
+        state.chunkIndex, firstUncollapsibleChunkIndex);
+    const lineCount = collapsibleChunks.reduce(
+        (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
+
+    let groups = this._chunksToGroups(
+        collapsibleChunks,
+        state.lineNums.left + 1,
+        state.lineNums.right + 1);
+
+    if (this.context !== WHOLE_FILE) {
+      const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
+      const hiddenEnd = lineCount - (
+        firstUncollapsibleChunkIndex === chunks.length ?
+          0 : this.context);
+      groups = GrDiffGroup.hideInContextControl(
+          groups, hiddenStart, hiddenEnd);
+    }
+
+    return {
+      lineDelta: {
+        left: lineCount,
+        right: lineCount,
+      },
+      groups,
+      newChunkIndex: firstUncollapsibleChunkIndex,
+    };
+  }
+
+  _commonChunkLength(chunk) {
+    console.assert(chunk.ab || chunk.common);
+    console.assert(
+        !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
+   * @param {number} offsetLeft
+   * @param {number} offsetRight
+   * @return {!Array<!Object>} (GrDiffGroup)
+   */
+  _chunksToGroups(chunks, offsetLeft, offsetRight) {
+    return chunks.map(chunk => {
+      const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this._commonChunkLength(chunk);
+      offsetLeft += chunkLength;
+      offsetRight += chunkLength;
       return group;
-    },
+    });
+  }
 
-    _linesFromChunk(chunk, offsetLeft, offsetRight) {
-      if (chunk.ab) {
-        return chunk.ab.map((row, i) => this._lineFromRow(
-            GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
-      }
-      let lines = [];
-      if (chunk.a) {
-        // Avoiding a.push(...b) because that causes callstack overflows for
-        // large b, which can occur when large files are added removed.
-        lines = lines.concat(this._linesFromRows(
-            GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
-            chunk[DiffHighlights.REMOVED]));
-      }
-      if (chunk.b) {
-        // Avoiding a.push(...b) because that causes callstack overflows for
-        // large b, which can occur when large files are added removed.
-        lines = lines.concat(this._linesFromRows(
-            GrDiffLine.Type.ADD, chunk.b, offsetRight,
-            chunk[DiffHighlights.ADDED]));
-      }
-      return lines;
-    },
+  /**
+   * @param {!Object} chunk
+   * @param {number} offsetLeft
+   * @param {number} offsetRight
+   * @return {!Object} (GrDiffGroup)
+   */
+  _chunkToGroup(chunk, offsetLeft, offsetRight) {
+    const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
+    const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
+    const group = new GrDiffGroup(type, lines);
+    group.keyLocation = chunk.keyLocation;
+    group.dueToRebase = chunk.due_to_rebase;
+    group.ignoredWhitespaceOnly = chunk.common;
+    return group;
+  }
 
-    /**
-     * @param {string} lineType (GrDiffLine.Type)
-     * @param {!Array<string>} rows
-     * @param {number} offset
-     * @param {?Array<!Gerrit.IntralineInfo>=} opt_intralineInfos
-     * @return {!Array<!Object>} (GrDiffLine)
-     */
-    _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
-      const grDiffHighlights = opt_intralineInfos ?
-        this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
-      return rows.map((row, i) => this._lineFromRow(
-          lineType, offset, offset, row, i, grDiffHighlights));
-    },
+  _linesFromChunk(chunk, offsetLeft, offsetRight) {
+    if (chunk.ab) {
+      return chunk.ab.map((row, i) => this._lineFromRow(
+          GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
+    }
+    let lines = [];
+    if (chunk.a) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(this._linesFromRows(
+          GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
+          chunk[DiffHighlights.REMOVED]));
+    }
+    if (chunk.b) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(this._linesFromRows(
+          GrDiffLine.Type.ADD, chunk.b, offsetRight,
+          chunk[DiffHighlights.ADDED]));
+    }
+    return lines;
+  }
 
-    /**
-     * @param {string} type (GrDiffLine.Type)
-     * @param {number} offsetLeft
-     * @param {number} offsetRight
-     * @param {string} row
-     * @param {number} i
-     * @param {!Array<!Object>=} opt_highlights
-     * @return {!Object} (GrDiffLine)
-     */
-    _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
-      const line = new GrDiffLine(type);
-      line.text = row;
-      if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
-      if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
-      if (opt_highlights) {
-        line.hasIntralineInfo = true;
-        line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
+  /**
+   * @param {string} lineType (GrDiffLine.Type)
+   * @param {!Array<string>} rows
+   * @param {number} offset
+   * @param {?Array<!Gerrit.IntralineInfo>=} opt_intralineInfos
+   * @return {!Array<!Object>} (GrDiffLine)
+   */
+  _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
+    const grDiffHighlights = opt_intralineInfos ?
+      this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
+    return rows.map((row, i) => this._lineFromRow(
+        lineType, offset, offset, row, i, grDiffHighlights));
+  }
+
+  /**
+   * @param {string} type (GrDiffLine.Type)
+   * @param {number} offsetLeft
+   * @param {number} offsetRight
+   * @param {string} row
+   * @param {number} i
+   * @param {!Array<!Object>=} opt_highlights
+   * @return {!Object} (GrDiffLine)
+   */
+  _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
+    const line = new GrDiffLine(type);
+    line.text = row;
+    if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
+    if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
+    if (opt_highlights) {
+      line.hasIntralineInfo = true;
+      line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
+    } else {
+      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.
+   *
+   * This is done to prevent doing too much work on the main thread in one
+   * uninterrupted rendering step, which would make the browser unresponsive.
+   *
+   * Note that in the case of unmodified chunks, we only split chunks if the
+   * context is set to file (because otherwise they are split up further down
+   * the processing into the visible and hidden context), and only split it
+   * into 2 chunks, one max sized one and the rest (for reasons that are
+   * unclear to me).
+   *
+   * @param {!Array<!Gerrit.DiffChunk>} chunks Chunks as returned from the server
+   * @return {!Array<!Gerrit.DiffChunk>} Finer grained chunks.
+   */
+  _splitLargeChunks(chunks) {
+    const newChunks = [];
+
+    for (const chunk of chunks) {
+      if (!chunk.ab) {
+        for (const subChunk of this._breakdownChunk(chunk)) {
+          newChunks.push(subChunk);
+        }
+        continue;
+      }
+
+      // If the context is set to "whole file", then break down the shared
+      // chunks so they can be rendered incrementally. Note: this is not
+      // enabled for any other context preference because manipulating the
+      // chunks in this way violates assumptions by the context grouper logic.
+      if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+        // Split large shared chunks in two, where the first is the maximum
+        // group size.
+        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
+        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
       } else {
-        line.hasIntralineInfo = false;
+        newChunks.push(chunk);
       }
-      return line;
-    },
+    }
+    return newChunks;
+  }
 
-    _makeFileComments() {
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = GrDiffLine.FILE;
-      line.afterNumber = GrDiffLine.FILE;
-      return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
-    },
+  /**
+   * In order to show key locations, such as comments, out of the bounds of
+   * the selected context, treat them as separate chunks within the model so
+   * that the content (and context surrounding it) renders correctly.
+   *
+   * @param {!Array<!Object>} chunks DiffContents as returned from server.
+   * @return {!Array<!Object>} Finer grained DiffContents.
+   */
+  _splitCommonChunksWithKeyLocations(chunks) {
+    const result = [];
+    let leftLineNum = 1;
+    let rightLineNum = 1;
 
+    for (const chunk of chunks) {
+      // If it isn't a common chunk, append it as-is and update line numbers.
+      if (!chunk.ab && !chunk.common) {
+        if (chunk.a) {
+          leftLineNum += chunk.a.length;
+        }
+        if (chunk.b) {
+          rightLineNum += chunk.b.length;
+        }
+        result.push(chunk);
+        continue;
+      }
 
-    /**
-     * Split chunks into smaller chunks of the same kind.
-     *
-     * This is done to prevent doing too much work on the main thread in one
-     * uninterrupted rendering step, which would make the browser unresponsive.
-     *
-     * Note that in the case of unmodified chunks, we only split chunks if the
-     * context is set to file (because otherwise they are split up further down
-     * the processing into the visible and hidden context), and only split it
-     * into 2 chunks, one max sized one and the rest (for reasons that are
-     * unclear to me).
-     *
-     * @param {!Array<!Gerrit.DiffChunk>} chunks Chunks as returned from the server
-     * @return {!Array<!Gerrit.DiffChunk>} Finer grained chunks.
-     */
-    _splitLargeChunks(chunks) {
-      const newChunks = [];
+      if (chunk.common && chunk.a.length != chunk.b.length) {
+        throw new Error(
+            'DiffContent with common=true must always have equal length');
+      }
+      const numLines = this._commonChunkLength(chunk);
+      const chunkEnds = this._findChunkEndsAtKeyLocations(
+          numLines, leftLineNum, rightLineNum);
+      leftLineNum += numLines;
+      rightLineNum += numLines;
 
-      for (const chunk of chunks) {
-        if (!chunk.ab) {
-          for (const subChunk of this._breakdownChunk(chunk)) {
-            newChunks.push(subChunk);
-          }
+      if (chunk.ab) {
+        result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
+            .map(({lines, keyLocation}) =>
+              Object.assign({}, chunk, {ab: lines, keyLocation})));
+      } else if (chunk.common) {
+        const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
+        const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
+        result.push(...aChunks.map(({lines, keyLocation}, i) =>
+          Object.assign(
+              {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
+   *   new chunk ends, including whether it's a key location.
+   */
+  _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
+    const result = [];
+    let lastChunkEnd = 0;
+    for (let i=0; i<numLines; i++) {
+      // If this line should not be collapsed.
+      if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
+          this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
+        // If any lines have been accumulated into the chunk leading up to
+        // this non-collapse line, then add them as a chunk and start a new
+        // one.
+        if (i > lastChunkEnd) {
+          result.push({offset: i, keyLocation: false});
+          lastChunkEnd = i;
+        }
+
+        // Add the non-collapse line as its own chunk.
+        result.push({offset: i + 1, keyLocation: true});
+      }
+    }
+
+    if (numLines > lastChunkEnd) {
+      result.push({offset: numLines, keyLocation: false});
+    }
+
+    return result;
+  }
+
+  _splitAtChunkEnds(lines, chunkEnds) {
+    const result = [];
+    let lastChunkEndOffset = 0;
+    for (const {offset, keyLocation} of chunkEnds) {
+      result.push(
+          {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
+      lastChunkEndOffset = offset;
+    }
+    return result;
+  }
+
+  /**
+   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+   * for rendering.
+   *
+   * @param {!Array<string>} rows
+   * @param {!Array<!Gerrit.IntralineInfo>} intralineInfos
+   * @return {!Array<!Object>} (GrDiffLine.Highlight)
+   */
+  _convertIntralineInfos(rows, intralineInfos) {
+    let rowIndex = 0;
+    let idx = 0;
+    const normalized = [];
+    for (const [skipLength, markLength] of intralineInfos) {
+      let line = rows[rowIndex] + '\n';
+      let j = 0;
+      while (j < skipLength) {
+        if (idx === line.length) {
+          idx = 0;
+          line = rows[++rowIndex] + '\n';
           continue;
         }
-
-        // If the context is set to "whole file", then break down the shared
-        // chunks so they can be rendered incrementally. Note: this is not
-        // enabled for any other context preference because manipulating the
-        // chunks in this way violates assumptions by the context grouper logic.
-        if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
-          // Split large shared chunks in two, where the first is the maximum
-          // group size.
-          newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
-          newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
-        } else {
-          newChunks.push(chunk);
-        }
+        idx++;
+        j++;
       }
-      return newChunks;
-    },
+      let lineHighlight = {
+        contentIndex: rowIndex,
+        startIndex: idx,
+      };
 
-    /**
-     * In order to show key locations, such as comments, out of the bounds of
-     * the selected context, treat them as separate chunks within the model so
-     * that the content (and context surrounding it) renders correctly.
-     *
-     * @param {!Array<!Object>} chunks DiffContents as returned from server.
-     * @return {!Array<!Object>} Finer grained DiffContents.
-     */
-    _splitCommonChunksWithKeyLocations(chunks) {
-      const result = [];
-      let leftLineNum = 1;
-      let rightLineNum = 1;
-
-      for (const chunk of chunks) {
-        // If it isn't a common chunk, append it as-is and update line numbers.
-        if (!chunk.ab && !chunk.common) {
-          if (chunk.a) {
-            leftLineNum += chunk.a.length;
-          }
-          if (chunk.b) {
-            rightLineNum += chunk.b.length;
-          }
-          result.push(chunk);
+      j = 0;
+      while (line && j < markLength) {
+        if (idx === line.length) {
+          idx = 0;
+          line = rows[++rowIndex] + '\n';
+          normalized.push(lineHighlight);
+          lineHighlight = {
+            contentIndex: rowIndex,
+            startIndex: idx,
+          };
           continue;
         }
-
-        if (chunk.common && chunk.a.length != chunk.b.length) {
-          throw new Error(
-              'DiffContent with common=true must always have equal length');
-        }
-        const numLines = this._commonChunkLength(chunk);
-        const chunkEnds = this._findChunkEndsAtKeyLocations(
-            numLines, leftLineNum, rightLineNum);
-        leftLineNum += numLines;
-        rightLineNum += numLines;
-
-        if (chunk.ab) {
-          result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
-              .map(({lines, keyLocation}) =>
-                Object.assign({}, chunk, {ab: lines, keyLocation})));
-        } else if (chunk.common) {
-          const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
-          const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
-          result.push(...aChunks.map(({lines, keyLocation}, i) =>
-            Object.assign(
-                {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
-        }
+        idx++;
+        j++;
       }
+      lineHighlight.endIndex = idx;
+      normalized.push(lineHighlight);
+    }
+    return normalized;
+  }
 
-      return result;
-    },
+  /**
+   * If a group is an addition or a removal, break it down into smaller groups
+   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
+   * or a delta it is returned as the single element of the result array.
+   *
+   * @param {!Gerrit.DiffChunk} chunk A raw chunk from a diff response.
+   * @return {!Array<!Array<!Object>>}
+   */
+  _breakdownChunk(chunk) {
+    let key = null;
+    if (chunk.a && !chunk.b) {
+      key = 'a';
+    } else if (chunk.b && !chunk.a) {
+      key = 'b';
+    } else if (chunk.ab) {
+      key = 'ab';
+    }
 
-    /**
-     * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
-     *   new chunk ends, including whether it's a key location.
-     */
-    _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
-      const result = [];
-      let lastChunkEnd = 0;
-      for (let i=0; i<numLines; i++) {
-        // If this line should not be collapsed.
-        if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
-            this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
-          // If any lines have been accumulated into the chunk leading up to
-          // this non-collapse line, then add them as a chunk and start a new
-          // one.
-          if (i > lastChunkEnd) {
-            result.push({offset: i, keyLocation: false});
-            lastChunkEnd = i;
+    if (!key) { return [chunk]; }
+
+    return this._breakdown(chunk[key], MAX_GROUP_SIZE)
+        .map(subChunkLines => {
+          const subChunk = {};
+          subChunk[key] = subChunkLines;
+          if (chunk.due_to_rebase) {
+            subChunk.due_to_rebase = true;
           }
+          return subChunk;
+        });
+  }
 
-          // Add the non-collapse line as its own chunk.
-          result.push({offset: i + 1, keyLocation: true});
-        }
-      }
+  /**
+   * Given an array and a size, return an array of arrays where no inner array
+   * is larger than that size, preserving the original order.
+   *
+   * @param {!Array<T>} array
+   * @param {number} size
+   * @return {!Array<!Array<T>>}
+   * @template T
+   */
+  _breakdown(array, size) {
+    if (!array.length) { return []; }
+    if (array.length < size) { return [array]; }
 
-      if (numLines > lastChunkEnd) {
-        result.push({offset: numLines, keyLocation: false});
-      }
+    const head = array.slice(0, array.length - size);
+    const tail = array.slice(array.length - size);
 
-      return result;
-    },
+    return this._breakdown(head, size).concat([tail]);
+  }
+}
 
-    _splitAtChunkEnds(lines, chunkEnds) {
-      const result = [];
-      let lastChunkEndOffset = 0;
-      for (const {offset, keyLocation} of chunkEnds) {
-        result.push(
-            {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
-        lastChunkEndOffset = offset;
-      }
-      return result;
-    },
-
-    /**
-     * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
-     * for rendering.
-     *
-     * @param {!Array<string>} rows
-     * @param {!Array<!Gerrit.IntralineInfo>} intralineInfos
-     * @return {!Array<!Object>} (GrDiffLine.Highlight)
-     */
-    _convertIntralineInfos(rows, intralineInfos) {
-      let rowIndex = 0;
-      let idx = 0;
-      const normalized = [];
-      for (const [skipLength, markLength] of intralineInfos) {
-        let line = rows[rowIndex] + '\n';
-        let j = 0;
-        while (j < skipLength) {
-          if (idx === line.length) {
-            idx = 0;
-            line = rows[++rowIndex] + '\n';
-            continue;
-          }
-          idx++;
-          j++;
-        }
-        let lineHighlight = {
-          contentIndex: rowIndex,
-          startIndex: idx,
-        };
-
-        j = 0;
-        while (line && j < markLength) {
-          if (idx === line.length) {
-            idx = 0;
-            line = rows[++rowIndex] + '\n';
-            normalized.push(lineHighlight);
-            lineHighlight = {
-              contentIndex: rowIndex,
-              startIndex: idx,
-            };
-            continue;
-          }
-          idx++;
-          j++;
-        }
-        lineHighlight.endIndex = idx;
-        normalized.push(lineHighlight);
-      }
-      return normalized;
-    },
-
-    /**
-     * If a group is an addition or a removal, break it down into smaller groups
-     * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
-     * or a delta it is returned as the single element of the result array.
-     *
-     * @param {!Gerrit.DiffChunk} chunk A raw chunk from a diff response.
-     * @return {!Array<!Array<!Object>>}
-     */
-    _breakdownChunk(chunk) {
-      let key = null;
-      if (chunk.a && !chunk.b) {
-        key = 'a';
-      } else if (chunk.b && !chunk.a) {
-        key = 'b';
-      } else if (chunk.ab) {
-        key = 'ab';
-      }
-
-      if (!key) { return [chunk]; }
-
-      return this._breakdown(chunk[key], MAX_GROUP_SIZE)
-          .map(subChunkLines => {
-            const subChunk = {};
-            subChunk[key] = subChunkLines;
-            if (chunk.due_to_rebase) {
-              subChunk.due_to_rebase = true;
-            }
-            return subChunk;
-          });
-    },
-
-    /**
-     * Given an array and a size, return an array of arrays where no inner array
-     * is larger than that size, preserving the original order.
-     *
-     * @param {!Array<T>} array
-     * @param {number} size
-     * @return {!Array<!Array<T>>}
-     * @template T
-     */
-    _breakdown(array, size) {
-      if (!array.length) { return []; }
-      if (array.length < size) { return [array]; }
-
-      const head = array.slice(0, array.length - size);
-      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..50bfe107 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-processor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,901 +31,906 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-processor tests', () => {
-    const WHOLE_FILE = -1;
-    const loremIpsum =
-        'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
-        'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
-        'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
-        'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
-        'fugit assum per.';
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-processor.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
 
-    let element;
-    let sandbox;
+suite('gr-diff-processor tests', () => {
+  const WHOLE_FILE = -1;
+  const loremIpsum =
+      'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+      'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+      'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+      'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+      'fugit assum per.';
 
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('not logged in', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+
+      element.context = 4;
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('not logged in', () => {
-      setup(() => {
-        element = fixture('basic');
-
-        element.context = 4;
-      });
-
-      test('process loaded content', () => {
-        const content = [
-          {
-            ab: [
-              '<!DOCTYPE html>',
-              '<meta charset="utf-8">',
-            ],
-          },
-          {
-            a: [
-              '  Welcome ',
-              '  to the wooorld of tomorrow!',
-            ],
-            b: [
-              '  Hello, world!',
-            ],
-          },
-          {
-            ab: [
-              'Leela: This is the only place the ship can’t hear us, so ',
-              'everyone pretend to shower.',
-              'Fry: Same as every day. Got it.',
-            ],
-          },
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          assert.equal(groups.length, 4);
-
-          let group = groups[0];
-          assert.equal(group.type, GrDiffGroup.Type.BOTH);
-          assert.equal(group.lines.length, 1);
-          assert.equal(group.lines[0].text, '');
-          assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
-          assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
-
-          group = groups[1];
-          assert.equal(group.type, GrDiffGroup.Type.BOTH);
-          assert.equal(group.lines.length, 2);
-          assert.equal(group.lines.length, 2);
-
-          function beforeNumberFn(l) { return l.beforeNumber; }
-          function afterNumberFn(l) { return l.afterNumber; }
-          function textFn(l) { return l.text; }
-
-          assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
-          assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
-          assert.deepEqual(group.lines.map(textFn), [
+    test('process loaded content', () => {
+      const content = [
+        {
+          ab: [
             '<!DOCTYPE html>',
             '<meta charset="utf-8">',
-          ]);
-
-          group = groups[2];
-          assert.equal(group.type, GrDiffGroup.Type.DELTA);
-          assert.equal(group.lines.length, 3);
-          assert.equal(group.adds.length, 1);
-          assert.equal(group.removes.length, 2);
-          assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
-          assert.deepEqual(group.adds.map(afterNumberFn), [3]);
-          assert.deepEqual(group.removes.map(textFn), [
+          ],
+        },
+        {
+          a: [
             '  Welcome ',
             '  to the wooorld of tomorrow!',
-          ]);
-          assert.deepEqual(group.adds.map(textFn), [
+          ],
+          b: [
             '  Hello, world!',
-          ]);
-
-          group = groups[3];
-          assert.equal(group.type, GrDiffGroup.Type.BOTH);
-          assert.equal(group.lines.length, 3);
-          assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
-          assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
-          assert.deepEqual(group.lines.map(textFn), [
+          ],
+        },
+        {
+          ab: [
             'Leela: This is the only place the ship can’t hear us, so ',
             'everyone pretend to shower.',
             'Fry: Same as every day. Got it.',
-          ]);
-        });
-      });
+          ],
+        },
+      ];
 
-      test('first group is for file', () => {
+      return element.process(content).then(() => {
+        const groups = element.groups;
+
+        assert.equal(groups.length, 4);
+
+        let group = groups[0];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 1);
+        assert.equal(group.lines[0].text, '');
+        assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
+        assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
+
+        group = groups[1];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 2);
+        assert.equal(group.lines.length, 2);
+
+        function beforeNumberFn(l) { return l.beforeNumber; }
+        function afterNumberFn(l) { return l.afterNumber; }
+        function textFn(l) { return l.text; }
+
+        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(textFn), [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ]);
+
+        group = groups[2];
+        assert.equal(group.type, GrDiffGroup.Type.DELTA);
+        assert.equal(group.lines.length, 3);
+        assert.equal(group.adds.length, 1);
+        assert.equal(group.removes.length, 2);
+        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+        assert.deepEqual(group.removes.map(textFn), [
+          '  Welcome ',
+          '  to the wooorld of tomorrow!',
+        ]);
+        assert.deepEqual(group.adds.map(textFn), [
+          '  Hello, world!',
+        ]);
+
+        group = groups[3];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 3);
+        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+        assert.deepEqual(group.lines.map(textFn), [
+          'Leela: This is the only place the ship can’t hear us, so ',
+          'everyone pretend to shower.',
+          'Fry: Same as every day. Got it.',
+        ]);
+      });
+    });
+
+    test('first group is for file', () => {
+      const content = [
+        {b: ['foo']},
+      ];
+
+      return element.process(content).then(() => {
+        const groups = element.groups;
+
+        assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+        assert.equal(groups[0].lines.length, 1);
+        assert.equal(groups[0].lines[0].text, '');
+        assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+        assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+      });
+    });
+
+    suite('context groups', () => {
+      test('at the beginning, larger than context', () => {
+        element.context = 10;
         const content = [
-          {b: ['foo']},
+          {ab: new Array(100)
+              .fill('all work and no play make jack a dull boy')},
+          {a: ['all work and no play make andybons a dull boy']},
         ];
 
         return element.process(content).then(() => {
           const groups = element.groups;
 
-          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[0].lines.length, 1);
-          assert.equal(groups[0].lines[0].text, '');
-          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
-          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
+          for (const l of groups[1].lines[0].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
         });
       });
 
-      suite('context groups', () => {
-        test('at the beginning, larger than context', () => {
-          element.context = 10;
-          const content = [
-            {ab: new Array(100)
-                .fill('all work and no play make jack a dull boy')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-
-            assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
-            assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
-            for (const l of groups[1].lines[0].contextGroups[0].lines) {
-              assert.equal(l.text, 'all work and no play make jack a dull boy');
-            }
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 10);
-            for (const l of groups[2].lines) {
-              assert.equal(l.text, 'all work and no play make jack a dull boy');
-            }
-          });
-        });
-
-        test('at the beginning, smaller than context', () => {
-          element.context = 10;
-          const content = [
-            {ab: new Array(5)
-                .fill('all work and no play make jack a dull boy')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-
-            assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[1].lines.length, 5);
-            for (const l of groups[1].lines) {
-              assert.equal(l.text, 'all work and no play make jack a dull boy');
-            }
-          });
-        });
-
-        test('at the end, larger than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(100)
-                .fill('all work and no play make jill a dull girl')},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 10);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
-            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
-            for (const l of groups[3].lines[0].contextGroups[0].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('at the end, smaller than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(5)
-                .fill('all work and no play make jill a dull girl')},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 5);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('for interleaved ab and common: true chunks', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(3)
-                .fill('all work and no play make jill a dull girl')},
-            {
-              a: new Array(3).fill(
-                  'all work and no play make jill a dull girl'),
-              b: new Array(3).fill(
-                  '  all work and no play make jill a dull girl'),
-              common: true,
-            },
-            {ab: new Array(3)
-                .fill('all work and no play make jill a dull girl')},
-            {
-              a: new Array(3).fill(
-                  'all work and no play make jill a dull girl'),
-              b: new Array(3).fill(
-                  '  all work and no play make jill a dull girl'),
-              common: true,
-            },
-            {ab: new Array(3)
-                .fill('all work and no play make jill a dull girl')},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            // The first three interleaved chunks are completely shown because
-            // they are part of the context (3 * 3 <= 10)
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 3);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
-            assert.equal(groups[3].lines.length, 6);
-            assert.equal(groups[3].adds.length, 3);
-            assert.equal(groups[3].removes.length, 3);
-            for (const l of groups[3].removes) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-            for (const l of groups[3].adds) {
-              assert.equal(
-                  l.text, '  all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[4].lines.length, 3);
-            for (const l of groups[4].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            // The next chunk is partially shown, so it results in two groups
-
-            assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-            assert.equal(groups[5].lines.length, 2);
-            assert.equal(groups[5].adds.length, 1);
-            assert.equal(groups[5].removes.length, 1);
-            for (const l of groups[5].removes) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-            for (const l of groups[5].adds) {
-              assert.equal(
-                  l.text, '  all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.equal(groups[6].lines[0].contextGroups.length, 2);
-
-            assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
-            assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
-            assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
-            for (const l of groups[6].lines[0].contextGroups[0].removes) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-            for (const l of groups[6].lines[0].contextGroups[0].adds) {
-              assert.equal(
-                  l.text, '  all work and no play make jill a dull girl');
-            }
-
-            // The final chunk is completely hidden
-            assert.equal(
-                groups[6].lines[0].contextGroups[1].type,
-                GrDiffGroup.Type.BOTH);
-            assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
-            for (const l of groups[6].lines[0].contextGroups[1].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('in the middle, larger than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(100)
-                .fill('all work and no play make jill a dull girl')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 10);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-            assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
-            assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
-            for (const l of groups[3].lines[0].contextGroups[0].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-
-            assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[4].lines.length, 10);
-            for (const l of groups[4].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-
-        test('in the middle, smaller than context', () => {
-          element.context = 10;
-          const content = [
-            {a: ['all work and no play make andybons a dull boy']},
-            {ab: new Array(5)
-                .fill('all work and no play make jill a dull girl')},
-            {a: ['all work and no play make andybons a dull boy']},
-          ];
-
-          return element.process(content).then(() => {
-            const groups = element.groups;
-
-            // group[0] is the file group
-            // group[1] is the "a" group
-
-            assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-            assert.equal(groups[2].lines.length, 5);
-            for (const l of groups[2].lines) {
-              assert.equal(
-                  l.text, 'all work and no play make jill a dull girl');
-            }
-          });
-        });
-      });
-
-      test('break up common diff chunks', () => {
-        element.keyLocations = {
-          left: {1: true},
-          right: {10: true},
-        };
-
+      test('at the beginning, smaller than context', () => {
+        element.context = 10;
         const content = [
-          {
-            ab: [
-              'Copyright (C) 2015 The Android Open Source Project',
-              '',
-              'Licensed under the Apache License, Version 2.0 (the "License");',
-              'you may not use this file except in compliance with the ' +
-                  'License.',
-              'You may obtain a copy of the License at',
-              '',
-              'http://www.apache.org/licenses/LICENSE-2.0',
-              '',
-              'Unless required by applicable law or agreed to in writing, ',
-              'software distributed under the License is distributed on an ',
-              '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
-              'either express or implied. See the License for the specific ',
-              'language governing permissions and limitations under the ' +
-                  'License.',
-            ],
-          },
+          {ab: new Array(5)
+              .fill('all work and no play make jack a dull boy')},
+          {a: ['all work and no play make andybons a dull boy']},
         ];
-        const result =
-            element._splitCommonChunksWithKeyLocations(content);
-        assert.deepEqual(result, [
-          {
-            ab: ['Copyright (C) 2015 The Android Open Source Project'],
-            keyLocation: true,
-          },
-          {
-            ab: [
-              '',
-              'Licensed under the Apache License, Version 2.0 (the "License");',
-              'you may not use this file except in compliance with the ' +
-                  'License.',
-              'You may obtain a copy of the License at',
-              '',
-              'http://www.apache.org/licenses/LICENSE-2.0',
-              '',
-              'Unless required by applicable law or agreed to in writing, ',
-            ],
-            keyLocation: false,
-          },
-          {
-            ab: [
-              'software distributed under the License is distributed on an '],
-            keyLocation: true,
-          },
-          {
-            ab: [
-              '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
-              'either express or implied. See the License for the specific ',
-              'language governing permissions and limitations under the ' +
-                  'License.',
-            ],
-            keyLocation: false,
-          },
-        ]);
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[1].lines.length, 5);
+          for (const l of groups[1].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
       });
 
-      test('breaks down shared chunks w/ whole-file', () => {
-        const size = 120 * 2 + 5;
-        const content = [{
-          ab: _.times(size, () => { return `${Math.random()}`; }),
-        }];
-        element.context = -1;
-        const result = element._splitLargeChunks(content);
-        assert.equal(result.length, 2);
-        assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
-        assert.deepEqual(result[1].ab, content[0].ab.slice(120));
+      test('at the end, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(100)
+              .fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
+          for (const l of groups[3].lines[0].contextGroups[0].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
       });
 
-      test('does not break-down common chunks w/ context', () => {
-        const content = [{
-          ab: _.times(75, () => { return `${Math.random()}`; }),
-        }];
-        element.context = 4;
-        const result =
-            element._splitCommonChunksWithKeyLocations(content);
-        assert.equal(result.length, 1);
-        assert.deepEqual(result[0].ab, content[0].ab);
-        assert.isFalse(result[0].keyLocation);
+      test('at the end, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5)
+              .fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
       });
 
-      test('intraline normalization', () => {
-        // The content and highlights are in the format returned by the Gerrit
-        // REST API.
-        let content = [
-          '      <section class="summary">',
-          '        <gr-linked-text content="' +
-              '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
-          '      </section>',
-        ];
-        let highlights = [
-          [31, 34], [42, 26],
+      test('for interleaved ab and common: true chunks', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill(
+                'all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+                '  all work and no play make jill a dull girl'),
+            common: true,
+          },
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill(
+                'all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+                '  all work and no play make jill a dull girl'),
+            common: true,
+          },
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
         ];
 
-        let results = element._convertIntralineInfos(content,
-            highlights);
-        assert.deepEqual(results, [
-          {
-            contentIndex: 0,
-            startIndex: 31,
-          },
-          {
-            contentIndex: 1,
-            startIndex: 0,
-            endIndex: 33,
-          },
-          {
-            contentIndex: 1,
-            startIndex: 75,
-          },
-          {
-            contentIndex: 2,
-            startIndex: 0,
-            endIndex: 6,
-          },
-        ]);
+        return element.process(content).then(() => {
+          const groups = element.groups;
 
-        content = [
-          '        this._path = value.path;',
-          '',
-          '        // When navigating away from the page, there is a ' +
-            'possibility that the',
-          '        // patch number is no longer a part of the URL ' +
-            '(say when navigating to',
-          '        // the top-level change info view) and therefore ' +
-            'undefined in `params`.',
-          '        if (!this._patchRange.patchNum) {',
-        ];
-        highlights = [
-          [14, 17],
-          [11, 70],
-          [12, 67],
-          [12, 67],
-          [14, 29],
-        ];
-        results = element._convertIntralineInfos(content, highlights);
-        assert.deepEqual(results, [
-          {
-            contentIndex: 0,
-            startIndex: 14,
-            endIndex: 31,
-          },
-          {
-            contentIndex: 2,
-            startIndex: 8,
-            endIndex: 78,
-          },
-          {
-            contentIndex: 3,
-            startIndex: 11,
-            endIndex: 78,
-          },
-          {
-            contentIndex: 4,
-            startIndex: 11,
-            endIndex: 78,
-          },
-          {
-            contentIndex: 5,
-            startIndex: 12,
-            endIndex: 41,
-          },
-        ]);
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          // The first three interleaved chunks are completely shown because
+          // they are part of the context (3 * 3 <= 10)
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 3);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[3].lines.length, 6);
+          assert.equal(groups[3].adds.length, 3);
+          assert.equal(groups[3].removes.length, 3);
+          for (const l of groups[3].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[3].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 3);
+          for (const l of groups[4].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          // The next chunk is partially shown, so it results in two groups
+
+          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[5].lines.length, 2);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].removes.length, 1);
+          for (const l of groups[5].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[5].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.equal(groups[6].lines[0].contextGroups.length, 2);
+
+          assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
+          assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
+          assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
+          for (const l of groups[6].lines[0].contextGroups[0].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[6].lines[0].contextGroups[0].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          // The final chunk is completely hidden
+          assert.equal(
+              groups[6].lines[0].contextGroups[1].type,
+              GrDiffGroup.Type.BOTH);
+          assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
+          for (const l of groups[6].lines[0].contextGroups[1].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
       });
 
-      test('scrolling pauses rendering', () => {
-        const contentRow = {
+      test('in the middle, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(100)
+              .fill('all work and no play make jill a dull girl')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
+          for (const l of groups[3].lines[0].contextGroups[0].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 10);
+          for (const l of groups[4].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5)
+              .fill('all work and no play make jill a dull girl')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+    });
+
+    test('break up common diff chunks', () => {
+      element.keyLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+
+      const content = [
+        {
           ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
+            'Copyright (C) 2015 The Android Open Source Project',
+            '',
+            'Licensed under the Apache License, Version 2.0 (the "License");',
+            'you may not use this file except in compliance with the ' +
+                'License.',
+            'You may obtain a copy of the License at',
+            '',
+            'http://www.apache.org/licenses/LICENSE-2.0',
+            '',
+            'Unless required by applicable law or agreed to in writing, ',
+            'software distributed under the License is distributed on an ',
+            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+            'either express or implied. See the License for the specific ',
+            'language governing permissions and limitations under the ' +
+                'License.',
           ],
-        };
-        const content = _.times(200, _.constant(contentRow));
-        sandbox.stub(element, 'async');
-        element._isScrolling = true;
-        element.process(content);
-        // Just the files group - no more processing during scrolling.
-        assert.equal(element.groups.length, 1);
-
-        element._isScrolling = false;
-        element.process(content);
-        // More groups have been processed. How many does not matter here.
-        assert.isAtLeast(element.groups.length, 2);
-      });
-
-      test('image diffs', () => {
-        const contentRow = {
+        },
+      ];
+      const result =
+          element._splitCommonChunksWithKeyLocations(content);
+      assert.deepEqual(result, [
+        {
+          ab: ['Copyright (C) 2015 The Android Open Source Project'],
+          keyLocation: true,
+        },
+        {
           ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
+            '',
+            'Licensed under the Apache License, Version 2.0 (the "License");',
+            'you may not use this file except in compliance with the ' +
+                'License.',
+            'You may obtain a copy of the License at',
+            '',
+            'http://www.apache.org/licenses/LICENSE-2.0',
+            '',
+            'Unless required by applicable law or agreed to in writing, ',
           ],
-        };
-        const content = _.times(200, _.constant(contentRow));
-        sandbox.stub(element, 'async');
-        element.process(content, true);
-        assert.equal(element.groups.length, 1);
+          keyLocation: false,
+        },
+        {
+          ab: [
+            'software distributed under the License is distributed on an '],
+          keyLocation: true,
+        },
+        {
+          ab: [
+            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+            'either express or implied. See the License for the specific ',
+            'language governing permissions and limitations under the ' +
+                'License.',
+          ],
+          keyLocation: false,
+        },
+      ]);
+    });
 
-        // Image diffs don't process content, just the 'FILE' line.
-        assert.equal(element.groups[0].lines.length, 1);
+    test('breaks down shared chunks w/ whole-file', () => {
+      const size = 120 * 2 + 5;
+      const content = [{
+        ab: _.times(size, () => `${Math.random()}`),
+      }];
+      element.context = -1;
+      const result = element._splitLargeChunks(content);
+      assert.equal(result.length, 2);
+      assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
+      assert.deepEqual(result[1].ab, content[0].ab.slice(120));
+    });
+
+    test('does not break-down common chunks w/ context', () => {
+      const content = [{
+        ab: _.times(75, () => `${Math.random()}`),
+      }];
+      element.context = 4;
+      const result =
+          element._splitCommonChunksWithKeyLocations(content);
+      assert.equal(result.length, 1);
+      assert.deepEqual(result[0].ab, content[0].ab);
+      assert.isFalse(result[0].keyLocation);
+    });
+
+    test('intraline normalization', () => {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      let content = [
+        '      <section class="summary">',
+        '        <gr-linked-text content="' +
+            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '      </section>',
+      ];
+      let highlights = [
+        [31, 34], [42, 26],
+      ];
+
+      let results = element._convertIntralineInfos(content,
+          highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 75,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 6,
+        },
+      ]);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a ' +
+          'possibility that the',
+        '        // patch number is no longer a part of the URL ' +
+          '(say when navigating to',
+        '        // the top-level change info view) and therefore ' +
+          'undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element._convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        },
+      ]);
+    });
+
+    test('scrolling pauses rendering', () => {
+      const contentRow = {
+        ab: [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sandbox.stub(element, 'async');
+      element._isScrolling = true;
+      element.process(content);
+      // Just the files group - no more processing during scrolling.
+      assert.equal(element.groups.length, 1);
+
+      element._isScrolling = false;
+      element.process(content);
+      // More groups have been processed. How many does not matter here.
+      assert.isAtLeast(element.groups.length, 2);
+    });
+
+    test('image diffs', () => {
+      const contentRow = {
+        ab: [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sandbox.stub(element, 'async');
+      element.process(content, true);
+      assert.equal(element.groups.length, 1);
+
+      // Image diffs don't process content, just the 'FILE' line.
+      assert.equal(element.groups[0].lines.length, 1);
+    });
+
+    suite('_processNext', () => {
+      let rows;
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
       });
 
-      suite('_processNext', () => {
-        let rows;
+      test('WHOLE_FILE', () => {
+        element.context = WHOLE_FILE;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
+        assert.equal(result.groups[0].lines.length, rows.length);
+
+        // Line numbers are set correctly.
+        assert.equal(
+            result.groups[0].lines[0].beforeNumber,
+            state.lineNums.left + 1);
+        assert.equal(
+            result.groups[0].lines[0].afterNumber,
+            state.lineNums.right + 1);
+
+        assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
+            state.lineNums.left + rows.length);
+        assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
+            state.lineNums.right + rows.length);
+      });
+
+      test('with context', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * element.context;
+
+        assert.equal(result.groups.length, 3, 'Results in three groups');
+
+        // The first and last are uncollapsed context, whereas the middle has
+        // a single context-control line.
+        assert.equal(result.groups[0].lines.length, element.context);
+        assert.equal(result.groups[1].lines.length, 1);
+        assert.equal(result.groups[2].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+            expectedCollapseSize);
+      });
+
+      test('first', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [
+          {ab: rows},
+          {a: ['foo']},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+        const expectedCollapseSize = rows.length - element.context;
+
+        assert.equal(result.groups.length, 2, 'Results in two groups');
+
+        // Only the first group is collapsed.
+        assert.equal(result.groups[0].lines.length, 1);
+        assert.equal(result.groups[1].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+            expectedCollapseSize);
+      });
+
+      test('few-rows', () => {
+        // Only ten rows.
+        rows = rows.slice(0, 10);
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [
+          {ab: rows},
+          {a: ['foo']},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      test('no single line collapse', () => {
+        rows = rows.slice(0, 7);
+        element.context = 3;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      suite('with key location', () => {
+        let state;
+        let chunks;
 
         setup(() => {
-          rows = loremIpsum.split(' ');
-        });
-
-        test('WHOLE_FILE', () => {
-          element.context = WHOLE_FILE;
-          const state = {
+          state = {
             lineNums: {left: 10, right: 100},
-            chunkIndex: 1,
           };
-          const chunks = [
-            {a: ['foo']},
-            {ab: rows},
-            {a: ['bar']},
-          ];
-          const result = element._processNext(state, chunks);
-
-          // Results in one, uncollapsed group with all rows.
-          assert.equal(result.groups.length, 1);
-          assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
-          assert.equal(result.groups[0].lines.length, rows.length);
-
-          // Line numbers are set correctly.
-          assert.equal(
-              result.groups[0].lines[0].beforeNumber,
-              state.lineNums.left + 1);
-          assert.equal(
-              result.groups[0].lines[0].afterNumber,
-              state.lineNums.right + 1);
-
-          assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
-              state.lineNums.left + rows.length);
-          assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
-              state.lineNums.right + rows.length);
-        });
-
-        test('with context', () => {
           element.context = 10;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 1,
-          };
-          const chunks = [
-            {a: ['foo']},
+          chunks = [
             {ab: rows},
-            {a: ['bar']},
+            {ab: ['foo'], keyLocation: true},
+            {ab: rows},
           ];
-          const result = element._processNext(state, chunks);
-          const expectedCollapseSize = rows.length - 2 * element.context;
-
-          assert.equal(result.groups.length, 3, 'Results in three groups');
-
-          // The first and last are uncollapsed context, whereas the middle has
-          // a single context-control line.
-          assert.equal(result.groups[0].lines.length, element.context);
-          assert.equal(result.groups[1].lines.length, 1);
-          assert.equal(result.groups[2].lines.length, element.context);
-
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
-              expectedCollapseSize);
         });
 
-        test('first', () => {
-          element.context = 10;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 0,
-          };
-          const chunks = [
-            {ab: rows},
-            {a: ['foo']},
-            {a: ['bar']},
-          ];
+        test('context before', () => {
+          state.chunkIndex = 0;
           const result = element._processNext(state, chunks);
-          const expectedCollapseSize = rows.length - element.context;
 
-          assert.equal(result.groups.length, 2, 'Results in two groups');
-
-          // Only the first group is collapsed.
+          // The first chunk is split into two groups:
+          // 1) A context-control, hiding everything but the context before
+          //    the key location.
+          // 2) The context before the key location.
+          // The key location is not processed in this call to _processNext
+          assert.equal(result.groups.length, 2);
           assert.equal(result.groups[0].lines.length, 1);
-          assert.equal(result.groups[1].lines.length, element.context);
-
           // The collapsed group has the hidden lines as its context group.
           assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
-              expectedCollapseSize);
+              rows.length - element.context);
+          assert.equal(result.groups[1].lines.length, element.context);
         });
 
-        test('few-rows', () => {
-          // Only ten rows.
-          rows = rows.slice(0, 10);
-          element.context = 10;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 0,
-          };
-          const chunks = [
-            {ab: rows},
-            {a: ['foo']},
-            {a: ['bar']},
-          ];
+        test('key location itself', () => {
+          state.chunkIndex = 1;
           const result = element._processNext(state, chunks);
 
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.groups.length, 1, 'Results in one group');
-          assert.equal(result.groups[0].lines.length, rows.length);
+          // The second chunk results in a single group, that is just the
+          // line with the key location
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.lineDelta.left, 1);
+          assert.equal(result.lineDelta.right, 1);
         });
 
-        test('no single line collapse', () => {
-          rows = rows.slice(0, 7);
-          element.context = 3;
-          const state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 1,
-          };
-          const chunks = [
-            {a: ['foo']},
-            {ab: rows},
-            {a: ['bar']},
-          ];
+        test('context after', () => {
+          state.chunkIndex = 2;
           const result = element._processNext(state, chunks);
 
-          // Results in one uncollapsed group with all rows.
-          assert.equal(result.groups.length, 1, 'Results in one group');
-          assert.equal(result.groups[0].lines.length, rows.length);
-        });
-
-        suite('with key location', () => {
-          let state;
-          let chunks;
-
-          setup(() => {
-            state = {
-              lineNums: {left: 10, right: 100},
-            };
-            element.context = 10;
-            chunks = [
-              {ab: rows},
-              {ab: ['foo'], keyLocation: true},
-              {ab: rows},
-            ];
-          });
-
-          test('context before', () => {
-            state.chunkIndex = 0;
-            const result = element._processNext(state, chunks);
-
-            // The first chunk is split into two groups:
-            // 1) A context-control, hiding everything but the context before
-            //    the key location.
-            // 2) The context before the key location.
-            // The key location is not processed in this call to _processNext
-            assert.equal(result.groups.length, 2);
-            assert.equal(result.groups[0].lines.length, 1);
-            // The collapsed group has the hidden lines as its context group.
-            assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
-                rows.length - element.context);
-            assert.equal(result.groups[1].lines.length, element.context);
-          });
-
-          test('key location itself', () => {
-            state.chunkIndex = 1;
-            const result = element._processNext(state, chunks);
-
-            // The second chunk results in a single group, that is just the
-            // line with the key location
-            assert.equal(result.groups.length, 1);
-            assert.equal(result.groups[0].lines.length, 1);
-            assert.equal(result.lineDelta.left, 1);
-            assert.equal(result.lineDelta.right, 1);
-          });
-
-          test('context after', () => {
-            state.chunkIndex = 2;
-            const result = element._processNext(state, chunks);
-
-            // The last chunk is split into two groups:
-            // 1) The context after the key location.
-            // 1) A context-control, hiding everything but the context after the
-            //    key location.
-            assert.equal(result.groups.length, 2);
-            assert.equal(result.groups[0].lines.length, element.context);
-            assert.equal(result.groups[1].lines.length, 1);
-            // The collapsed group has the hidden lines as its context group.
-            assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
-                rows.length - element.context);
-          });
-        });
-      });
-
-      suite('gr-diff-processor helpers', () => {
-        let rows;
-
-        setup(() => {
-          rows = loremIpsum.split(' ');
-        });
-
-        test('_linesFromRows', () => {
-          const startLineNum = 10;
-          let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
-              startLineNum + 1);
-
-          assert.equal(result.length, rows.length);
-          assert.equal(result[0].type, GrDiffLine.Type.ADD);
-          assert.equal(result[0].afterNumber, startLineNum + 1);
-          assert.notOk(result[0].beforeNumber);
-          assert.equal(result[result.length - 1].afterNumber,
-              startLineNum + rows.length);
-          assert.notOk(result[result.length - 1].beforeNumber);
-
-          result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
-              startLineNum + 1);
-
-          assert.equal(result.length, rows.length);
-          assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
-          assert.equal(result[0].beforeNumber, startLineNum + 1);
-          assert.notOk(result[0].afterNumber);
-          assert.equal(result[result.length - 1].beforeNumber,
-              startLineNum + rows.length);
-          assert.notOk(result[result.length - 1].afterNumber);
-        });
-      });
-
-      suite('_breakdown*', () => {
-        test('_breakdownChunk breaks down additions', () => {
-          sandbox.spy(element, '_breakdown');
-          const chunk = {b: ['blah', 'blah', 'blah']};
-          const result = element._breakdownChunk(chunk);
-          assert.deepEqual(result, [chunk]);
-          assert.isTrue(element._breakdown.called);
-        });
-
-        test('_breakdownChunk keeps due_to_rebase for broken down additions',
-            () => {
-              sandbox.spy(element, '_breakdown');
-              const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-              const result = element._breakdownChunk(chunk);
-              for (const subResult of result) {
-                assert.isTrue(subResult.due_to_rebase);
-              }
-            });
-
-        test('_breakdown common case', () => {
-          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-              .split(' ');
-          const size = 3;
-
-          const result = element._breakdown(array, size);
-
-          for (const subResult of result) {
-            assert.isAtMost(subResult.length, size);
-          }
-          const flattened = result
-              .reduce((a, b) => { return a.concat(b); }, []);
-          assert.deepEqual(flattened, array);
-        });
-
-        test('_breakdown smaller than size', () => {
-          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-              .split(' ');
-          const size = 10;
-          const expected = [array];
-
-          const result = element._breakdown(array, size);
-
-          assert.deepEqual(result, expected);
-        });
-
-        test('_breakdown empty', () => {
-          const array = [];
-          const size = 10;
-          const expected = [];
-
-          const result = element._breakdown(array, size);
-
-          assert.deepEqual(result, expected);
+          // The last chunk is split into two groups:
+          // 1) The context after the key location.
+          // 1) A context-control, hiding everything but the context after the
+          //    key location.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].lines.length, element.context);
+          assert.equal(result.groups[1].lines.length, 1);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+              rows.length - element.context);
         });
       });
     });
 
-    test('detaching cancels', () => {
-      element = fixture('basic');
-      sandbox.stub(element, 'cancel');
-      element.detached();
-      assert(element.cancel.called);
+    suite('gr-diff-processor helpers', () => {
+      let rows;
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('_linesFromRows', () => {
+        const startLineNum = 10;
+        let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLine.Type.ADD);
+        assert.equal(result[0].afterNumber, startLineNum + 1);
+        assert.notOk(result[0].beforeNumber);
+        assert.equal(result[result.length - 1].afterNumber,
+            startLineNum + rows.length);
+        assert.notOk(result[result.length - 1].beforeNumber);
+
+        result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
+        assert.equal(result[0].beforeNumber, startLineNum + 1);
+        assert.notOk(result[0].afterNumber);
+        assert.equal(result[result.length - 1].beforeNumber,
+            startLineNum + rows.length);
+        assert.notOk(result[result.length - 1].afterNumber);
+      });
+    });
+
+    suite('_breakdown*', () => {
+      test('_breakdownChunk breaks down additions', () => {
+        sandbox.spy(element, '_breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah']};
+        const result = element._breakdownChunk(chunk);
+        assert.deepEqual(result, [chunk]);
+        assert.isTrue(element._breakdown.called);
+      });
+
+      test('_breakdownChunk keeps due_to_rebase for broken down additions',
+          () => {
+            sandbox.spy(element, '_breakdown');
+            const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+            const result = element._breakdownChunk(chunk);
+            for (const subResult of result) {
+              assert.isTrue(subResult.due_to_rebase);
+            }
+          });
+
+      test('_breakdown common case', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+            .split(' ');
+        const size = 3;
+
+        const result = element._breakdown(array, size);
+
+        for (const subResult of result) {
+          assert.isAtMost(subResult.length, size);
+        }
+        const flattened = result
+            .reduce((a, b) => a.concat(b), []);
+        assert.deepEqual(flattened, array);
+      });
+
+      test('_breakdown smaller than size', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+            .split(' ');
+        const size = 10;
+        const expected = [array];
+
+        const result = element._breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+
+      test('_breakdown empty', () => {
+        const array = [];
+        const size = 10;
+        const expected = [];
+
+        const result = element._breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
     });
   });
+
+  test('detaching cancels', () => {
+    element = fixture('basic');
+    sandbox.stub(element, 'cancel');
+    element.detached();
+    assert(element.cancel.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
deleted file mode 100644
index cfa46a0..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
+++ /dev/null
@@ -1,30 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/dom-util-behavior/dom-util-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-diff-selection">
-  <template>
-    <div class="contentWrapper">
-      <slot></slot>
-    </div>
-  </template>
-  <script src="../gr-diff-highlight/gr-range-normalizer.js"></script>
-  <script src="gr-diff-selection.js"></script>
-</dom-module>
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..08967e8 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
@@ -14,26 +14,47 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * Possible CSS classes indicating the state of selection. Dynamically added/
-   * removed based on where the user clicks within the diff.
-   */
-  const SelectionClass = {
-    COMMENT: 'selected-comment',
-    LEFT: 'selected-left',
-    RIGHT: 'selected-right',
-    BLAME: 'selected-blame',
-  };
+import '../../../styles/shared-styles.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-selection_html.js';
+import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
+import {GrRangeNormalizer} from '../gr-diff-highlight/gr-range-normalizer.js';
+import {util} from '../../../scripts/util.js';
 
-  const getNewCache = () => { return {left: null, right: null}; };
+/**
+ * Possible CSS classes indicating the state of selection. Dynamically added/
+ * removed based on where the user clicks within the diff.
+ */
+const SelectionClass = {
+  COMMENT: 'selected-comment',
+  LEFT: 'selected-left',
+  RIGHT: 'selected-right',
+  BLAME: 'selected-blame',
+};
 
-  Polymer({
-    is: 'gr-diff-selection',
+const getNewCache = () => { return {left: null, right: null}; };
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrDiffSelection extends mixinBehaviors( [
+  DomUtilBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-selection'; }
+
+  static get properties() {
+    return {
       diff: Object,
       /** @type {?Object} */
       _cachedDiffBuilder: Object,
@@ -41,304 +62,315 @@
         type: Object,
         value: getNewCache(),
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_diffChanged(diff)',
-    ],
+    ];
+  }
 
-    listeners: {
-      copy: '_handleCopy',
-      down: '_handleDown',
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('copy',
+        e => this._handleCopy(e));
+    addListener(this, 'down',
+        e => this._handleDown(e));
+  }
 
-    behaviors: [
-      Gerrit.DomUtilBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this.classList.add(SelectionClass.RIGHT);
+  }
 
-    attached() {
-      this.classList.add(SelectionClass.RIGHT);
-    },
+  get diffBuilder() {
+    if (!this._cachedDiffBuilder) {
+      this._cachedDiffBuilder =
+          dom(this).querySelector('gr-diff-builder');
+    }
+    return this._cachedDiffBuilder;
+  }
 
-    get diffBuilder() {
-      if (!this._cachedDiffBuilder) {
-        this._cachedDiffBuilder =
-            Polymer.dom(this).querySelector('gr-diff-builder');
-      }
-      return this._cachedDiffBuilder;
-    },
+  _diffChanged() {
+    this._linesCache = getNewCache();
+  }
 
-    _diffChanged() {
-      this._linesCache = getNewCache();
-    },
-
-    _handleDownOnRangeComment(node) {
-      if (node &&
-          node.nodeName &&
-          node.nodeName.toLowerCase() === 'gr-comment-thread') {
-        this._setClasses([
-          SelectionClass.COMMENT,
-          node.commentSide === 'left' ?
-            SelectionClass.LEFT :
-            SelectionClass.RIGHT,
-        ]);
-        return true;
-      }
-      return false;
-    },
-
-    _handleDown(e) {
-      // Handle the down event on comment thread in Polymer 2
-      const handled = this._handleDownOnRangeComment(e.target);
-      if (handled) return;
-
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
-      if (!lineEl && !blameSelected) { return; }
-
-      const targetClasses = [];
-
-      if (blameSelected) {
-        targetClasses.push(SelectionClass.BLAME);
-      } else {
-        const commentSelected =
-            this._elementDescendedFromClass(e.target, 'gr-comment');
-        const side = this.diffBuilder.getSideByLineEl(lineEl);
-
-        targetClasses.push(side === 'left' ?
+  _handleDownOnRangeComment(node) {
+    if (node &&
+        node.nodeName &&
+        node.nodeName.toLowerCase() === 'gr-comment-thread') {
+      this._setClasses([
+        SelectionClass.COMMENT,
+        node.commentSide === 'left' ?
           SelectionClass.LEFT :
-          SelectionClass.RIGHT);
+          SelectionClass.RIGHT,
+      ]);
+      return true;
+    }
+    return false;
+  }
 
-        if (commentSelected) {
-          targetClasses.push(SelectionClass.COMMENT);
-        }
-      }
+  _handleDown(e) {
+    // Handle the down event on comment thread in Polymer 2
+    const handled = this._handleDownOnRangeComment(e.target);
+    if (handled) return;
 
-      this._setClasses(targetClasses);
-    },
+    const lineEl = this.diffBuilder.getLineElByChild(e.target);
+    const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
+    if (!lineEl && !blameSelected) { return; }
 
-    /**
-     * Set the provided list of classes on the element, to the exclusion of all
-     * other SelectionClass values.
-     *
-     * @param {!Array<!string>} targetClasses
-     */
-    _setClasses(targetClasses) {
-      // Remove any selection classes that do not belong.
-      for (const key in SelectionClass) {
-        if (SelectionClass.hasOwnProperty(key)) {
-          const className = SelectionClass[key];
-          if (!targetClasses.includes(className)) {
-            this.classList.remove(SelectionClass[key]);
-          }
-        }
-      }
-      // Add new selection classes iff they are not already present.
-      for (const _class of targetClasses) {
-        if (!this.classList.contains(_class)) {
-          this.classList.add(_class);
-        }
-      }
-    },
+    const targetClasses = [];
 
-    _getCopyEventTarget(e) {
-      return Polymer.dom(e).rootTarget;
-    },
-
-    /**
-     * Utility function to determine whether an element is a descendant of
-     * another element with the particular className.
-     *
-     * @param {!Element} element
-     * @param {!string} className
-     * @return {boolean}
-     */
-    _elementDescendedFromClass(element, className) {
-      return this.descendedFromClass(element, className,
-          this.diffBuilder.diffElement);
-    },
-
-    _handleCopy(e) {
-      let commentSelected = false;
-      const target = this._getCopyEventTarget(e);
-      if (target.type === 'textarea') { return; }
-      if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
-      if (this.classList.contains(SelectionClass.COMMENT)) {
-        commentSelected = true;
-      }
-      const lineEl = this.diffBuilder.getLineElByChild(target);
-      if (!lineEl) {
-        return;
-      }
+    if (blameSelected) {
+      targetClasses.push(SelectionClass.BLAME);
+    } else {
+      const commentSelected =
+          this._elementDescendedFromClass(e.target, 'gr-comment');
       const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const text = this._getSelectedText(side, commentSelected);
-      if (text) {
-        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();
-    },
+      targetClasses.push(side === 'left' ?
+        SelectionClass.LEFT :
+        SelectionClass.RIGHT);
 
-    /**
-     * Get the text of the current selection. If commentSelected is
-     * true, it returns only the text of comments within the selection.
-     * Otherwise it returns the text of the selected diff region.
-     *
-     * @param {!string} side The side that is selected.
-     * @param {boolean} commentSelected Whether or not a comment is selected.
-     * @return {string} The selected text.
-     */
-    _getSelectedText(side, commentSelected) {
-      const sel = this._getSelection();
-      if (sel.rangeCount != 1) {
-        return ''; // No multi-select support yet.
-      }
       if (commentSelected) {
-        return this._getCommentLines(sel, side);
+        targetClasses.push(SelectionClass.COMMENT);
       }
-      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      const startLineEl =
-          this.diffBuilder.getLineElByChild(range.startContainer);
-      const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
-      // Happens when triple click in side-by-side mode with other side empty.
-      const endsAtOtherEmptySide = !endLineEl &&
-          range.endOffset === 0 &&
-          range.endContainer.nodeName === 'TD' &&
-          (range.endContainer.classList.contains('left') ||
-           range.endContainer.classList.contains('right'));
-      const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
-      let endLineNum;
-      if (endsAtOtherEmptySide) {
-        endLineNum = startLineNum + 1;
-      } else if (endLineEl) {
-        endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
-      }
+    }
 
-      return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
-          range.endOffset, side);
-    },
+    this._setClasses(targetClasses);
+  }
 
-    /**
-     * Query the diff object for the selected lines.
-     *
-     * @param {number} startLineNum
-     * @param {number} startOffset
-     * @param {number|undefined} endLineNum Use undefined to get the range
-     *     extending to the end of the file.
-     * @param {number} endOffset
-     * @param {!string} side The side that is currently selected.
-     * @return {string} The selected diff text.
-     */
-    _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
-      const lines =
-          this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
-      if (lines.length) {
-        lines[lines.length - 1] = lines[lines.length - 1]
-            .substring(0, endOffset);
-        lines[0] = lines[0].substring(startOffset);
-      }
-      return lines.join('\n');
-    },
-
-    /**
-     * Query the diff object for the lines from a particular side.
-     *
-     * @param {!string} side The side that is currently selected.
-     * @return {!Array<string>} An array of strings indexed by line number.
-     */
-    _getDiffLines(side) {
-      if (this._linesCache[side]) {
-        return this._linesCache[side];
-      }
-      let lines = [];
-      const key = side === 'left' ? 'a' : 'b';
-      for (const chunk of this.diff.content) {
-        if (chunk.ab) {
-          lines = lines.concat(chunk.ab);
-        } else if (chunk[key]) {
-          lines = lines.concat(chunk[key]);
+  /**
+   * Set the provided list of classes on the element, to the exclusion of all
+   * other SelectionClass values.
+   *
+   * @param {!Array<!string>} targetClasses
+   */
+  _setClasses(targetClasses) {
+    // Remove any selection classes that do not belong.
+    for (const key in SelectionClass) {
+      if (SelectionClass.hasOwnProperty(key)) {
+        const className = SelectionClass[key];
+        if (!targetClasses.includes(className)) {
+          this.classList.remove(SelectionClass[key]);
         }
       }
-      this._linesCache[side] = lines;
-      return lines;
-    },
+    }
+    // Add new selection classes iff they are not already present.
+    for (const _class of targetClasses) {
+      if (!this.classList.contains(_class)) {
+        this.classList.add(_class);
+      }
+    }
+  }
 
-    /**
-     * Query the diffElement for comments and check whether they lie inside the
-     * selection range.
-     *
-     * @param {!Selection} sel The selection of the window.
-     * @param {!string} side The side that is currently selected.
-     * @return {string} The selected comment text.
-     */
-    _getCommentLines(sel, side) {
-      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      const content = [];
-      // Query the diffElement for comments.
-      const messages = this.diffBuilder.diffElement.querySelectorAll(
-          `.side-by-side [data-side="${side
-          }"] .message *, .unified .message *`);
+  _getCopyEventTarget(e) {
+    return dom(e).rootTarget;
+  }
 
-      for (let i = 0; i < messages.length; i++) {
-        const el = messages[i];
-        // Check if the comment element exists inside the selection.
-        if (sel.containsNode(el, true)) {
-          // Padded elements require newlines for accurate spacing.
-          if (el.parentElement.id === 'container' ||
-              el.parentElement.nodeName === 'BLOCKQUOTE') {
-            if (content.length && content[content.length - 1] !== '') {
-              content.push('');
-            }
-          }
+  /**
+   * Utility function to determine whether an element is a descendant of
+   * another element with the particular className.
+   *
+   * @param {!Element} element
+   * @param {!string} className
+   * @return {boolean}
+   */
+  _elementDescendedFromClass(element, className) {
+    return this.descendedFromClass(element, className,
+        this.diffBuilder.diffElement);
+  }
 
-          if (el.id === 'output' &&
-              !this._elementDescendedFromClass(el, 'collapsed')) {
-            content.push(this._getTextContentForRange(el, sel, range));
+  _handleCopy(e) {
+    let commentSelected = false;
+    const target = this._getCopyEventTarget(e);
+    if (target.type === 'textarea') { return; }
+    if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
+    if (this.classList.contains(SelectionClass.COMMENT)) {
+      commentSelected = true;
+    }
+    const lineEl = this.diffBuilder.getLineElByChild(target);
+    if (!lineEl) {
+      return;
+    }
+    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    const text = this._getSelectedText(side, commentSelected);
+    if (text) {
+      e.clipboardData.setData('Text', text);
+      e.preventDefault();
+    }
+  }
+
+  _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
+   * true, it returns only the text of comments within the selection.
+   * Otherwise it returns the text of the selected diff region.
+   *
+   * @param {!string} side The side that is selected.
+   * @param {boolean} commentSelected Whether or not a comment is selected.
+   * @return {string} The selected text.
+   */
+  _getSelectedText(side, commentSelected) {
+    const sel = this._getSelection();
+    if (sel.rangeCount != 1) {
+      return ''; // No multi-select support yet.
+    }
+    if (commentSelected) {
+      return this._getCommentLines(sel, side);
+    }
+    const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+    const startLineEl =
+        this.diffBuilder.getLineElByChild(range.startContainer);
+    const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide = !endLineEl &&
+        range.endOffset === 0 &&
+        range.endContainer.nodeName === 'TD' &&
+        (range.endContainer.classList.contains('left') ||
+         range.endContainer.classList.contains('right'));
+    const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
+    let endLineNum;
+    if (endsAtOtherEmptySide) {
+      endLineNum = startLineNum + 1;
+    } else if (endLineEl) {
+      endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+    }
+
+    return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
+        range.endOffset, side);
+  }
+
+  /**
+   * Query the diff object for the selected lines.
+   *
+   * @param {number} startLineNum
+   * @param {number} startOffset
+   * @param {number|undefined} endLineNum Use undefined to get the range
+   *     extending to the end of the file.
+   * @param {number} endOffset
+   * @param {!string} side The side that is currently selected.
+   * @return {string} The selected diff text.
+   */
+  _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
+    const lines =
+        this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    if (lines.length) {
+      lines[lines.length - 1] = lines[lines.length - 1]
+          .substring(0, endOffset);
+      lines[0] = lines[0].substring(startOffset);
+    }
+    return lines.join('\n');
+  }
+
+  /**
+   * Query the diff object for the lines from a particular side.
+   *
+   * @param {!string} side The side that is currently selected.
+   * @return {!Array<string>} An array of strings indexed by line number.
+   */
+  _getDiffLines(side) {
+    if (this._linesCache[side]) {
+      return this._linesCache[side];
+    }
+    let lines = [];
+    const key = side === 'left' ? 'a' : 'b';
+    for (const chunk of this.diff.content) {
+      if (chunk.ab) {
+        lines = lines.concat(chunk.ab);
+      } else if (chunk[key]) {
+        lines = lines.concat(chunk[key]);
+      }
+    }
+    this._linesCache[side] = lines;
+    return lines;
+  }
+
+  /**
+   * Query the diffElement for comments and check whether they lie inside the
+   * selection range.
+   *
+   * @param {!Selection} sel The selection of the window.
+   * @param {!string} side The side that is currently selected.
+   * @return {string} The selected comment text.
+   */
+  _getCommentLines(sel, side) {
+    const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+    const content = [];
+    // Query the diffElement for comments.
+    const messages = this.diffBuilder.diffElement.querySelectorAll(
+        `.side-by-side [data-side="${side
+        }"] .message *, .unified .message *`);
+
+    for (let i = 0; i < messages.length; i++) {
+      const el = messages[i];
+      // Check if the comment element exists inside the selection.
+      if (sel.containsNode(el, true)) {
+        // Padded elements require newlines for accurate spacing.
+        if (el.parentElement.id === 'container' ||
+            el.parentElement.nodeName === 'BLOCKQUOTE') {
+          if (content.length && content[content.length - 1] !== '') {
+            content.push('');
           }
         }
-      }
 
-      return content.join('\n');
-    },
-
-    /**
-     * Given a DOM node, a selection, and a selection range, recursively get all
-     * of the text content within that selection.
-     * Using a domNode that isn't in the selection returns an empty string.
-     *
-     * @param {!Node} domNode The root DOM node.
-     * @param {!Selection} sel The selection.
-     * @param {!Range} range The normalized selection range.
-     * @return {string} The text within the selection.
-     */
-    _getTextContentForRange(domNode, sel, range) {
-      if (!sel.containsNode(domNode, true)) { return ''; }
-
-      let text = '';
-      if (domNode instanceof Text) {
-        text = domNode.textContent;
-        if (domNode === range.endContainer) {
-          text = text.substring(0, range.endOffset);
-        }
-        if (domNode === range.startContainer) {
-          text = text.substring(range.startOffset);
-        }
-      } else {
-        for (const childNode of domNode.childNodes) {
-          text += this._getTextContentForRange(childNode, sel, range);
+        if (el.id === 'output' &&
+            !this._elementDescendedFromClass(el, 'collapsed')) {
+          content.push(this._getTextContentForRange(el, sel, range));
         }
       }
-      return text;
-    },
-  });
-})();
+    }
+
+    return content.join('\n');
+  }
+
+  /**
+   * Given a DOM node, a selection, and a selection range, recursively get all
+   * of the text content within that selection.
+   * Using a domNode that isn't in the selection returns an empty string.
+   *
+   * @param {!Node} domNode The root DOM node.
+   * @param {!Selection} sel The selection.
+   * @param {!Range} range The normalized selection range.
+   * @return {string} The text within the selection.
+   */
+  _getTextContentForRange(domNode, sel, range) {
+    if (!sel.containsNode(domNode, true)) { return ''; }
+
+    let text = '';
+    if (domNode instanceof Text) {
+      text = domNode.textContent;
+      if (domNode === range.endContainer) {
+        text = text.substring(0, range.endOffset);
+      }
+      if (domNode === range.startContainer) {
+        text = text.substring(range.startOffset);
+      }
+    } else {
+      for (const childNode of domNode.childNodes) {
+        text += this._getTextContentForRange(childNode, sel, range);
+      }
+    }
+    return text;
+  }
+}
+
+customElements.define(GrDiffSelection.is, GrDiffSelection);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
new file mode 100644
index 0000000..620ef02
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <div class="contentWrapper">
+    <slot></slot>
+  </div>
+`;
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..1221b58 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-selection.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -106,298 +103,299 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-selection', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-selection.js';
+suite('gr-diff-selection', () => {
+  let element;
+  let sandbox;
 
-    const emulateCopyOn = function(target) {
-      const fakeEvent = {
-        target,
-        preventDefault: sandbox.stub(),
-        clipboardData: {
-          setData: sandbox.stub(),
+  const emulateCopyOn = function(target) {
+    const fakeEvent = {
+      target,
+      preventDefault: sandbox.stub(),
+      clipboardData: {
+        setData: sandbox.stub(),
+      },
+    };
+    element._getCopyEventTarget.returns(target);
+    element._handleCopy(fakeEvent);
+    return fakeEvent;
+  };
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(element, '_getCopyEventTarget');
+    element._cachedDiffBuilder = {
+      getLineElByChild: sandbox.stub().returns({}),
+      getSideByLineEl: sandbox.stub(),
+      diffElement: element.querySelector('#diffTable'),
+    };
+    element.diff = {
+      content: [
+        {
+          a: ['ba ba'],
+          b: ['some other text'],
         },
-      };
-      element._getCopyEventTarget.returns(target);
-      element._handleCopy(fakeEvent);
-      return fakeEvent;
+        {
+          a: ['zin'],
+          b: ['more more more'],
+        },
+        {
+          a: ['ga ga'],
+          b: ['some other text'],
+        },
+      ],
+    };
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('applies selected-left on left side click', () => {
+    element.classList.add('selected-right');
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-left'), 'adds selected-left');
+    assert.isFalse(
+        element.classList.contains('selected-right'),
+        'removes selected-right');
+  });
+
+  test('applies selected-right on right side click', () => {
+    element.classList.add('selected-left');
+    element._cachedDiffBuilder.getSideByLineEl.returns('right');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-right'), 'adds selected-right');
+    assert.isFalse(
+        element.classList.contains('selected-left'), 'removes selected-left');
+  });
+
+  test('applies selected-blame on blame click', () => {
+    element.classList.add('selected-left');
+    element.diffBuilder.getLineElByChild.returns(null);
+    sandbox.stub(element, '_elementDescendedFromClass',
+        (el, className) => className === 'blame');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-blame'), 'adds selected-right');
+    assert.isFalse(
+        element.classList.contains('selected-left'), 'removes selected-left');
+  });
+
+  test('ignores copy for non-content Element', () => {
+    sandbox.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('.not-diff-row'));
+    assert.isFalse(element._getSelectedText.called);
+  });
+
+  test('asks for text for left side Elements', () => {
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    sandbox.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    sandbox.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.isTrue(element._getSelectedText.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    sandbox.stub(element, '_getSelectedText');
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    element._getSelectedText.returns('test');
+    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    assert.isTrue(event.preventDefault.called);
+  });
+
+  test('inserts text into clipboard on copy', () => {
+    sandbox.stub(element, '_getSelectedText').returns('the text');
+    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    assert.deepEqual(
+        ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
+  });
+
+  test('_setClasses adds given SelectionClass values, removes others', () => {
+    element.classList.add('selected-right');
+    element._setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(element.classList.contains('selected-comment'));
+    assert.isTrue(element.classList.contains('selected-left'));
+    assert.isFalse(element.classList.contains('selected-right'));
+    assert.isFalse(element.classList.contains('selected-blame'));
+
+    element._setClasses(['selected-blame']);
+    assert.isFalse(element.classList.contains('selected-comment'));
+    assert.isFalse(element.classList.contains('selected-left'));
+    assert.isFalse(element.classList.contains('selected-right'));
+    assert.isTrue(element.classList.contains('selected-blame'));
+  });
+
+  test('_setClasses removes before it ads', () => {
+    element.classList.add('selected-right');
+    const addStub = sandbox.stub(element.classList, 'add');
+    const removeStub = sandbox.stub(element.classList, 'remove', () => {
+      assert.isFalse(addStub.called);
+    });
+    element._setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(addStub.called);
+    assert.isTrue(removeStub.called);
+  });
+
+  test('copies content correctly', () => {
+    // Fetch the line number.
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
     };
 
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(element.querySelector('div.contentText').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[4].firstChild, 2);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+  });
+
+  test('copies comments', () => {
+    element.classList.add('selected-left');
+    element.classList.add('selected-comment');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+        element.querySelector('.gr-formatted-text *').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
+    selection.addRange(range);
+    assert.equal('s is a comment\nThis is a differ',
+        element._getSelectedText('left', true));
+  });
+
+  test('respects astral chars in comments', () => {
+    element.classList.add('selected-left');
+    element.classList.add('selected-comment');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const nodes = element.querySelectorAll('.gr-formatted-text *');
+    range.setStart(nodes[2].childNodes[2], 13);
+    range.setEnd(nodes[2].childNodes[2], 23);
+    selection.addRange(range);
+    assert.equal('mment 💩 u',
+        element._getSelectedText('left', true));
+  });
+
+  test('defers to default behavior for textarea', () => {
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+    const selectedTextSpy = sandbox.spy(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('textarea'));
+    assert.isFalse(selectedTextSpy.called);
+  });
+
+  test('regression test for 4794', () => {
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
+    };
+
+    element.classList.add('selected-right');
+    element.classList.remove('selected-left');
+
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+        element.querySelectorAll('div.contentText')[1].firstChild, 4);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[1].firstChild, 10);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('right'), ' other');
+  });
+
+  test('copies to end of side (issue 7895)', () => {
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      // Return null for the end container.
+      if (child.textContent === 'ga ga') { return null; }
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
+    };
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(element.querySelector('div.contentText').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[4].firstChild, 2);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+  });
+
+  suite('_getTextContentForRange', () => {
+    let selection;
+    let range;
+    let nodes;
+
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(element, '_getCopyEventTarget');
-      element._cachedDiffBuilder = {
-        getLineElByChild: sandbox.stub().returns({}),
-        getSideByLineEl: sandbox.stub(),
-        diffElement: element.querySelector('#diffTable'),
-      };
-      element.diff = {
-        content: [
-          {
-            a: ['ba ba'],
-            b: ['some other text'],
-          },
-          {
-            a: ['zin'],
-            b: ['more more more'],
-          },
-          {
-            a: ['ga ga'],
-            b: ['some other text'],
-          },
-        ],
-      };
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('applies selected-left on left side click', () => {
-      element.classList.add('selected-right');
-      element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      MockInteractions.down(element);
-      assert.isTrue(
-          element.classList.contains('selected-left'), 'adds selected-left');
-      assert.isFalse(
-          element.classList.contains('selected-right'),
-          'removes selected-right');
-    });
-
-    test('applies selected-right on right side click', () => {
-      element.classList.add('selected-left');
-      element._cachedDiffBuilder.getSideByLineEl.returns('right');
-      MockInteractions.down(element);
-      assert.isTrue(
-          element.classList.contains('selected-right'), 'adds selected-right');
-      assert.isFalse(
-          element.classList.contains('selected-left'), 'removes selected-left');
-    });
-
-    test('applies selected-blame on blame click', () => {
-      element.classList.add('selected-left');
-      element.diffBuilder.getLineElByChild.returns(null);
-      sandbox.stub(element, '_elementDescendedFromClass',
-          (el, className) => className === 'blame');
-      MockInteractions.down(element);
-      assert.isTrue(
-          element.classList.contains('selected-blame'), 'adds selected-right');
-      assert.isFalse(
-          element.classList.contains('selected-left'), 'removes selected-left');
-    });
-
-    test('ignores copy for non-content Element', () => {
-      sandbox.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('.not-diff-row'));
-      assert.isFalse(element._getSelectedText.called);
-    });
-
-    test('asks for text for left side Elements', () => {
-      element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      sandbox.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('div.contentText'));
-      assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
-    });
-
-    test('reacts to copy for content Elements', () => {
-      sandbox.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('div.contentText'));
-      assert.isTrue(element._getSelectedText.called);
-    });
-
-    test('copy event is prevented for content Elements', () => {
-      sandbox.stub(element, '_getSelectedText');
-      element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      element._getSelectedText.returns('test');
-      const event = emulateCopyOn(element.querySelector('div.contentText'));
-      assert.isTrue(event.preventDefault.called);
-    });
-
-    test('inserts text into clipboard on copy', () => {
-      sandbox.stub(element, '_getSelectedText').returns('the text');
-      const event = emulateCopyOn(element.querySelector('div.contentText'));
-      assert.deepEqual(
-          ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
-    });
-
-    test('_setClasses adds given SelectionClass values, removes others', () => {
-      element.classList.add('selected-right');
-      element._setClasses(['selected-comment', 'selected-left']);
-      assert.isTrue(element.classList.contains('selected-comment'));
-      assert.isTrue(element.classList.contains('selected-left'));
-      assert.isFalse(element.classList.contains('selected-right'));
-      assert.isFalse(element.classList.contains('selected-blame'));
-
-      element._setClasses(['selected-blame']);
-      assert.isFalse(element.classList.contains('selected-comment'));
-      assert.isFalse(element.classList.contains('selected-left'));
-      assert.isFalse(element.classList.contains('selected-right'));
-      assert.isTrue(element.classList.contains('selected-blame'));
-    });
-
-    test('_setClasses removes before it ads', () => {
-      element.classList.add('selected-right');
-      const addStub = sandbox.stub(element.classList, 'add');
-      const removeStub = sandbox.stub(element.classList, 'remove', () => {
-        assert.isFalse(addStub.called);
-      });
-      element._setClasses(['selected-comment', 'selected-left']);
-      assert.isTrue(addStub.called);
-      assert.isTrue(removeStub.called);
-    });
-
-    test('copies content correctly', () => {
-      // Fetch the line number.
-      element._cachedDiffBuilder.getLineElByChild = function(child) {
-        while (!child.classList.contains('content') && child.parentElement) {
-          child = child.parentElement;
-        }
-        return child.previousElementSibling;
-      };
-
-      element.classList.add('selected-left');
-      element.classList.remove('selected-right');
-
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(element.querySelector('div.contentText').firstChild, 3);
-      range.setEnd(
-          element.querySelectorAll('div.contentText')[4].firstChild, 2);
-      selection.addRange(range);
-      assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-    });
-
-    test('copies comments', () => {
       element.classList.add('selected-left');
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
-      const selection = window.getSelection();
+      selection = window.getSelection();
       selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(
-          element.querySelector('.gr-formatted-text *').firstChild, 3);
-      range.setEnd(
-          element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
+      range = document.createRange();
+      nodes = element.querySelectorAll('.gr-formatted-text *');
+    });
+
+    test('multi level element contained in range', () => {
+      range.setStart(nodes[2].childNodes[0], 1);
+      range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
-      assert.equal('s is a comment\nThis is a differ',
-          element._getSelectedText('left', true));
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'his is a differ');
     });
 
-    test('respects astral chars in comments', () => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      const nodes = element.querySelectorAll('.gr-formatted-text *');
-      range.setStart(nodes[2].childNodes[2], 13);
-      range.setEnd(nodes[2].childNodes[2], 23);
+    test('multi level element as startContainer of range', () => {
+      range.setStart(nodes[2].childNodes[1], 0);
+      range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
-      assert.equal('mment 💩 u',
-          element._getSelectedText('left', true));
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'a differ');
     });
 
-    test('defers to default behavior for textarea', () => {
-      element.classList.add('selected-left');
-      element.classList.remove('selected-right');
-      const selectedTextSpy = sandbox.spy(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('textarea'));
-      assert.isFalse(selectedTextSpy.called);
-    });
-
-    test('regression test for 4794', () => {
-      element._cachedDiffBuilder.getLineElByChild = function(child) {
-        while (!child.classList.contains('content') && child.parentElement) {
-          child = child.parentElement;
-        }
-        return child.previousElementSibling;
-      };
-
-      element.classList.add('selected-right');
-      element.classList.remove('selected-left');
-
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(
-          element.querySelectorAll('div.contentText')[1].firstChild, 4);
-      range.setEnd(
-          element.querySelectorAll('div.contentText')[1].firstChild, 10);
+    test('startContainer === endContainer', () => {
+      range.setStart(nodes[0].firstChild, 2);
+      range.setEnd(nodes[0].firstChild, 12);
       selection.addRange(range);
-      assert.equal(element._getSelectedText('right'), ' other');
-    });
-
-    test('copies to end of side (issue 7895)', () => {
-      element._cachedDiffBuilder.getLineElByChild = function(child) {
-        // Return null for the end container.
-        if (child.textContent === 'ga ga') { return null; }
-        while (!child.classList.contains('content') && child.parentElement) {
-          child = child.parentElement;
-        }
-        return child.previousElementSibling;
-      };
-      element.classList.add('selected-left');
-      element.classList.remove('selected-right');
-      const selection = window.getSelection();
-      selection.removeAllRanges();
-      const range = document.createRange();
-      range.setStart(element.querySelector('div.contentText').firstChild, 3);
-      range.setEnd(
-          element.querySelectorAll('div.contentText')[4].firstChild, 2);
-      selection.addRange(range);
-      assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-    });
-
-    suite('_getTextContentForRange', () => {
-      let selection;
-      let range;
-      let nodes;
-
-      setup(() => {
-        element.classList.add('selected-left');
-        element.classList.add('selected-comment');
-        element.classList.remove('selected-right');
-        selection = window.getSelection();
-        selection.removeAllRanges();
-        range = document.createRange();
-        nodes = element.querySelectorAll('.gr-formatted-text *');
-      });
-
-      test('multi level element contained in range', () => {
-        range.setStart(nodes[2].childNodes[0], 1);
-        range.setEnd(nodes[2].childNodes[2], 7);
-        selection.addRange(range);
-        assert.equal(element._getTextContentForRange(element, selection, range),
-            '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);
-        selection.addRange(range);
-        assert.equal(element._getTextContentForRange(element, selection, range),
-            'a differ');
-      });
-
-      test('startContainer === endContainer', () => {
-        range.setStart(nodes[0].firstChild, 2);
-        range.setEnd(nodes[0].firstChild, 12);
-        selection.addRange(range);
-        assert.equal(element._getTextContentForRange(element, selection, range),
-            'is is a co');
-      });
-    });
-
-    test('cache is reset when diff changes', () => {
-      element._linesCache = {left: 'test', right: 'test'};
-      element.diff = {};
-      flushAsynchronousOperations();
-      assert.deepEqual(element._linesCache, {left: null, right: null});
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'is is a co');
     });
   });
+
+  test('cache is reset when diff changes', () => {
+    element._linesCache = {left: 'test', right: 'test'};
+    element.diff = {};
+    flushAsynchronousOperations();
+    assert.deepEqual(element._linesCache, {left: null, right: null});
+  });
+});
 </script>
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
deleted file mode 100644
index 17b8b4c..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ /dev/null
@@ -1,373 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-<link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<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-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">
-<link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
-
-<dom-module id="gr-diff-view">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--view-background-color);
-      }
-      .hidden {
-        display: none;
-      }
-      gr-patch-range-select {
-        display: block;
-      }
-      gr-diff {
-        border: none;
-        --diff-container-styles: {
-          border-bottom: 1px solid var(--border-color);
-        }
-      }
-      gr-fixed-panel {
-        background-color: var(--view-background-color);
-        border-bottom: 1px solid var(--border-color);
-        z-index: 1;
-      }
-      header,
-      .subHeader {
-        align-items: center;
-        display: flex;
-        justify-content: space-between;
-      }
-      header {
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .patchRangeLeft {
-        align-items: center;
-        display: flex;
-      }
-      .navLink:not([href]) {
-        color: var(--deemphasized-text-color);
-      }
-      .navLinks {
-        align-items: center;
-        display: flex;
-        white-space: nowrap;
-      }
-      .navLink {
-        padding: 0 var(--spacing-xs);
-      }
-      .reviewed {
-        display: inline-block;
-        margin: 0 var(--spacing-xs);
-        vertical-align: .15em;
-      }
-      .jumpToFileContainer {
-        display: inline-block;
-      }
-      .mobile {
-        display: none;
-      }
-      gr-button {
-        padding: var(--spacing-s) 0;
-        text-decoration: none;
-      }
-      .loading {
-        color: var(--deemphasized-text-color);
-        font-size: var(--font-size-h1);
-        height: 100%;
-        padding: var(--spacing-l);
-        text-align: center;
-      }
-      .subHeader {
-        flex-wrap: wrap;
-        padding: 0 var(--spacing-l) var(--spacing-s);
-      }
-      .prefsButton {
-        text-align: right;
-      }
-      .noOverflow {
-        display: block;
-        overflow: auto;
-      }
-      .editMode .hideOnEdit {
-        display: none;
-      }
-      .blameLoader,
-      .fileNum {
-        display: none;
-      }
-      .blameLoader.show,
-      .fileNum.show ,
-      .download,
-      .preferences,
-      .rightControls {
-        align-items: center;
-        display: flex;
-      }
-      .diffModeSelector,
-      .editButton {
-        align-items: center;
-        display: flex;
-      }
-      .diffModeSelector span,
-      .editButton span {
-        margin-right: var(--spacing-xs);
-      }
-      .diffModeSelector.hide,
-      .separator.hide {
-        display: none;
-      }
-      gr-dropdown-list {
-        --trigger-style: {
-          text-transform: none;
-        }
-      }
-      .editButtona a {
-        text-decoration: none;
-      }
-      @media screen and (max-width: 50em) {
-        header {
-          padding: var(--spacing-s) var(--spacing-l);
-        }
-        .dash {
-          display: none;
-        }
-        .desktop {
-          display: none;
-        }
-        .fileNav {
-          align-items: flex-start;
-          display: flex;
-          margin: 0 var(--spacing-xs);
-        }
-        .fullFileName {
-          display: block;
-          font-style: italic;
-          min-width: 50%;
-          padding: 0 var(--spacing-xxs);
-          text-align: center;
-          width: 100%;
-          word-wrap: break-word;
-        }
-        .reviewed {
-          vertical-align: -1px;
-        }
-        .mobileNavLink {
-          color: var(--primary-text-color);
-          font-size: var(--font-size-h2);
-          font-weight: var(--font-weight-bold);
-          text-decoration: none;
-        }
-        .mobileNavLink:not([href]) {
-          color: var(--deemphasized-text-color);
-        }
-        .jumpToFileContainer {
-          display: block;
-          width: 100%;
-        }
-        gr-dropdown-list {
-          width: 100%;
-          --gr-select-style: {
-            display: block;
-            width: 100%;
-          }
-          --native-select-style: {
-            width: 100%;
-          }
-        }
-      }
-    </style>
-    <gr-fixed-panel
-        class$="[[_computeContainerClass(_editMode)]]"
-        floating-disabled="[[_panelFloatingDisabled]]"
-        keep-on-scroll
-        ready-for-measure="[[!_loading]]">
-      <header>
-        <h3>
-          <a href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
-            [[_changeNum]]</a><span>:</span>
-          <span>[[_change.subject]]</span>
-          <span class="dash">—</span>
-          <input id="reviewed"
-              class="reviewed hideOnEdit"
-              type="checkbox"
-              on-change="_handleReviewedChange"
-              hidden$="[[!_loggedIn]]" hidden>
-          <div class="jumpToFileContainer">
-            <gr-dropdown-list
-                id="dropdown"
-                value="[[_path]]"
-                on-value-change="_handleFileChange"
-                items="[[_formattedFiles]]"
-                initial-count="75">
-           </gr-dropdown-list>
-          </div>
-        </h3>
-        <div class="navLinks desktop">
-          <span class$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]">
-            File [[_fileNum]] of [[_formattedFiles.length]]
-            <span class="separator"></span>
-          </span>
-          <a class="navLink"
-              href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
-            Prev</a>
-          <span class="separator"></span>
-          <a class="navLink"
-              href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
-            Up</a>
-          <span class="separator"></span>
-          <a class="navLink"
-              href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
-            Next</a>
-        </div>
-      </header>
-      <div class="subHeader">
-        <div class="patchRangeLeft">
-          <gr-patch-range-select
-              id="rangeSelect"
-              change-num="[[_changeNum]]"
-              change-comments="[[_changeComments]]"
-              patch-num="[[_patchRange.patchNum]]"
-              base-patch-num="[[_patchRange.basePatchNum]]"
-              files-weblinks="[[_filesWeblinks]]"
-              available-patches="[[_allPatchSets]]"
-              revisions="[[_change.revisions]]"
-              revision-info="[[_revisionInfo]]"
-              on-patch-range-change="_handlePatchChange">
-          </gr-patch-range-select>
-          <span class="download desktop">
-            <span class="separator"></span>
-            <gr-dropdown
-                link
-                down-arrow
-                items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
-                horizontal-align="left">
-              <span class="downloadTitle">
-                Download
-              </span>
-            </gr-dropdown>
-          </span>
-        </div>
-        <div class="rightControls">
-          <span class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff)]]">
-            <gr-button
-                link
-                disabled="[[_isBlameLoading]]"
-                on-click="_toggleBlame">[[_computeBlameToggleLabel(_isBlameLoaded, _isBlameLoading)]]</gr-button>
-            <span class="separator"></span>
-          </span>
-          <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
-            <span class="separator"></span>
-            <span class="editButton">
-              <gr-button
-                link
-                title="Edit current file"
-                on-click="_goToEditFile">edit</gr-button>
-            </span>
-          </template>
-          <span class="separator"></span>
-          <div class$="diffModeSelector [[_computeModeSelectHideClass(_isImageDiff)]]">
-            <span>Diff view:</span>
-            <gr-diff-mode-selector
-                id="modeSelect"
-                save-on-change="[[!_diffPrefsDisabled]]"
-                mode="{{changeViewState.diffMode}}"></gr-diff-mode-selector>
-          </div>
-          <span id="diffPrefsContainer"
-              hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]" hidden>
-            <span class="preferences desktop">
-              <gr-button
-                  link
-                  class="prefsButton"
-                  has-tooltip
-                  title="Diff preferences"
-                  on-click="_handlePrefsTap"><iron-icon icon="gr-icons:settings"></iron-icon></gr-button>
-            </span>
-          </span>
-          <gr-endpoint-decorator name="annotation-toggler">
-            <span hidden id="annotation-span">
-              <label for="annotation-checkbox" id="annotation-label"></label>
-              <iron-input type="checkbox" disabled>
-                <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
-              </iron-input>
-            </span>
-          </gr-endpoint-decorator>
-        </div>
-      </div>
-      <div class="fileNav mobile">
-        <a class="mobileNavLink"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
-          &lt;</a>
-        <div class="fullFileName mobile">[[computeDisplayPath(_path)]]
-        </div>
-        <a class="mobileNavLink"
-            href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
-          &gt;</a>
-      </div>
-    </gr-fixed-panel>
-    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <gr-diff-host
-        id="diffHost"
-        hidden
-        hidden$="[[_loading]]"
-        class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
-        is-image-diff="{{_isImageDiff}}"
-        files-weblinks="{{_filesWeblinks}}"
-        diff="{{_diff}}"
-        change-num="[[_changeNum]]"
-        commit-range="[[_commitRange]]"
-        patch-range="[[_patchRange]]"
-        path="[[_path]]"
-        prefs="[[_prefs]]"
-        project-name="[[_change.project]]"
-        view-mode="[[_diffMode]]"
-        is-blame-loaded="{{_isBlameLoaded}}"
-        on-comment-anchor-tap="_onLineSelected"
-        on-line-selected="_onLineSelected">
-    </gr-diff-host>
-    <gr-diff-preferences-dialog
-        id="diffPreferencesDialog"
-        diff-prefs="{{_prefs}}"
-        on-reload-diff-preference="_handleReloadingDiffPreference">
-    </gr-diff-preferences-dialog>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-    <gr-diff-cursor id="cursor"></gr-diff-cursor>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-diff-view.js"></script>
-</dom-module>
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 bd0486f..e434e65 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,44 +14,89 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-  const MSG_LOADING_BLAME = 'Loading blame...';
-  const MSG_LOADED_BLAME = 'Blame loaded';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
+import '../../shared/gr-fixed-panel/gr-fixed-panel.js';
+import '../../shared/gr-icons/gr-icons.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../../shared/revision-info/revision-info.js';
+import '../gr-comment-api/gr-comment-api.js';
+import '../gr-diff-cursor/gr-diff-cursor.js';
+import '../gr-apply-fix-dialog/gr-apply-fix-dialog.js';
+import '../gr-diff-host/gr-diff-host.js';
+import '../gr-diff-mode-selector/gr-diff-mode-selector.js';
+import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
+import '../gr-patch-range-select/gr-patch-range-select.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-view_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
 
-  const PARENT = 'PARENT';
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+const MSG_LOADING_BLAME = 'Loading blame...';
+const MSG_LOADED_BLAME = 'Blame loaded';
 
-  const DiffSides = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+const PARENT = 'PARENT';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const DiffSides = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
 
-  Polymer({
-    is: 'gr-diff-view',
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
+/**
+ * @appliesMixin PatchSetMixin
+ * @extends Polymer.Element
+ */
+class GrDiffView extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+  PatchSetBehavior,
+  PathListBehavior,
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired when user tries to navigate away while comments are pending save.
+   *
+   * @event show-alert
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
+     * URL params passed from the router.
      */
-
-    /**
-     * Fired when user tries to navigate away while comments are pending save.
-     *
-     * @event show-alert
-     */
-
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -101,8 +146,8 @@
       // element for selected a file to view.
       _formattedFiles: {
         type: Array,
-        computed: '_formatFilesForDropdown(_files, _patchRange.patchNum, ' +
-            '_changeComments)',
+        computed: '_formatFilesForDropdown(_files, ' +
+          '_patchRange.patchNum, _changeComments)',
       },
       // An sorted array of files, as returned by the rest API.
       _fileList: {
@@ -164,7 +209,7 @@
       },
       _panelFloatingDisabled: {
         type: Boolean,
-        value: () => { return window.PANEL_FLOATING_DISABLED; },
+        value: () => window.PANEL_FLOATING_DISABLED,
       },
       _editMode: {
         type: Boolean,
@@ -187,1004 +232,1081 @@
         type: Object,
         value: () => new Set(),
       },
-    },
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+      /**
+       * gr-diff-view has gr-fixed-panel on top. The panel can
+       * intersect a main element and partially hides a content of
+       * the main element. To correctly calculates visibility of an
+       * element, the cursor must know how much height occuped by a fixed
+       * panel.
+       * The scrollTopMargin defines margin occuped by fixed panel.
+       */
+      _scrollTopMargin: {
+        type: Number,
+        value: 0,
+      },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_getProjectConfig(_change.project)',
       '_getFiles(_changeNum, _patchRange.*, _changeComments)',
       '_setReviewedObserver(_loggedIn, params.*, _prefs)',
-    ],
+      '_recomputeComments(_files.changeFilesByPath,' +
+      '_path, _patchRange, _projectConfig)',
+    ];
+  }
 
-    keyBindings: {
+  get keyBindings() {
+    return {
       esc: '_handleEscKey',
-    },
+    };
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
-        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
-        [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
-        [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
-        [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
-            '_handleNextLineOrFileWithComments',
-        [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
-            '_handlePrevLineOrFileWithComments',
-        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
-        [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
-        [this.Shortcut.NEXT_FILE]: '_handleNextFile',
-        [this.Shortcut.PREV_FILE]: '_handlePrevFile',
-        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
-        [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
-        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
-        [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
-        [this.Shortcut.OPEN_REPLY_DIALOG]:
-            '_handleOpenReplyDialogOrToggleLeftPane',
-        [this.Shortcut.TOGGLE_LEFT_PANE]:
-            '_handleOpenReplyDialogOrToggleLeftPane',
-        [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
-        [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
-        [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-        [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
-        [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+      [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+      [this.Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+      [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
+          '_handleNextLineOrFileWithComments',
+      [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
+          '_handlePrevLineOrFileWithComments',
+      [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+      [this.Shortcut.NEXT_FILE]: '_handleNextFile',
+      [this.Shortcut.PREV_FILE]: '_handlePrevFile',
+      [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+      [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+      [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+      [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+      [this.Shortcut.OPEN_REPLY_DIALOG]:
+          '_handleOpenReplyDialogOrToggleLeftPane',
+      [this.Shortcut.TOGGLE_LEFT_PANE]:
+          '_handleOpenReplyDialogOrToggleLeftPane',
+      [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+      [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+      [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+      [this.Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
 
-        // Final two are actually handled by gr-comment-thread.
-        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-      };
-    },
+      // Final two are actually handled by gr-comment-thread.
+      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
 
-    attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
 
-      this.$.cursor.push('diffs', this.$.diffHost);
-    },
+    this.addEventListener('open-fix-preview',
+        this._onOpenFixPreview.bind(this));
+    this.$.cursor.push('diffs', this.$.diffHost);
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
+    const onRender = () => {
+      this.$.diffHost.removeEventListener('render', onRender);
+      this.$.cursor.reInitCursor();
+    };
+    this.$.diffHost.addEventListener('render', onRender);
+  }
 
-    _getProjectConfig(project) {
-      return this.$.restAPI.getProjectConfig(project).then(
-          config => {
-            this._projectConfig = config;
-          });
-    },
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
 
-    _getChangeDetail(changeNum) {
-      return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
-        this._change = change;
-        return change;
-      });
-    },
-
-    _getChangeEdit(changeNum) {
-      return this.$.restAPI.getChangeEdit(this._changeNum);
-    },
-
-    _getSortedFileList(files) {
-      return files.sortedFileList;
-    },
-
-    _getFiles(changeNum, patchRangeRecord, changeComments) {
-      // Polymer 2: check for undefined
-      if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
-          .some(arg => arg === undefined)) {
-        return Promise.resolve();
-      }
-
-      const patchRange = patchRangeRecord.base;
-      return this.$.restAPI.getChangeFiles(
-          changeNum, patchRange).then(changeFiles => {
-        if (!changeFiles) return;
-        const commentedPaths = changeComments.getPaths(patchRange);
-        const files = Object.assign({}, changeFiles);
-        Object.keys(commentedPaths).forEach(commentedPath => {
-          if (files.hasOwnProperty(commentedPath)) { return; }
-          files[commentedPath] = {status: 'U'};
+  _getProjectConfig(project) {
+    return this.$.restAPI.getProjectConfig(project).then(
+        config => {
+          this._projectConfig = config;
         });
-        this._files = {
-          sortedFileList: Object.keys(files).sort(this.specialFilePathCompare),
-          changeFilesByPath: files,
-        };
+  }
+
+  _getChangeDetail(changeNum) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      this._change = change;
+      return change;
+    });
+  }
+
+  _getChangeEdit(changeNum) {
+    return this.$.restAPI.getChangeEdit(this._changeNum);
+  }
+
+  _getSortedFileList(files) {
+    return files.sortedFileList;
+  }
+
+  _getFiles(changeNum, patchRangeRecord, changeComments) {
+    // Polymer 2: check for undefined
+    if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
+        .some(arg => arg === undefined)) {
+      return Promise.resolve();
+    }
+
+    const patchRange = patchRangeRecord.base;
+    return this.$.restAPI.getChangeFiles(
+        changeNum, patchRange).then(changeFiles => {
+      if (!changeFiles) return;
+      const commentedPaths = changeComments.getPaths(patchRange);
+      const files = Object.assign({}, changeFiles);
+      Object.keys(commentedPaths).forEach(commentedPath => {
+        if (files.hasOwnProperty(commentedPath)) { return; }
+        files[commentedPath] = {status: 'U'};
       });
-    },
+      this._files = {
+        sortedFileList: Object.keys(files).sort(this.specialFilePathCompare),
+        changeFilesByPath: files,
+      };
+    });
+  }
 
-    _getDiffPreferences() {
-      return this.$.restAPI.getDiffPreferences().then(prefs => {
-        this._prefs = prefs;
-      });
-    },
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this._prefs = prefs;
+    });
+  }
 
-    _getPreferences() {
-      return this.$.restAPI.getPreferences();
-    },
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
 
-    _getWindowWidth() {
-      return window.innerWidth;
-    },
+  _getWindowWidth() {
+    return window.innerWidth;
+  }
 
-    _handleReviewedChange(e) {
-      this._setReviewed(Polymer.dom(e).rootTarget.checked);
-    },
+  _handleReviewedChange(e) {
+    this._setReviewed(dom(e).rootTarget.checked);
+  }
 
-    _setReviewed(reviewed) {
-      if (this._editMode) { return; }
-      this.$.reviewed.checked = reviewed;
-      this._saveReviewedState(reviewed).catch(err => {
-        this.fire('show-alert', {message: ERR_REVIEW_STATUS});
-        throw err;
-      });
-    },
+  _setReviewed(reviewed) {
+    if (this._editMode) { return; }
+    this.$.reviewed.checked = reviewed;
+    this._saveReviewedState(reviewed).catch(err => {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_REVIEW_STATUS},
+        composed: true, bubbles: true,
+      }));
+      throw err;
+    });
+  }
 
-    _saveReviewedState(reviewed) {
-      return this.$.restAPI.saveFileReviewed(this._changeNum,
-          this._patchRange.patchNum, this._path, reviewed);
-    },
+  _saveReviewedState(reviewed) {
+    return this.$.restAPI.saveFileReviewed(this._changeNum,
+        this._patchRange.patchNum, this._path, reviewed);
+  }
 
-    _handleToggleFileReviewed(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+  _handleToggleFileReviewed(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-      e.preventDefault();
-      this._setReviewed(!this.$.reviewed.checked);
-    },
+    e.preventDefault();
+    this._setReviewed(!this.$.reviewed.checked);
+  }
 
-    _handleEscKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+  _handleEscKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
 
-      e.preventDefault();
-      this.$.diffHost.displayLine = false;
-    },
+    e.preventDefault();
+    this.$.diffHost.displayLine = false;
+  }
 
-    _handleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+  _handleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-      e.preventDefault();
-      this.$.cursor.moveLeft();
-    },
+    e.preventDefault();
+    this.$.cursor.moveLeft();
+  }
 
-    _handleRightPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+  _handleRightPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-      e.preventDefault();
-      this.$.cursor.moveRight();
-    },
+    e.preventDefault();
+    this.$.cursor.moveRight();
+  }
 
-    _handlePrevLineOrFileWithComments(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (e.detail.keyboardEvent.shiftKey &&
-          e.detail.keyboardEvent.keyCode === 75) { // 'K'
-        this._moveToPreviousFileWithComment();
-        return;
-      }
-      if (this.modifierPressed(e)) { return; }
+  _handlePrevLineOrFileWithComments(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (e.detail.keyboardEvent.shiftKey &&
+        e.detail.keyboardEvent.keyCode === 75) { // 'K'
+      this._moveToPreviousFileWithComment();
+      return;
+    }
+    if (this.modifierPressed(e)) { return; }
 
-      e.preventDefault();
-      this.$.diffHost.displayLine = true;
-      this.$.cursor.moveUp();
-    },
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveUp();
+  }
 
-    _handleNextLineOrFileWithComments(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (e.detail.keyboardEvent.shiftKey &&
-          e.detail.keyboardEvent.keyCode === 74) { // 'J'
-        this._moveToNextFileWithComment();
-        return;
-      }
-      if (this.modifierPressed(e)) { return; }
+  _handleVisibleLine(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-      e.preventDefault();
-      this.$.diffHost.displayLine = true;
-      this.$.cursor.moveDown();
-    },
+    e.preventDefault();
+    this.$.cursor.moveToVisibleArea();
+  }
 
-    _moveToPreviousFileWithComment() {
-      if (!this._commentSkips) { return; }
+  _onOpenFixPreview(e) {
+    this.$.applyFixDialog.open(e);
+  }
 
-      // If there is no previous diff with comments, then return to the change
-      // view.
-      if (!this._commentSkips.previous) {
-        this._navToChangeView();
-        return;
-      }
+  _handleNextLineOrFileWithComments(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (e.detail.keyboardEvent.shiftKey &&
+        e.detail.keyboardEvent.keyCode === 74) { // 'J'
+      this._moveToNextFileWithComment();
+      return;
+    }
+    if (this.modifierPressed(e)) { return; }
 
-      Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.previous,
-          this._patchRange.patchNum, this._patchRange.basePatchNum);
-    },
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveDown();
+  }
 
-    _moveToNextFileWithComment() {
-      if (!this._commentSkips) { return; }
+  _moveToPreviousFileWithComment() {
+    if (!this._commentSkips) { return; }
 
-      // If there is no next diff with comments, then return to the change view.
-      if (!this._commentSkips.next) {
-        this._navToChangeView();
-        return;
-      }
-
-      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; }
-
-      e.preventDefault();
-      const line = this.$.cursor.getTargetLineElement();
-      if (line) {
-        this.$.diffHost.addDraftAtLine(line);
-      }
-    },
-
-    _handlePrevFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._navToFile(this._path, this._fileList, -1);
-    },
-
-    _handleNextFile(e) {
-      // Check for meta key to avoid overriding native chrome shortcut.
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.getKeyboardEvent(e).metaKey) { return; }
-
-      e.preventDefault();
-      this._navToFile(this._path, this._fileList, 1);
-    },
-
-    _handleNextChunkOrCommentThread(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      if (e.detail.keyboardEvent.shiftKey) {
-        this.$.cursor.moveToNextCommentThread();
-      } else {
-        if (this.modifierPressed(e)) { return; }
-        this.$.cursor.moveToNextChunk();
-      }
-    },
-
-    _handlePrevChunkOrCommentThread(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      e.preventDefault();
-      if (e.detail.keyboardEvent.shiftKey) {
-        this.$.cursor.moveToPreviousCommentThread();
-      } else {
-        if (this.modifierPressed(e)) { return; }
-        this.$.cursor.moveToPreviousChunk();
-      }
-    },
-
-    _handleOpenReplyDialogOrToggleLeftPane(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
-        e.preventDefault();
-        this.$.diffHost.toggleLeftDiff();
-        return;
-      }
-
-      if (this.modifierPressed(e)) { return; }
-
-      if (!this._loggedIn) { return; }
-
-      this.set('changeViewState.showReplyDialog', true);
-      e.preventDefault();
+    // If there is no previous diff with comments, then return to the change
+    // view.
+    if (!this._commentSkips.previous) {
       this._navToChangeView();
-    },
+      return;
+    }
 
-    _handleUpToChange(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+    GerritNav.navigateToDiff(this._change, this._commentSkips.previous,
+        this._patchRange.patchNum, this._patchRange.basePatchNum);
+  }
 
-      e.preventDefault();
+  _moveToNextFileWithComment() {
+    if (!this._commentSkips) { return; }
+
+    // If there is no next diff with comments, then return to the change view.
+    if (!this._commentSkips.next) {
       this._navToChangeView();
-    },
+      return;
+    }
 
-    _handleCommaKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-      if (this._diffPrefsDisabled) { return; }
+    GerritNav.navigateToDiff(this._change, this._commentSkips.next,
+        this._patchRange.patchNum, this._patchRange.basePatchNum);
+  }
 
+  _handleNewComment(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    e.preventDefault();
+    this.$.cursor.createCommentInPlace();
+  }
+
+  _handlePrevFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, -1);
+  }
+
+  _handleNextFile(e) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.getKeyboardEvent(e).metaKey) { return; }
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, 1);
+  }
+
+  _handleNextChunkOrCommentThread(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent.shiftKey) {
+      this.$.cursor.moveToNextCommentThread();
+    } else {
+      if (this.modifierPressed(e)) { return; }
+      this.$.cursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunkOrCommentThread(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent.shiftKey) {
+      this.$.cursor.moveToPreviousCommentThread();
+    } else {
+      if (this.modifierPressed(e)) { return; }
+      this.$.cursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleOpenReplyDialogOrToggleLeftPane(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
       e.preventDefault();
-      this.$.diffPreferencesDialog.open();
-    },
+      this.$.diffHost.toggleLeftDiff();
+      return;
+    }
 
-    _handleToggleDiffMode(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+    if (this.modifierPressed(e)) { return; }
 
-      e.preventDefault();
-      if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-        this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
-      } else {
-        this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
-      }
-    },
+    if (!this._loggedIn) { return; }
 
-    _navToChangeView() {
-      if (!this._changeNum || !this._patchRange.patchNum) { return; }
+    this.set('changeViewState.showReplyDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleUpToChange(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleCommaKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+    if (this._diffPrefsDisabled) { return; }
+
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _handleToggleDiffMode(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+        this.modifierPressed(e)) { return; }
+
+    e.preventDefault();
+    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
+
+  _navToChangeView() {
+    if (!this._changeNum || !this._patchRange.patchNum) { return; }
+    this._navigateToChange(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions);
+  }
+
+  _navToFile(path, fileList, direction) {
+    const newPath = this._getNavLinkPath(path, fileList, direction);
+    if (!newPath) { return; }
+
+    if (newPath.up) {
       this._navigateToChange(
           this._change,
           this._patchRange,
           this._change && this._change.revisions);
-    },
+      return;
+    }
 
-    _navToFile(path, fileList, direction) {
-      const newPath = this._getNavLinkPath(path, fileList, direction);
-      if (!newPath) { return; }
+    GerritNav.navigateToDiff(this._change, newPath.path,
+        this._patchRange.patchNum, this._patchRange.basePatchNum);
+  }
 
-      if (newPath.up) {
-        this._navigateToChange(
-            this._change,
-            this._patchRange,
-            this._change && this._change.revisions);
-        return;
-      }
+  /**
+   * @param {?string} path The path of the current file being shown.
+   * @param {!Array<string>} fileList The list of files in this change and
+   *     patch range.
+   * @param {number} direction Either 1 (next file) or -1 (prev file).
+   * @param {(number|boolean)} opt_noUp Whether to return to the change view
+   *     when advancing the file goes outside the bounds of fileList.
+   *
+   * @return {?string} The next URL when proceeding in the specified
+   *     direction.
+   */
+  _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
+    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+    if (!newPath) { return null; }
 
-      Gerrit.Nav.navigateToDiff(this._change, newPath.path,
-          this._patchRange.patchNum, this._patchRange.basePatchNum);
-    },
+    if (newPath.up) {
+      return this._getChangePath(
+          this._change,
+          this._patchRange,
+          this._change && this._change.revisions);
+    }
+    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+  }
 
-    /**
-     * @param {?string} path The path of the current file being shown.
-     * @param {!Array<string>} fileList The list of files in this change and
-     *     patch range.
-     * @param {number} direction Either 1 (next file) or -1 (prev file).
-     * @param {(number|boolean)} opt_noUp Whether to return to the change view
-     *     when advancing the file goes outside the bounds of fileList.
-     *
-     * @return {?string} The next URL when proceeding in the specified
-     *     direction.
-     */
-    _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
-      const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
-      if (!newPath) { return null; }
+  _goToEditFile() {
+    // TODO(taoalpha): add a shortcut for editing
+    const editUrl = GerritNav.getEditUrlForDiff(
+        this._change, this._path, this._patchRange.patchNum);
+    return GerritNav.navigateToRelativeUrl(editUrl);
+  }
 
-      if (newPath.up) {
-        return this._getChangePath(
-            this._change,
-            this._patchRange,
-            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
+   * right through the change. The resulting object will have one of the
+   * following forms:
+   *   * {path: "<target file path>"} - When another file path should be the
+   *     result of the navigation.
+   *   * {up: true} - When the result of navigating should go back to the
+   *     change view.
+   *   * null - When no navigation is possible for the given direction.
+   *
+   * @param {?string} path The path of the current file being shown.
+   * @param {!Array<string>} fileList The list of files in this change and
+   *     patch range.
+   * @param {number} direction Either 1 (next file) or -1 (prev file).
+   * @param {?number|boolean=} opt_noUp Whether to return to the change view
+   *     when advancing the file goes outside the bounds of fileList.
+   * @return {?Object}
+   */
+  _getNavLinkPath(path, fileList, direction, opt_noUp) {
+    if (!path || !fileList || fileList.length === 0) { return null; }
 
-    _goToEditFile() {
-      // TODO(taoalpha): add a shortcut for editing
-      const editUrl = Gerrit.Nav.getEditUrlForDiff(
-          this._change, this._path, this._patchRange.patchNum);
-      return Gerrit.Nav.navigateToRelativeUrl(editUrl);
-    },
+    let idx = fileList.indexOf(path);
+    if (idx === -1) {
+      const file = direction > 0 ?
+        fileList[0] :
+        fileList[fileList.length - 1];
+      return {path: file};
+    }
 
-    /**
-     * Gives an object representing the target of navigating either left or
-     * right through the change. The resulting object will have one of the
-     * following forms:
-     *   * {path: "<target file path>"} - When another file path should be the
-     *     result of the navigation.
-     *   * {up: true} - When the result of navigating should go back to the
-     *     change view.
-     *   * null - When no navigation is possible for the given direction.
-     *
-     * @param {?string} path The path of the current file being shown.
-     * @param {!Array<string>} fileList The list of files in this change and
-     *     patch range.
-     * @param {number} direction Either 1 (next file) or -1 (prev file).
-     * @param {?number|boolean=} opt_noUp Whether to return to the change view
-     *     when advancing the file goes outside the bounds of fileList.
-     * @return {?Object}
-     */
-    _getNavLinkPath(path, fileList, direction, opt_noUp) {
-      if (!path || !fileList || fileList.length === 0) { return null; }
+    idx += direction;
+    // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+    // outside the bounds of [0, fileList.length).
+    if (idx < 0 || idx > fileList.length - 1) {
+      if (opt_noUp) { return null; }
+      return {up: true};
+    }
 
-      let idx = fileList.indexOf(path);
-      if (idx === -1) {
-        const file = direction > 0 ?
-          fileList[0] :
-          fileList[fileList.length - 1];
-        return {path: file};
-      }
+    return {path: fileList[idx]};
+  }
 
-      idx += direction;
-      // Redirect to the change view if opt_noUp isn’t truthy and idx falls
-      // outside the bounds of [0, fileList.length).
-      if (idx < 0 || idx > fileList.length - 1) {
-        if (opt_noUp) { return null; }
-        return {up: true};
-      }
+  _getReviewedFiles(changeNum, patchNum) {
+    return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
+        .then(files => {
+          this._reviewedFiles = new Set(files);
+          return this._reviewedFiles;
+        });
+  }
 
-      return {path: fileList[idx]};
-    },
+  _getReviewedStatus(editMode, changeNum, patchNum, path) {
+    if (editMode) { return Promise.resolve(false); }
+    return this._getReviewedFiles(changeNum, patchNum)
+        .then(files => files.has(path));
+  }
 
-    _getReviewedFiles(changeNum, patchNum) {
-      return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-          .then(files => {
-            this._reviewedFiles = new Set(files);
-            return this._reviewedFiles;
-          });
-    },
+  _paramsChanged(value) {
+    if (value.view !== GerritNav.View.DIFF) { return; }
 
-    _getReviewedStatus(editMode, changeNum, patchNum, path) {
-      if (editMode) { return Promise.resolve(false); }
-      return this._getReviewedFiles(changeNum, patchNum)
-          .then(files => files.has(path));
-    },
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
 
-    _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.DIFF) { return; }
+    this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
+    this._initCursor(this.params);
 
-      if (value.changeNum && value.project) {
-        this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-      }
+    this._changeNum = value.changeNum;
+    this._path = value.path;
+    this._patchRange = {
+      patchNum: value.patchNum,
+      basePatchNum: value.basePatchNum || PARENT,
+    };
 
-      this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
-      this._initCursor(this.params);
-
-      this._changeNum = value.changeNum;
-      this._path = value.path;
-      this._patchRange = {
-        patchNum: value.patchNum,
-        basePatchNum: value.basePatchNum || PARENT,
-      };
-
-      // NOTE: This may be called before attachment (e.g. while parentElement is
-      // null). Fire title-change in an async so that, if attachment to the DOM
-      // has been queued, the event can bubble up to the handler in gr-app.
-      this.async(() => {
-        this.fire('title-change',
-            {title: this.computeTruncatedPath(this._path)});
-      });
-
-      // When navigating away from the page, there is a possibility that the
-      // patch number is no longer a part of the URL (say when navigating to
-      // the top-level change info view) and therefore undefined in `params`.
-      if (!this._patchRange.patchNum) {
-        return;
-      }
-
-      const promises = [];
-
-      promises.push(this._getDiffPreferences());
-
-      promises.push(this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
+    // NOTE: This may be called before attachment (e.g. while parentElement is
+    // null). Fire title-change in an async so that, if attachment to the DOM
+    // has been queued, the event can bubble up to the handler in gr-app.
+    this.async(() => {
+      this.dispatchEvent(new CustomEvent('title-change', {
+        detail: {title: this.computeTruncatedPath(this._path)},
+        composed: true, bubbles: true,
       }));
+    });
 
-      promises.push(this._getChangeDetail(this._changeNum).then(change => {
-        let commit;
-        let baseCommit;
-        if (change) {
-          for (const commitSha in change.revisions) {
-            if (!change.revisions.hasOwnProperty(commitSha)) continue;
-            const revision = change.revisions[commitSha];
-            const patchNum = revision._number.toString();
-            if (patchNum === this._patchRange.patchNum) {
-              commit = commitSha;
-              const commitObj = revision.commit || {};
-              const parents = commitObj.parents || [];
-              if (this._patchRange.basePatchNum === PARENT && parents.length) {
-                baseCommit = parents[parents.length - 1].commit;
-              }
-            } else if (patchNum === this._patchRange.basePatchNum) {
-              baseCommit = commitSha;
+    // When navigating away from the page, there is a possibility that the
+    // patch number is no longer a part of the URL (say when navigating to
+    // the top-level change info view) and therefore undefined in `params`.
+    if (!this._patchRange.patchNum) {
+      return;
+    }
+
+    const promises = [];
+
+    promises.push(this._getDiffPreferences());
+
+    promises.push(this._getPreferences().then(prefs => {
+      this._userPrefs = prefs;
+    }));
+
+    promises.push(this._getChangeDetail(this._changeNum).then(change => {
+      let commit;
+      let baseCommit;
+      if (change) {
+        for (const commitSha in change.revisions) {
+          if (!change.revisions.hasOwnProperty(commitSha)) continue;
+          const revision = change.revisions[commitSha];
+          const patchNum = revision._number.toString();
+          if (patchNum === this._patchRange.patchNum) {
+            commit = commitSha;
+            const commitObj = revision.commit || {};
+            const parents = commitObj.parents || [];
+            if (this._patchRange.basePatchNum === PARENT && parents.length) {
+              baseCommit = parents[parents.length - 1].commit;
             }
+          } else if (patchNum === this._patchRange.basePatchNum) {
+            baseCommit = commitSha;
           }
-          this._commitRange = {commit, baseCommit};
         }
-      }));
+        this._commitRange = {commit, baseCommit};
+      }
+    }));
 
-      promises.push(this._loadComments());
+    promises.push(this._loadComments());
 
-      promises.push(this._getChangeEdit(this._changeNum));
+    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,
-          });
-        }
-        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 = 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,
+            });
+          }
+          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) {
+      // If screen size is small, always default to unified view.
+      this.$.restAPI.getPreferences().then(prefs => {
+        this.set('changeViewState.diffMode', prefs.default_diff_view);
       });
-    },
+    }
+  }
 
-    _changeViewStateChanged(changeViewState) {
-      if (changeViewState.diffMode === null) {
-        // If screen size is small, always default to unified view.
-        this.$.restAPI.getPreferences().then(prefs => {
-          this.set('changeViewState.diffMode', prefs.default_diff_view);
-        });
-      }
-    },
+  _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
+    // Polymer 2: check for undefined
+    if ([_loggedIn, paramsRecord, _prefs].some(arg => arg === undefined)) {
+      return;
+    }
 
-    _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
-      // Polymer 2: check for undefined
-      if ([_loggedIn, paramsRecord, _prefs].some(arg => arg === undefined)) {
-        return;
-      }
+    const params = paramsRecord.base || {};
+    if (!_loggedIn) { return; }
 
-      const params = paramsRecord.base || {};
-      if (!_loggedIn) { return; }
-
-      if (_prefs.manual_review) {
-        // Checkbox state needs to be set explicitly only when manual_review
-        // is specified.
-        this._getReviewedStatus(this.editMode, this._changeNum,
-            this._patchRange.patchNum, this._path).then(status => {
-          this.$.reviewed.checked = status;
-        });
-        return;
-      }
-
-      if (params.view === Gerrit.Nav.View.DIFF) {
-        this._setReviewed(true);
-      }
-    },
-
-    /**
-     * If the params specify a diff address then configure the diff cursor.
-     */
-    _initCursor(params) {
-      if (params.lineNum === undefined) { return; }
-      if (params.leftSide) {
-        this.$.cursor.side = DiffSides.LEFT;
-      } else {
-        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) {
-        this.fire('title-change',
-            {title: this.computeTruncatedPath(path)});
-      }
-
-      if (this._fileList.length == 0) { return; }
-
-      this.set('changeViewState.selectedFileIndex',
-          this._fileList.indexOf(path));
-    },
-
-    _getDiffUrl(change, patchRange, path) {
-      if ([change, patchRange, path].some(arg => arg === undefined)) {
-        return '';
-      }
-      return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
-          patchRange.basePatchNum);
-    },
-
-    _patchRangeStr(patchRange) {
-      let patchStr = patchRange.patchNum;
-      if (patchRange.basePatchNum != null &&
-          patchRange.basePatchNum != PARENT) {
-        patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
-      }
-      return patchStr;
-    },
-
-    /**
-     * When the latest patch of the change is selected (and there is no base
-     * patch) then the patch range need not appear in the URL. Return a patch
-     * range object with undefined values when a range is not needed.
-     *
-     * @param {!Object} patchRange
-     * @param {!Object} revisions
-     * @return {!Object}
-     */
-    _getChangeUrlRange(patchRange, revisions) {
-      let patchNum = undefined;
-      let basePatchNum = undefined;
-      let latestPatchNum = -1;
-      for (const rev of Object.values(revisions || {})) {
-        latestPatchNum = Math.max(latestPatchNum, rev._number);
-      }
-      if (patchRange.basePatchNum !== PARENT ||
-          parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
-        patchNum = patchRange.patchNum;
-        basePatchNum = patchRange.basePatchNum;
-      }
-      return {patchNum, basePatchNum};
-    },
-
-    _getChangePath(change, patchRange, revisions) {
-      if ([change, patchRange].some(arg => arg === undefined)) {
-        return '';
-      }
-      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(files, patchNum, changeComments) {
-      // Polymer 2: check for undefined
-      if ([
-        files,
-        patchNum,
-        changeComments,
-      ].some(arg => arg === undefined)) {
-        return;
-      }
-
-      if (!files) { return; }
-      const dropdownContent = [];
-      for (const path of files.sortedFileList) {
-        dropdownContent.push({
-          text: this.computeDisplayPath(path),
-          mobileText: this.computeTruncatedPath(path),
-          value: path,
-          bottomText: this._computeCommentString(changeComments, patchNum,
-              path, files.changeFilesByPath[path]),
-        });
-      }
-      return dropdownContent;
-    },
-
-    _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
-      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
-          path);
-      const commentCount = changeComments.computeCommentCount(patchNum, path);
-      const commentString = GrCountStringFormatter.computePluralString(
-          commentCount, 'comment');
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
-
-      const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
-
-      return [
-        unmodifiedString,
-        commentString,
-        unresolvedString]
-          .filter(v => v && v.length > 0).join(', ');
-    },
-
-    _computePrefsButtonHidden(prefs, prefsDisabled) {
-      return prefsDisabled || !prefs;
-    },
-
-    _handleFileChange(e) {
-      // This is when it gets set initially.
-      const path = e.detail.value;
-      if (path === this._path) {
-        return;
-      }
-
-      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
-      // dropdown closes (This was a bug for touch devices).
-      this.async(() => {
-        this.$.dropdown.close();
-      }, 1);
-    },
-
-    _handlePatchChange(e) {
-      const {basePatchNum, patchNum} = e.detail;
-      if (this.patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-          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
-     * the current state.
-     *
-     * The expected behavior is to use the mode specified in the user's
-     * preferences unless they have manually chosen the alternative view or they
-     * are on a mobile device. If the user navigates up to the change view, it
-     * should clear this choice and revert to the preference the next time a
-     * diff is viewed.
-     *
-     * Use side-by-side if the user is not logged in.
-     *
-     * @return {string}
-     */
-    _getDiffViewMode() {
-      if (this.changeViewState.diffMode) {
-        return this.changeViewState.diffMode;
-      } else if (this._userPrefs) {
-        this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
-        return this._userPrefs.default_diff_view;
-      } else {
-        return 'SIDE_BY_SIDE';
-      }
-    },
-
-    _computeModeSelectHideClass(isImageDiff) {
-      return isImageDiff ? 'hide' : '';
-    },
-
-    _onLineSelected(e, detail) {
-      this.$.cursor.moveToLineNumber(detail.number, detail.side);
-      if (!this._change) { return; }
-      const cursorAddress = this.$.cursor.getAddress();
-      const number = cursorAddress ? cursorAddress.number : undefined;
-      const leftSide = cursorAddress ? cursorAddress.leftSide : undefined;
-      const url = Gerrit.Nav.getUrlForDiffById(this._changeNum,
-          this._change.project, this._path, this._patchRange.patchNum,
-          this._patchRange.basePatchNum, number, leftSide);
-      history.replaceState(null, '', url);
-    },
-
-    _computeDownloadDropdownLinks(
-        project, changeNum, patchRange, path, diff) {
-      if (!patchRange || !patchRange.patchNum) { return []; }
-
-      const links = [
-        {
-          url: this._computeDownloadPatchLink(
-              project, changeNum, patchRange, path),
-          name: 'Patch',
-        },
-      ];
-
-      if (diff && diff.meta_a) {
-        let leftPath = path;
-        if (diff.change_type === 'RENAMED') {
-          leftPath = diff.meta_a.name;
-        }
-        links.push(
-            {
-              url: this._computeDownloadFileLink(
-                  project, changeNum, patchRange, leftPath, true),
-              name: 'Left Content',
-            }
-        );
-      }
-
-      if (diff && diff.meta_b) {
-        links.push(
-            {
-              url: this._computeDownloadFileLink(
-                  project, changeNum, patchRange, path, false),
-              name: 'Right Content',
-            }
-        );
-      }
-
-      return links;
-    },
-
-    _computeDownloadFileLink(
-        project, changeNum, patchRange, path, isBase) {
-      let patchNum = patchRange.patchNum;
-
-      const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
-
-      if (isBase && !comparedAgainsParent) {
-        patchNum = patchRange.basePatchNum;
-      }
-
-      let url = this.changeBaseURL(project, changeNum, patchNum) +
-          `/files/${encodeURIComponent(path)}/download`;
-
-      if (isBase && comparedAgainsParent) {
-        url += '?parent=1';
-      }
-
-      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 => {
-        this._changeComments = comments;
-        this._commentMap = this._getPaths(this._patchRange);
-
-        this._commentsForDiff = this._getCommentsForPath(this._path,
-            this._patchRange, this._projectConfig);
+    if (_prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+      this._getReviewedStatus(this.editMode, this._changeNum,
+          this._patchRange.patchNum, this._path).then(status => {
+        this.$.reviewed.checked = status;
       });
-    },
+      return;
+    }
 
-    _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
-      if ([
-        commentMap,
-        fileList,
-        path,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      const skips = {previous: null, next: null};
-      if (!fileList.length) { return skips; }
-      const pathIndex = fileList.indexOf(path);
-
-      // Scan backward for the previous file.
-      for (let i = pathIndex - 1; i >= 0; i--) {
-        if (commentMap[fileList[i]]) {
-          skips.previous = fileList[i];
-          break;
-        }
-      }
-
-      // Scan forward for the next file.
-      for (let i = pathIndex + 1; i < fileList.length; i++) {
-        if (commentMap[fileList[i]]) {
-          skips.next = fileList[i];
-          break;
-        }
-      }
-
-      return skips;
-    },
-
-    _computeDiffClass(panelFloatingDisabled) {
-      if (panelFloatingDisabled) {
-        return 'noOverflow';
-      }
-    },
-
-    /**
-     * @param {!Object} patchRangeRecord
-     */
-    _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.
-     * Otherwise hide it.
-     */
-    _toggleBlame() {
-      if (this._isBlameLoaded) {
-        this.$.diffHost.clearBlame();
-        return;
-      }
-
-      this._isBlameLoading = true;
-      this.fire('show-alert', {message: MSG_LOADING_BLAME});
-      this.$.diffHost.loadBlame()
-          .then(() => {
-            this._isBlameLoading = false;
-            this.fire('show-alert', {message: MSG_LOADED_BLAME});
-          })
-          .catch(() => {
-            this._isBlameLoading = false;
-          });
-    },
-
-    _computeBlameLoaderClass(isImageDiff) {
-      return !isImageDiff ? 'show' : '';
-    },
-
-    _getRevisionInfo(change) {
-      return new Gerrit.RevisionInfo(change);
-    },
-
-    _computeFileNum(file, files) {
-      // Polymer 2: check for undefined
-      if ([file, files].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      return files.findIndex(({value}) => value === file) + 1;
-    },
-
-    /**
-     * @param {number} fileNum
-     * @param {!Array<string>} files
-     * @return {string}
-     */
-    _computeFileNumClass(fileNum, files) {
-      if (files && fileNum > 0) {
-        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; }
+    if (params.view === GerritNav.View.DIFF) {
       this._setReviewed(true);
-      // Ensure that the currently viewed file always appears in unreviewedFiles
-      // so we resolve the right "next" file.
-      const unreviewedFiles = this._fileList
-          .filter(file =>
-            (file === this._path || !this._reviewedFiles.has(file)));
-      this._navToFile(this._path, unreviewedFiles, 1);
-    },
+    }
+  }
 
-    _handleReloadingDiffPreference() {
-      this._getDiffPreferences();
-    },
+  /**
+   * If the params specify a diff address then configure the diff cursor.
+   */
+  _initCursor(params) {
+    if (params.lineNum === undefined) { return; }
+    if (params.leftSide) {
+      this.$.cursor.side = DiffSides.LEFT;
+    } else {
+      this.$.cursor.side = DiffSides.RIGHT;
+    }
+    this.$.cursor.initialLineNumber = params.lineNum;
+  }
 
-    _computeCanEdit(loggedIn, changeChangeRecord) {
-      if ([changeChangeRecord, changeChangeRecord.base]
-          .some(arg => arg === undefined)) {
-        return false;
+  _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) {
+      this.dispatchEvent(new CustomEvent('title-change', {
+        detail: {title: this.computeTruncatedPath(path)},
+        composed: true, bubbles: true,
+      }));
+    }
+
+    if (this._fileList.length == 0) { return; }
+
+    this.set('changeViewState.selectedFileIndex',
+        this._fileList.indexOf(path));
+  }
+
+  _getDiffUrl(change, patchRange, path) {
+    if ([change, patchRange, path].some(arg => arg === undefined)) {
+      return '';
+    }
+    return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
+        patchRange.basePatchNum);
+  }
+
+  _patchRangeStr(patchRange) {
+    let patchStr = patchRange.patchNum;
+    if (patchRange.basePatchNum != null &&
+        patchRange.basePatchNum != PARENT) {
+      patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
+    }
+    return patchStr;
+  }
+
+  /**
+   * When the latest patch of the change is selected (and there is no base
+   * patch) then the patch range need not appear in the URL. Return a patch
+   * range object with undefined values when a range is not needed.
+   *
+   * @param {!Object} patchRange
+   * @param {!Object} revisions
+   * @return {!Object}
+   */
+  _getChangeUrlRange(patchRange, revisions) {
+    let patchNum = undefined;
+    let basePatchNum = undefined;
+    let latestPatchNum = -1;
+    for (const rev of Object.values(revisions || {})) {
+      latestPatchNum = Math.max(latestPatchNum, rev._number);
+    }
+    if (patchRange.basePatchNum !== PARENT ||
+        parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
+      patchNum = patchRange.patchNum;
+      basePatchNum = patchRange.basePatchNum;
+    }
+    return {patchNum, basePatchNum};
+  }
+
+  _getChangePath(change, patchRange, revisions) {
+    if ([change, patchRange].some(arg => arg === undefined)) {
+      return '';
+    }
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    return GerritNav.getUrlForChange(change, range.patchNum,
+        range.basePatchNum);
+  }
+
+  _navigateToChange(change, patchRange, revisions) {
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
+  }
+
+  _computeChangePath(change, patchRangeRecord, revisions) {
+    return this._getChangePath(change, patchRangeRecord.base, revisions);
+  }
+
+  _formatFilesForDropdown(files, patchNum, changeComments) {
+    // Polymer 2: check for undefined
+    if ([
+      files,
+      patchNum,
+      changeComments,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (!files) { return; }
+    const dropdownContent = [];
+    for (const path of files.sortedFileList) {
+      dropdownContent.push({
+        text: this.computeDisplayPath(path),
+        mobileText: this.computeTruncatedPath(path),
+        value: path,
+        bottomText: this._computeCommentString(changeComments, patchNum,
+            path, files.changeFilesByPath[path]),
+      });
+    }
+    return dropdownContent;
+  }
+
+  _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
+    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum,
+      path});
+    const commentCount = changeComments.computeCommentCount({patchNum, path});
+    const commentString = GrCountStringFormatter.computePluralString(
+        commentCount, 'comment');
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
+
+    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
+
+    return [
+      unmodifiedString,
+      commentString,
+      unresolvedString]
+        .filter(v => v && v.length > 0).join(', ');
+  }
+
+  _computePrefsButtonHidden(prefs, prefsDisabled) {
+    return prefsDisabled || !prefs;
+  }
+
+  _handleFileChange(e) {
+    // This is when it gets set initially.
+    const path = e.detail.value;
+    if (path === this._path) {
+      return;
+    }
+
+    GerritNav.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
+    // dropdown closes (This was a bug for touch devices).
+    this.async(() => {
+      this.$.dropdown.close();
+    }, 1);
+  }
+
+  _handlePatchChange(e) {
+    const {basePatchNum, patchNum} = e.detail;
+    if (this.patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+        this.patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
+    GerritNav.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
+   * the current state.
+   *
+   * The expected behavior is to use the mode specified in the user's
+   * preferences unless they have manually chosen the alternative view or they
+   * are on a mobile device. If the user navigates up to the change view, it
+   * should clear this choice and revert to the preference the next time a
+   * diff is viewed.
+   *
+   * Use side-by-side if the user is not logged in.
+   *
+   * @return {string}
+   */
+  _getDiffViewMode() {
+    if (this.changeViewState.diffMode) {
+      return this.changeViewState.diffMode;
+    } else if (this._userPrefs) {
+      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
+      return this._userPrefs.default_diff_view;
+    } else {
+      return 'SIDE_BY_SIDE';
+    }
+  }
+
+  _computeModeSelectHideClass(isImageDiff) {
+    return isImageDiff ? 'hide' : '';
+  }
+
+  _onLineSelected(e, detail) {
+    if (!this._change) { return; }
+    const cursorAddress = this.$.cursor.getAddress();
+    const number = cursorAddress ? cursorAddress.number : undefined;
+    const leftSide = cursorAddress ? cursorAddress.leftSide : undefined;
+    const url = GerritNav.getUrlForDiffById(this._changeNum,
+        this._change.project, this._path, this._patchRange.patchNum,
+        this._patchRange.basePatchNum, number, leftSide);
+    history.replaceState(null, '', url);
+  }
+
+  _computeDownloadDropdownLinks(
+      project, changeNum, patchRange, path, diff) {
+    if (!patchRange || !patchRange.patchNum) { return []; }
+
+    const links = [
+      {
+        url: this._computeDownloadPatchLink(
+            project, changeNum, patchRange, path),
+        name: 'Patch',
+      },
+    ];
+
+    if (diff && diff.meta_a) {
+      let leftPath = path;
+      if (diff.change_type === 'RENAMED') {
+        leftPath = diff.meta_a.name;
       }
-      return loggedIn && this.changeIsOpen(changeChangeRecord.base);
-    },
-  });
-})();
+      links.push(
+          {
+            url: this._computeDownloadFileLink(
+                project, changeNum, patchRange, leftPath, true),
+            name: 'Left Content',
+          }
+      );
+    }
+
+    if (diff && diff.meta_b) {
+      links.push(
+          {
+            url: this._computeDownloadFileLink(
+                project, changeNum, patchRange, path, false),
+            name: 'Right Content',
+          }
+      );
+    }
+
+    return links;
+  }
+
+  _computeDownloadFileLink(
+      project, changeNum, patchRange, path, isBase) {
+    let patchNum = patchRange.patchNum;
+
+    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+
+    if (isBase && !comparedAgainsParent) {
+      patchNum = patchRange.basePatchNum;
+    }
+
+    let url = this.changeBaseURL(project, changeNum, patchNum) +
+        `/files/${encodeURIComponent(path)}/download`;
+
+    if (isBase && comparedAgainsParent) {
+      url += '?parent=1';
+    }
+
+    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 => {
+      this._changeComments = comments;
+      this._commentMap = this._getPaths(this._patchRange);
+
+      this._commentsForDiff = this._getCommentsForPath(this._path,
+          this._patchRange, this._projectConfig);
+    });
+  }
+
+  _recomputeComments(files, path, patchRange, projectConfig) {
+    // Polymer 2: check for undefined
+    if ([
+      files,
+      path,
+      patchRange,
+      projectConfig,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const file = files[path];
+    if (file && file.old_path) {
+      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+          {path, oldPath: file.old_path},
+          patchRange,
+          projectConfig);
+
+      this.$.diffHost.comments = this._commentsForDiff;
+    }
+  }
+
+  _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
+    if ([
+      commentMap,
+      fileList,
+      path,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    const skips = {previous: null, next: null};
+    if (!fileList.length) { return skips; }
+    const pathIndex = fileList.indexOf(path);
+
+    // Scan backward for the previous file.
+    for (let i = pathIndex - 1; i >= 0; i--) {
+      if (commentMap[fileList[i]]) {
+        skips.previous = fileList[i];
+        break;
+      }
+    }
+
+    // Scan forward for the next file.
+    for (let i = pathIndex + 1; i < fileList.length; i++) {
+      if (commentMap[fileList[i]]) {
+        skips.next = fileList[i];
+        break;
+      }
+    }
+
+    return skips;
+  }
+
+  _computeDiffClass(panelFloatingDisabled) {
+    if (panelFloatingDisabled) {
+      return 'noOverflow';
+    }
+  }
+
+  /**
+   * @param {!Object} patchRangeRecord
+   */
+  _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.
+   * Otherwise hide it.
+   */
+  _toggleBlame() {
+    if (this._isBlameLoaded) {
+      this.$.diffHost.clearBlame();
+      return;
+    }
+
+    this._isBlameLoading = true;
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {message: MSG_LOADING_BLAME},
+      composed: true, bubbles: true,
+    }));
+    this.$.diffHost.loadBlame()
+        .then(() => {
+          this._isBlameLoading = false;
+          this.dispatchEvent(new CustomEvent('show-alert', {
+            detail: {message: MSG_LOADED_BLAME},
+            composed: true, bubbles: true,
+          }));
+        })
+        .catch(() => {
+          this._isBlameLoading = false;
+        });
+  }
+
+  _handleToggleBlame(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e)) { return; }
+    this._toggleBlame();
+  }
+
+  _computeBlameLoaderClass(isImageDiff, path) {
+    return !this.isMagicPath(path) && !isImageDiff ? 'show' : '';
+  }
+
+  _getRevisionInfo(change) {
+    return new RevisionInfo(change);
+  }
+
+  _computeFileNum(file, files) {
+    // Polymer 2: check for undefined
+    if ([file, files].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    return files.findIndex(({value}) => value === file) + 1;
+  }
+
+  /**
+   * @param {number} fileNum
+   * @param {!Array<string>} files
+   * @return {string}
+   */
+  _computeFileNumClass(fileNum, files) {
+    if (files && fileNum > 0) {
+      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; }
+    this._setReviewed(true);
+    // Ensure that the currently viewed file always appears in unreviewedFiles
+    // so we resolve the right "next" file.
+    const unreviewedFiles = this._fileList
+        .filter(file =>
+          (file === this._path || !this._reviewedFiles.has(file)));
+    this._navToFile(this._path, unreviewedFiles, 1);
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences();
+  }
+
+  _onChangeHeaderPanelHeightChanged(e) {
+    this._scrollTopMargin = e.detail.value;
+  }
+
+  _computeCanEdit(loggedIn, changeChangeRecord) {
+    if ([changeChangeRecord, changeChangeRecord.base]
+        .some(arg => arg === undefined)) {
+      return false;
+    }
+    return loggedIn && this.changeIsOpen(changeChangeRecord.base);
+  }
+}
+
+customElements.define(GrDiffView.is, GrDiffView);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
new file mode 100644
index 0000000..c74a192
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
@@ -0,0 +1,424 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--view-background-color);
+    }
+    .hidden {
+      display: none;
+    }
+    gr-patch-range-select {
+      display: block;
+    }
+    gr-diff {
+      border: none;
+      --diff-container-styles: {
+        border-bottom: 1px solid var(--border-color);
+      }
+    }
+    gr-fixed-panel {
+      background-color: var(--view-background-color);
+      border-bottom: 1px solid var(--border-color);
+      z-index: 1;
+    }
+    header,
+    .subHeader {
+      align-items: center;
+      display: flex;
+      justify-content: space-between;
+    }
+    header {
+      padding: var(--spacing-s) var(--spacing-xl);
+      border-bottom: 1px solid var(--border-color);
+    }
+    .changeNumberColon {
+      color: transparent;
+    }
+    .headerSubject {
+      margin-right: var(--spacing-m);
+      font-weight: var(--font-weight-bold);
+    }
+    .patchRangeLeft {
+      align-items: center;
+      display: flex;
+    }
+    .navLink:not([href]) {
+      color: var(--deemphasized-text-color);
+    }
+    .navLinks {
+      align-items: center;
+      display: flex;
+      white-space: nowrap;
+    }
+    .navLink {
+      padding: 0 var(--spacing-xs);
+    }
+    .reviewed {
+      display: inline-block;
+      margin: 0 var(--spacing-xs);
+      vertical-align: 0.15em;
+    }
+    .jumpToFileContainer {
+      display: inline-block;
+    }
+    .mobile {
+      display: none;
+    }
+    gr-button {
+      padding: var(--spacing-s) 0;
+      text-decoration: none;
+    }
+    .loading {
+      color: var(--deemphasized-text-color);
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h1);
+      font-weight: var(--font-weight-h1);
+      line-height: var(--line-height-h1);
+      height: 100%;
+      padding: var(--spacing-l);
+      text-align: center;
+    }
+    .subHeader {
+      background-color: var(--background-color-secondary);
+      flex-wrap: wrap;
+      padding: 0 var(--spacing-l);
+    }
+    .prefsButton {
+      text-align: right;
+    }
+    .noOverflow {
+      display: block;
+      overflow: auto;
+    }
+    .editMode .hideOnEdit {
+      display: none;
+    }
+    .blameLoader,
+    .fileNum {
+      display: none;
+    }
+    .blameLoader.show,
+    .fileNum.show,
+    .download,
+    .preferences,
+    .rightControls {
+      align-items: center;
+      display: flex;
+    }
+    .diffModeSelector,
+    .editButton {
+      align-items: center;
+      display: flex;
+    }
+    .diffModeSelector span,
+    .editButton span {
+      margin-right: var(--spacing-xs);
+    }
+    .diffModeSelector.hide,
+    .separator.hide {
+      display: none;
+    }
+    gr-dropdown-list {
+      --trigger-style: {
+        text-transform: none;
+      }
+    }
+    .editButtona a {
+      text-decoration: none;
+    }
+    @media screen and (max-width: 50em) {
+      header {
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      .dash {
+        display: none;
+      }
+      .desktop {
+        display: none;
+      }
+      .fileNav {
+        align-items: flex-start;
+        display: flex;
+        margin: 0 var(--spacing-xs);
+      }
+      .fullFileName {
+        display: block;
+        font-style: italic;
+        min-width: 50%;
+        padding: 0 var(--spacing-xxs);
+        text-align: center;
+        width: 100%;
+        word-wrap: break-word;
+      }
+      .reviewed {
+        vertical-align: -1px;
+      }
+      .mobileNavLink {
+        color: var(--primary-text-color);
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+        text-decoration: none;
+      }
+      .mobileNavLink:not([href]) {
+        color: var(--deemphasized-text-color);
+      }
+      .jumpToFileContainer {
+        display: block;
+        width: 100%;
+      }
+      gr-dropdown-list {
+        width: 100%;
+        --gr-select-style: {
+          display: block;
+          width: 100%;
+        }
+        --native-select-style: {
+          width: 100%;
+        }
+      }
+    }
+  </style>
+  <gr-fixed-panel
+    class$="[[_computeContainerClass(_editMode)]]"
+    floating-disabled="[[_panelFloatingDisabled]]"
+    keep-on-scroll=""
+    ready-for-measure="[[!_loading]]"
+    on-floating-height-changed="_onChangeHeaderPanelHeightChanged"
+  >
+    <header>
+      <div>
+        <a
+          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
+          >[[_changeNum]]</a
+        ><!--
+       --><span class="changeNumberColon">:</span>
+        <span class="headerSubject">[[_change.subject]]</span>
+        <input
+          id="reviewed"
+          class="reviewed hideOnEdit"
+          type="checkbox"
+          on-change="_handleReviewedChange"
+          hidden$="[[!_loggedIn]]"
+          hidden=""
+        /><!--
+       -->
+        <div class="jumpToFileContainer">
+          <gr-dropdown-list
+            id="dropdown"
+            value="[[_path]]"
+            on-value-change="_handleFileChange"
+            items="[[_formattedFiles]]"
+            initial-count="75"
+          >
+          </gr-dropdown-list>
+        </div>
+      </div>
+      <div class="navLinks desktop">
+        <span
+          class$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]"
+        >
+          File [[_fileNum]] of [[_formattedFiles.length]]
+          <span class="separator"></span>
+        </span>
+        <a
+          class="navLink"
+          title="[[createTitle(Shortcut.PREV_FILE,
+                    ShortcutSection.NAVIGATION)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+        >
+          Prev</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title="[[createTitle(Shortcut.UP_TO_CHANGE,
+                ShortcutSection.NAVIGATION)]]"
+          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
+        >
+          Up</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title="[[createTitle(Shortcut.NEXT_FILE,
+                ShortcutSection.NAVIGATION)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+        >
+          Next</a
+        >
+      </div>
+    </header>
+    <div class="subHeader">
+      <div class="patchRangeLeft">
+        <gr-patch-range-select
+          id="rangeSelect"
+          change-num="[[_changeNum]]"
+          change-comments="[[_changeComments]]"
+          patch-num="[[_patchRange.patchNum]]"
+          base-patch-num="[[_patchRange.basePatchNum]]"
+          files-weblinks="[[_filesWeblinks]]"
+          available-patches="[[_allPatchSets]]"
+          revisions="[[_change.revisions]]"
+          revision-info="[[_revisionInfo]]"
+          on-patch-range-change="_handlePatchChange"
+        >
+        </gr-patch-range-select>
+        <span class="download desktop">
+          <span class="separator"></span>
+          <gr-dropdown
+            link=""
+            down-arrow=""
+            items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
+            horizontal-align="left"
+          >
+            <span class="downloadTitle">
+              Download
+            </span>
+          </gr-dropdown>
+        </span>
+      </div>
+      <div class="rightControls">
+        <span
+          class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]"
+        >
+          <gr-button
+            link=""
+            id="toggleBlame"
+            title="[[createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)]]"
+            disabled="[[_isBlameLoading]]"
+            on-click="_toggleBlame"
+            >[[_computeBlameToggleLabel(_isBlameLoaded,
+            _isBlameLoading)]]</gr-button
+          >
+        </span>
+        <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
+          <span class="separator"></span>
+          <span class="editButton">
+            <gr-button
+              link=""
+              title="Edit current file"
+              on-click="_goToEditFile"
+              >edit</gr-button
+            >
+          </span>
+        </template>
+        <span class="separator"></span>
+        <div
+          class$="diffModeSelector [[_computeModeSelectHideClass(_isImageDiff)]]"
+        >
+          <span>Diff view:</span>
+          <gr-diff-mode-selector
+            id="modeSelect"
+            save-on-change="[[!_diffPrefsDisabled]]"
+            mode="{{changeViewState.diffMode}}"
+          ></gr-diff-mode-selector>
+        </div>
+        <span
+          id="diffPrefsContainer"
+          hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]"
+          hidden=""
+        >
+          <span class="preferences desktop">
+            <gr-button
+              link=""
+              class="prefsButton"
+              has-tooltip=""
+              title="Diff preferences"
+              on-click="_handlePrefsTap"
+              ><iron-icon icon="gr-icons:settings"></iron-icon
+            ></gr-button>
+          </span>
+        </span>
+        <gr-endpoint-decorator name="annotation-toggler">
+          <span hidden="" id="annotation-span">
+            <label for="annotation-checkbox" id="annotation-label"></label>
+            <iron-input type="checkbox" disabled="">
+              <input
+                is="iron-input"
+                type="checkbox"
+                id="annotation-checkbox"
+                disabled=""
+              />
+            </iron-input>
+          </span>
+        </gr-endpoint-decorator>
+      </div>
+    </div>
+    <div class="fileNav mobile">
+      <a
+        class="mobileNavLink"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+      >
+        &lt;</a
+      >
+      <div class="fullFileName mobile">[[computeDisplayPath(_path)]]</div>
+      <a
+        class="mobileNavLink"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+      >
+        &gt;</a
+      >
+    </div>
+  </gr-fixed-panel>
+  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+  <gr-diff-host
+    id="diffHost"
+    hidden=""
+    hidden$="[[_loading]]"
+    class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
+    is-image-diff="{{_isImageDiff}}"
+    files-weblinks="{{_filesWeblinks}}"
+    diff="{{_diff}}"
+    change-num="[[_changeNum]]"
+    commit-range="[[_commitRange]]"
+    patch-range="[[_patchRange]]"
+    path="[[_path]]"
+    prefs="[[_prefs]]"
+    project-name="[[_change.project]]"
+    view-mode="[[_diffMode]]"
+    is-blame-loaded="{{_isBlameLoaded}}"
+    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}}"
+    on-reload-diff-preference="_handleReloadingDiffPreference"
+  >
+  </gr-diff-preferences-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+  <gr-diff-cursor
+    id="cursor"
+    scroll-top-margin="[[_scrollTopMargin]]"
+  ></gr-diff-cursor>
+  <gr-comment-api id="commentAPI"></gr-comment-api>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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 9c59577..f5275e2 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
@@ -17,19 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-diff-view.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -43,9 +38,16 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-view tests', () => {
-    const kb = window.Gerrit.KeyboardShortcutBinder;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+suite('gr-diff-view tests', () => {
+  suite('basic tests', () => {
+    const kb = KeyboardShortcutBinder;
     kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
     kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
     kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
@@ -70,6 +72,7 @@
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
     kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
     kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_BLAME, 'b');
 
     let element;
     let sandbox;
@@ -135,7 +138,7 @@
       sandbox.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sandbox.spy(element, '_paramsChanged');
       element.params = {
-        view: Gerrit.Nav.View.DIFF,
+        view: GerritNav.View.DIFF,
         changeNum: '42',
         patchNum: '2',
         basePatchNum: '1',
@@ -172,8 +175,8 @@
       element.changeViewState.selectedFileIndex = 1;
       element._loggedIn = true;
 
-      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      const diffNavStub = sandbox.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(GerritNav, 'navigateToChange');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(changeNavStub.lastCall.calledWith(element._change),
@@ -278,8 +281,8 @@
           ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
 
-      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      const diffNavStub = sandbox.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(GerritNav, 'navigateToChange');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
@@ -314,8 +317,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';
 
@@ -343,8 +349,8 @@
           ['chell.go', 'glados.txt', 'wheatley.md']);
       element._path = 'glados.txt';
 
-      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
-      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      const diffNavStub = sandbox.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(GerritNav, 'navigateToChange');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
@@ -376,8 +382,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, '[');
@@ -400,9 +409,9 @@
           b: {_number: 2, commit: {parents: []}},
         },
       };
-      const redirectStub = sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl');
+      const redirectStub = sandbox.stub(GerritNav, 'navigateToRelativeUrl');
       flush(() => {
-        const editBtn = Polymer.dom(element.root)
+        const editBtn = element.shadowRoot
             .querySelector('.editButton gr-button');
         assert.isTrue(!!editBtn);
         MockInteractions.tap(editBtn);
@@ -428,7 +437,7 @@
           },
         };
         flush(() => {
-          const editBtn = Polymer.dom(element.root)
+          const editBtn = element.shadowRoot
               .querySelector('.editButton gr-button');
           resolve(!!editBtn);
         });
@@ -508,7 +517,7 @@
       const overlayOpenStub = sandbox.stub(element.$.diffPreferencesDialog,
           'open');
       const prefsButton =
-          Polymer.dom(element.root).querySelector('.prefsButton');
+          dom(element.root).querySelector('.prefsButton');
 
       MockInteractions.tap(prefsButton);
 
@@ -517,21 +526,20 @@
     });
 
     test('_computeCommentString', done => {
-      loadCommentSpy = sandbox.spy(element.$.commentAPI, 'loadAll');
       const path = '/test';
       element.$.commentAPI.loadAll().then(comments => {
         const commentCountStub =
             sandbox.stub(comments, 'computeCommentCount');
         const unresolvedCountStub =
             sandbox.stub(comments, 'computeUnresolvedNum');
-        commentCountStub.withArgs(1, path).returns(0);
-        commentCountStub.withArgs(2, path).returns(1);
-        commentCountStub.withArgs(3, path).returns(2);
-        commentCountStub.withArgs(4, path).returns(0);
-        unresolvedCountStub.withArgs(1, path).returns(1);
-        unresolvedCountStub.withArgs(2, path).returns(0);
-        unresolvedCountStub.withArgs(3, path).returns(2);
-        unresolvedCountStub.withArgs(4, path).returns(0);
+        commentCountStub.withArgs({patchNum: 1, path}).returns(0);
+        commentCountStub.withArgs({patchNum: 2, path}).returns(1);
+        commentCountStub.withArgs({patchNum: 3, path}).returns(2);
+        commentCountStub.withArgs({patchNum: 4, path}).returns(0);
+        unresolvedCountStub.withArgs({patchNum: 1, path}).returns(1);
+        unresolvedCountStub.withArgs({patchNum: 2, path}).returns(0);
+        unresolvedCountStub.withArgs({patchNum: 3, path}).returns(2);
+        unresolvedCountStub.withArgs({patchNum: 4, path}).returns(0);
 
         assert.equal(element._computeCommentString(comments, 1, path, {}),
             '1 unresolved');
@@ -545,7 +553,9 @@
             element._computeCommentString(comments, 3, path, {status: 'A'}),
             '2 comments, 2 unresolved');
         assert.equal(
-            element._computeCommentString(comments, 4, path, {status: 'M'}), '');
+            element._computeCommentString(
+                comments, 4, path, {status: 'M'}
+            ), '');
         assert.equal(
             element._computeCommentString(comments, 4, path, {status: 'U'}),
             'no changes');
@@ -555,12 +565,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(
+            GerritNav,
+            'getUrlForDiff',
+            (c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
+        sandbox.stub(
+            GerritNav
+            , 'getUrlForChange',
+            (c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
       });
 
       test('_formattedFiles', () => {
@@ -625,7 +637,7 @@
             ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
         flushAsynchronousOperations();
-        const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+        const linkEls = dom(element.root).querySelectorAll('.navLink');
         assert.equal(linkEls.length, 3);
         assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
         assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
@@ -668,7 +680,7 @@
             ['chell.go', 'glados.txt', 'wheatley.md']);
         element._path = 'glados.txt';
         flushAsynchronousOperations();
-        const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+        const linkEls = dom(element.root).querySelectorAll('.navLink');
         assert.equal(linkEls.length, 3);
         assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
         assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
@@ -687,7 +699,7 @@
     });
 
     test('_handlePatchChange calls navigateToDiff correctly', () => {
-      const navigateStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const navigateStub = sandbox.stub(GerritNav, 'navigateToDiff');
       element._change = {_number: 321, project: 'foo/bar'};
       element._path = 'path/to/file.txt';
 
@@ -717,7 +729,7 @@
       sandbox.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
       element.params = {
-        view: Gerrit.Nav.View.DIFF,
+        view: GerritNav.View.DIFF,
         changeNum: '42',
         patchNum: '2',
         basePatchNum: '1',
@@ -743,7 +755,7 @@
 
       element._loggedIn = true;
       element.params = {
-        view: Gerrit.Nav.View.DIFF,
+        view: GerritNav.View.DIFF,
         changeNum: '42',
         patchNum: '2',
         basePatchNum: '1',
@@ -752,7 +764,7 @@
       element._prefs = {};
       flushAsynchronousOperations();
 
-      const commitMsg = Polymer.dom(element.root).querySelector(
+      const commitMsg = dom(element.root).querySelector(
           'input[type="checkbox"]');
 
       assert.isTrue(commitMsg.checked);
@@ -765,11 +777,11 @@
       assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
       const callCount = saveReviewedStub.callCount;
 
-      element.set('params.view', Gerrit.Nav.View.CHANGE);
+      element.set('params.view', GerritNav.View.CHANGE);
       flushAsynchronousOperations();
 
       // saveReviewedState observer observes params, but should not fire when
-      // view !== Gerrit.Nav.View.DIFF.
+      // view !== GerritNav.View.DIFF.
       assert.equal(saveReviewedStub.callCount, callCount);
     });
 
@@ -790,7 +802,7 @@
 
       element._loggedIn = true;
       element.params = {
-        view: Gerrit.Nav.View.DIFF,
+        view: GerritNav.View.DIFF,
         changeNum: '42',
         patchNum: '2',
         basePatchNum: '1',
@@ -877,7 +889,7 @@
 
       test('uses the patchNum and basePatchNum ', done => {
         element.params = {
-          view: Gerrit.Nav.View.DIFF,
+          view: GerritNav.View.DIFF,
           changeNum: '42',
           patchNum: '4',
           basePatchNum: '2',
@@ -894,7 +906,7 @@
 
       test('uses the parent when there is no base patch num ', done => {
         element.params = {
-          view: Gerrit.Nav.View.DIFF,
+          view: GerritNav.View.DIFF,
           changeNum: '42',
           patchNum: '5',
           path: '/COMMIT_MSG',
@@ -949,9 +961,8 @@
     });
 
     test('_onLineSelected', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sandbox.stub(history, 'replaceState');
-      const moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
       sandbox.stub(element.$.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: false});
 
@@ -966,16 +977,12 @@
 
       element._onLineSelected(e, detail);
 
-      assert.isTrue(moveStub.called);
-      assert.equal(moveStub.lastCall.args[0], detail.number);
-      assert.equal(moveStub.lastCall.args[1], detail.side);
-
       assert.isTrue(replaceStateStub.called);
       assert.isTrue(getUrlStub.called);
     });
 
     test('_onLineSelected w/o line address', () => {
-      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
       sandbox.stub(history, 'replaceState');
       sandbox.stub(element.$.cursor, 'moveToLineNumber');
       sandbox.stub(element.$.cursor, 'getAddress').returns(null);
@@ -1091,7 +1098,7 @@
 
         setup(() => {
           navToChangeStub = sandbox.stub(element, '_navToChangeView');
-          navToDiffStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+          navToDiffStub = sandbox.stub(GerritNav, 'navigateToDiff');
           element._files = getFilesFromFileList([
             'path/one.jpg', 'path/two.m4v', 'path/three.wav',
           ]);
@@ -1208,6 +1215,21 @@
       return Promise.all(promises);
     });
 
+    suite('blame', () => {
+      test('toggle blame with button', () => {
+        const toggleBlame = sandbox.stub(
+            element.$.diffHost, 'loadBlame', () => Promise.resolve());
+        MockInteractions.tap(element.$.toggleBlame);
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+      test('toggle blame with shortcut', () => {
+        const toggleBlame = sandbox.stub(
+            element.$.diffHost, 'loadBlame', () => Promise.resolve());
+        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+    });
+
     suite('editMode behavior', () => {
       setup(() => {
         element._loggedIn = true;
@@ -1235,7 +1257,7 @@
       sandbox.stub(element, '_initCursor');
       const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
       element._paramsChanged({
-        view: Gerrit.Nav.View.DIFF,
+        view: GerritNav.View.DIFF,
         changeNum: 101,
         project: 'test-project',
         path: '',
@@ -1265,25 +1287,25 @@
       element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
       sandbox.stub(element, '_getLineOfInterest');
       sandbox.stub(element, '_initCursor');
-      sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      sandbox.stub(GerritNav, 'navigateToDiff');
 
       // Load file1
       element._paramsChanged({
-        view: Gerrit.Nav.View.DIFF,
+        view: GerritNav.View.DIFF,
         patchNum: 1,
         changeNum: 101,
         project: 'test-project',
         path: 'file1',
       });
-      assert.isTrue(Gerrit.Nav.navigateToDiff.notCalled);
+      assert.isTrue(GerritNav.navigateToDiff.notCalled);
 
       // Switch to file2
       element.$.dropdown.value = 'file2';
-      assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
+      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
 
       // This is to mock the param change triggered by above navigate
       element._paramsChanged({
-        view: Gerrit.Nav.View.DIFF,
+        view: GerritNav.View.DIFF,
         patchNum: 1,
         changeNum: 101,
         project: 'test-project',
@@ -1291,7 +1313,7 @@
       });
 
       // No extra call
-      assert.isTrue(Gerrit.Nav.navigateToDiff.calledOnce);
+      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
     });
 
     test('_computeDownloadDropdownLinks', () => {
@@ -1391,6 +1413,8 @@
   });
 
   suite('gr-diff-view tests unmodified files with comments', () => {
+    let sandbox;
+    let element;
     setup(() => {
       sandbox = sinon.sandbox.create();
       const changedFiles = {
@@ -1425,18 +1449,23 @@
         },
       };
       const changeComments = {
-        getPaths: sandbox.stub().returns({'file2.txt': {}, 'file1.txt': {}}),
+        getPaths: sandbox.stub().returns({
+          'file2.txt': {},
+          'file1.txt': {},
+        }),
       };
-      return element._getFiles(23, patchChangeRecord, changeComments).then(() => {
-        assert.deepEqual(element._files, {
-          sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
-          changeFilesByPath: {
-            'file1.txt': {},
-            'file2.txt': {status: 'U'},
-            'a/b/test.c': {},
-          },
-        });
-      });
+      return element._getFiles(23, patchChangeRecord, changeComments)
+          .then(() => {
+            assert.deepEqual(element._files, {
+              sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
+              changeFilesByPath: {
+                'file1.txt': {},
+                'file2.txt': {status: 'U'},
+                'a/b/test.c': {},
+              },
+            });
+          });
     });
   });
+});
 </script>
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..bfd063a 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,276 +14,269 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffLine) {
-  'use strict';
+import {GrDiffLine} from './gr-diff-line.js';
 
-  // Prevent redefinition.
-  if (window.GrDiffGroup) { return; }
+/**
+ * A chunk of the diff that should be rendered together.
+ *
+ * @constructor
+ * @param {!GrDiffGroup.Type} type
+ * @param {!Array<!GrDiffLine>=} opt_lines
+ */
+export function GrDiffGroup(type, opt_lines) {
+  /** @type {!GrDiffGroup.Type} */
+  this.type = type;
+
+  /** @type {boolean} */
+  this.dueToRebase = false;
 
   /**
-   * A chunk of the diff that should be rendered together.
+   * True means all changes in this line are whitespace changes that should
+   * not be highlighted as changed as per the user settings.
    *
-   * @param {!GrDiffGroup.Type} type
-   * @param {!Array<!GrDiffLine>=} opt_lines
+   * @type{boolean}
    */
-  function GrDiffGroup(type, opt_lines) {
-    /** @type {!GrDiffGroup.Type} */
-    this.type = type;
+  this.ignoredWhitespaceOnly = false;
 
-    /** @type {boolean} */
-    this.dueToRebase = false;
+  /**
+   * True means it should not be collapsed (because it was in the URL, or
+   * there is a comment on that line)
+   */
+  this.keyLocation = false;
 
-    /**
-     * True means all changes in this line are whitespace changes that should
-     * not be highlighted as changed as per the user settings.
-     *
-     * @type{boolean}
-     */
-    this.ignoredWhitespaceOnly = false;
+  /** @type {?HTMLElement} */
+  this.element = null;
 
-    /**
-     * True means it should not be collapsed (because it was in the URL, or
-     * there is a comment on that line)
-     */
-    this.keyLocation = false;
+  /** @type {!Array<!GrDiffLine>} */
+  this.lines = [];
+  /** @type {!Array<!GrDiffLine>} */
+  this.adds = [];
+  /** @type {!Array<!GrDiffLine>} */
+  this.removes = [];
 
-    /** @type {?HTMLElement} */
-    this.element = null;
+  /** Both start and end line are inclusive. */
+  this.lineRange = {
+    left: {start: null, end: null},
+    right: {start: null, end: null},
+  };
 
-    /** @type {!Array<!GrDiffLine>} */
-    this.lines = [];
-    /** @type {!Array<!GrDiffLine>} */
-    this.adds = [];
-    /** @type {!Array<!GrDiffLine>} */
-    this.removes = [];
+  if (opt_lines) {
+    opt_lines.forEach(this.addLine, this);
+  }
+}
 
-    /** Both start and end line are inclusive. */
-    this.lineRange = {
-      left: {start: null, end: null},
-      right: {start: null, end: null},
-    };
+/** @enum {string} */
+GrDiffGroup.Type = {
+  /** Unchanged context. */
+  BOTH: 'both',
 
-    if (opt_lines) {
-      opt_lines.forEach(this.addLine, this);
+  /** A widget used to show more context. */
+  CONTEXT_CONTROL: 'contextControl',
+
+  /** Added, removed or modified chunk. */
+  DELTA: 'delta',
+};
+
+/**
+ * Hides lines in the given range behind a context control group.
+ *
+ * Groups that would be partially visible are split into their visible and
+ * hidden parts, respectively.
+ * The groups need to be "common groups", meaning they have to have either
+ * originated from an `ab` chunk, or from an `a`+`b` chunk with
+ * `common: true`.
+ *
+ * If the hidden range is 1 line or less, nothing is hidden and no context
+ * control group is created.
+ *
+ * @param {!Array<!GrDiffGroup>} groups Common groups, ordered by their line
+ *     ranges.
+ * @param {number} hiddenStart The first element to be hidden, as a
+ *     non-negative line number offset relative to the first group's start
+ *     line, left and right respectively.
+ * @param {number} hiddenEnd The first visible element after the hidden range,
+ *     as a non-negative line number offset relative to the first group's
+ *     start line, left and right respectively.
+ * @return {!Array<!GrDiffGroup>}
+ */
+GrDiffGroup.hideInContextControl = function(groups, hiddenStart, hiddenEnd) {
+  if (groups.length === 0) return [];
+  // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
+  hiddenStart = Math.max(hiddenStart, 0);
+  hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+
+  let before = [];
+  let hidden = groups;
+  let after = [];
+
+  const numHidden = hiddenEnd - hiddenStart;
+
+  // Only collapse if there is more than 1 line to be hidden.
+  if (numHidden > 1) {
+    if (hiddenStart) {
+      [before, hidden] = GrDiffGroup._splitCommonGroups(hidden, hiddenStart);
+    }
+    if (hiddenEnd) {
+      [hidden, after] = GrDiffGroup._splitCommonGroups(
+          hidden, hiddenEnd - hiddenStart);
+    }
+  } else {
+    [hidden, after] = [[], hidden];
+  }
+
+  const result = [...before];
+  if (hidden.length) {
+    const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+    ctxLine.contextGroups = hidden;
+    const ctxGroup = new GrDiffGroup(
+        GrDiffGroup.Type.CONTEXT_CONTROL, [ctxLine]);
+    result.push(ctxGroup);
+  }
+  result.push(...after);
+  return result;
+};
+
+/**
+ * Splits a list of common groups into two lists of groups.
+ *
+ * Groups where all lines are before or all lines are after the split will be
+ * retained as is and put into the first or second list respectively. Groups
+ * with some lines before and some lines after the split will be split into
+ * two groups, which will be put into the first and second list.
+ *
+ * @param {!Array<!GrDiffGroup>} groups
+ * @param {number} split A line number offset relative to the first group's
+ *     start line at which the groups should be split.
+ * @return {!Array<!Array<!GrDiffGroup>>} The outer array has 2 elements, the
+ *   list of groups before and the list of groups after the split.
+ */
+GrDiffGroup._splitCommonGroups = function(groups, split) {
+  if (groups.length === 0) return [[], []];
+  const leftSplit = groups[0].lineRange.left.start + split;
+  const rightSplit = groups[0].lineRange.right.start + split;
+
+  const beforeGroups = [];
+  const afterGroups = [];
+  for (const group of groups) {
+    if (group.lineRange.left.end < leftSplit ||
+        group.lineRange.right.end < rightSplit) {
+      beforeGroups.push(group);
+      continue;
+    }
+    if (leftSplit <= group.lineRange.left.start ||
+        rightSplit <= group.lineRange.right.start) {
+      afterGroups.push(group);
+      continue;
+    }
+
+    const before = [];
+    const after = [];
+    for (const line of group.lines) {
+      if ((line.beforeNumber && line.beforeNumber < leftSplit) ||
+          (line.afterNumber && line.afterNumber < rightSplit)) {
+        before.push(line);
+      } else {
+        after.push(line);
+      }
+    }
+
+    if (before.length) {
+      beforeGroups.push(before.length === group.lines.length ?
+        group : group.cloneWithLines(before));
+    }
+    if (after.length) {
+      afterGroups.push(after.length === group.lines.length ?
+        group : group.cloneWithLines(after));
+    }
+  }
+  return [beforeGroups, afterGroups];
+};
+
+/**
+ * Creates a new group with the same properties but different lines.
+ *
+ * The element property is not copied, because the original element is still a
+ * rendering of the old lines, so that would not make sense.
+ *
+ * @param {!Array<!GrDiffLine>} lines
+ * @return {!GrDiffGroup}
+ */
+GrDiffGroup.prototype.cloneWithLines = function(lines) {
+  const group = new GrDiffGroup(this.type, lines);
+  group.dueToRebase = this.dueToRebase;
+  group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
+  return group;
+};
+
+/** @param {!GrDiffLine} line */
+GrDiffGroup.prototype.addLine = function(line) {
+  this.lines.push(line);
+
+  const notDelta = (this.type === GrDiffGroup.Type.BOTH ||
+      this.type === GrDiffGroup.Type.CONTEXT_CONTROL);
+  if (notDelta && (line.type === GrDiffLine.Type.ADD ||
+      line.type === GrDiffLine.Type.REMOVE)) {
+    throw Error('Cannot add delta line to a non-delta group.');
+  }
+
+  if (line.type === GrDiffLine.Type.ADD) {
+    this.adds.push(line);
+  } else if (line.type === GrDiffLine.Type.REMOVE) {
+    this.removes.push(line);
+  }
+  this._updateRange(line);
+};
+
+/** @return {!Array<{left: GrDiffLine, right: GrDiffLine}>} */
+GrDiffGroup.prototype.getSideBySidePairs = function() {
+  if (this.type === GrDiffGroup.Type.BOTH ||
+      this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
+    return this.lines.map(line => {
+      return {
+        left: line,
+        right: line,
+      };
+    });
+  }
+
+  const pairs = [];
+  let i = 0;
+  let j = 0;
+  while (i < this.removes.length || j < this.adds.length) {
+    pairs.push({
+      left: this.removes[i] || GrDiffLine.BLANK_LINE,
+      right: this.adds[j] || GrDiffLine.BLANK_LINE,
+    });
+    i++;
+    j++;
+  }
+  return pairs;
+};
+
+GrDiffGroup.prototype._updateRange = function(line) {
+  if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') { return; }
+
+  if (line.type === GrDiffLine.Type.ADD ||
+      line.type === GrDiffLine.Type.BOTH) {
+    if (this.lineRange.right.start === null ||
+        line.afterNumber < this.lineRange.right.start) {
+      this.lineRange.right.start = line.afterNumber;
+    }
+    if (this.lineRange.right.end === null ||
+        line.afterNumber > this.lineRange.right.end) {
+      this.lineRange.right.end = line.afterNumber;
     }
   }
 
-  /** @enum {string} */
-  GrDiffGroup.Type = {
-    /** Unchanged context. */
-    BOTH: 'both',
-
-    /** A widget used to show more context. */
-    CONTEXT_CONTROL: 'contextControl',
-
-    /** Added, removed or modified chunk. */
-    DELTA: 'delta',
-  };
-
-
-  /**
-   * Hides lines in the given range behind a context control group.
-   *
-   * Groups that would be partially visible are split into their visible and
-   * hidden parts, respectively.
-   * The groups need to be "common groups", meaning they have to have either
-   * originated from an `ab` chunk, or from an `a`+`b` chunk with
-   * `common: true`.
-   *
-   * If the hidden range is 1 line or less, nothing is hidden and no context
-   * control group is created.
-   *
-   * @param {!Array<!GrDiffGroup>} groups Common groups, ordered by their line
-   *     ranges.
-   * @param {number} hiddenStart The first element to be hidden, as a
-   *     non-negative line number offset relative to the first group's start
-   *     line, left and right respectively.
-   * @param {number} hiddenEnd The first visible element after the hidden range,
-   *     as a non-negative line number offset relative to the first group's
-   *     start line, left and right respectively.
-   * @return {!Array<!GrDiffGroup>}
-   */
-  GrDiffGroup.hideInContextControl = function(groups, hiddenStart, hiddenEnd) {
-    if (groups.length === 0) return [];
-    // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
-    hiddenStart = Math.max(hiddenStart, 0);
-    hiddenEnd = Math.max(hiddenEnd, hiddenStart);
-
-    let before = [];
-    let hidden = groups;
-    let after = [];
-
-    const numHidden = hiddenEnd - hiddenStart;
-
-    // Only collapse if there is more than 1 line to be hidden.
-    if (numHidden > 1) {
-      if (hiddenStart) {
-        [before, hidden] = GrDiffGroup._splitCommonGroups(hidden, hiddenStart);
-      }
-      if (hiddenEnd) {
-        [hidden, after] = GrDiffGroup._splitCommonGroups(
-            hidden, hiddenEnd - hiddenStart);
-      }
-    } else {
-      [hidden, after] = [[], hidden];
+  if (line.type === GrDiffLine.Type.REMOVE ||
+      line.type === GrDiffLine.Type.BOTH) {
+    if (this.lineRange.left.start === null ||
+        line.beforeNumber < this.lineRange.left.start) {
+      this.lineRange.left.start = line.beforeNumber;
     }
-
-    const result = [...before];
-    if (hidden.length) {
-      const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-      ctxLine.contextGroups = hidden;
-      const ctxGroup = new GrDiffGroup(
-          GrDiffGroup.Type.CONTEXT_CONTROL, [ctxLine]);
-      result.push(ctxGroup);
+    if (this.lineRange.left.end === null ||
+        line.beforeNumber > this.lineRange.left.end) {
+      this.lineRange.left.end = line.beforeNumber;
     }
-    result.push(...after);
-    return result;
-  };
-
-  /**
-   * Splits a list of common groups into two lists of groups.
-   *
-   * Groups where all lines are before or all lines are after the split will be
-   * retained as is and put into the first or second list respectively. Groups
-   * with some lines before and some lines after the split will be split into
-   * two groups, which will be put into the first and second list.
-   *
-   * @param {!Array<!GrDiffGroup>} groups
-   * @param {number} split A line number offset relative to the first group's
-   *     start line at which the groups should be split.
-   * @return {!Array<!Array<!GrDiffGroup>>} The outer array has 2 elements, the
-   *   list of groups before and the list of groups after the split.
-   */
-  GrDiffGroup._splitCommonGroups = function(groups, split) {
-    if (groups.length === 0) return [[], []];
-    const leftSplit = groups[0].lineRange.left.start + split;
-    const rightSplit = groups[0].lineRange.right.start + split;
-
-    const beforeGroups = [];
-    const afterGroups = [];
-    for (const group of groups) {
-      if (group.lineRange.left.end < leftSplit ||
-          group.lineRange.right.end < rightSplit) {
-        beforeGroups.push(group);
-        continue;
-      }
-      if (leftSplit <= group.lineRange.left.start ||
-          rightSplit <= group.lineRange.right.start) {
-        afterGroups.push(group);
-        continue;
-      }
-
-      const before = [];
-      const after = [];
-      for (const line of group.lines) {
-        if ((line.beforeNumber && line.beforeNumber < leftSplit) ||
-            (line.afterNumber && line.afterNumber < rightSplit)) {
-          before.push(line);
-        } else {
-          after.push(line);
-        }
-      }
-
-      if (before.length) {
-        beforeGroups.push(before.length === group.lines.length ?
-          group : group.cloneWithLines(before));
-      }
-      if (after.length) {
-        afterGroups.push(after.length === group.lines.length ?
-          group : group.cloneWithLines(after));
-      }
-    }
-    return [beforeGroups, afterGroups];
-  };
-
-  /**
-   * Creates a new group with the same properties but different lines.
-   *
-   * The element property is not copied, because the original element is still a
-   * rendering of the old lines, so that would not make sense.
-   *
-   * @param {!Array<!GrDiffLine>} lines
-   * @return {!GrDiffGroup}
-   */
-  GrDiffGroup.prototype.cloneWithLines = function(lines) {
-    const group = new GrDiffGroup(this.type, lines);
-    group.dueToRebase = this.dueToRebase;
-    group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
-    return group;
-  };
-
-  /** @param {!GrDiffLine} line */
-  GrDiffGroup.prototype.addLine = function(line) {
-    this.lines.push(line);
-
-    const notDelta = (this.type === GrDiffGroup.Type.BOTH ||
-        this.type === GrDiffGroup.Type.CONTEXT_CONTROL);
-    if (notDelta && (line.type === GrDiffLine.Type.ADD ||
-        line.type === GrDiffLine.Type.REMOVE)) {
-      throw Error('Cannot add delta line to a non-delta group.');
-    }
-
-    if (line.type === GrDiffLine.Type.ADD) {
-      this.adds.push(line);
-    } else if (line.type === GrDiffLine.Type.REMOVE) {
-      this.removes.push(line);
-    }
-    this._updateRange(line);
-  };
-
-  /** @return {!Array<{left: GrDiffLine, right: GrDiffLine}>} */
-  GrDiffGroup.prototype.getSideBySidePairs = function() {
-    if (this.type === GrDiffGroup.Type.BOTH ||
-        this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
-      return this.lines.map(line => {
-        return {
-          left: line,
-          right: line,
-        };
-      });
-    }
-
-    const pairs = [];
-    let i = 0;
-    let j = 0;
-    while (i < this.removes.length || j < this.adds.length) {
-      pairs.push({
-        left: this.removes[i] || GrDiffLine.BLANK_LINE,
-        right: this.adds[j] || GrDiffLine.BLANK_LINE,
-      });
-      i++;
-      j++;
-    }
-    return pairs;
-  };
-
-  GrDiffGroup.prototype._updateRange = function(line) {
-    if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') { return; }
-
-    if (line.type === GrDiffLine.Type.ADD ||
-        line.type === GrDiffLine.Type.BOTH) {
-      if (this.lineRange.right.start === null ||
-          line.afterNumber < this.lineRange.right.start) {
-        this.lineRange.right.start = line.afterNumber;
-      }
-      if (this.lineRange.right.end === null ||
-          line.afterNumber > this.lineRange.right.end) {
-        this.lineRange.right.end = line.afterNumber;
-      }
-    }
-
-    if (line.type === GrDiffLine.Type.REMOVE ||
-        line.type === GrDiffLine.Type.BOTH) {
-      if (this.lineRange.left.start === null ||
-          line.beforeNumber < this.lineRange.left.start) {
-        this.lineRange.left.start = line.beforeNumber;
-      }
-      if (this.lineRange.left.end === null ||
-          line.beforeNumber > this.lineRange.left.end) {
-        this.lineRange.left.end = line.beforeNumber;
-      }
-    }
-  };
-
-  window.GrDiffGroup = GrDiffGroup;
-})(window, GrDiffLine);
+  }
+};
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..d50a7f4 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
@@ -17,193 +17,193 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="gr-diff-line.js"></script>
-<script src="gr-diff-group.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-diff-group tests', () => {
-    test('delta line pairs', () => {
-      let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
-      const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
-      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
-      group.addLine(l1);
-      group.addLine(l2);
-      group.addLine(l3);
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, [l1, l2]);
-      assert.deepEqual(group.removes, [l3]);
-      assert.deepEqual(group.lineRange, {
-        left: {start: 64, end: 64},
-        right: {start: 128, end: 129},
-      });
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {GrDiffLine} from './gr-diff-line.js';
+import {GrDiffGroup} from './gr-diff-group.js';
 
-      let pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l3, right: l1},
-        {left: GrDiffLine.BLANK_LINE, right: l2},
-      ]);
-
-      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, [l1, l2]);
-      assert.deepEqual(group.removes, [l3]);
-
-      pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l3, right: l1},
-        {left: GrDiffLine.BLANK_LINE, right: l2},
-      ]);
+suite('gr-diff-group tests', () => {
+  test('delta line pairs', () => {
+    let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+    const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
+    const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
+    const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
+    group.addLine(l1);
+    group.addLine(l2);
+    group.addLine(l3);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+    assert.deepEqual(group.lineRange, {
+      left: {start: 64, end: 64},
+      right: {start: 128, end: 129},
     });
 
-    test('group/header line pairs', () => {
-      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
-      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
-      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: GrDiffLine.BLANK_LINE, right: l2},
+    ]);
 
-      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+    group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
 
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, []);
-      assert.deepEqual(group.removes, []);
-
-      assert.deepEqual(group.lineRange, {
-        left: {start: 64, end: 66},
-        right: {start: 128, end: 130},
-      });
-
-      let pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l1, right: l1},
-        {left: l2, right: l2},
-        {left: l3, right: l3},
-      ]);
-
-      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
-      assert.deepEqual(group.lines, [l1, l2, l3]);
-      assert.deepEqual(group.adds, []);
-      assert.deepEqual(group.removes, []);
-
-      pairs = group.getSideBySidePairs();
-      assert.deepEqual(pairs, [
-        {left: l1, right: l1},
-        {left: l2, right: l2},
-        {left: l3, right: l3},
-      ]);
-    });
-
-    test('adding delta lines to non-delta group', () => {
-      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
-
-      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
-      assert.throws(group.addLine.bind(group, l1));
-      assert.throws(group.addLine.bind(group, l2));
-      assert.doesNotThrow(group.addLine.bind(group, l3));
-
-      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
-      assert.throws(group.addLine.bind(group, l1));
-      assert.throws(group.addLine.bind(group, l2));
-      assert.doesNotThrow(group.addLine.bind(group, l3));
-    });
-
-    suite('hideInContextControl', () => {
-      let groups;
-      setup(() => {
-        groups = [
-          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
-            new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
-          ]),
-          new GrDiffGroup(GrDiffGroup.Type.DELTA, [
-            new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
-            new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
-            new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
-            new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
-            new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
-            new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
-          ]),
-          new GrDiffGroup(GrDiffGroup.Type.BOTH, [
-            new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
-            new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
-          ]),
-        ];
-      });
-
-      test('hides hidden groups in context control', () => {
-        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
-        assert.equal(collapsedGroups.length, 3);
-
-        assert.equal(collapsedGroups[0], groups[0]);
-
-        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-        assert.equal(collapsedGroups[1].lines.length, 1);
-        assert.equal(
-            collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
-        assert.equal(
-            collapsedGroups[1].lines[0].contextGroups.length, 1);
-        assert.equal(
-            collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
-
-        assert.equal(collapsedGroups[2], groups[2]);
-      });
-
-      test('splits partially hidden groups', () => {
-        const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
-        assert.equal(collapsedGroups.length, 4);
-        assert.equal(collapsedGroups[0], groups[0]);
-
-        assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
-        assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
-        assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
-
-        assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-        assert.equal(collapsedGroups[2].lines.length, 1);
-        assert.equal(
-            collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
-        assert.equal(
-            collapsedGroups[2].lines[0].contextGroups.length, 2);
-
-        assert.equal(
-            collapsedGroups[2].lines[0].contextGroups[0].type,
-            GrDiffGroup.Type.DELTA);
-        assert.deepEqual(
-            collapsedGroups[2].lines[0].contextGroups[0].adds,
-            groups[1].adds.slice(1));
-        assert.deepEqual(
-            collapsedGroups[2].lines[0].contextGroups[0].removes,
-            groups[1].removes.slice(1));
-
-        assert.equal(
-            collapsedGroups[2].lines[0].contextGroups[1].type,
-            GrDiffGroup.Type.BOTH);
-        assert.deepEqual(
-            collapsedGroups[2].lines[0].contextGroups[1].lines,
-            [groups[2].lines[0]]);
-
-        assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
-        assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
-      });
-
-      test('groups unchanged if the hidden range is empty', () => {
-        assert.deepEqual(
-            GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
-      });
-
-      test('groups unchanged if there is only 1 line to hide', () => {
-        assert.deepEqual(
-            GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
-      });
-    });
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: GrDiffLine.BLANK_LINE, right: l2},
+    ]);
   });
 
+  test('group/header line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
+    const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
+    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
+
+    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    assert.deepEqual(group.lineRange, {
+      left: {start: 64, end: 66},
+      right: {start: 128, end: 130},
+    });
+
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+
+    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+  });
+
+  test('adding delta lines to non-delta group', () => {
+    const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+    const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+
+    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+    assert.throws(group.addLine.bind(group, l1));
+    assert.throws(group.addLine.bind(group, l2));
+    assert.doesNotThrow(group.addLine.bind(group, l3));
+
+    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
+    assert.throws(group.addLine.bind(group, l1));
+    assert.throws(group.addLine.bind(group, l2));
+    assert.doesNotThrow(group.addLine.bind(group, l3));
+  });
+
+  suite('hideInContextControl', () => {
+    let groups;
+    setup(() => {
+      groups = [
+        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+          new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
+        ]),
+        new GrDiffGroup(GrDiffGroup.Type.DELTA, [
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
+        ]),
+        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+          new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
+        ]),
+      ];
+    });
+
+    test('hides hidden groups in context control', () => {
+      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
+      assert.equal(collapsedGroups.length, 3);
+
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[1].lines.length, 1);
+      assert.equal(
+          collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+      assert.equal(
+          collapsedGroups[1].lines[0].contextGroups.length, 1);
+      assert.equal(
+          collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
+
+      assert.equal(collapsedGroups[2], groups[2]);
+    });
+
+    test('splits partially hidden groups', () => {
+      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
+      assert.equal(collapsedGroups.length, 4);
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
+      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+      assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[2].lines.length, 1);
+      assert.equal(
+          collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
+      assert.equal(
+          collapsedGroups[2].lines[0].contextGroups.length, 2);
+
+      assert.equal(
+          collapsedGroups[2].lines[0].contextGroups[0].type,
+          GrDiffGroup.Type.DELTA);
+      assert.deepEqual(
+          collapsedGroups[2].lines[0].contextGroups[0].adds,
+          groups[1].adds.slice(1));
+      assert.deepEqual(
+          collapsedGroups[2].lines[0].contextGroups[0].removes,
+          groups[1].removes.slice(1));
+
+      assert.equal(
+          collapsedGroups[2].lines[0].contextGroups[1].type,
+          GrDiffGroup.Type.BOTH);
+      assert.deepEqual(
+          collapsedGroups[2].lines[0].contextGroups[1].lines,
+          [groups[2].lines[0]]);
+
+      assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
+      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+    });
+
+    test('groups unchanged if the hidden range is empty', () => {
+      assert.deepEqual(
+          GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
+    });
+
+    test('groups unchanged if there is only 1 line to hide', () => {
+      assert.deepEqual(
+          GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
+    });
+  });
+});
 </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..70387ca 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
@@ -14,66 +14,60 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrDiffLine) { return; }
+/**
+ * @constructor
+ * @param {GrDiffLine.Type} type
+ * @param {number|string=} opt_beforeLine
+ * @param {number|string=} opt_afterLine
+ */
+export function GrDiffLine(type, opt_beforeLine, opt_afterLine) {
+  this.type = type;
 
-  /**
-   * @param {GrDiffLine.Type} type
-   * @param {number|string=} opt_beforeLine
-   * @param {number|string=} opt_afterLine
-   */
-  function GrDiffLine(type, opt_beforeLine, opt_afterLine) {
-    this.type = type;
+  /** @type {number|string} */
+  this.beforeNumber = opt_beforeLine || 0;
 
-    /** @type {number|string} */
-    this.beforeNumber = opt_beforeLine || 0;
+  /** @type {number|string} */
+  this.afterNumber = opt_afterLine || 0;
 
-    /** @type {number|string} */
-    this.afterNumber = opt_afterLine || 0;
+  /** @type {boolean} */
+  this.hasIntralineInfo = false;
 
-    /** @type {boolean} */
-    this.hasIntralineInfo = false;
+  /** @type {!Array<GrDiffLine.Highlights>} */
+  this.highlights = [];
 
-    /** @type {Array<GrDiffLine.Highlights>} */
-    this.highlights = [];
+  /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
+  this.contextGroups = null;
 
-    /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
-    this.contextGroups = null;
+  this.text = '';
+}
 
-    this.text = '';
-  }
+/** @enum {string} */
+GrDiffLine.Type = {
+  ADD: 'add',
+  BOTH: 'both',
+  BLANK: 'blank',
+  CONTEXT_CONTROL: 'contextControl',
+  REMOVE: 'remove',
+};
 
-  GrDiffLine.Type = {
-    ADD: 'add',
-    BOTH: 'both',
-    BLANK: 'blank',
-    CONTEXT_CONTROL: 'contextControl',
-    REMOVE: 'remove',
-  };
+/**
+ * A line highlight object consists of three fields:
+ * - contentIndex: The index of the chunk `content` field (the line
+ *   being referred to).
+ * - startIndex: Index of the character where the highlight should begin.
+ * - endIndex: (optional) Index of the character where the highlight should
+ *   end. If omitted, the highlight is meant to be a continuation onto the
+ *   next line.
+ *
+ * @typedef {{
+ *  contentIndex: number,
+ *  startIndex: number,
+ *  endIndex: number
+ * }}
+ */
+GrDiffLine.Highlights;
 
-  /**
-   * A line highlight object consists of three fields:
-   * - contentIndex: The index of the chunk `content` field (the line
-   *   being referred to).
-   * - startIndex: Index of the character where the highlight should begin.
-   * - endIndex: (optional) Index of the character where the highlight should
-   *   end. If omitted, the highlight is meant to be a continuation onto the
-   *   next line.
-   *
-   * @typedef {{
-   *  contentIndex: number,
-   *  startIndex: number,
-   *  endIndex: number
-   * }}
-   */
-  GrDiffLine.Highlights;
+GrDiffLine.FILE = 'FILE';
 
-  GrDiffLine.FILE = 'FILE';
-
-  GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
-
-  window.GrDiffLine = GrDiffLine;
-})(window);
+GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.js
new file mode 100644
index 0000000..7eee071
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.js
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** @enum {string} */
+export const DiffSide = {
+  LEFT: 'left',
+  RIGHT: 'right',
+};
+
+/**
+ * Compare two ranges. Either argument may be falsy, but will only return
+ * true if both are falsy or if neither are falsy and have the same position
+ * values.
+ *
+ * @param {Range=} a range 1
+ * @param {Range=} b range 2
+ * @return {boolean}
+ */
+export function rangesEqual(a, b) {
+  if (!a && !b) { return true; }
+  if (!a || !b) { return false; }
+  return a.start_line === b.start_line &&
+      a.start_character === b.start_character &&
+      a.end_line === b.end_line &&
+      a.end_character === b.end_character;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
deleted file mode 100644
index f6abb14..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ /dev/null
@@ -1,417 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
-<link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
-<link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
-<link rel="import" href="../gr-syntax-themes/gr-syntax-theme.html">
-<link rel="import" href="../gr-ranged-comment-themes/gr-ranged-comment-theme.html">
-
-<script src="../../../scripts/hiddenscroll.js"></script>
-
-<dom-module id="gr-diff">
-  <template>
-    <style include="shared-styles">
-      :host(.no-left) .sideBySide .left,
-      :host(.no-left) .sideBySide .left + td,
-      :host(.no-left) .sideBySide .right:not([data-value]),
-      :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));
-        line-height: var(--line-height-code, 1.334);
-      }
-
-      .thread-group {
-        display: block;
-        max-width: var(--content-width, 80ch);
-        white-space: normal;
-      }
-      .diffContainer {
-        display: flex;
-        font-family: var(--monospace-font-family);
-        @apply --diff-container-styles;
-      }
-      .diffContainer.hiddenscroll {
-        margin-bottom: var(--spacing-m);
-      }
-      table {
-        border-collapse: collapse;
-        border-right: 1px solid var(--border-color);
-        table-layout: fixed;
-      }
-      .lineNum {
-        background-color: var(--table-header-background-color);
-      }
-      .image-diff .gr-diff {
-        text-align: center;
-      }
-      .image-diff img {
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        max-width: 50em;
-      }
-      .image-diff .right.lineNum {
-        border-left: 1px solid var(--border-color);
-      }
-      .image-diff label,
-      .binary-diff label {
-        font-family: var(--font-family);
-        font-style: italic;
-      }
-      .diff-row {
-        outline: none;
-      }
-      .diff-row.target-row.target-side-left .lineNum.left,
-      .diff-row.target-row.target-side-right .lineNum.right,
-      .diff-row.target-row.unified .lineNum {
-        background-color: var(--diff-selection-background-color);
-        color: var(--primary-text-color);
-      }
-      .content {
-        background-color: var(--view-background-color);
-      }
-      .blank {
-        background-color: var(--diff-blank-background-color);
-      }
-      .image-diff .content {
-        background-color: var(--table-header-background-color);
-      }
-      .full-width {
-        width: 100%;
-      }
-      .full-width .contentText {
-        white-space: pre-wrap;
-        word-wrap: break-word;
-      }
-      .lineNum,
-      .content {
-        vertical-align: top;
-        white-space: pre;
-      }
-      .contextLineNum,
-      .lineNum {
-        -webkit-user-select: none;
-        -moz-user-select: none;
-        -ms-user-select: none;
-        user-select: none;
-
-        color: var(--deemphasized-text-color);
-        padding: 0 var(--spacing-m);
-        text-align: right;
-      }
-      .canComment .lineNum {
-        cursor: pointer;
-      }
-      .content {
-        /* Set min width since setting width on table cells still
-           allows them to shrink. Do not set max width because
-           CJK (Chinese-Japanese-Korean) glyphs have variable width */
-        min-width: var(--content-width, 80ch);
-        width: var(--content-width, 80ch);
-      }
-      .content.add .intraline,
-      /* If there are no intraline info, consider everything changed */
-      .content.add.no-intraline-info,
-      .delta.total .content.add {
-        background-color: var(--dark-add-highlight-color);
-      }
-      .content.add {
-        background-color: var(--light-add-highlight-color);
-      }
-      .content.remove .intraline,
-      /* If there are no intraline info, consider everything changed */
-      .content.remove.no-intraline-info,
-      .delta.total .content.remove {
-        background-color: var(--dark-remove-highlight-color);
-      }
-      .content.remove {
-        background-color: var(--light-remove-highlight-color);
-      }
-
-      /* dueToRebase */
-      .dueToRebase .content.add .intraline,
-      .delta.total.dueToRebase .content.add {
-        background-color: var(--dark-rebased-add-highlight-color);
-      }
-      .dueToRebase .content.add {
-        background-color: var(--light-rebased-add-highlight-color);
-      }
-      .dueToRebase .content.remove .intraline,
-      .delta.total.dueToRebase .content.remove {
-        background-color: var(--dark-rebased-remove-highlight-color);
-      }
-      .dueToRebase .content.remove {
-        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 {
-        background: none;
-      }
-
-      .content .contentText:empty:after {
-        /* Newline, to ensure empty lines are one line-height tall. */
-        content: '\A';
-      }
-      .contextControl {
-        background-color: var(--diff-context-control-background-color);
-        border: 1px solid var(--diff-context-control-border-color);
-        color: var(--diff-context-control-color);
-      }
-      .contextControl gr-button {
-        display: inline-block;
-        text-decoration: none;
-        --gr-button: {
-          color: var(--diff-context-control-color);
-          padding: var(--spacing-xs);
-        }
-      }
-      .contextControl td:not(.lineNum) {
-        text-align: center;
-      }
-      .displayLine .diff-row.target-row td {
-        box-shadow: inset 0 -1px var(--border-color);
-      }
-      .br:after {
-        /* Line feed */
-        content: '\A';
-      }
-      .tab {
-        display: inline-block;
-      }
-      .tab-indicator:before {
-        color: var(--diff-tab-indicator-color);
-        /* >> character */
-        content: '\00BB';
-        position: absolute;
-      }
-      /* Is defined after other background-colors, such that this
-         rule wins in case of same specificity. */
-      .trailing-whitespace,
-      .content .trailing-whitespace,
-      .trailing-whitespace .intraline,
-      .content .trailing-whitespace .intraline {
-        border-radius: var(--border-radius, 4px);
-        background-color: var(--diff-trailing-whitespace-indicator);
-      }
-      #diffHeader {
-        background-color: var(--table-header-background-color);
-        border-bottom: 1px solid var(--border-color);
-        color: var(--link-color);
-        padding: var(--spacing-m) 0 var(--spacing-m) 48px;
-      }
-      #loadingError,
-      #sizeWarning {
-        display: none;
-        margin: var(--spacing-l) auto;
-        max-width: 60em;
-        text-align: center;
-      }
-      #loadingError {
-        color: var(--error-text-color);
-      }
-      #sizeWarning gr-button {
-        margin: var(--spacing-l);
-      }
-      #loadingError.showError,
-      #sizeWarning.warn {
-        display: block;
-      }
-      .target-row td.blame {
-        background: var(--diff-selection-background-color);
-      }
-      col.blame {
-        display: none;
-      }
-      td.blame {
-        display: none;
-        padding: 0 var(--spacing-m);
-        white-space: pre;
-      }
-      :host(.showBlame) col.blame {
-        display: table-column;
-      }
-      :host(.showBlame) td.blame {
-        display: table-cell;
-      }
-      td.blame > span {
-        opacity: 0.6;
-      }
-      td.blame > span.startOfRange {
-        opacity: 1;
-      }
-      td.blame .sha {
-        font-family: var(--monospace-font-family);
-      }
-      .full-width td.blame {
-        overflow: hidden;
-        width: 200px;
-      }
-      /** Support the line length indicator **/
-      .full-width td.content,
-      .full-width td.blank {
-        /* Base 64 encoded 1x1px of #ddd */
-        background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mO8+x8AAr8B3gzOjaQAAAAASUVORK5CYII=');
-        background-position: var(--line-limit) 0;
-        background-repeat: repeat-y;
-      }
-      .newlineWarning {
-        color: var(--deemphasized-text-color);
-        text-align: center;
-      }
-      .newlineWarning.hidden {
-        display: none;
-      }
-      .lineNum.COVERED {
-         background-color: #E0F2F1;
-      }
-      .lineNum.NOT_COVERED {
-        background-color: #FFD1A4;
-      }
-      .lineNum.PARTIALLY_COVERED {
-        background: linear-gradient(to right bottom, #FFD1A4 0%, #FFD1A4 50%, #E0F2F1 50%, #E0F2F1 100%);
-      }
-
-      /** BEGIN: Select and copy for Polymer 2 */
-      /** Below was copied and modified from the original css in gr-diff-selection.html */
-      .content,
-      .contextControl,
-      .blame {
-        -webkit-user-select: none;
-        -moz-user-select: none;
-        -ms-user-select: none;
-        user-select: none;
-      }
-
-      .selected-left:not(.selected-comment) .side-by-side .left + .content .contentText,
-      .selected-right:not(.selected-comment) .side-by-side .right + .content .contentText,
-      .selected-left:not(.selected-comment) .unified .left.lineNum ~ .content:not(.both) .contentText,
-      .selected-right:not(.selected-comment) .unified .right.lineNum ~ .content .contentText,
-      .selected-left.selected-comment .side-by-side .left + .content .message,
-      .selected-right.selected-comment .side-by-side .right + .content .message :not(.collapsedContent),
-      .selected-comment .unified .message :not(.collapsedContent),
-      .selected-blame .blame {
-        -webkit-user-select: text;
-        -moz-user-select: text;
-        -ms-user-select: text;
-        user-select: text;
-      }
-
-      /** Make comments selectable when selected */
-      .selected-left.selected-comment ::slotted(gr-comment-thread[comment-side=left]),
-      .selected-right.selected-comment ::slotted(gr-comment-thread[comment-side=right]) {
-        -webkit-user-select: text;
-        -moz-user-select: text;
-        -ms-user-select: text;
-        user-select: text;
-      }
-      /** END: Select and copy for Polymer 2 */
-
-      .whitespace-change-only-message {
-        background-color: var(--diff-context-control-background-color);
-        border: 1px solid var(--diff-context-control-border-color);
-        text-align: center;
-      }
-    </style>
-    <style include="gr-syntax-theme"></style>
-    <style include="gr-ranged-comment-theme"></style>
-    <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
-      <template
-          is="dom-repeat"
-          items="[[_diffHeaderItems]]">
-        <div>[[item]]</div>
-      </template>
-    </div>
-    <div class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
-        on-tap="_handleTap">
-      <gr-diff-selection diff="[[diff]]">
-        <gr-diff-highlight
-            id="highlights"
-            logged-in="[[loggedIn]]"
-            comment-ranges="{{_commentRanges}}">
-          <gr-diff-builder
-              id="diffBuilder"
-              comment-ranges="[[_commentRanges]]"
-              coverage-ranges="[[coverageRanges]]"
-              project-name="[[projectName]]"
-              diff="[[diff]]"
-              path="[[path]]"
-              change-num="[[changeNum]]"
-              patch-num="[[patchRange.patchNum]]"
-              view-mode="[[viewMode]]"
-              line-wrapping="[[lineWrapping]]"
-              is-image-diff="[[isImageDiff]]"
-              base-image="[[baseImage]]"
-              layers="[[layers]]"
-              revision-image="[[revisionImage]]">
-            <table
-                id="diffTable"
-                class$="[[_diffTableClass]]"
-                role="presentation"></table>
-
-            <template is="dom-if" if="[[showNoChangeMessage(loading, prefs, _diffLength)]]">
-              <div class="whitespace-change-only-message">
-                This file only contains whitespace changes.
-                Modify the whitespace setting to see the changes.
-              </div>
-            </template>
-          </gr-diff-builder>
-        </gr-diff-highlight>
-      </gr-diff-selection>
-    </div>
-    <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
-      [[_newlineWarning]]
-    </div>
-    <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
-      [[errorMessage]]
-    </div>
-    <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
-      <p>
-        Prevented render because "Whole file" is enabled and this diff is very
-        large (about [[_diffLength]] lines).
-      </p>
-      <gr-button on-click="_handleLimitedBypass">
-        Render with limited context
-      </gr-button>
-      <gr-button on-click="_handleFullBypass">
-        Render anyway (may be slow)
-      </gr-button>
-    </div>
-  </template>
-  <script src="gr-diff-line.js"></script>
-  <script src="gr-diff-group.js"></script>
-  <!-- gr-diff.js contains an 'import' statement, which is allowed only in modules -->
-  <script src="../../../scripts/shadow.js"></script>
-  <script src="gr-diff.js"></script>
-</dom-module>
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 c6b2e3a..06ffabc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,112 +14,107 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
-  const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
-      'of an edit.';
-  const ERR_INVALID_LINE = 'Invalid line number: ';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../gr-diff-builder/gr-diff-builder-element.js';
+import '../gr-diff-highlight/gr-diff-highlight.js';
+import '../gr-diff-selection/gr-diff-selection.js';
+import '../gr-syntax-themes/gr-syntax-theme.js';
+import '../gr-ranged-comment-themes/gr-ranged-comment-theme.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {htmlTemplate} from './gr-diff_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {GrDiffLine} from './gr-diff-line.js';
+import {DiffSide, rangesEqual} from './gr-diff-utils.js';
+import {getHiddenScroll} from '../../../scripts/hiddenscroll.js';
+import * as shadow from 'shadow-selection-polyfill/shadow.js';
 
-  const NO_NEWLINE_BASE = 'No newline at end of base file.';
-  const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
+const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
+    'of an edit.';
+const ERR_INVALID_LINE = 'Invalid line number: ';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+const NO_NEWLINE_BASE = 'No newline at end of base file.';
+const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
 
-  const DiffSide = {
-    LEFT: 'left',
-    RIGHT: 'right',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
 
-  const LARGE_DIFF_THRESHOLD_LINES = 10000;
-  const FULL_CONTEXT = -1;
-  const LIMITED_CONTEXT = 10;
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+const FULL_CONTEXT = -1;
+const LIMITED_CONTEXT = 10;
 
+function isThreadEl(node) {
+  return node.nodeType === Node.ELEMENT_NODE &&
+      node.classList.contains('comment-thread');
+}
+
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+/**
+ * 72 is the inofficial length standard for git commit messages.
+ * Derived from the fact that git log/show appends 4 ws in the beginning of
+ * each line when displaying commit messages. To center the commit message
+ * in an 80 char terminal a 4 ws border is added to the rightmost side:
+ * 4 + 72 + 4
+ */
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
+
+/**
+ * @extends Polymer.Element
+ */
+class GrDiff extends mixinBehaviors( [
+  PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff'; }
   /**
-   * Compare two ranges. Either argument may be falsy, but will only return
-   * true if both are falsy or if neither are falsy and have the same position
-   * values.
+   * Fired when the user selects a line.
    *
-   * @param {Gerrit.Range=} a range 1
-   * @param {Gerrit.Range=} b range 2
-   * @return {boolean}
+   * @event line-selected
    */
-  Gerrit.rangesEqual = function(a, b) {
-    if (!a && !b) { return true; }
-    if (!a || !b) { return false; }
-    return a.start_line === b.start_line &&
-        a.start_character === b.start_character &&
-        a.end_line === b.end_line &&
-        a.end_character === b.end_character;
-  };
-
-  function isThreadEl(node) {
-    return node.nodeType === Node.ELEMENT_NODE &&
-        node.classList.contains('comment-thread');
-  }
 
   /**
-   * Turn a slot element into the corresponding content element.
-   * Slots are only fully supported in Polymer 2 - in Polymer 1, they are
-   * replaced with content elements during template parsing. This conversion is
-   * not applied for imperatively created slot elements, so this method
-   * implements the same behavior as the template parsing for imperative slots.
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
    */
-  Gerrit.slotToContent = function(slot) {
-    if (Polymer.Element) {
-      return slot;
-    }
-    const content = document.createElement('content');
-    content.name = slot.name;
-    content.setAttribute('select', `[slot='${slot.name}']`);
-    return content;
-  };
 
-  const COMMIT_MSG_PATH = '/COMMIT_MSG';
   /**
-   * 72 is the inofficial length standard for git commit messages.
-   * Derived from the fact that git log/show appends 4 ws in the beginning of
-   * each line when displaying commit messages. To center the commit message
-   * in an 80 char terminal a 4 ws border is added to the rightmost side:
-   * 4 + 72 + 4
+   * Fired when a comment is created
+   *
+   * @event create-comment
    */
-  const COMMIT_MSG_LINE_LENGTH = 72;
 
-  const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
+  /**
+   * Fired when rendering, including syntax highlighting, is done. Also fired
+   * when no rendering can be done because required preferences are not set.
+   *
+   * @event render
+   */
 
-  Polymer({
-    is: 'gr-diff',
+  /**
+   * 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
+   */
 
-    /**
-     * Fired when the user selects a line.
-     *
-     * @event line-selected
-     */
-
-    /**
-     * Fired if being logged in is required.
-     *
-     * @event show-auth-required
-     */
-
-    /**
-     * Fired when a comment is created
-     *
-     * @event create-comment
-     */
-
-    /**
-     * Fired when rendering, including syntax highlighting, is done. Also fired
-     * when no rendering can be done because required preferences are not set.
-     *
-     * @event render
-     */
-
-    properties: {
+  static get properties() {
+    return {
       changeNum: String,
       noAutoRender: {
         type: Boolean,
@@ -231,9 +226,19 @@
 
       parentIndex: Number,
 
+      showNewlineWarningLeft: {
+        type: Boolean,
+        value: false,
+      },
+      showNewlineWarningRight: {
+        type: Boolean,
+        value: false,
+      },
+
       _newlineWarning: {
         type: String,
-        computed: '_computeNewlineWarning(diff)',
+        computed: '_computeNewlineWarning(' +
+            'showNewlineWarningLeft, showNewlineWarningRight)',
       },
 
       _diffLength: Number,
@@ -258,736 +263,736 @@
       /** Set by Polymer. */
       isAttached: Boolean,
       layers: Array,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-    ],
-
-    listeners: {
-      'create-range-comment': '_handleCreateRangeComment',
-      'render-content': '_handleRenderContent',
-    },
-
-    observers: [
+  static get observers() {
+    return [
       '_enableSelectionObserver(loggedIn, isAttached)',
-    ],
+    ];
+  }
 
-    attached() {
-      this._observeNodes();
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('create-range-comment',
+        e => this._handleCreateRangeComment(e));
+    this.addEventListener('render-content',
+        () => this._handleRenderContent());
+  }
 
-    detached() {
-      this._unobserveIncrementalNodes();
-      this._unobserveNodes();
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this._observeNodes();
+  }
 
-    showNoChangeMessage(loading, prefs, diffLength) {
-      return !loading &&
-        prefs && prefs.ignore_whitespace !== 'IGNORE_NONE'
-        && diffLength === 0;
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this._unobserveIncrementalNodes();
+    this._unobserveNodes();
+  }
 
-    _enableSelectionObserver(loggedIn, isAttached) {
-      // Polymer 2: check for undefined
-      if ([loggedIn, isAttached].some(arg => arg === undefined)) {
-        return;
+  showNoChangeMessage(loading, prefs, diffLength) {
+    return !loading &&
+      prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+      diffLength === 0;
+  }
+
+  _enableSelectionObserver(loggedIn, isAttached) {
+    // Polymer 2: check for undefined
+    if ([loggedIn, isAttached].some(arg => arg === undefined)) {
+      return;
+    }
+
+    if (loggedIn && isAttached) {
+      this.listen(document, '-shadow-selectionchange',
+          '_handleSelectionChange');
+      this.listen(document, 'mouseup', '_handleMouseUp');
+    } else {
+      this.unlisten(document, '-shadow-selectionchange',
+          '_handleSelectionChange');
+      this.unlisten(document, 'mouseup', '_handleMouseUp');
+    }
+  }
+
+  _handleSelectionChange() {
+    // Because of shadow DOM selections, we handle the selectionchange here,
+    // and pass the shadow DOM selection into gr-diff-highlight, where the
+    // 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
+    // mouse-up if there's a selection that just covers a line change. We
+    // 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() {
+    // When using native shadow DOM, the selection returned by
+    // document.getSelection() cannot reference the actual DOM elements making
+    // up the diff in Safari because they are in the shadow DOM of the gr-diff
+    // element. This takes the shadow DOM selection if one exists.
+    return this.root.getSelection ?
+      this.root.getSelection() :
+      this._isSafari() ?
+        shadow.getRange(this.root) :
+        document.getSelection();
+  }
+
+  _observeNodes() {
+    this._nodeObserver = dom(this).observeNodes(info => {
+      const addedThreadEls = info.addedNodes.filter(isThreadEl);
+      const removedThreadEls = info.removedNodes.filter(isThreadEl);
+      this._updateRanges(addedThreadEls, removedThreadEls);
+      this._redispatchHoverEvents(addedThreadEls);
+    });
+  }
+
+  _updateRanges(addedThreadEls, removedThreadEls) {
+    function commentRangeFromThreadEl(threadEl) {
+      const side = threadEl.getAttribute('comment-side');
+      const range = JSON.parse(threadEl.getAttribute('range'));
+      return {side, range, hovering: false};
+    }
+
+    const addedCommentRanges = addedThreadEls
+        .map(commentRangeFromThreadEl)
+        .filter(({range}) => range);
+    const removedCommentRanges = removedThreadEls
+        .map(commentRangeFromThreadEl)
+        .filter(({range}) => range);
+    for (const removedCommentRange of removedCommentRanges) {
+      const i = this._commentRanges
+          .findIndex(
+              cr => cr.side === removedCommentRange.side &&
+            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,
+   * where lines should not be collapsed.
+   *
+   * @return {{left: Object<(string|number), boolean>,
+   *     right: Object<(string|number), boolean>}}
+   */
+  _computeKeyLocations() {
+    const keyLocations = {left: {}, right: {}};
+    if (this.lineOfInterest) {
+      const side = this.lineOfInterest.leftSide ? 'left' : 'right';
+      keyLocations[side][this.lineOfInterest.number] = true;
+    }
+    const threadEls = dom(this).getEffectiveChildNodes()
+        .filter(isThreadEl);
+
+    for (const threadEl of threadEls) {
+      const commentSide = threadEl.getAttribute('comment-side');
+      const lineNum = Number(threadEl.getAttribute('line-num')) ||
+          GrDiffLine.FILE;
+      const commentRange = threadEl.range || {};
+      keyLocations[commentSide][lineNum] = true;
+      // Add start_line as well if exists,
+      // the being and end of the range should not be collapsed.
+      if (commentRange.start_line) {
+        keyLocations[commentSide][commentRange.start_line] = true;
       }
+    }
+    return keyLocations;
+  }
 
-      if (loggedIn && isAttached) {
-        this.listen(document, '-shadow-selectionchange', '_handleSelectionChange');
-        this.listen(document, 'mouseup', '_handleMouseUp');
-      } else {
-        this.unlisten(document, '-shadow-selectionchange', '_handleSelectionChange');
-        this.unlisten(document, 'mouseup', '_handleMouseUp');
-      }
-    },
-
-    _handleSelectionChange() {
-      // Because of shadow DOM selections, we handle the selectionchange here,
-      // and pass the shadow DOM selection into gr-diff-highlight, where the
-      // 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
-      // mouse-up if there's a selection that just covers a line change. We
-      // 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() {
-      // When using native shadow DOM, the selection returned by
-      // document.getSelection() cannot reference the actual DOM elements making
-      // up the diff, because they are in the shadow DOM of the gr-diff element.
-      // This takes the shadow DOM selection if one exists.
-      return this.root.getSelection ?
-        this.root.getSelection() :
-        // This is coming from shadow.js
-        getRange(this.root);
-    },
-
-    _observeNodes() {
-      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
-        const addedThreadEls = info.addedNodes.filter(isThreadEl);
-        const removedThreadEls = info.removedNodes.filter(isThreadEl);
-        this._updateRanges(addedThreadEls, removedThreadEls);
-        this._redispatchHoverEvents(addedThreadEls);
+  // Dispatch events that are handled by the gr-diff-highlight.
+  _redispatchHoverEvents(addedThreadEls) {
+    for (const threadEl of addedThreadEls) {
+      threadEl.addEventListener('mouseenter', () => {
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseenter', {bubbles: true, composed: true}));
       });
-    },
+      threadEl.addEventListener('mouseleave', () => {
+        threadEl.dispatchEvent(new CustomEvent(
+            'comment-thread-mouseleave', {bubbles: true, composed: true}));
+      });
+    }
+  }
 
-    _updateRanges(addedThreadEls, removedThreadEls) {
-      function commentRangeFromThreadEl(threadEl) {
-        const side = threadEl.getAttribute('comment-side');
-        const range = JSON.parse(threadEl.getAttribute('range'));
-        return {side, range, hovering: false};
-      }
+  /** Cancel any remaining diff builder rendering work. */
+  cancel() {
+    this.$.diffBuilder.cancel();
+    this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
+  }
 
-      const addedCommentRanges = addedThreadEls
-          .map(commentRangeFromThreadEl)
-          .filter(({range}) => range);
-      const removedCommentRanges = removedThreadEls
-          .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);
-        });
-        this.splice('_commentRanges', i, 1);
-      }
+  /** @return {!Array<!HTMLElement>} */
+  getCursorStops() {
+    if (this.hidden && this.noAutoRender) {
+      return [];
+    }
 
-      if (addedCommentRanges && addedCommentRanges.length) {
-        this.push('_commentRanges', ...addedCommentRanges);
-      }
-    },
+    return Array.from(
+        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'));
+  }
 
-    /**
-     * The key locations based on the comments and line of interests,
-     * where lines should not be collapsed.
-     *
-     * @return {{left: Object<(string|number), boolean>,
-     *     right: Object<(string|number), boolean>}}
-     */
-    _computeKeyLocations() {
-      const keyLocations = {left: {}, right: {}};
-      if (this.lineOfInterest) {
-        const side = this.lineOfInterest.leftSide ? 'left' : 'right';
-        keyLocations[side][this.lineOfInterest.number] = true;
-      }
-      const threadEls = Polymer.dom(this).getEffectiveChildNodes()
-          .filter(isThreadEl);
+  /** @return {boolean} */
+  isRangeSelected() {
+    return !!this.$.highlights.selectedRange;
+  }
 
-      for (const threadEl of threadEls) {
-        const commentSide = threadEl.getAttribute('comment-side');
-        const lineNum = Number(threadEl.getAttribute('line-num')) ||
-            GrDiffLine.FILE;
-        const commentRange = threadEl.range || {};
-        keyLocations[commentSide][lineNum] = true;
-        // Add start_line as well if exists,
-        // the being and end of the range should not be collapsed.
-        if (commentRange.start_line) {
-          keyLocations[commentSide][commentRange.start_line] = true;
-        }
-      }
-      return keyLocations;
-    },
+  toggleLeftDiff() {
+    this.toggleClass('no-left');
+  }
 
-    // Dispatch events that are handled by the gr-diff-highlight.
-    _redispatchHoverEvents(addedThreadEls) {
-      for (const threadEl of addedThreadEls) {
-        threadEl.addEventListener('mouseenter', () => {
-          threadEl.dispatchEvent(new CustomEvent(
-              'comment-thread-mouseenter', {bubbles: true, composed: true}));
-        });
-        threadEl.addEventListener('mouseleave', () => {
-          threadEl.dispatchEvent(new CustomEvent(
-              'comment-thread-mouseleave', {bubbles: true, composed: true}));
-        });
-      }
-    },
+  _blameChanged(newValue) {
+    this.$.diffBuilder.setBlame(newValue);
+    if (newValue) {
+      this.classList.add('showBlame');
+    } else {
+      this.classList.remove('showBlame');
+    }
+  }
 
-    /** Cancel any remaining diff builder rendering work. */
-    cancel() {
-      this.$.diffBuilder.cancel();
-      this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
-    },
+  /** @return {string} */
+  _computeContainerClass(loggedIn, viewMode, displayLine) {
+    const classes = ['diffContainer'];
+    switch (viewMode) {
+      case DiffViewMode.UNIFIED:
+        classes.push('unified');
+        break;
+      case DiffViewMode.SIDE_BY_SIDE:
+        classes.push('sideBySide');
+        break;
+      default:
+        throw Error('Invalid view mode: ', viewMode);
+    }
+    if (getHiddenScroll()) {
+      classes.push('hiddenscroll');
+    }
+    if (loggedIn) {
+      classes.push('canComment');
+    }
+    if (displayLine) {
+      classes.push('displayLine');
+    }
+    return classes.join(' ');
+  }
 
-    /** @return {!Array<!HTMLElement>} */
-    getCursorStops() {
-      if (this.hidden && this.noAutoRender) {
-        return [];
-      }
+  _handleTap(e) {
+    const el = dom(e).localTarget;
 
-      return Array.from(
-          Polymer.dom(this.root).querySelectorAll('.diff-row'));
-    },
+    if (el.classList.contains('showContext')) {
+      this.dispatchEvent(new CustomEvent('diff-context-expanded', {
+        detail: {
+          numLines: e.detail.numLines,
+        },
+        composed: true, bubbles: true,
+      }));
+      this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
+    } else if (el.classList.contains('lineNum') ||
+               el.classList.contains('lineNumButton')) {
+      this.addDraftAtLine(el);
+    } else if (el.tagName === 'HL' ||
+        el.classList.contains('content') ||
+        el.classList.contains('contentText')) {
+      const target = this.$.diffBuilder.getLineElByChild(el);
+      if (target) { this._selectLine(target); }
+    }
+  }
 
-    /** @return {boolean} */
-    isRangeSelected() {
-      return this.$.highlights.isRangeSelected();
-    },
-
-    toggleLeftDiff() {
-      this.toggleClass('no-left');
-    },
-
-    _blameChanged(newValue) {
-      this.$.diffBuilder.setBlame(newValue);
-      if (newValue) {
-        this.classList.add('showBlame');
-      } else {
-        this.classList.remove('showBlame');
-      }
-    },
-
-    /** @return {string} */
-    _computeContainerClass(loggedIn, viewMode, displayLine) {
-      const classes = ['diffContainer'];
-      switch (viewMode) {
-        case DiffViewMode.UNIFIED:
-          classes.push('unified');
-          break;
-        case DiffViewMode.SIDE_BY_SIDE:
-          classes.push('sideBySide');
-          break;
-        default:
-          throw Error('Invalid view mode: ', viewMode);
-      }
-      if (Gerrit.hiddenscroll) {
-        classes.push('hiddenscroll');
-      }
-      if (loggedIn) {
-        classes.push('canComment');
-      }
-      if (displayLine) {
-        classes.push('displayLine');
-      }
-      return classes.join(' ');
-    },
-
-    _handleTap(e) {
-      const el = Polymer.dom(e).localTarget;
-
-      if (el.classList.contains('showContext')) {
-        this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
-      } else if (el.classList.contains('lineNum')) {
-        this.addDraftAtLine(el);
-      } else if (el.tagName === 'HL' ||
-          el.classList.contains('content') ||
-          el.classList.contains('contentText')) {
-        const target = this.$.diffBuilder.getLineElByChild(el);
-        if (target) { this._selectLine(target); }
-      }
-    },
-
-    _selectLine(el) {
-      this.fire('line-selected', {
+  _selectLine(el) {
+    this.dispatchEvent(new CustomEvent('line-selected', {
+      detail: {
         side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
         number: el.getAttribute('data-value'),
         path: this.path,
-      });
-    },
+      },
+      composed: true, bubbles: true,
+    }));
+  }
 
-    addDraftAtLine(el) {
-      this._selectLine(el);
-      if (!this._isValidElForComment(el)) { return; }
+  addDraftAtLine(el) {
+    this._selectLine(el);
+    if (!this._isValidElForComment(el)) { return; }
 
-      const value = el.getAttribute('data-value');
-      let lineNum;
-      if (value !== GrDiffLine.FILE) {
-        lineNum = parseInt(value, 10);
-        if (isNaN(lineNum)) {
-          this.fire('show-alert', {message: ERR_INVALID_LINE + value});
-          return;
-        }
+    const value = el.getAttribute('data-value');
+    let lineNum;
+    if (value !== GrDiffLine.FILE) {
+      lineNum = parseInt(value, 10);
+      if (isNaN(lineNum)) {
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: ERR_INVALID_LINE + value},
+          composed: true, bubbles: true,
+        }));
+        return;
       }
-      this._createComment(el, lineNum);
-    },
+    }
+    this._createComment(el, lineNum);
+  }
 
-    _handleCreateRangeComment(e) {
-      const range = e.detail.range;
-      const side = e.detail.side;
-      const lineNum = range.end_line;
-      const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+  createRangeComment() {
+    if (!this.isRangeSelected()) {
+      throw Error('Selection is needed for new range comment');
+    }
+    const {side, range} = this.$.highlights.selectedRange;
+    this._createCommentForSelection(side, range);
+  }
 
-      if (this._isValidElForComment(lineEl)) {
-        this._createComment(lineEl, lineNum, 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);
+    }
+  }
 
-    /** @return {boolean} */
-    _isValidElForComment(el) {
-      if (!this.loggedIn) {
-        this.fire('show-auth-required');
-        return false;
-      }
-      const patchNum = el.classList.contains(DiffSide.LEFT) ?
-        this.patchRange.basePatchNum :
-        this.patchRange.patchNum;
+  _handleCreateRangeComment(e) {
+    const range = e.detail.range;
+    const side = e.detail.side;
+    this._createCommentForSelection(side, range);
+  }
 
-      const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
-      const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
-          this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
-
-      if (isEdit) {
-        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
-        return false;
-      } else if (isEditBase) {
-        this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
-        return false;
-      }
-      return true;
-    },
-
-    /**
-     * @param {!Object} lineEl
-     * @param {number=} lineNum
-     * @param {string=} side
-     * @param {!Object=} range
-     */
-    _createComment(lineEl, lineNum=undefined, side=undefined, range=undefined) {
-      const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-      const contentEl = contentText.parentElement;
-      side = side ||
-          this._getCommentSideByLineAndContent(lineEl, contentEl);
-      const patchForNewThreads = this._getPatchNumByLineAndContent(
-          lineEl, contentEl);
-      const isOnParent =
-          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      this.dispatchEvent(new CustomEvent('create-comment', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          lineNum,
-          side,
-          patchNum: patchForNewThreads,
-          isOnParent,
-          range,
-        },
+  /** @return {boolean} */
+  _isValidElForComment(el) {
+    if (!this.loggedIn) {
+      this.dispatchEvent(new CustomEvent('show-auth-required', {
+        composed: true, bubbles: true,
       }));
-    },
-
-    _getThreadGroupForLine(contentEl) {
-      return contentEl.querySelector('.thread-group');
-    },
-
-    /**
-     * Gets or creates a comment thread group for a specific line and side on a
-     * diff.
-     *
-     * @param {!Object} contentEl
-     * @param {!Gerrit.DiffSide} commentSide
-     * @return {!Node}
-     */
-    _getOrCreateThreadGroup(contentEl, commentSide) {
-      // Check if thread group exists.
-      let threadGroupEl = this._getThreadGroupForLine(contentEl);
-      if (!threadGroupEl) {
-        threadGroupEl = document.createElement('div');
-        threadGroupEl.className = 'thread-group';
-        threadGroupEl.setAttribute('data-side', commentSide);
-        contentEl.appendChild(threadGroupEl);
-      }
-      return threadGroupEl;
-    },
-
-    /**
-     * The value to be used for the patch number of new comments created at the
-     * given line and content elements.
-     *
-     * In two cases of creating a comment on the left side, the patch number to
-     * be used should actually be right side of the patch range:
-     * - When the patch range is against the parent comment of a normal change.
-     *   Such comments declare themmselves to be on the left using side=PARENT.
-     * - If the patch range is against the indexed parent of a merge change.
-     *   Such comments declare themselves to be on the given parent by
-     *   specifying the parent index via parent=i.
-     *
-     * @return {number}
-     */
-    _getPatchNumByLineAndContent(lineEl, contentEl) {
-      let patchNum = this.patchRange.patchNum;
-
-      if ((lineEl.classList.contains(DiffSide.LEFT) ||
-          contentEl.classList.contains('remove')) &&
-          this.patchRange.basePatchNum !== 'PARENT' &&
-          !this.isMergeParent(this.patchRange.basePatchNum)) {
-        patchNum = this.patchRange.basePatchNum;
-      }
-      return patchNum;
-    },
-
-    /** @return {boolean} */
-    _getIsParentCommentByLineAndContent(lineEl, contentEl) {
-      if ((lineEl.classList.contains(DiffSide.LEFT) ||
-          contentEl.classList.contains('remove')) &&
-          (this.patchRange.basePatchNum === 'PARENT' ||
-          this.isMergeParent(this.patchRange.basePatchNum))) {
-        return true;
-      }
       return false;
-    },
+    }
+    const patchNum = el.classList.contains(DiffSide.LEFT) ?
+      this.patchRange.basePatchNum :
+      this.patchRange.patchNum;
 
-    /** @return {string} */
-    _getCommentSideByLineAndContent(lineEl, contentEl) {
-      let side = 'right';
-      if (lineEl.classList.contains(DiffSide.LEFT) ||
-          contentEl.classList.contains('remove')) {
-        side = 'left';
+    const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
+    const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
+        this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
+
+    if (isEdit) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_COMMENT_ON_EDIT},
+        composed: true, bubbles: true,
+      }));
+      return false;
+    } else if (isEditBase) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {message: ERR_COMMENT_ON_EDIT_BASE},
+        composed: true, bubbles: true,
+      }));
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * @param {!Object} lineEl
+   * @param {number=} lineNum
+   * @param {string=} side
+   * @param {!Object=} range
+   */
+  _createComment(lineEl, lineNum, side, range) {
+    const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+    if (!contentText) {
+      return;
+    }
+    const contentEl = contentText.parentElement;
+    side = side ||
+        this._getCommentSideByLineAndContent(lineEl, contentEl);
+    const patchForNewThreads = this._getPatchNumByLineAndContent(
+        lineEl, contentEl);
+    const isOnParent =
+        this._getIsParentCommentByLineAndContent(lineEl, contentEl);
+    this.dispatchEvent(new CustomEvent('create-comment', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        lineNum,
+        side,
+        patchNum: patchForNewThreads,
+        isOnParent,
+        range,
+      },
+    }));
+  }
+
+  _getThreadGroupForLine(contentEl) {
+    return contentEl.querySelector('.thread-group');
+  }
+
+  /**
+   * Gets or creates a comment thread group for a specific line and side on a
+   * diff.
+   *
+   * @param {!Object} contentEl
+   * @param {!DiffSide} commentSide
+   * @return {!Node}
+   */
+  _getOrCreateThreadGroup(contentEl, commentSide) {
+    // Check if thread group exists.
+    let threadGroupEl = this._getThreadGroupForLine(contentEl);
+    if (!threadGroupEl) {
+      threadGroupEl = document.createElement('div');
+      threadGroupEl.className = 'thread-group';
+      threadGroupEl.setAttribute('data-side', commentSide);
+      contentEl.appendChild(threadGroupEl);
+    }
+    return threadGroupEl;
+  }
+
+  /**
+   * The value to be used for the patch number of new comments created at the
+   * given line and content elements.
+   *
+   * In two cases of creating a comment on the left side, the patch number to
+   * be used should actually be right side of the patch range:
+   * - When the patch range is against the parent comment of a normal change.
+   *   Such comments declare themmselves to be on the left using side=PARENT.
+   * - If the patch range is against the indexed parent of a merge change.
+   *   Such comments declare themselves to be on the given parent by
+   *   specifying the parent index via parent=i.
+   *
+   * @return {number}
+   */
+  _getPatchNumByLineAndContent(lineEl, contentEl) {
+    let patchNum = this.patchRange.patchNum;
+
+    if ((lineEl.classList.contains(DiffSide.LEFT) ||
+        contentEl.classList.contains('remove')) &&
+        this.patchRange.basePatchNum !== 'PARENT' &&
+        !this.isMergeParent(this.patchRange.basePatchNum)) {
+      patchNum = this.patchRange.basePatchNum;
+    }
+    return patchNum;
+  }
+
+  /** @return {boolean} */
+  _getIsParentCommentByLineAndContent(lineEl, contentEl) {
+    if ((lineEl.classList.contains(DiffSide.LEFT) ||
+        contentEl.classList.contains('remove')) &&
+        (this.patchRange.basePatchNum === 'PARENT' ||
+        this.isMergeParent(this.patchRange.basePatchNum))) {
+      return true;
+    }
+    return false;
+  }
+
+  /** @return {string} */
+  _getCommentSideByLineAndContent(lineEl, contentEl) {
+    let side = 'right';
+    if (lineEl.classList.contains(DiffSide.LEFT) ||
+        contentEl.classList.contains('remove')) {
+      side = 'left';
+    }
+    return side;
+  }
+
+  _prefsObserver(newPrefs, oldPrefs) {
+    if (!this._prefsEqual(newPrefs, oldPrefs)) {
+      this._prefsChanged(newPrefs);
+    }
+  }
+
+  _prefsEqual(prefs1, prefs2) {
+    if (prefs1 === prefs2) {
+      return true;
+    }
+    if (!prefs1 || !prefs2) {
+      return false;
+    }
+    // Scan the preference objects one level deep to see if they differ.
+    const keys1 = Object.keys(prefs1);
+    const keys2 = Object.keys(prefs2);
+    return keys1.length === keys2.length &&
+        keys1.every(key => prefs1[key] === prefs2[key]) &&
+        keys2.every(key => prefs1[key] === prefs2[key]);
+  }
+
+  _pathObserver() {
+    // Call _prefsChanged(), because line-limit style value depends on path.
+    this._prefsChanged(this.prefs);
+  }
+
+  _viewModeObserver() {
+    this._prefsChanged(this.prefs);
+  }
+
+  _cleanup() {
+    this.cancel();
+    this._blame = null;
+    this._safetyBypass = null;
+    this._showWarning = false;
+    this.clearDiffContent();
+  }
+
+  /** @param {boolean} newValue */
+  _loadingChanged(newValue) {
+    if (newValue) {
+      this._cleanup();
+    }
+  }
+
+  _lineWrappingObserver() {
+    this._prefsChanged(this.prefs);
+  }
+
+  _prefsChanged(prefs) {
+    if (!prefs) { return; }
+
+    this._blame = null;
+
+    const lineLength = this.path === COMMIT_MSG_PATH ?
+      COMMIT_MSG_LINE_LENGTH : prefs.line_length;
+    const stylesToUpdate = {};
+
+    if (prefs.line_wrapping) {
+      this._diffTableClass = 'full-width';
+      if (this.viewMode === 'SIDE_BY_SIDE') {
+        stylesToUpdate['--content-width'] = 'none';
+        stylesToUpdate['--line-limit'] = lineLength + 'ch';
       }
-      return side;
-    },
+    } else {
+      this._diffTableClass = '';
+      stylesToUpdate['--content-width'] = lineLength + 'ch';
+    }
 
-    _prefsObserver(newPrefs, oldPrefs) {
-      if (!this._prefsEqual(newPrefs, oldPrefs)) {
-        this._prefsChanged(newPrefs);
-      }
-    },
+    if (prefs.font_size) {
+      stylesToUpdate['--font-size'] = prefs.font_size + 'px';
+    }
 
-    _prefsEqual(prefs1, prefs2) {
-      if (prefs1 === prefs2) {
-        return true;
-      }
-      if (!prefs1 || !prefs2) {
-        return false;
-      }
-      // Scan the preference objects one level deep to see if they differ.
-      const keys1 = Object.keys(prefs1);
-      const keys2 = Object.keys(prefs2);
-      return keys1.length === keys2.length &&
-          keys1.every(key => prefs1[key] === prefs2[key]) &&
-          keys2.every(key => prefs1[key] === prefs2[key]);
-    },
+    this.updateStyles(stylesToUpdate);
 
-    _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) {
-      if (newValue) {
-        this.cancel();
-        this._blame = null;
-        this._safetyBypass = null;
-        this._showWarning = false;
-        this.clearDiffContent();
-      }
-    },
-
-    _lineWrappingObserver() {
-      this._prefsChanged(this.prefs);
-    },
-
-    _prefsChanged(prefs) {
-      if (!prefs) { return; }
-
-      this._blame = null;
-
-      const lineLength = this.path === COMMIT_MSG_PATH ?
-        COMMIT_MSG_LINE_LENGTH : prefs.line_length;
-      const stylesToUpdate = {};
-
-      if (prefs.line_wrapping) {
-        this._diffTableClass = 'full-width';
-        if (this.viewMode === 'SIDE_BY_SIDE') {
-          stylesToUpdate['--content-width'] = 'none';
-          stylesToUpdate['--line-limit'] = lineLength + 'ch';
-        }
-      } else {
-        this._diffTableClass = '';
-        stylesToUpdate['--content-width'] = lineLength + 'ch';
-      }
-
-      if (prefs.font_size) {
-        stylesToUpdate['--font-size'] = prefs.font_size + 'px';
-      }
-
-      this.updateStyles(stylesToUpdate);
-
-      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
-     * _renderDiffTable only once, in the next microtask, unless it is cancelled
-     * before that microtask runs.
-     *
-     * This should be used instead of calling _renderDiffTable directly to
-     * render the diff in response to an input change, because there may be
-     * multiple inputs changing in the same microtask, but we only want to
-     * render once.
-     */
-    _debounceRenderDiffTable() {
-      this.debounce(
-          RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
-    },
-
-    _renderDiffTable() {
-      this._unobserveIncrementalNodes();
-      if (!this.prefs) {
-        this.dispatchEvent(
-            new CustomEvent('render', {bubbles: true, composed: true}));
-        return;
-      }
-      if (this.prefs.context === -1 &&
-          this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
-          this._safetyBypass === null) {
-        this._showWarning = true;
-        this.dispatchEvent(
-            new CustomEvent('render', {bubbles: true, composed: true}));
-        return;
-      }
-
-      this._showWarning = false;
-
-      const keyLocations = this._computeKeyLocations();
-      this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
-          .then(() => {
-            this.dispatchEvent(
-                new CustomEvent('render', {
-                  bubbles: true,
-                  composed: true,
-                  detail: {contentRendered: true},
-                }));
-          });
-    },
-
-    _handleRenderContent() {
-      this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
-        const addedThreadEls = info.addedNodes.filter(isThreadEl);
-        // Removed nodes do not need to be handled because all this code does is
-        // adding a slot for the added thread elements, and the extra slots do
-        // not hurt. It's probably a bigger performance cost to remove them than
-        // to keep them around. Medium term we can even consider to add one slot
-        // 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 contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-          const contentEl = contentText.parentElement;
-          const threadGroupEl = this._getOrCreateThreadGroup(
-              contentEl, commentSide);
-          // Create a slot for the thread and attach it to the thread group.
-          // The Polyfill has some bugs and this only works if the slot is
-          // attached to the group after the group is attached to the DOM.
-          // The thread group may already have a slot with the right name, but
-          // that is okay because the first matching slot is used and the rest
-          // are ignored.
-          const slot = document.createElement('slot');
-          slot.name = threadEl.getAttribute('slot');
-          Polymer.dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
-          lastEl = threadEl;
-        }
-
-        // Safari is not binding newly created comment-thread
-        // with the slot somehow, replace itself will rebind it
-        // @see Issue 11182
-        if (lastEl && lastEl.replaceWith) {
-          lastEl.replaceWith(lastEl);
-        }
-      });
-    },
-
-    _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).
-     */
-    _getBypassPrefs() {
-      if (this._safetyBypass !== null) {
-        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 ') ||
-            item.startsWith('index ') ||
-            item.startsWith('+++ ') ||
-            item.startsWith('--- ') ||
-            item === 'Binary files differ');
-      });
-    },
-
-    /** @return {boolean} */
-    _computeDiffHeaderHidden(items) {
-      return items.length === 0;
-    },
-
-    _handleFullBypass() {
-      this._safetyBypass = FULL_CONTEXT;
+    if (this.diff && !this.noRenderOnPrefsChange) {
       this._debounceRenderDiffTable();
-    },
+    }
+  }
 
-    _handleLimitedBypass() {
-      this._safetyBypass = LIMITED_CONTEXT;
+  _diffChanged(newValue) {
+    if (newValue) {
+      this._cleanup();
+      this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
-    },
+    }
+  }
 
-    /** @return {string} */
-    _computeWarningClass(showWarning) {
-      return showWarning ? 'warn' : '';
-    },
+  /**
+   * When called multiple times from the same microtask, will call
+   * _renderDiffTable only once, in the next microtask, unless it is cancelled
+   * before that microtask runs.
+   *
+   * This should be used instead of calling _renderDiffTable directly to
+   * render the diff in response to an input change, because there may be
+   * multiple inputs changing in the same microtask, but we only want to
+   * render once.
+   */
+  _debounceRenderDiffTable() {
+    this.debounce(
+        RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
+  }
 
-    /**
-     * @param {string} errorMessage
-     * @return {string}
-     */
-    _computeErrorClass(errorMessage) {
-      return errorMessage ? 'showError' : '';
-    },
+  _renderDiffTable() {
+    if (!this.prefs) {
+      this.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true}));
+      return;
+    }
+    if (this.prefs.context === -1 &&
+        this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
+        this._safetyBypass === null) {
+      this._showWarning = true;
+      this.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true}));
+      return;
+    }
 
-    expandAllContext() {
-      this._handleFullBypass();
-    },
+    this._showWarning = false;
 
-    /**
-     * Find the last chunk for the given side.
-     *
-     * @param {!Object} diff
-     * @param {boolean} leftSide true if checking the base of the diff,
-     *     false if testing the revision.
-     * @return {Object|null} returns the chunk object or null if there was
-     *     no chunk for that side.
-     */
-    _lastChunkForSide(diff, leftSide) {
-      if (!diff.content.length) { return null; }
+    const keyLocations = this._computeKeyLocations();
+    this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
+        .then(() => {
+          this.dispatchEvent(
+              new CustomEvent('render', {
+                bubbles: true,
+                composed: true,
+                detail: {contentRendered: true},
+              }));
+        });
+  }
 
-      let chunkIndex = diff.content.length;
-      let chunk;
-
-      // Walk backwards until we find a chunk for the given side.
-      do {
-        chunkIndex--;
-        chunk = diff.content[chunkIndex];
-      } while (
-      // We haven't reached the beginning.
-        chunkIndex >= 0 &&
-
-          // The chunk doesn't have both sides.
-          !chunk.ab &&
-
-          // The chunk doesn't have the given side.
-          ((leftSide && (!chunk.a || !chunk.a.length)) ||
-           (!leftSide && (!chunk.b || !chunk.b.length))));
-
-      // If we reached the beginning of the diff and failed to find a chunk
-      // with the given side, return null.
-      if (chunkIndex === -1) { return null; }
-
-      return chunk;
-    },
-
-    /**
-     * Check whether the specified side of the diff has a trailing newline.
-     *
-     * @param {!Object} diff
-     * @param {boolean} leftSide true if checking the base of the diff,
-     *     false if testing the revision.
-     * @return {boolean|null} Return true if the side has a trailing newline.
-     *     Return false if it doesn't. Return null if not applicable (for
-     *     example, if the diff has no content on the specified side).
-     */
-    _hasTrailingNewlines(diff, leftSide) {
-      const chunk = this._lastChunkForSide(diff, leftSide);
-      if (!chunk) { return null; }
-      let lines;
-      if (chunk.ab) {
-        lines = chunk.ab;
-      } else {
-        lines = leftSide ? chunk.a : chunk.b;
-      }
-      return lines[lines.length - 1] === '';
-    },
-
-    /**
-     * @param {!Object} diff
-     * @return {string|null}
-     */
-    _computeNewlineWarning(diff) {
-      const hasLeft = this._hasTrailingNewlines(diff, true);
-      const hasRight = this._hasTrailingNewlines(diff, false);
-      const messages = [];
-      if (hasLeft === false) {
-        messages.push(NO_NEWLINE_BASE);
-      }
-      if (hasRight === false) {
-        messages.push(NO_NEWLINE_REVISION);
-      }
-      if (!messages.length) { return null; }
-      return messages.join(' — ');
-    },
-
-    /**
-     * @param {string} warning
-     * @param {boolean} loading
-     * @return {string}
-     */
-    _computeNewlineWarningClass(warning, loading) {
-      if (loading || !warning) { return 'newlineWarning hidden'; }
-      return 'newlineWarning';
-    },
-
-    /**
-     * Get the approximate length of the diff as the sum of the maximum
-     * length of the chunks.
-     *
-     * @param {Object} diff object
-     * @return {number}
-     */
-    getDiffLength(diff) {
-      if (!diff) return 0;
-      return diff.content.reduce((sum, sec) => {
-        if (sec.hasOwnProperty('ab')) {
-          return sum + sec.ab.length;
-        } else {
-          return sum + Math.max(
-              sec.hasOwnProperty('a') ? sec.a.length : 0,
-              sec.hasOwnProperty('b') ? sec.b.length : 0);
+  _handleRenderContent() {
+    this._unobserveIncrementalNodes();
+    this._incrementalNodeObserver = dom(this).observeNodes(info => {
+      const addedThreadEls = info.addedNodes.filter(isThreadEl);
+      // Removed nodes do not need to be handled because all this code does is
+      // adding a slot for the added thread elements, and the extra slots do
+      // not hurt. It's probably a bigger performance cost to remove them than
+      // to keep them around. Medium term we can even consider to add one slot
+      // 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 contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+        if (!contentText) {
+          continue;
         }
-      }, 0);
-    },
-  });
-})();
+        const contentEl = contentText.parentElement;
+        const threadGroupEl = this._getOrCreateThreadGroup(
+            contentEl, commentSide);
+        // Create a slot for the thread and attach it to the thread group.
+        // The Polyfill has some bugs and this only works if the slot is
+        // attached to the group after the group is attached to the DOM.
+        // The thread group may already have a slot with the right name, but
+        // that is okay because the first matching slot is used and the rest
+        // are ignored.
+        const slot = document.createElement('slot');
+        slot.name = threadEl.getAttribute('slot');
+        dom(threadGroupEl).appendChild(slot);
+        lastEl = threadEl;
+      }
+
+      // Safari is not binding newly created comment-thread
+      // with the slot somehow, replace itself will rebind it
+      // @see Issue 11182
+      if (lastEl && lastEl.replaceWith) {
+        lastEl.replaceWith(lastEl);
+      }
+    });
+  }
+
+  _unobserveIncrementalNodes() {
+    if (this._incrementalNodeObserver) {
+      dom(this).unobserveNodes(this._incrementalNodeObserver);
+    }
+  }
+
+  _unobserveNodes() {
+    if (this._nodeObserver) {
+      dom(this).unobserveNodes(this._nodeObserver);
+    }
+  }
+
+  /**
+   * Get the preferences object including the safety bypass context (if any).
+   */
+  _getBypassPrefs() {
+    if (this._safetyBypass !== null) {
+      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 => !(item.startsWith('diff --git ') ||
+          item.startsWith('index ') ||
+          item.startsWith('+++ ') ||
+          item.startsWith('--- ') ||
+          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
+   * @return {string}
+   */
+  _computeErrorClass(errorMessage) {
+    return errorMessage ? 'showError' : '';
+  }
+
+  expandAllContext() {
+    this._handleFullBypass();
+  }
+
+  /**
+   * @param {!boolean} warnLeft
+   * @param {!boolean} warnRight
+   * @return {string|null}
+   */
+  _computeNewlineWarning(warnLeft, warnRight) {
+    const messages = [];
+    if (warnLeft) {
+      messages.push(NO_NEWLINE_BASE);
+    }
+    if (warnRight) {
+      messages.push(NO_NEWLINE_REVISION);
+    }
+    if (!messages.length) { return null; }
+    return messages.join(' \u2014 ');// \u2014 - '—'
+  }
+
+  /**
+   * @param {string} warning
+   * @param {boolean} loading
+   * @return {string}
+   */
+  _computeNewlineWarningClass(warning, loading) {
+    if (loading || !warning) { return 'newlineWarning hidden'; }
+    return 'newlineWarning';
+  }
+
+  /**
+   * Get the approximate length of the diff as the sum of the maximum
+   * length of the chunks.
+   *
+   * @param {Object} diff object
+   * @return {number}
+   */
+  getDiffLength(diff) {
+    if (!diff) return 0;
+    return diff.content.reduce((sum, sec) => {
+      if (sec.hasOwnProperty('ab')) {
+        return sum + sec.ab.length;
+      } else {
+        return sum + Math.max(
+            sec.hasOwnProperty('a') ? sec.a.length : 0,
+            sec.hasOwnProperty('b') ? sec.b.length : 0);
+      }
+    }, 0);
+  }
+
+  _isSafari() {
+    return (
+      /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
+      (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
+    );
+  }
+}
+
+customElements.define(GrDiff.is, GrDiff);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
new file mode 100644
index 0000000..55abd38
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
@@ -0,0 +1,454 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host(.no-left) .sideBySide .left,
+    :host(.no-left) .sideBySide .left + td,
+    :host(.no-left) .sideBySide .right:not([data-value]),
+    :host(.no-left) .sideBySide .right:not([data-value]) + td {
+      display: none;
+    }
+    :host {
+      font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+      font-size: var(--font-size, var(--font-size-code, 12px));
+      line-height: var(--line-height-code, 1.334);
+    }
+
+    .thread-group {
+      display: block;
+      max-width: var(--content-width, 80ch);
+      white-space: normal;
+      background-color: var(--diff-blank-background-color);
+    }
+    .diffContainer {
+      display: flex;
+      font-family: var(--monospace-font-family);
+      @apply --diff-container-styles;
+    }
+    .diffContainer.hiddenscroll {
+      margin-bottom: var(--spacing-m);
+    }
+    table {
+      border-collapse: collapse;
+      border-right: 1px solid var(--border-color);
+      table-layout: fixed;
+    }
+    .lineNumButton {
+      display: block;
+      width: 100%;
+      height: 100%;
+      background-color: var(--diff-blank-background-color);
+    }
+    /*
+      The only way to focus this (clicking) will apply our own focus styling,
+      so this default styling is not needed and distracting.
+      */
+    .lineNumButton:focus {
+      outline: none;
+    }
+    .image-diff .gr-diff {
+      text-align: center;
+    }
+    .image-diff img {
+      box-shadow: var(--elevation-level-1);
+      max-width: 50em;
+    }
+    .image-diff .right.lineNumButton {
+      border-left: 1px solid var(--border-color);
+    }
+    .image-diff label,
+    .binary-diff label {
+      font-family: var(--font-family);
+      font-style: italic;
+    }
+    .diff-row {
+      outline: none;
+      user-select: none;
+    }
+    .diff-row.target-row.target-side-left .lineNumButton.left,
+    .diff-row.target-row.target-side-right .lineNumButton.right,
+    .diff-row.target-row.unified .lineNumButton {
+      background-color: var(--diff-selection-background-color);
+      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(--diff-blank-background-color);
+    }
+    .full-width {
+      width: 100%;
+    }
+    .full-width .contentText {
+      white-space: pre-wrap;
+      word-wrap: break-word;
+    }
+    .lineNumButton,
+    .content {
+      vertical-align: top;
+      white-space: pre;
+    }
+    .contextLineNum,
+    .lineNumButton {
+      -webkit-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+      user-select: none;
+
+      color: var(--deemphasized-text-color);
+      padding: 0 var(--spacing-m);
+      text-align: right;
+    }
+    .canComment .lineNumButton {
+      cursor: pointer;
+    }
+    .content {
+      /* Set min width since setting width on table cells still
+           allows them to shrink. Do not set max width because
+           CJK (Chinese-Japanese-Korean) glyphs have variable width */
+      min-width: var(--content-width, 80ch);
+      width: var(--content-width, 80ch);
+    }
+    .content.add .contentText .intraline,
+      /* If there are no intraline info, consider everything changed */
+      .content.add.no-intraline-info .contentText,
+      .delta.total .content.add .contentText {
+      background-color: var(--dark-add-highlight-color);
+    }
+    .content.add .contentText {
+      background-color: var(--light-add-highlight-color);
+    }
+    .content.remove .contentText .intraline,
+      /* If there are no intraline info, consider everything changed */
+      .content.remove.no-intraline-info .contentText,
+      .delta.total .content.remove .contentText {
+      background-color: var(--dark-remove-highlight-color);
+    }
+    .content.remove .contentText {
+      background-color: var(--light-remove-highlight-color);
+    }
+
+    /* dueToRebase */
+    .dueToRebase .content.add .contentText .intraline,
+    .delta.total.dueToRebase .content.add .contentText {
+      background-color: var(--dark-rebased-add-highlight-color);
+    }
+    .dueToRebase .content.add .contentText {
+      background-color: var(--light-rebased-add-highlight-color);
+    }
+    .dueToRebase .content.remove .contentText .intraline,
+    .delta.total.dueToRebase .content.remove .contentText {
+      background-color: var(--dark-rebased-remove-highlight-color);
+    }
+    .dueToRebase .content.remove .contentText {
+      background-color: var(--light-remove-add-highlight-color);
+    }
+
+    /* ignoredWhitespaceOnly */
+    .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-color: var(--view-background-color);
+    }
+
+    .content .contentText:empty:after {
+      /* Newline, to ensure empty lines are one line-height tall. */
+      content: '\\A';
+    }
+    .contextControl {
+      background-color: var(--diff-context-control-background-color);
+      border: 1px solid var(--diff-context-control-border-color);
+      color: var(--diff-context-control-color);
+    }
+    .contextControl gr-button {
+      display: inline-block;
+      text-decoration: none;
+      vertical-align: top;
+      line-height: var(--line-height-mono, 18px);
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        padding: var(--spacing-xxs) var(--spacing-l);
+      }
+    }
+    .contextControl gr-button iron-icon {
+      /* should match line-height of gr-button */
+      width: var(--line-height-mono, 18px);
+      height: var(--line-height-mono, 18px);
+    }
+    .contextControl td:not(.lineNumButton) {
+      text-align: center;
+    }
+    .displayLine .diff-row.target-row td {
+      box-shadow: inset 0 -1px var(--border-color);
+    }
+    .br:after {
+      /* Line feed */
+      content: '\\A';
+    }
+    .tab {
+      display: inline-block;
+    }
+    .tab-indicator:before {
+      color: var(--diff-tab-indicator-color);
+      /* >> character */
+      content: '\\00BB';
+      position: absolute;
+    }
+    /* Is defined after other background-colors, such that this
+         rule wins in case of same specificity. */
+    .trailing-whitespace,
+    .content .trailing-whitespace,
+    .trailing-whitespace .intraline,
+    .content .trailing-whitespace .intraline {
+      border-radius: var(--border-radius, 4px);
+      background-color: var(--diff-trailing-whitespace-indicator);
+    }
+    #diffHeader {
+      background-color: var(--table-header-background-color);
+      border-bottom: 1px solid var(--border-color);
+      color: var(--link-color);
+      padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+    }
+    #loadingError,
+    #sizeWarning {
+      display: none;
+      margin: var(--spacing-l) auto;
+      max-width: 60em;
+      text-align: center;
+    }
+    #loadingError {
+      color: var(--error-text-color);
+    }
+    #sizeWarning gr-button {
+      margin: var(--spacing-l);
+    }
+    #loadingError.showError,
+    #sizeWarning.warn {
+      display: block;
+    }
+    .target-row td.blame {
+      background: var(--diff-selection-background-color);
+    }
+    col.blame {
+      display: none;
+    }
+    td.blame {
+      display: none;
+      padding: 0 var(--spacing-m);
+      white-space: pre;
+    }
+    :host(.showBlame) col.blame {
+      display: table-column;
+    }
+    :host(.showBlame) td.blame {
+      display: table-cell;
+    }
+    td.blame > span {
+      opacity: 0.6;
+    }
+    td.blame > span.startOfRange {
+      opacity: 1;
+    }
+    td.blame .blameDate {
+      font-family: var(--monospace-font-family);
+      color: var(--link-color);
+      text-decoration: none;
+    }
+    .full-width td.blame {
+      overflow: hidden;
+      width: 200px;
+    }
+    /** Support the line length indicator **/
+    .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;
+      background-repeat: repeat-y;
+    }
+    .newlineWarning {
+      color: var(--deemphasized-text-color);
+      text-align: center;
+    }
+    .newlineWarning.hidden {
+      display: none;
+    }
+    .lineNum.COVERED .lineNumButton {
+      background-color: var(--coverage-covered, #e0f2f1);
+    }
+    .lineNum.NOT_COVERED .lineNumButton {
+      background-color: var(--coverage-not-covered, #ffd1a4);
+    }
+    .lineNum.PARTIALLY_COVERED .lineNumButton {
+      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 */
+    /** Below was copied and modified from the original css in gr-diff-selection.html */
+    .content,
+    .contextControl,
+    .blame {
+      -webkit-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+      user-select: none;
+    }
+
+    .selected-left:not(.selected-comment)
+      .side-by-side
+      .left
+      + .content
+      .contentText,
+    .selected-right:not(.selected-comment)
+      .side-by-side
+      .right
+      + .content
+      .contentText,
+    .selected-left:not(.selected-comment)
+      .unified
+      .left.lineNum
+      ~ .content:not(.both)
+      .contentText,
+    .selected-right:not(.selected-comment)
+      .unified
+      .right.lineNum
+      ~ .content
+      .contentText,
+    .selected-left.selected-comment .side-by-side .left + .content .message,
+    .selected-right.selected-comment
+      .side-by-side
+      .right
+      + .content
+      .message
+      :not(.collapsedContent),
+    .selected-comment .unified .message :not(.collapsedContent),
+    .selected-blame .blame {
+      -webkit-user-select: text;
+      -moz-user-select: text;
+      -ms-user-select: text;
+      user-select: text;
+    }
+
+    /** Make comments selectable when selected */
+    .selected-left.selected-comment
+      ::slotted(gr-comment-thread[comment-side='left']),
+    .selected-right.selected-comment
+      ::slotted(gr-comment-thread[comment-side='right']) {
+      -webkit-user-select: text;
+      -moz-user-select: text;
+      -ms-user-select: text;
+      user-select: text;
+    }
+    /** END: Select and copy for Polymer 2 */
+
+    .whitespace-change-only-message {
+      background-color: var(--diff-context-control-background-color);
+      border: 1px solid var(--diff-context-control-border-color);
+      text-align: center;
+    }
+  </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" items="[[_diffHeaderItems]]">
+      <div>[[item]]</div>
+    </template>
+  </div>
+  <div
+    class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
+    on-tap="_handleTap"
+  >
+    <gr-diff-selection diff="[[diff]]">
+      <gr-diff-highlight
+        id="highlights"
+        logged-in="[[loggedIn]]"
+        comment-ranges="{{_commentRanges}}"
+      >
+        <gr-diff-builder
+          id="diffBuilder"
+          comment-ranges="[[_commentRanges]]"
+          coverage-ranges="[[coverageRanges]]"
+          project-name="[[projectName]]"
+          diff="[[diff]]"
+          path="[[path]]"
+          change-num="[[changeNum]]"
+          patch-num="[[patchRange.patchNum]]"
+          view-mode="[[viewMode]]"
+          is-image-diff="[[isImageDiff]]"
+          base-image="[[baseImage]]"
+          layers="[[layers]]"
+          revision-image="[[revisionImage]]"
+        >
+          <table
+            id="diffTable"
+            class$="[[_diffTableClass]]"
+            role="presentation"
+          ></table>
+
+          <template
+            is="dom-if"
+            if="[[showNoChangeMessage(loading, prefs, _diffLength)]]"
+          >
+            <div class="whitespace-change-only-message">
+              This file only contains whitespace changes. Modify the whitespace
+              setting to see the changes.
+            </div>
+          </template>
+        </gr-diff-builder>
+      </gr-diff-highlight>
+    </gr-diff-selection>
+  </div>
+  <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+    [[_newlineWarning]]
+  </div>
+  <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
+    [[errorMessage]]
+  </div>
+  <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
+    <p>
+      Prevented render because "Whole file" is enabled and this diff is very
+      large (about [[_diffLength]] lines).
+    </p>
+    <gr-button on-click="_handleLimitedBypass">
+      Render with limited context
+    </gr-button>
+    <gr-button on-click="_handleFullBypass">
+      Render anyway (may be slow)
+    </gr-button>
+  </div>
+`;
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 b177bad..bb0366b 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
@@ -17,20 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-diff.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/components/web-component-tester/data/a11ySuite.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -38,1037 +32,249 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
+import './gr-diff.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
+import {util} from '../../../scripts/util.js';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
 
-    const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+suite('gr-diff tests', () => {
+  let element;
+  let sandbox;
+
+  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('selectionchange event handling', () => {
+    const emulateSelection = function() {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('selectionchange event handling', () => {
-      const emulateSelection = function() {
-        document.dispatchEvent(new CustomEvent('selectionchange'));
-      };
-
-      setup(() => {
-        element = fixture('basic');
-        sandbox.stub(element.$.highlights, 'handleSelectionChange');
-      });
-
-      test('enabled if logged in', () => {
-        element.loggedIn = true;
-        emulateSelection();
-        assert.isTrue(element.$.highlights.handleSelectionChange.called);
-      });
-
-      test('ignored if logged out', () => {
-        element.loggedIn = false;
-        emulateSelection();
-        assert.isFalse(element.$.highlights.handleSelectionChange.called);
-      });
-    });
-
-
-    test('cancel', () => {
       element = fixture('basic');
-      const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
-      element.cancel();
-      assert.isTrue(cancelStub.calledOnce);
+      sandbox.stub(element.$.highlights, 'handleSelectionChange');
     });
 
-    test('line limit with line_wrapping', () => {
+    test('enabled if logged in', () => {
+      element.loggedIn = true;
+      emulateSelection();
+      assert.isTrue(element.$.highlights.handleSelectionChange.called);
+    });
+
+    test('ignored if logged out', () => {
+      element.loggedIn = false;
+      emulateSelection();
+      assert.isFalse(element.$.highlights.handleSelectionChange.called);
+    });
+  });
+
+  test('cancel', () => {
+    element = fixture('basic');
+    const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
+    element.cancel();
+    assert.isTrue(cancelStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', () => {
+    element = fixture('basic');
+    element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
+    flushAsynchronousOperations();
+    assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', () => {
+    element = fixture('basic');
+    element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
+    flushAsynchronousOperations();
+    assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
+  });
+
+  suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
+    let lineEl;
+    let contentEl;
+
+    setup(() => {
       element = fixture('basic');
-      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
-      flushAsynchronousOperations();
-      assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
+      lineEl = document.createElement('td');
+      contentEl = document.createElement('span');
     });
 
-    test('line limit without line_wrapping', () => {
-      element = fixture('basic');
-      element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
-      flushAsynchronousOperations();
-      assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
-    });
-
-    suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
-      let lineEl;
-      let contentEl;
-
-      setup(() => {
-        element = fixture('basic');
-        lineEl = document.createElement('td');
-        contentEl = document.createElement('span');
+    suite('_getPatchNumByLineAndContent', () => {
+      test('right side', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('right');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
       });
 
-      suite('_getPatchNumByLineAndContent', () => {
-        test('right side', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('right');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side parent by linenum', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('left');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side parent by content', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          contentEl.classList.add('remove');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side merge parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: -2};
-          contentEl.classList.add('remove');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              4);
-        });
-
-        test('left side non parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 3};
-          contentEl.classList.add('remove');
-          assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-              3);
-        });
+      test('left side parent by linenum', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('left');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
       });
 
-      suite('_getIsParentCommentByLineAndContent', () => {
-        test('right side', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('right');
-          assert.isFalse(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
+      test('left side parent by content', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
 
-        test('left side parent by linenum', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          lineEl.classList.add('left');
-          assert.isTrue(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
+      test('left side merge parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: -2};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
 
-        test('left side parent by content', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-          contentEl.classList.add('remove');
-          assert.isTrue(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
-
-        test('left side merge parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: -2};
-          contentEl.classList.add('remove');
-          assert.isTrue(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
-
-        test('left side non parent', () => {
-          element.patchRange = {patchNum: 4, basePatchNum: 3};
-          contentEl.classList.add('remove');
-          assert.isFalse(
-              element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-        });
+      test('left side non parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 3};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            3);
       });
     });
 
-    suite('not logged in', () => {
-      setup(() => {
-        const getLoggedInPromise = Promise.resolve(false);
-        stub('gr-rest-api-interface', {
-          getLoggedIn() { return getLoggedInPromise; },
-        });
-        element = fixture('basic');
-        return getLoggedInPromise;
-      });
-
-      test('toggleLeftDiff', () => {
-        element.toggleLeftDiff();
-        assert.isTrue(element.classList.contains('no-left'));
-        element.toggleLeftDiff();
-        assert.isFalse(element.classList.contains('no-left'));
-      });
-
-      test('addDraftAtLine', () => {
-        sandbox.stub(element, '_selectLine');
-        const loggedInErrorSpy = sandbox.spy();
-        element.addEventListener('show-auth-required', loggedInErrorSpy);
-        element.addDraftAtLine();
-        assert.isTrue(loggedInErrorSpy.called);
-      });
-
-      test('view does not start with displayLine classList', () => {
+    suite('_getIsParentCommentByLineAndContent', () => {
+      test('right side', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('right');
         assert.isFalse(
-            element.$$('.diffContainer').classList.contains('displayLine'));
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
 
-      test('displayLine class added called when displayLine is true', () => {
-        const spy = sandbox.spy(element, '_computeContainerClass');
-        element.displayLine = true;
-        assert.isTrue(spy.called);
+      test('left side parent by linenum', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('left');
         assert.isTrue(
-            element.$$('.diffContainer').classList.contains('displayLine'));
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
 
-      test('thread groups', () => {
-        const contentEl = document.createElement('div');
-
-        element.changeNum = 123;
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        element.path = 'file.txt';
-
-        const mock = document.createElement('mock-diff-response');
-        element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-            mock.diffResponse, Object.assign({}, MINIMAL_PREFS));
-
-        // No thread groups.
-        assert.isNotOk(element._getThreadGroupForLine(contentEl));
-
-        // A thread group gets created.
-        const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
-        assert.isOk(threadGroupEl);
-
-        // The new thread group can be fetched.
-        assert.isOk(element._getThreadGroupForLine(contentEl));
-
-        assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
-      });
-
-      suite('image diffs', () => {
-        let mockFile1;
-        let mockFile2;
-        setup(() => {
-          mockFile1 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAAAAAA/w==',
-            type: 'image/bmp',
-          };
-          mockFile2 = {
-            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-            'wsAAAAAAAAAAAAA/////w==',
-            type: 'image/bmp',
-          };
-
-          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.isImageDiff = true;
-          element.prefs = {
-            auto_hide_diff_table_header: true,
-            context: 10,
-            cursor_blink_rate: 0,
-            font_size: 12,
-            ignore_whitespace: 'IGNORE_NONE',
-            intraline_difference: true,
-            line_length: 100,
-            line_wrapping: false,
-            show_line_endings: true,
-            show_tabs: true,
-            show_whitespace_errors: true,
-            syntax_highlighting: true,
-            tab_size: 8,
-            theme: 'DEFAULT',
-          };
-        });
-
-        test('renders image diffs with same file name', done => {
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isNotOk(rightLabelName);
-            assert.isNotOk(leftLabelName);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.revisionImage = mockFile2;
-          element.diff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-        });
-
-        test('renders image diffs with a different file name', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot2.jpg',
-              'index 2adc47d..f9c2f2c 100644',
-              '--- a/carrot.jpg',
-              '+++ b/carrot2.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-
-          const rendered = () => {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            // Left image rendered with the parent commit's version of the file.
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const leftLabel =
-                element.$.diffTable.querySelector('td.left label');
-            const leftLabelContent = leftLabel.querySelector('.label');
-            const leftLabelName = leftLabel.querySelector('.name');
-
-            const rightImage =
-                element.$.diffTable.querySelector('td.right img');
-            const rightLabel = element.$.diffTable.querySelector(
-                'td.right label');
-            const rightLabelContent = rightLabel.querySelector('.label');
-            const rightLabelName = rightLabel.querySelector('.name');
-
-            assert.isOk(rightLabelName);
-            assert.isOk(leftLabelName);
-            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-            let leftLoaded = false;
-            let rightLoaded = false;
-
-            leftImage.addEventListener('load', () => {
-              assert.isOk(leftImage);
-              assert.equal(leftImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile1.body);
-              assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-              leftLoaded = true;
-              if (rightLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-
-            rightImage.addEventListener('load', () => {
-              assert.isOk(rightImage);
-              assert.equal(rightImage.getAttribute('src'),
-                  'data:image/bmp;base64, ' + mockFile2.body);
-              assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-              rightLoaded = true;
-              if (leftLoaded) {
-                element.removeEventListener('render', rendered);
-                done();
-              }
-            });
-          };
-
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.baseImage._name = mockDiff.meta_a.name;
-          element.revisionImage = mockFile2;
-          element.revisionImage._name = mockDiff.meta_b.name;
-          element.diff = mockDiff;
-        });
-
-        test('renders added image', done => {
-          const mockDiff = {
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'ADDED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index 0000000..f9c2f2c 100644',
-              '--- /dev/null',
-              '+++ b/carrot.jpg',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-
-          function rendered() {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const rightImage = element.$.diffTable.querySelector('td.right img');
-
-            assert.isNotOk(leftImage);
-            assert.isOk(rightImage);
-            done();
-            element.removeEventListener('render', rendered);
-          }
-          element.addEventListener('render', rendered);
-
-          element.revisionImage = mockFile2;
-          element.diff = mockDiff;
-        });
-
-        test('renders removed image', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-
-          function rendered() {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            const rightImage = element.$.diffTable.querySelector('td.right img');
-
-            assert.isOk(leftImage);
-            assert.isNotOk(rightImage);
-            done();
-            element.removeEventListener('render', rendered);
-          }
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.diff = mockDiff;
-        });
-
-        test('does not render disallowed image type', done => {
-          const mockDiff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-              lines: 560},
-            intraline_status: 'OK',
-            change_type: 'DELETED',
-            diff_header: [
-              'diff --git a/carrot.jpg b/carrot.jpg',
-              'index f9c2f2c..0000000 100644',
-              '--- a/carrot.jpg',
-              '+++ /dev/null',
-              'Binary files differ',
-            ],
-            content: [{skip: 66}],
-            binary: true,
-          };
-          mockFile1.type = 'image/jpeg-evil';
-
-          function rendered() {
-            // Recognizes that it should be an image diff.
-            assert.isTrue(element.isImageDiff);
-            assert.instanceOf(
-                element.$.diffBuilder._builder, GrDiffBuilderImage);
-            const leftImage = element.$.diffTable.querySelector('td.left img');
-            assert.isNotOk(leftImage);
-            done();
-            element.removeEventListener('render', rendered);
-          }
-          element.addEventListener('render', rendered);
-
-          element.baseImage = mockFile1;
-          element.diff = mockDiff;
-        });
-      });
-
-      test('_handleTap lineNum', done => {
-        const addDraftStub = sandbox.stub(element, 'addDraftAtLine');
-        const el = document.createElement('div');
-        el.className = 'lineNum';
-        el.addEventListener('click', e => {
-          element._handleTap(e);
-          assert.isTrue(addDraftStub.called);
-          assert.equal(addDraftStub.lastCall.args[0], el);
-          done();
-        });
-        el.click();
-      });
-
-      test('_handleTap context', done => {
-        const showContextStub =
-            sandbox.stub(element.$.diffBuilder, 'showContext');
-        const el = document.createElement('div');
-        el.className = 'showContext';
-        el.addEventListener('click', e => {
-          element._handleTap(e);
-          assert.isTrue(showContextStub.called);
-          done();
-        });
-        el.click();
-      });
-
-      test('_handleTap content', done => {
-        const content = document.createElement('div');
-        const lineEl = document.createElement('div');
-
-        const selectStub = sandbox.stub(element, '_selectLine');
-        sandbox.stub(element.$.diffBuilder, 'getLineElByChild', () => lineEl);
-
-        content.className = 'content';
-        content.addEventListener('click', e => {
-          element._handleTap(e);
-          assert.isTrue(selectStub.called);
-          assert.equal(selectStub.lastCall.args[0], lineEl);
-          done();
-        });
-        content.click();
-      });
-
-      suite('getCursorStops', () => {
-        const setupDiff = function() {
-          const mock = document.createElement('mock-diff-response');
-          element.diff = mock.diffResponse;
-          element.prefs = {
-            context: 10,
-            tab_size: 8,
-            font_size: 12,
-            line_length: 100,
-            cursor_blink_rate: 0,
-            line_wrapping: false,
-            intraline_difference: true,
-            show_line_endings: true,
-            show_tabs: true,
-            show_whitespace_errors: true,
-            syntax_highlighting: true,
-            auto_hide_diff_table_header: true,
-            theme: 'DEFAULT',
-            ignore_whitespace: 'IGNORE_NONE',
-          };
-
-          element._renderDiffTable();
-          flushAsynchronousOperations();
-        };
-
-        test('getCursorStops returns [] when hidden and noAutoRender', () => {
-          element.noAutoRender = true;
-          setupDiff();
-          element.hidden = true;
-          assert.equal(element.getCursorStops().length, 0);
-        });
-
-        test('getCursorStops', () => {
-          setupDiff();
-          assert.equal(element.getCursorStops().length, 50);
-        });
-      });
-
-      test('adds .hiddenscroll', () => {
-        Gerrit.hiddenscroll = true;
-        element.displayLine = true;
-        assert.include(element.$$('.diffContainer').className, 'hiddenscroll');
-      });
-    });
-
-    suite('logged in', () => {
-      let fakeLineEl;
-      setup(() => {
-        element = fixture('basic');
-        element.loggedIn = true;
-        element.patchRange = {};
-
-        fakeLineEl = {
-          getAttribute: sandbox.stub().returns(42),
-          classList: {
-            contains: sandbox.stub().returns(true),
-          },
-        };
-      });
-
-      test('addDraftAtLine', () => {
-        sandbox.stub(element, '_selectLine');
-        sandbox.stub(element, '_createComment');
-        element.addDraftAtLine(fakeLineEl);
-        assert.isTrue(element._createComment
-            .calledWithExactly(fakeLineEl, 42));
-      });
-
-      test('addDraftAtLine on an edit', () => {
-        element.patchRange.basePatchNum = element.EDIT_NAME;
-        sandbox.stub(element, '_selectLine');
-        sandbox.stub(element, '_createComment');
-        const alertSpy = sandbox.spy();
-        element.addEventListener('show-alert', alertSpy);
-        element.addDraftAtLine(fakeLineEl);
-        assert.isTrue(alertSpy.called);
-        assert.isFalse(element._createComment.called);
-      });
-
-      test('addDraftAtLine on an edit base', () => {
-        element.patchRange.patchNum = element.EDIT_NAME;
-        element.patchRange.basePatchNum = element.PARENT_NAME;
-        sandbox.stub(element, '_selectLine');
-        sandbox.stub(element, '_createComment');
-        const alertSpy = sandbox.spy();
-        element.addEventListener('show-alert', alertSpy);
-        element.addDraftAtLine(fakeLineEl);
-        assert.isTrue(alertSpy.called);
-        assert.isFalse(element._createComment.called);
-      });
-
-      suite('change in preferences', () => {
-        setup(() => {
-          element.diff = {
-            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-              lines: 560},
-            diff_header: [],
-            intraline_status: 'OK',
-            change_type: 'MODIFIED',
-            content: [{skip: 66}],
-          };
-          element.flushDebouncer('renderDiffTable');
-        });
-
-        test('change in preferences re-renders diff', () => {
-          sandbox.stub(element, '_renderDiffTable');
-          element.prefs = Object.assign(
-              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
-          element.flushDebouncer('renderDiffTable');
-          assert.isTrue(element._renderDiffTable.called);
-        });
-
-        test('adding/removing property in preferences re-renders diff', () => {
-          const stub = sandbox.stub(element, '_renderDiffTable');
-          const newPrefs1 = Object.assign({}, MINIMAL_PREFS,
-              {line_wrapping: true});
-          element.prefs = newPrefs1;
-          element.flushDebouncer('renderDiffTable');
-          assert.isTrue(element._renderDiffTable.called);
-          stub.reset();
-
-          const newPrefs2 = Object.assign({}, newPrefs1);
-          delete newPrefs2.line_wrapping;
-          element.prefs = newPrefs2;
-          element.flushDebouncer('renderDiffTable');
-          assert.isTrue(element._renderDiffTable.called);
-        });
-
-        test('change in preferences does not re-renders diff with ' +
-            'noRenderOnPrefsChange', () => {
-          sandbox.stub(element, '_renderDiffTable');
-          element.noRenderOnPrefsChange = true;
-          element.prefs = Object.assign(
-              {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
-          element.flushDebouncer('renderDiffTable');
-          assert.isFalse(element._renderDiffTable.called);
-        });
-      });
-    });
-
-    suite('diff header', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-      });
-
-      test('hidden', () => {
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', '--- a/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', '+++ b/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'test');
-        assert.equal(element._diffHeaderItems.length, 1);
-        flushAsynchronousOperations();
-
-        assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-      });
-
-      test('binary files', () => {
-        element.diff.binary = true;
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-        assert.equal(element._diffHeaderItems.length, 0);
-        element.push('diff.diff_header', 'test');
-        assert.equal(element._diffHeaderItems.length, 1);
-        element.push('diff.diff_header', 'Binary files differ');
-        assert.equal(element._diffHeaderItems.length, 1);
-      });
-    });
-
-    suite('safety and bypass', () => {
-      let renderStub;
-
-      setup(() => {
-        element = fixture('basic');
-        renderStub = sandbox.stub(element.$.diffBuilder, 'render',
-            () => {
-              element.$.diffBuilder.dispatchEvent(
-                  new CustomEvent('render', {bubbles: true, composed: true}));
-              return Promise.resolve({});
-            });
-        const mock = document.createElement('mock-diff-response');
-        sandbox.stub(element, 'getDiffLength').returns(10000);
-        element.diff = mock.diffResponse;
-        element.noRenderOnPrefsChange = true;
-      });
-
-      test('large render w/ context = 10', done => {
-        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
-        function rendered() {
-          assert.isTrue(renderStub.called);
-          assert.isFalse(element._showWarning);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-        element._renderDiffTable();
-      });
-
-      test('large render w/ whole file and bypass', done => {
-        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
-        element._safetyBypass = 10;
-        function rendered() {
-          assert.isTrue(renderStub.called);
-          assert.isFalse(element._showWarning);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-        element._renderDiffTable();
-      });
-
-      test('large render w/ whole file and no bypass', done => {
-        element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
-        function rendered() {
-          assert.isFalse(renderStub.called);
-          assert.isTrue(element._showWarning);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-        element._renderDiffTable();
-      });
-    });
-
-    suite('blame', () => {
-      setup(() => {
-        element = fixture('basic');
-      });
-
-      test('unsetting', () => {
-        element.blame = [];
-        const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
-        element.classList.add('showBlame');
-        element.blame = null;
-        assert.isTrue(setBlameSpy.calledWithExactly(null));
-        assert.isFalse(element.classList.contains('showBlame'));
-      });
-
-      test('setting', () => {
-        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-        element.blame = mockBlame;
-        assert.isTrue(element.classList.contains('showBlame'));
-      });
-    });
-
-    suite('trailing newlines', () => {
-      setup(() => {
-        element = fixture('basic');
-      });
-
-      suite('_lastChunkForSide', () => {
-        test('deltas', () => {
-          const diff = {content: [
-            {a: ['foo', 'bar'], b: ['baz']},
-            {ab: ['foo', 'bar', 'baz']},
-            {b: ['foo']},
-          ]};
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
-
-          diff.content.push({a: ['foo'], b: ['bar']});
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
-        });
-
-        test('addition with a undefined', () => {
-          const diff = {content: [
-            {b: ['foo', 'bar', 'baz']},
-          ]};
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-          assert.isNull(element._lastChunkForSide(diff, true));
-        });
-
-        test('addition with a empty', () => {
-          const diff = {content: [
-            {a: [], b: ['foo', 'bar', 'baz']},
-          ]};
-          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-          assert.isNull(element._lastChunkForSide(diff, true));
-        });
-
-
-        test('deletion with b undefined', () => {
-          const diff = {content: [
-            {a: ['foo', 'bar', 'baz']},
-          ]};
-          assert.isNull(element._lastChunkForSide(diff, false));
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-        });
-
-        test('deletion with b empty', () => {
-          const diff = {content: [
-            {a: ['foo', 'bar', 'baz'], b: []},
-          ]};
-          assert.isNull(element._lastChunkForSide(diff, false));
-          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-        });
-
-        test('empty', () => {
-          const diff = {content: []};
-          assert.isNull(element._lastChunkForSide(diff, false));
-          assert.isNull(element._lastChunkForSide(diff, true));
-        });
-      });
-
-      suite('_hasTrailingNewlines', () => {
-        test('shared no trailing', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide')
-              .returns({ab: ['foo', 'bar']});
-          assert.isFalse(element._hasTrailingNewlines(diff, false));
-          assert.isFalse(element._hasTrailingNewlines(diff, true));
-        });
-
-        test('delta trailing in right', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide')
-              .returns({a: ['foo', 'bar'], b: ['baz', '']});
-          assert.isTrue(element._hasTrailingNewlines(diff, false));
-          assert.isFalse(element._hasTrailingNewlines(diff, true));
-        });
-
-        test('addition', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
-            if (leftSide) { return null; }
-            return {b: ['foo', '']};
-          });
-          assert.isTrue(element._hasTrailingNewlines(diff, false));
-          assert.isNull(element._hasTrailingNewlines(diff, true));
-        });
-
-        test('deletion', () => {
-          const diff = undefined;
-          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
-            if (!leftSide) { return null; }
-            return {a: ['foo']};
-          });
-          assert.isNull(element._hasTrailingNewlines(diff, false));
-          assert.isFalse(element._hasTrailingNewlines(diff, true));
-        });
-      });
-
-      test('_computeNewlineWarning', () => {
-        const NO_NEWLINE_BASE = 'No newline at end of base file.';
-        const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
-
-        let hasLeft;
-        let hasRight;
-        sandbox.stub(element, '_hasTrailingNewlines', (diff, left) => {
-          if (left) { return hasLeft; }
-          return hasRight;
-        });
-        const diff = undefined;
-
-        // The revision has a trailing newline, but the base doesn't.
-        hasLeft = true;
-        hasRight = false;
-        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_REVISION);
-
-        // Trailing newlines were undetermined in the revision.
-        hasLeft = true;
-        hasRight = null;
-        assert.isNull(element._computeNewlineWarning(diff));
-
-        // Missing trailing newlines in the base.
-        hasLeft = false;
-        hasRight = null;
-        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_BASE);
-
-        // Missing trailing newlines in both.
-        hasLeft = false;
-        hasRight = false;
-        assert.equal(element._computeNewlineWarning(diff),
-            NO_NEWLINE_BASE + ' — ' + NO_NEWLINE_REVISION);
-      });
-
-      test('_computeNewlineWarningClass', () => {
-        const hidden = 'newlineWarning hidden';
-        const shown = 'newlineWarning';
-        assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-        assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-        assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-        assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-      });
-
-      test('_prefsEqual', () => {
-        element = fixture('basic');
-        assert.isTrue(element._prefsEqual(null, null));
-        assert.isTrue(element._prefsEqual({}, {}));
-        assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
+      test('left side parent by content', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        contentEl.classList.add('remove');
         assert.isTrue(
-            element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-        const somePref = {abc: 'def', p: true};
-        assert.isTrue(element._prefsEqual(somePref, somePref));
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
 
-        assert.isFalse(element._prefsEqual({}, null));
-        assert.isFalse(element._prefsEqual(null, {}));
-        assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-        assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-        assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-        assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
+      test('left side merge parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: -2};
+        contentEl.classList.add('remove');
+        assert.isTrue(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
+
+      test('left side non parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 3};
+        contentEl.classList.add('remove');
+        assert.isFalse(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
       });
     });
+  });
 
-    suite('key locations', () => {
-      let renderStub;
+  suite('not logged in', () => {
+    setup(() => {
+      const getLoggedInPromise = Promise.resolve(false);
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return getLoggedInPromise; },
+      });
+      element = fixture('basic');
+      return getLoggedInPromise;
+    });
 
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    test('addDraftAtLine', () => {
+      sandbox.stub(element, '_selectLine');
+      const loggedInErrorSpy = sandbox.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      element.addDraftAtLine();
+      assert.isTrue(loggedInErrorSpy.called);
+    });
+
+    test('view does not start with displayLine classList', () => {
+      assert.isFalse(
+          element.shadowRoot
+              .querySelector('.diffContainer')
+              .classList
+              .contains('displayLine'));
+    });
+
+    test('displayLine class added called when displayLine is true', () => {
+      const spy = sandbox.spy(element, '_computeContainerClass');
+      element.displayLine = true;
+      assert.isTrue(spy.called);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('.diffContainer')
+              .classList
+              .contains('displayLine'));
+    });
+
+    test('thread groups', () => {
+      const contentEl = document.createElement('div');
+
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+
+      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
+          getMockDiffResponse(), Object.assign({}, MINIMAL_PREFS));
+
+      // No thread groups.
+      assert.isNotOk(element._getThreadGroupForLine(contentEl));
+
+      // A thread group gets created.
+      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
+      assert.isOk(threadGroupEl);
+
+      // The new thread group can be fetched.
+      assert.isOk(element._getThreadGroupForLine(contentEl));
+
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+    });
+
+    suite('image diffs', () => {
+      let mockFile1;
+      let mockFile2;
       setup(() => {
-        element = fixture('basic');
-        element.prefs = {};
-        renderStub = sandbox.stub(element.$.diffBuilder, 'render')
-            .returns(new Promise(() => {}));
-      });
+        mockFile1 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
 
-      test('lineOfInterest is a key location', () => {
-        element.lineOfInterest = {number: 789, leftSide: true};
-        element._renderDiffTable();
-        assert.isTrue(renderStub.called);
-        assert.deepEqual(renderStub.lastCall.args[0], {
-          left: {789: true},
-          right: {},
-        });
-      });
-
-      test('line comments are key locations', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'right');
-        threadEl.setAttribute('line-num', 3);
-        Polymer.dom(element).appendChild(threadEl);
-        Polymer.dom.flush();
-
-        element._renderDiffTable();
-        assert.isTrue(renderStub.called);
-        assert.deepEqual(renderStub.lastCall.args[0], {
-          left: {},
-          right: {3: true},
-        });
-      });
-
-      test('file comments are key locations', () => {
-        const threadEl = document.createElement('div');
-        threadEl.className = 'comment-thread';
-        threadEl.setAttribute('comment-side', 'left');
-        Polymer.dom(element).appendChild(threadEl);
-        Polymer.dom.flush();
-
-        element._renderDiffTable();
-        assert.isTrue(renderStub.called);
-        assert.deepEqual(renderStub.lastCall.args[0], {
-          left: {FILE: true},
-          right: {},
-        });
-      });
-    });
-
-    suite('whitespace changes only message', () => {
-      const setupDiff = function(ignore_whitespace, diffContent) {
-        element = fixture('basic');
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.isImageDiff = true;
         element.prefs = {
-          ignore_whitespace,
           auto_hide_diff_table_header: true,
           context: 10,
           cursor_blink_rate: 0,
           font_size: 12,
+          ignore_whitespace: 'IGNORE_NONE',
           intraline_difference: true,
           line_length: 100,
           line_wrapping: false,
@@ -1079,98 +285,883 @@
           tab_size: 8,
           theme: 'DEFAULT',
         };
+      });
 
+      test('renders image diffs with same file name', done => {
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isNotOk(rightLabelName);
+          assert.isNotOk(leftLabelName);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
         element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
           intraline_status: 'OK',
           change_type: 'MODIFIED',
           diff_header: [
-            'diff --git a/carrot.js b/carrot.js',
+            'diff --git a/carrot.jpg b/carrot.jpg',
             'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.js',
-            '+++ b/carrot.jjs',
-            'file differ',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
           ],
-          content: diffContent,
+          content: [{skip: 66}],
           binary: true,
         };
+      });
+
+      test('renders image diffs with a different file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isOk(rightLabelName);
+          assert.isOk(leftLabelName);
+          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b.name;
+        element.diff = mockDiff;
+      });
+
+      test('renders added image', done => {
+        const mockDiff = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const rightImage = element.$.diffTable.querySelector('td.right img');
+
+          assert.isNotOk(leftImage);
+          assert.isOk(rightImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+      });
+
+      test('renders removed image', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const rightImage = element.$.diffTable.querySelector('td.right img');
+
+          assert.isOk(leftImage);
+          assert.isNotOk(rightImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+      });
+
+      test('does not render disallowed image type', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          assert.isNotOk(leftImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+      });
+    });
+
+    test('_handleTap lineNum', done => {
+      const addDraftStub = sandbox.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        done();
+      });
+      el.click();
+    });
+
+    test('_handleTap context', done => {
+      const showContextStub =
+          sandbox.stub(element.$.diffBuilder, 'showContext');
+      const el = document.createElement('div');
+      el.className = 'showContext';
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(showContextStub.called);
+        done();
+      });
+      el.click();
+    });
+
+    test('_handleTap content', done => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+
+      const selectStub = sandbox.stub(element, '_selectLine');
+      sandbox.stub(element.$.diffBuilder, 'getLineElByChild', () => lineEl);
+
+      content.className = 'content';
+      content.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        done();
+      });
+      content.click();
+    });
+
+    suite('getCursorStops', () => {
+      const setupDiff = function() {
+        element.diff = getMockDiffResponse();
+        element.prefs = {
+          context: 10,
+          tab_size: 8,
+          font_size: 12,
+          line_length: 100,
+          cursor_blink_rate: 0,
+          line_wrapping: false,
+          intraline_difference: true,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          auto_hide_diff_table_header: true,
+          theme: 'DEFAULT',
+          ignore_whitespace: 'IGNORE_NONE',
+        };
 
         element._renderDiffTable();
         flushAsynchronousOperations();
       };
 
-      test('show the message if ignore_whitespace is criteria matches', () => {
-        setupDiff('IGNORE_ALL', [{skip: 100}]);
-        assert.isTrue(element.showNoChangeMessage(
-            /* loading= */ false,
-            element.prefs,
-            element._diffLength
-        ));
+      test('getCursorStops returns [] when hidden and noAutoRender', () => {
+        element.noAutoRender = true;
+        setupDiff();
+        element.hidden = true;
+        assert.equal(element.getCursorStops().length, 0);
       });
 
-      test('do not show the message if still loading', () => {
-        setupDiff('IGNORE_ALL', [{skip: 100}]);
-        assert.isFalse(element.showNoChangeMessage(
-            /* loading= */ true,
-            element.prefs,
-            element._diffLength
-        ));
-      });
-
-      test('do not show the message if contains valid changes', () => {
-        const content = [{
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        }, {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        }];
-        setupDiff('IGNORE_ALL', content);
-        assert.equal(element._diffLength, 3);
-        assert.isFalse(element.showNoChangeMessage(
-            /* loading= */ false,
-            element.prefs,
-            element._diffLength
-        ));
-      });
-
-      test('do not show message if ignore whitespace is disabled', () => {
-        const content = [{
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        }, {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        }];
-        setupDiff('IGNORE_NONE', content);
-        assert.isFalse(element.showNoChangeMessage(
-            /* loading= */ false,
-            element.prefs,
-            element._diffLength
-        ));
+      test('getCursorStops', () => {
+        setupDiff();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
       });
     });
 
-    test('getDiffLength', () => {
-      const diff = document.createElement('mock-diff-response').diffResponse;
-      assert.equal(element.getDiffLength(diff), 52);
+    test('adds .hiddenscroll', () => {
+      _setHiddenScroll(true);
+      element.displayLine = true;
+      assert.include(element.shadowRoot
+          .querySelector('.diffContainer').className, 'hiddenscroll');
     });
+  });
 
-    test('`render` event has contentRendered field in detail', done => {
+  suite('logged in', () => {
+    let fakeLineEl;
+    setup(() => {
       element = fixture('basic');
-      element.prefs = {};
-      renderStub = sandbox.stub(element.$.diffBuilder, 'render')
-          .returns(Promise.resolve());
-      element.addEventListener('render', event => {
-        assert.isTrue(event.detail.contentRendered);
-        done();
+      element.loggedIn = true;
+      element.patchRange = {};
+
+      fakeLineEl = {
+        getAttribute: sandbox.stub().returns(42),
+        classList: {
+          contains: sandbox.stub().returns(true),
+        },
+      };
+    });
+
+    test('addDraftAtLine', () => {
+      sandbox.stub(element, '_selectLine');
+      sandbox.stub(element, '_createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(element._createComment
+          .calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('addDraftAtLine on an edit', () => {
+      element.patchRange.basePatchNum = element.EDIT_NAME;
+      sandbox.stub(element, '_selectLine');
+      sandbox.stub(element, '_createComment');
+      const alertSpy = sandbox.spy();
+      element.addEventListener('show-alert', alertSpy);
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(alertSpy.called);
+      assert.isFalse(element._createComment.called);
+    });
+
+    test('addDraftAtLine on an edit base', () => {
+      element.patchRange.patchNum = element.EDIT_NAME;
+      element.patchRange.basePatchNum = element.PARENT_NAME;
+      sandbox.stub(element, '_selectLine');
+      sandbox.stub(element, '_createComment');
+      const alertSpy = sandbox.spy();
+      element.addEventListener('show-alert', alertSpy);
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(alertSpy.called);
+      assert.isFalse(element._createComment.called);
+    });
+
+    suite('change in preferences', () => {
+      setup(() => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        element.flushDebouncer('renderDiffTable');
       });
+
+      test('change in preferences re-renders diff', () => {
+        sandbox.stub(element, '_renderDiffTable');
+        element.prefs = Object.assign(
+            {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+      });
+
+      test('adding/removing property in preferences re-renders diff', () => {
+        const stub = sandbox.stub(element, '_renderDiffTable');
+        const newPrefs1 = Object.assign({}, MINIMAL_PREFS,
+            {line_wrapping: true});
+        element.prefs = newPrefs1;
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+        stub.reset();
+
+        const newPrefs2 = Object.assign({}, newPrefs1);
+        delete newPrefs2.line_wrapping;
+        element.prefs = newPrefs2;
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+      });
+
+      test('change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange', () => {
+        sandbox.stub(element, '_renderDiffTable');
+        element.noRenderOnPrefsChange = true;
+        element.prefs = Object.assign(
+            {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+        element.flushDebouncer('renderDiffTable');
+        assert.isFalse(element._renderDiffTable.called);
+      });
+    });
+  });
+
+  suite('diff header', () => {
+    setup(() => {
+      element = fixture('basic');
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+    });
+
+    test('hidden', () => {
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '--- a/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '+++ b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff.binary = true;
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      element.push('diff.diff_header', 'Binary files differ');
+      assert.equal(element._diffHeaderItems.length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub;
+
+    setup(() => {
+      element = fixture('basic');
+      renderStub = sandbox.stub(element.$.diffBuilder, 'render',
+          () => {
+            element.$.diffBuilder.dispatchEvent(
+                new CustomEvent('render', {bubbles: true, composed: true}));
+            return Promise.resolve({});
+          });
+      sandbox.stub(element, 'getDiffLength').returns(10000);
+      element.diff = getMockDiffResponse();
+      element.noRenderOnPrefsChange = true;
+    });
+
+    test('large render w/ context = 10', done => {
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+    });
+
+    test('large render w/ whole file and bypass', done => {
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+      element._safetyBypass = 10;
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+    });
+
+    test('large render w/ whole file and no bypass', done => {
+      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+      function rendered() {
+        assert.isFalse(renderStub.called);
+        assert.isTrue(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
       element._renderDiffTable();
     });
   });
 
-  a11ySuite('basic');
+  suite('blame', () => {
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('unsetting', () => {
+      element.blame = [];
+      const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', () => {
+      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      element.blame = mockBlame;
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_BASE = 'No newline at end of base file.';
+    const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+    const getWarning = element =>
+      element.shadowRoot.querySelector('.newlineWarning').textContent;
+
+    setup(() => {
+      element = fixture('basic');
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+    });
+
+    test('shows combined warning if both sides set to warn', () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      assert.include(getWarning(element),
+          NO_NEWLINE_BASE + ' \u2014 ' + NO_NEWLINE_REVISION);// \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningLeft = true;
+        assert.include(getWarning(element), NO_NEWLINE_BASE);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningLeft = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+      });
+
+      test('hide warning if undefined', () => {
+        element.showNewlineWarningLeft = undefined;
+        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningRight = true;
+        assert.include(getWarning(element), NO_NEWLINE_REVISION);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningRight = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+      });
+
+      test('hide warning if undefined', () => {
+        element.showNewlineWarningRight = undefined;
+        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+      });
+    });
+
+    test('_computeNewlineWarningClass', () => {
+      const hidden = 'newlineWarning hidden';
+      const shown = 'newlineWarning';
+      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
+      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
+      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
+      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
+    });
+
+    test('_prefsEqual', () => {
+      element = fixture('basic');
+      assert.isTrue(element._prefsEqual(null, null));
+      assert.isTrue(element._prefsEqual({}, {}));
+      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
+      assert.isTrue(
+          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
+      const somePref = {abc: 'def', p: true};
+      assert.isTrue(element._prefsEqual(somePref, somePref));
+
+      assert.isFalse(element._prefsEqual({}, null));
+      assert.isFalse(element._prefsEqual(null, {}));
+      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
+      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
+      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
+      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub;
+
+    setup(() => {
+      element = fixture('basic');
+      element.prefs = {};
+      renderStub = sandbox.stub(element.$.diffBuilder, 'render')
+          .returns(new Promise(() => {}));
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {number: 789, leftSide: true};
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      dom(element).appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'left');
+      dom(element).appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+  const setupSampleDiff = function(params) {
+    const {ignore_whitespace, content} = params;
+    element = fixture('basic');
+    element.prefs = {
+      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+      auto_hide_diff_table_header: true,
+      context: 10,
+      cursor_blink_rate: 0,
+      font_size: 12,
+      intraline_difference: true,
+      line_length: 100,
+      line_wrapping: false,
+      show_line_endings: true,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+      theme: 'DEFAULT',
+    };
+    element.diff = {
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/carrot.js b/carrot.js',
+        'index 2adc47d..f9c2f2c 100644',
+        '--- a/carrot.js',
+        '+++ b/carrot.jjs',
+        'file differ',
+      ],
+      content,
+      binary: false,
+    };
+    element._renderDiffTable();
+    flushAsynchronousOperations();
+  };
+
+  test('clear diff table content as soon as diff changes', () => {
+    const content = [{
+      a: ['all work and no play make andybons a dull boy'],
+    }, {
+      b: [
+        'Non eram nescius, Brute, cum, quae summis ingeniis ',
+      ],
+    }];
+    function assertDiffTableWithContent() {
+      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
+    }
+    setupSampleDiff({content});
+    assertDiffTableWithContent();
+    const diffCopy = Object.assign({}, element.diff);
+    element.diff = diffCopy;
+    // immediatelly cleaned up
+    assert.equal(element.$.diffTable.innerHTML, '');
+    element._renderDiffTable();
+    flushAsynchronousOperations();
+    // rendered again
+    assertDiffTableWithContent();
+  });
+
+  suite('selection test', () => {
+    test('user-select set correctly on side-by-side view', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({content});
+      flushAsynchronousOperations();
+      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      // click to mark it as selected
+      MockInteractions.tap(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+
+    test('user-select set correctly on unified view', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({content});
+      element.viewMode = 'UNIFIED_DIFF';
+      flushAsynchronousOperations();
+      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      MockInteractions.tap(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    test('show the message if ignore_whitespace is criteria matches', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isTrue(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+
+    test('do not show the message if still loading', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ true,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+
+    test('do not show the message if contains valid changes', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({content});
+      assert.equal(element._diffLength, 3);
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+
+    test('do not show message if ignore whitespace is disabled', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength
+      ));
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = getMockDiffResponse();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+
+  test('`render` event has contentRendered field in detail', done => {
+    element = fixture('basic');
+    element.prefs = {};
+    sandbox.stub(element.$.diffBuilder, 'render')
+        .returns(Promise.resolve());
+    element.addEventListener('render', event => {
+      assert.isTrue(event.detail.contentRendered);
+      done();
+    });
+    element._renderDiffTable();
+  });
+});
+
+a11ySuite('basic');
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
deleted file mode 100644
index ee1f536..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ /dev/null
@@ -1,92 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-patch-range-select">
-  <template>
-    <style include="shared-styles">
-      :host {
-        align-items: center;
-        display: flex;
-      }
-      select {
-        max-width: 15em;
-      }
-      .arrow {
-        color: var(--deemphasized-text-color);
-        margin: 0 var(--spacing-m);
-      }
-      gr-dropdown-list {
-        --trigger-style: {
-          color: var(--deemphasized-text-color);
-          text-transform: none;
-          font-family: var(--font-family);
-        }
-        --trigger-hover-color: rgba(0,0,0,.6);
-      }
-      @media screen and (max-width: 50em) {
-        .filesWeblinks {
-          display: none;
-        }
-        gr-dropdown-list {
-          --native-select-style: {
-            max-width: 5.25em;
-          }
-          --dropdown-content-stype: {
-            max-width: 300px;
-          }
-        }
-      }
-    </style>
-    <span class="patchRange">
-      <gr-dropdown-list
-          id="basePatchDropdown"
-          value="[[basePatchNum]]"
-          on-value-change="_handlePatchChange"
-          items="[[_baseDropdownContent]]">
-      </gr-dropdown-list>
-    </span>
-    <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
-      <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
-        <a target="_blank" rel="noopener"
-           href$="[[weblink.url]]">[[weblink.name]]</a>
-      </template>
-    </span>
-    <span class="arrow">&rarr;</span>
-    <span class="patchRange">
-      <gr-dropdown-list
-          id="patchNumDropdown"
-          value="[[patchNum]]"
-          on-value-change="_handlePatchChange"
-          items="[[_patchDropdownContent]]">
-      </gr-dropdown-list>
-      <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
-        <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
-          <a target="_blank"
-             href$="[[weblink.url]]">[[weblink.name]]</a>
-        </template>
-      </span>
-    </span>
-  </template>
-  <script src="gr-patch-range-select.js"></script>
-</dom-module>
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..8bdc1a8 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,35 +14,53 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Maximum length for patch set descriptions.
-  const PATCH_DESC_MAX_LENGTH = 500;
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
+import '../../shared/gr-select/gr-select.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-patch-range-select_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
 
-  /**
-   * Fired when the patch range changes
-   *
-   * @event patch-range-change
-   *
-   * @property {string} patchNum
-   * @property {string} basePatchNum
-   */
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
 
-  Polymer({
-    is: 'gr-patch-range-select',
+/**
+ * Fired when the patch range changes
+ *
+ * @event patch-range-change
+ *
+ * @property {string} patchNum
+ * @property {string} basePatchNum
+ * @extends Polymer.Element
+ */
+class GrPatchRangeSelect extends mixinBehaviors( [
+  PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-patch-range-select'; }
+
+  static get properties() {
+    return {
       availablePatches: Array,
       _baseDropdownContent: {
         type: Object,
         computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
-            '_sortedRevisions, changeComments, revisionInfo)',
+          '_sortedRevisions, changeComments, revisionInfo)',
       },
       _patchDropdownContent: {
         type: Object,
         computed: '_computePatchDropdownContent(availablePatches,' +
-            'basePatchNum, _sortedRevisions, changeComments)',
+          'basePatchNum, _sortedRevisions, changeComments)',
       },
       changeNum: String,
       changeComments: Object,
@@ -53,229 +71,230 @@
       revisions: Object,
       revisionInfo: Object,
       _sortedRevisions: Array,
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_updateSortedRevisions(revisions.*)',
-    ],
+    ];
+  }
 
-    behaviors: [Gerrit.PatchSetBehavior],
+  _getShaForPatch(patch) {
+    return patch.sha.substring(0, 10);
+  }
 
-    _getShaForPatch(patch) {
-      return patch.sha.substring(0, 10);
-    },
+  _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
+      changeComments, revisionInfo) {
+    // Polymer 2: check for undefined
+    if ([
+      availablePatches,
+      patchNum,
+      _sortedRevisions,
+      changeComments,
+      revisionInfo,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-    _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
-        changeComments, revisionInfo) {
-      // Polymer 2: check for undefined
-      if ([
-        availablePatches,
-        patchNum,
-        _sortedRevisions,
-        changeComments,
-        revisionInfo,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
+    const parentCounts = revisionInfo.getParentCountMap();
+    const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
+      parentCounts[patchNum] : 1;
+    const maxParents = revisionInfo.getMaxParents();
+    const isMerge = currentParentCount > 1;
 
-      const parentCounts = revisionInfo.getParentCountMap();
-      const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
-        parentCounts[patchNum] : 1;
-      const maxParents = revisionInfo.getMaxParents();
-      const isMerge = currentParentCount > 1;
+    const dropdownContent = [];
+    for (const basePatch of availablePatches) {
+      const basePatchNum = basePatch.num;
+      const entry = this._createDropdownEntry(basePatchNum, 'Patchset ',
+          _sortedRevisions, changeComments, this._getShaForPatch(basePatch));
+      dropdownContent.push(Object.assign({}, entry, {
+        disabled: this._computeLeftDisabled(
+            basePatch.num, patchNum, _sortedRevisions),
+      }));
+    }
 
-      const dropdownContent = [];
-      for (const basePatch of availablePatches) {
-        const basePatchNum = basePatch.num;
-        const entry = this._createDropdownEntry(basePatchNum, 'Patchset ',
-            _sortedRevisions, changeComments, this._getShaForPatch(basePatch));
-        dropdownContent.push(Object.assign({}, entry, {
-          disabled: this._computeLeftDisabled(
-              basePatch.num, patchNum, _sortedRevisions),
-        }));
-      }
+    dropdownContent.push({
+      text: isMerge ? 'Auto Merge' : 'Base',
+      value: 'PARENT',
+    });
 
+    for (let idx = 0; isMerge && idx < maxParents; idx++) {
       dropdownContent.push({
-        text: isMerge ? 'Auto Merge' : 'Base',
-        value: 'PARENT',
+        disabled: idx >= currentParentCount,
+        triggerText: `Parent ${idx + 1}`,
+        text: `Parent ${idx + 1}`,
+        mobileText: `Parent ${idx + 1}`,
+        value: -(idx + 1),
       });
+    }
 
-      for (let idx = 0; isMerge && idx < maxParents; idx++) {
-        dropdownContent.push({
-          disabled: idx >= currentParentCount,
-          triggerText: `Parent ${idx + 1}`,
-          text: `Parent ${idx + 1}`,
-          mobileText: `Parent ${idx + 1}`,
-          value: -(idx + 1),
-        });
-      }
+    return dropdownContent;
+  }
 
-      return dropdownContent;
-    },
+  _computeMobileText(patchNum, changeComments, revisions) {
+    return `${patchNum}` +
+        `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+        `${this._computePatchSetDescription(revisions, patchNum, true)}`;
+  }
 
-    _computeMobileText(patchNum, changeComments, revisions) {
-      return `${patchNum}` +
-          `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
-          `${this._computePatchSetDescription(revisions, patchNum, true)}`;
-    },
+  _computePatchDropdownContent(availablePatches, basePatchNum,
+      _sortedRevisions, changeComments) {
+    // Polymer 2: check for undefined
+    if ([
+      availablePatches,
+      basePatchNum,
+      _sortedRevisions,
+      changeComments,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-    _computePatchDropdownContent(availablePatches, basePatchNum,
-        _sortedRevisions, changeComments) {
-      // Polymer 2: check for undefined
-      if ([
-        availablePatches,
-        basePatchNum,
-        _sortedRevisions,
-        changeComments,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
+    const dropdownContent = [];
+    for (const patch of availablePatches) {
+      const patchNum = patch.num;
+      const entry = this._createDropdownEntry(
+          patchNum, patchNum === 'edit' ? '' : 'Patchset ', _sortedRevisions,
+          changeComments, this._getShaForPatch(patch));
+      dropdownContent.push(Object.assign({}, entry, {
+        disabled: this._computeRightDisabled(basePatchNum, patchNum,
+            _sortedRevisions),
+      }));
+    }
+    return dropdownContent;
+  }
 
-      const dropdownContent = [];
-      for (const patch of availablePatches) {
-        const patchNum = patch.num;
-        const entry = this._createDropdownEntry(
-            patchNum, patchNum === 'edit' ? '' : 'Patchset ', _sortedRevisions,
-            changeComments, this._getShaForPatch(patch));
-        dropdownContent.push(Object.assign({}, entry, {
-          disabled: this._computeRightDisabled(basePatchNum, patchNum,
-              _sortedRevisions),
-        }));
-      }
-      return dropdownContent;
-    },
+  _computeText(patchNum, prefix, changeComments, sha) {
+    return `${prefix}${patchNum}` +
+      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+        (` | ${sha}`);
+  }
 
-    _computeText(patchNum, prefix, changeComments, sha) {
-      return `${prefix}${patchNum}` +
-        `${this._computePatchSetCommentsString(changeComments, patchNum)}`
-          + (` | ${sha}`);
-    },
+  _createDropdownEntry(patchNum, prefix, sortedRevisions, changeComments,
+      sha) {
+    const entry = {
+      triggerText: `${prefix}${patchNum}`,
+      text: this._computeText(patchNum, prefix, changeComments, sha),
+      mobileText: this._computeMobileText(patchNum, changeComments,
+          sortedRevisions),
+      bottomText: `${this._computePatchSetDescription(
+          sortedRevisions, patchNum)}`,
+      value: patchNum,
+    };
+    const date = this._computePatchSetDate(sortedRevisions, patchNum);
+    if (date) {
+      entry['date'] = date;
+    }
+    return entry;
+  }
 
-    _createDropdownEntry(patchNum, prefix, sortedRevisions, changeComments,
-        sha) {
-      const entry = {
-        triggerText: `${prefix}${patchNum}`,
-        text: this._computeText(patchNum, prefix, changeComments, sha),
-        mobileText: this._computeMobileText(patchNum, changeComments,
-            sortedRevisions),
-        bottomText: `${this._computePatchSetDescription(
-            sortedRevisions, patchNum)}`,
-        value: patchNum,
-      };
-      const date = this._computePatchSetDate(sortedRevisions, patchNum);
-      if (date) {
-        entry['date'] = date;
-      }
-      return entry;
-    },
+  _updateSortedRevisions(revisionsRecord) {
+    const revisions = revisionsRecord.base;
+    this._sortedRevisions = this.sortRevisions(Object.values(revisions));
+  }
 
-    _updateSortedRevisions(revisionsRecord) {
-      const revisions = revisionsRecord.base;
-      this._sortedRevisions = this.sortRevisions(Object.values(revisions));
-    },
+  /**
+   * The basePatchNum should always be <= patchNum -- because sortedRevisions
+   * is sorted in reverse order (higher patchset nums first), invalid base
+   * patch nums have an index greater than the index of patchNum.
+   *
+   * @param {number|string} basePatchNum The possible base patch num.
+   * @param {number|string} patchNum The current selected patch num.
+   * @param {!Array} sortedRevisions
+   */
+  _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
+    return this.findSortedIndex(basePatchNum, sortedRevisions) <=
+        this.findSortedIndex(patchNum, sortedRevisions);
+  }
 
-    /**
-     * The basePatchNum should always be <= patchNum -- because sortedRevisions
-     * is sorted in reverse order (higher patchset nums first), invalid base
-     * patch nums have an index greater than the index of patchNum.
-     *
-     * @param {number|string} basePatchNum The possible base patch num.
-     * @param {number|string} patchNum The current selected patch num.
-     * @param {!Array} sortedRevisions
-     */
-    _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
-      return this.findSortedIndex(basePatchNum, sortedRevisions) <=
-          this.findSortedIndex(patchNum, sortedRevisions);
-    },
+  /**
+   * The basePatchNum should always be <= patchNum -- because sortedRevisions
+   * is sorted in reverse order (higher patchset nums first), invalid patch
+   * nums have an index greater than the index of basePatchNum.
+   *
+   * In addition, if the current basePatchNum is 'PARENT', all patchNums are
+   * valid.
+   *
+   * If the curent basePatchNum is a parent index, then only patches that have
+   * at least that many parents are valid.
+   *
+   * @param {number|string} basePatchNum The current selected base patch num.
+   * @param {number|string} patchNum The possible patch num.
+   * @param {!Array} sortedRevisions
+   * @return {boolean}
+   */
+  _computeRightDisabled(basePatchNum, patchNum, sortedRevisions) {
+    if (this.patchNumEquals(basePatchNum, 'PARENT')) { return false; }
 
-    /**
-     * The basePatchNum should always be <= patchNum -- because sortedRevisions
-     * is sorted in reverse order (higher patchset nums first), invalid patch
-     * nums have an index greater than the index of basePatchNum.
-     *
-     * In addition, if the current basePatchNum is 'PARENT', all patchNums are
-     * valid.
-     *
-     * If the curent basePatchNum is a parent index, then only patches that have
-     * at least that many parents are valid.
-     *
-     * @param {number|string} basePatchNum The current selected base patch num.
-     * @param {number|string} patchNum The possible patch num.
-     * @param {!Array} sortedRevisions
-     * @return {boolean}
-     */
-    _computeRightDisabled(basePatchNum, patchNum, sortedRevisions) {
-      if (this.patchNumEquals(basePatchNum, 'PARENT')) { return false; }
+    if (this.isMergeParent(basePatchNum)) {
+      // Note: parent indices use 1-offset.
+      return this.revisionInfo.getParentCount(patchNum) <
+          this.getParentIndex(basePatchNum);
+    }
 
-      if (this.isMergeParent(basePatchNum)) {
-        // Note: parent indices use 1-offset.
-        return this.revisionInfo.getParentCount(patchNum) <
-            this.getParentIndex(basePatchNum);
-      }
+    return this.findSortedIndex(basePatchNum, sortedRevisions) <=
+        this.findSortedIndex(patchNum, sortedRevisions);
+  }
 
-      return this.findSortedIndex(basePatchNum, sortedRevisions) <=
-          this.findSortedIndex(patchNum, sortedRevisions);
-    },
+  _computePatchSetCommentsString(changeComments, patchNum) {
+    if (!changeComments) { return; }
 
+    const commentCount = changeComments.computeCommentCount({patchNum});
+    const commentString = GrCountStringFormatter.computePluralString(
+        commentCount, 'comment');
 
-    _computePatchSetCommentsString(changeComments, patchNum) {
-      if (!changeComments) { return; }
+    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
+    const unresolvedString = GrCountStringFormatter.computeString(
+        unresolvedCount, 'unresolved');
 
-      const commentCount = changeComments.computeCommentCount(patchNum);
-      const commentString = GrCountStringFormatter.computePluralString(
-          commentCount, 'comment');
+    if (!commentString.length && !unresolvedString.length) {
+      return '';
+    }
 
-      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum);
-      const unresolvedString = GrCountStringFormatter.computeString(
-          unresolvedCount, 'unresolved');
+    return ` (${commentString}` +
+        // Add a comma + space if both comments and unresolved
+        (commentString && unresolvedString ? ', ' : '') +
+        `${unresolvedString})`;
+  }
 
-      if (!commentString.length && !unresolvedString.length) {
-        return '';
-      }
+  /**
+   * @param {!Array} revisions
+   * @param {number|string} patchNum
+   * @param {boolean=} opt_addFrontSpace
+   */
+  _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
+    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    return (rev && rev.description) ?
+      (opt_addFrontSpace ? ' ' : '') +
+        rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+  }
 
-      return ` (${commentString}` +
-          // Add a comma + space if both comments and unresolved
-          (commentString && unresolvedString ? ', ' : '') +
-          `${unresolvedString})`;
-    },
+  /**
+   * @param {!Array} revisions
+   * @param {number|string} patchNum
+   */
+  _computePatchSetDate(revisions, patchNum) {
+    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    return rev ? rev.created : undefined;
+  }
 
-    /**
-     * @param {!Array} revisions
-     * @param {number|string} patchNum
-     * @param {boolean=} opt_addFrontSpace
-     */
-    _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
-      const rev = this.getRevisionByPatchNum(revisions, patchNum);
-      return (rev && rev.description) ?
-        (opt_addFrontSpace ? ' ' : '') +
-          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    },
+  /**
+   * Catches value-change events from the patchset dropdowns and determines
+   * whether or not a patch change event should be fired.
+   */
+  _handlePatchChange(e) {
+    const detail = {patchNum: this.patchNum, basePatchNum: this.basePatchNum};
+    const target = dom(e).localTarget;
 
-    /**
-     * @param {!Array} revisions
-     * @param {number|string} patchNum
-     */
-    _computePatchSetDate(revisions, patchNum) {
-      const rev = this.getRevisionByPatchNum(revisions, patchNum);
-      return rev ? rev.created : undefined;
-    },
+    if (target === this.$.patchNumDropdown) {
+      detail.patchNum = e.detail.value;
+    } else {
+      detail.basePatchNum = e.detail.value;
+    }
 
-    /**
-     * Catches value-change events from the patchset dropdowns and determines
-     * whether or not a patch change event should be fired.
-     */
-    _handlePatchChange(e) {
-      const detail = {patchNum: this.patchNum, basePatchNum: this.basePatchNum};
-      const target = Polymer.dom(e).localTarget;
+    this.dispatchEvent(
+        new CustomEvent('patch-range-change', {detail, bubbles: false}));
+  }
+}
 
-      if (target === this.$.patchNumDropdown) {
-        detail.patchNum = e.detail.value;
-      } else {
-        detail.basePatchNum = e.detail.value;
-      }
-
-      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_html.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
new file mode 100644
index 0000000..1d4b440
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: flex;
+    }
+    select {
+      max-width: 15em;
+    }
+    .arrow {
+      color: var(--deemphasized-text-color);
+      margin: 0 var(--spacing-m);
+    }
+    gr-dropdown-list {
+      --trigger-style: {
+        color: var(--deemphasized-text-color);
+        text-transform: none;
+        font-family: var(--font-family);
+      }
+      --trigger-hover-color: rgba(0, 0, 0, 0.6);
+    }
+    @media screen and (max-width: 50em) {
+      .filesWeblinks {
+        display: none;
+      }
+      gr-dropdown-list {
+        --native-select-style: {
+          max-width: 5.25em;
+        }
+        --dropdown-content-stype: {
+          max-width: 300px;
+        }
+      }
+    }
+  </style>
+  <span class="patchRange">
+    <gr-dropdown-list
+      id="basePatchDropdown"
+      value="[[basePatchNum]]"
+      on-value-change="_handlePatchChange"
+      items="[[_baseDropdownContent]]"
+    >
+    </gr-dropdown-list>
+  </span>
+  <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
+    <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
+      <a target="_blank" rel="noopener" href$="[[weblink.url]]"
+        >[[weblink.name]]</a
+      >
+    </template>
+  </span>
+  <span class="arrow">→</span>
+  <span class="patchRange">
+    <gr-dropdown-list
+      id="patchNumDropdown"
+      value="[[patchNum]]"
+      on-value-change="_handlePatchChange"
+      items="[[_patchDropdownContent]]"
+    >
+    </gr-dropdown-list>
+    <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
+      <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
+        <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
+      </template>
+    </span>
+  </span>
+`;
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..63e6fc6 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
@@ -17,22 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="../../shared/revision-info/revision-info.html">
-
-<link rel="import" href="gr-patch-range-select.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <dom-module id="comment-api-mock">
   <template>
@@ -40,8 +32,7 @@
         change-comments="[[_changeComments]]"></gr-patch-range-select>
     <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
-  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
-</dom-module>
+  </dom-module>
 
 <test-fixture id="basic">
   <template>
@@ -49,381 +40,390 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-patch-range-select tests', () => {
-    let element;
-    let sandbox;
-    let commentApiWrapper;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-comment-api/gr-comment-api.js';
+import '../../shared/revision-info/revision-info.js';
+import './gr-patch-range-select.js';
+import '../gr-comment-api/gr-comment-api-mock_test.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
+suite('gr-patch-range-select tests', () => {
+  let element;
+  let sandbox;
+  let commentApiWrapper;
 
-    function getInfo(revisions) {
-      const revisionObj = {};
-      for (let i = 0; i < revisions.length; i++) {
-        revisionObj[i] = revisions[i];
-      }
-      return new Gerrit.RevisionInfo({revisions: revisionObj});
+  function getInfo(revisions) {
+    const revisionObj = {};
+    for (let i = 0; i < revisions.length; i++) {
+      revisionObj[i] = revisions[i];
     }
+    return new RevisionInfo({revisions: revisionObj});
+  }
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.patchRange;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+    stub('gr-rest-api-interface', {
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
     });
 
-    teardown(() => sandbox.restore());
+    // Element must be wrapped in an element with direct access to the
+    // comment API.
+    commentApiWrapper = fixture('basic');
+    element = commentApiWrapper.$.patchRange;
 
-    test('enabled/disabled options', () => {
-      const patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
-      const sortedRevisions = [
-        {_number: 3},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 2},
-        {_number: 1},
-      ];
-      for (const patchNum of ['1', '2', '3']) {
-        assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
-            patchNum, sortedRevisions));
-      }
-      for (const basePatchNum of ['1', '2']) {
-        assert.isFalse(element._computeLeftDisabled(basePatchNum,
-            patchRange.patchNum, sortedRevisions));
-      }
-      assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
-
-      patchRange.basePatchNum = element.EDIT_NAME;
-      assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
-          sortedRevisions));
-      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
-          sortedRevisions));
-      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
-          sortedRevisions));
-      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
-          sortedRevisions));
-      assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
-          element.EDIT_NAME, sortedRevisions));
-    });
-
-    test('_computeBaseDropdownContent', () => {
-      const availablePatches = [
-        {num: 'edit', sha: '1'},
-        {num: 3, sha: '2'},
-        {num: 2, sha: '3'},
-        {num: 1, sha: '4'},
-      ];
-      const revisions = [
-        {
-          commit: {parents: []},
-          _number: 2,
-          description: 'description',
-        },
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(revisions);
-      const patchNum = 1;
-      const sortedRevisions = [
-        {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 2, description: 'description'},
-        {_number: 1},
-      ];
-      const expectedResult = [
-        {
-          disabled: true,
-          triggerText: 'Patchset edit',
-          text: 'Patchset edit | 1',
-          mobileText: 'edit',
-          bottomText: '',
-          value: 'edit',
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 3',
-          text: 'Patchset 3 | 2',
-          mobileText: '3',
-          bottomText: '',
-          value: 3,
-          date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 2',
-          text: 'Patchset 2 | 3',
-          mobileText: '2 description',
-          bottomText: 'description',
-          value: 2,
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 1',
-          text: 'Patchset 1 | 4',
-          mobileText: '1',
-          bottomText: '',
-          value: 1,
-        },
-        {
-          text: 'Base',
-          value: 'PARENT',
-        },
-      ];
-      assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
-          patchNum, sortedRevisions, element.changeComments,
-          element.revisionInfo),
-      expectedResult);
-    });
-
-    test('_computeBaseDropdownContent called when patchNum updates', () => {
-      element.revisions = [
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(element.revisions);
-      element.availablePatches = [
-        {num: 1, sha: '1'},
-        {num: 2, sha: '2'},
-        {num: 3, sha: '3'},
-        {num: 'edit', sha: '4'},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
-
-      sandbox.stub(element, '_computeBaseDropdownContent');
-
-      // Should be recomputed for each available patch
-      element.set('patchNum', 1);
-      assert.equal(element._computeBaseDropdownContent.callCount, 1);
-    });
-
-    test('_computeBaseDropdownContent called when changeComments update',
-        done => {
-          element.revisions = [
-            {commit: {parents: []}},
-            {commit: {parents: []}},
-            {commit: {parents: []}},
-            {commit: {parents: []}},
-          ];
-          element.revisionInfo = getInfo(element.revisions);
-          element.availablePatches = [
-            {num: 'edit', sha: '1'},
-            {num: 3, sha: '2'},
-            {num: 2, sha: '3'},
-            {num: 1, sha: '4'},
-          ];
-          element.patchNum = 2;
-          element.basePatchNum = 'PARENT';
-          flushAsynchronousOperations();
-
-          // 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();
-          });
-        });
-
-    test('_computePatchDropdownContent called when basePatchNum updates', () => {
-      element.revisions = [
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(element.revisions);
-      element.availablePatches = [
-        {num: 1, sha: '1'},
-        {num: 2, sha: '2'},
-        {num: 3, sha: '3'},
-        {num: 'edit', sha: '4'},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
-
-      // Should be recomputed for each available patch
-      sandbox.stub(element, '_computePatchDropdownContent');
-      element.set('basePatchNum', 1);
-      assert.equal(element._computePatchDropdownContent.callCount, 1);
-    });
-
-    test('_computePatchDropdownContent called when comments update', done => {
-      element.revisions = [
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-        {commit: {parents: []}},
-      ];
-      element.revisionInfo = getInfo(element.revisions);
-      element.availablePatches = [
-        {num: 1, sha: '1'},
-        {num: 2, sha: '2'},
-        {num: 3, sha: '3'},
-        {num: 'edit', sha: '4'},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
-
-      // Should be recomputed for each available patch
-      sandbox.stub(element, '_computePatchDropdownContent');
-      assert.equal(element._computePatchDropdownContent.callCount, 0);
-      commentApiWrapper.loadComments().then().then(() => {
-        done();
-      });
-    });
-
-    test('_computePatchDropdownContent', () => {
-      const availablePatches = [
-        {num: 'edit', sha: '1'},
-        {num: 3, sha: '2'},
-        {num: 2, sha: '3'},
-        {num: 1, sha: '4'},
-      ];
-      const basePatchNum = 1;
-      const sortedRevisions = [
-        {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 2, description: 'description'},
-        {_number: 1},
-      ];
-
-      const expectedResult = [
-        {
-          disabled: false,
-          triggerText: 'edit',
-          text: 'edit | 1',
-          mobileText: 'edit',
-          bottomText: '',
-          value: 'edit',
-        },
-        {
-          disabled: false,
-          triggerText: 'Patchset 3',
-          text: 'Patchset 3 | 2',
-          mobileText: '3',
-          bottomText: '',
-          value: 3,
-          date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-        },
-        {
-          disabled: false,
-          triggerText: 'Patchset 2',
-          text: 'Patchset 2 | 3',
-          mobileText: '2 description',
-          bottomText: 'description',
-          value: 2,
-        },
-        {
-          disabled: true,
-          triggerText: 'Patchset 1',
-          text: 'Patchset 1 | 4',
-          mobileText: '1',
-          bottomText: '',
-          value: 1,
-        },
-      ];
-
-      assert.deepEqual(element._computePatchDropdownContent(availablePatches,
-          basePatchNum, sortedRevisions, element.changeComments),
-      expectedResult);
-    });
-
-    test('filesWeblinks', () => {
-      element.filesWeblinks = {
-        meta_a: [
-          {
-            name: 'foo',
-            url: 'f.oo',
-          },
-        ],
-        meta_b: [
-          {
-            name: 'bar',
-            url: 'ba.r',
-          },
-        ],
-      };
-      flushAsynchronousOperations();
-      const domApi = Polymer.dom(element.root);
-      assert.equal(
-          domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
-      assert.equal(
-          domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
-    });
-
-    test('_computePatchSetCommentsString', () => {
-      // Test string with unresolved comments.
-      element.changeComments._comments = {
-        foo: [{
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          unresolved: true,
-          updated: '2017-10-11 20:48:40.000000000',
-        }],
-        bar: [{
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-12 20:48:40.000000000',
-        },
-        {
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-13 20:48:40.000000000',
-        }],
-        abc: [],
-      };
-
-      assert.equal(element._computePatchSetCommentsString(
-          element.changeComments, 1), ' (3 comments, 1 unresolved)');
-
-      // Test string with no unresolved comments.
-      delete element.changeComments._comments['foo'];
-      assert.equal(element._computePatchSetCommentsString(
-          element.changeComments, 1), ' (2 comments)');
-
-      // Test string with no comments.
-      delete element.changeComments._comments['bar'];
-      assert.equal(element._computePatchSetCommentsString(
-          element.changeComments, 1), '');
-    });
-
-    test('patch-range-change fires', () => {
-      const handler = sandbox.stub();
-      element.basePatchNum = 1;
-      element.patchNum = 3;
-      element.addEventListener('patch-range-change', handler);
-
-      element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
-      assert.isTrue(handler.calledOnce);
-      assert.deepEqual(handler.lastCall.args[0].detail,
-          {basePatchNum: 2, patchNum: 3});
-
-      // BasePatchNum should not have changed, due to one-way data binding.
-      element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
-      assert.deepEqual(handler.lastCall.args[0].detail,
-          {basePatchNum: 1, patchNum: 'edit'});
-    });
+    // Stub methods on the changeComments object after changeComments has
+    // been initialized.
+    return commentApiWrapper.loadComments();
   });
+
+  teardown(() => sandbox.restore());
+
+  test('enabled/disabled options', () => {
+    const patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: '3',
+    };
+    const sortedRevisions = [
+      {_number: 3},
+      {_number: element.EDIT_NAME, basePatchNum: 2},
+      {_number: 2},
+      {_number: 1},
+    ];
+    for (const patchNum of ['1', '2', '3']) {
+      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
+          patchNum, sortedRevisions));
+    }
+    for (const basePatchNum of ['1', '2']) {
+      assert.isFalse(element._computeLeftDisabled(basePatchNum,
+          patchRange.patchNum, sortedRevisions));
+    }
+    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
+
+    patchRange.basePatchNum = element.EDIT_NAME;
+    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
+        sortedRevisions));
+    assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
+        element.EDIT_NAME, sortedRevisions));
+  });
+
+  test('_computeBaseDropdownContent', () => {
+    const availablePatches = [
+      {num: 'edit', sha: '1'},
+      {num: 3, sha: '2'},
+      {num: 2, sha: '3'},
+      {num: 1, sha: '4'},
+    ];
+    const revisions = [
+      {
+        commit: {parents: []},
+        _number: 2,
+        description: 'description',
+      },
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(revisions);
+    const patchNum = 1;
+    const sortedRevisions = [
+      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
+      {_number: element.EDIT_NAME, basePatchNum: 2},
+      {_number: 2, description: 'description'},
+      {_number: 1},
+    ];
+    const expectedResult = [
+      {
+        disabled: true,
+        triggerText: 'Patchset edit',
+        text: 'Patchset edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+      },
+      {
+        text: 'Base',
+        value: 'PARENT',
+      },
+    ];
+    assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
+        patchNum, sortedRevisions, element.changeComments,
+        element.revisionInfo),
+    expectedResult);
+  });
+
+  test('_computeBaseDropdownContent called when patchNum updates', () => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    sandbox.stub(element, '_computeBaseDropdownContent');
+
+    // Should be recomputed for each available patch
+    element.set('patchNum', 1);
+    assert.equal(element._computeBaseDropdownContent.callCount, 1);
+  });
+
+  test('_computeBaseDropdownContent called when changeComments update',
+      done => {
+        element.revisions = [
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+        ];
+        element.revisionInfo = getInfo(element.revisions);
+        element.availablePatches = [
+          {num: 'edit', sha: '1'},
+          {num: 3, sha: '2'},
+          {num: 2, sha: '3'},
+          {num: 1, sha: '4'},
+        ];
+        element.patchNum = 2;
+        element.basePatchNum = 'PARENT';
+        flushAsynchronousOperations();
+
+        // 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();
+            });
+      });
+
+  test('_computePatchDropdownContent called when basePatchNum updates', () => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    // Should be recomputed for each available patch
+    sandbox.stub(element, '_computePatchDropdownContent');
+    element.set('basePatchNum', 1);
+    assert.equal(element._computePatchDropdownContent.callCount, 1);
+  });
+
+  test('_computePatchDropdownContent called when comments update', done => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    // Should be recomputed for each available patch
+    sandbox.stub(element, '_computePatchDropdownContent');
+    assert.equal(element._computePatchDropdownContent.callCount, 0);
+    commentApiWrapper.loadComments().then()
+        .then(() => {
+          done();
+        });
+  });
+
+  test('_computePatchDropdownContent', () => {
+    const availablePatches = [
+      {num: 'edit', sha: '1'},
+      {num: 3, sha: '2'},
+      {num: 2, sha: '3'},
+      {num: 1, sha: '4'},
+    ];
+    const basePatchNum = 1;
+    const sortedRevisions = [
+      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
+      {_number: element.EDIT_NAME, basePatchNum: 2},
+      {_number: 2, description: 'description'},
+      {_number: 1},
+    ];
+
+    const expectedResult = [
+      {
+        disabled: false,
+        triggerText: 'edit',
+        text: 'edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+      },
+    ];
+
+    assert.deepEqual(element._computePatchDropdownContent(availablePatches,
+        basePatchNum, sortedRevisions, element.changeComments),
+    expectedResult);
+  });
+
+  test('filesWeblinks', () => {
+    element.filesWeblinks = {
+      meta_a: [
+        {
+          name: 'foo',
+          url: 'f.oo',
+        },
+      ],
+      meta_b: [
+        {
+          name: 'bar',
+          url: 'ba.r',
+        },
+      ],
+    };
+    flushAsynchronousOperations();
+    const domApi = dom(element.root);
+    assert.equal(
+        domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
+    assert.equal(
+        domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
+  });
+
+  test('_computePatchSetCommentsString', () => {
+    // Test string with unresolved comments.
+    element.changeComments._comments = {
+      foo: [{
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        unresolved: true,
+        updated: '2017-10-11 20:48:40.000000000',
+      }],
+      bar: [{
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        updated: '2017-10-12 20:48:40.000000000',
+      },
+      {
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        updated: '2017-10-13 20:48:40.000000000',
+      }],
+      abc: [],
+    };
+
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), ' (3 comments, 1 unresolved)');
+
+    // Test string with no unresolved comments.
+    delete element.changeComments._comments['foo'];
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), ' (2 comments)');
+
+    // Test string with no comments.
+    delete element.changeComments._comments['bar'];
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), '');
+  });
+
+  test('patch-range-change fires', () => {
+    const handler = sandbox.stub();
+    element.basePatchNum = 1;
+    element.patchNum = 3;
+    element.addEventListener('patch-range-change', handler);
+
+    element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
+    assert.isTrue(handler.calledOnce);
+    assert.deepEqual(handler.lastCall.args[0].detail,
+        {basePatchNum: 2, patchNum: 3});
+
+    // BasePatchNum should not have changed, due to one-way data binding.
+    element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
+    assert.deepEqual(handler.lastCall.args[0].detail,
+        {basePatchNum: 1, patchNum: 'edit'});
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
deleted file mode 100644
index 17a4866..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<dom-module id="gr-ranged-comment-layer">
-  <template>
-  </template>
-  <script src="../gr-diff-highlight/gr-annotation.js"></script>
-  <script src="gr-ranged-comment-layer.js"></script>
-</dom-module>
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..c3b6b87 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
@@ -14,29 +14,40 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Polymer 1 adds # before array's key, while Polymer 2 doesn't
-  const HOVER_PATH_PATTERN = /^(commentRanges\.\#?\d+)\.hovering$/;
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-ranged-comment-layer_html.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
 
-  const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
-  const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
+// Polymer 1 adds # before array's key, while Polymer 2 doesn't
+const HOVER_PATH_PATTERN = /^(commentRanges\.\#?\d+)\.hovering$/;
 
-  Polymer({
-    is: 'gr-ranged-comment-layer',
+const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
+const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
 
-    /**
-     * Fired when the range in a range comment was malformed and had to be
-     * normalized.
-     *
-     * It's `detail` has a `lineNum` and `side` parameter.
-     *
-     * @event normalize-range
-     */
+/** @extends Polymer.Element */
+class GrRangedCommentLayer extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /** @type {!Array<!Gerrit.HoveredRange>} */
+  static get is() { return 'gr-ranged-comment-layer'; }
+  /**
+   * Fired when the range in a range comment was malformed and had to be
+   * normalized.
+   *
+   * It's `detail` has a `lineNum` and `side` parameter.
+   *
+   * @event normalize-range
+   */
+
+  static get properties() {
+    return {
+    /** @type {!Array<!Gerrit.HoveredRange>} */
       commentRanges: Array,
       _listeners: {
         type: Array,
@@ -46,164 +57,186 @@
         type: Object,
         value() { return {left: {}, right: {}}; },
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_handleCommentRangesChange(commentRanges.*)',
-    ],
+    ];
+  }
 
-    get styleModuleName() {
-      return 'gr-ranged-comment-styles';
-    },
+  get styleModuleName() {
+    return 'gr-ranged-comment-styles';
+  }
 
-    /**
-     * Layer method to add annotations to a line.
-     *
-     * @param {!HTMLElement} el The DIV.contentText element to apply the
-     *     annotation to.
-     * @param {!HTMLElement} lineNumberEl
-     * @param {!Object} line The line object. (GrDiffLine)
-     */
-    annotate(el, lineNumberEl, line) {
-      let ranges = [];
-      if (line.type === GrDiffLine.Type.REMOVE || (
-        line.type === GrDiffLine.Type.BOTH &&
-          el.getAttribute('data-side') !== 'right')) {
-        ranges = ranges.concat(this._getRangesForLine(line, 'left'));
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param {!HTMLElement} el The DIV.contentText element to apply the
+   *     annotation to.
+   * @param {!HTMLElement} lineNumberEl
+   * @param {!Object} line The line object. (GrDiffLine)
+   */
+  annotate(el, lineNumberEl, line) {
+    let ranges = [];
+    if (line.type === GrDiffLine.Type.REMOVE || (
+      line.type === GrDiffLine.Type.BOTH &&
+        el.getAttribute('data-side') !== 'right')) {
+      ranges = ranges.concat(this._getRangesForLine(line, 'left'));
+    }
+    if (line.type === GrDiffLine.Type.ADD || (
+      line.type === GrDiffLine.Type.BOTH &&
+        el.getAttribute('data-side') !== 'left')) {
+      ranges = ranges.concat(this._getRangesForLine(line, 'right'));
+    }
+
+    for (const range of ranges) {
+      GrAnnotation.annotateElement(el, range.start,
+          range.end - range.start,
+          range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
+    }
+  }
+
+  /**
+   * Register a listener for layer updates.
+   *
+   * @param {function(number, number, string)} 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.
+   */
+  addListener(fn) {
+    this._listeners.push(fn);
+  }
+
+  /**
+   * 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')
+   */
+  _notifyUpdateRange(start, end, side) {
+    for (const listener of this._listeners) {
+      listener(start, end, side);
+    }
+  }
+
+  /**
+   * Handle change in the ranges by updating the ranges maps and by
+   * emitting appropriate update notifications.
+   *
+   * @param {Object} record The change record.
+   */
+  _handleCommentRangesChange(record) {
+    if (!record) return;
+
+    // If the entire set of comments was changed.
+    if (record.path === 'commentRanges') {
+      this._rangesMap = {left: {}, right: {}};
+      for (const {side, range, hovering} of record.value) {
+        this._updateRangesMap({
+          side, range, hovering,
+          operation: (forLine, start, end, hovering) => {
+            forLine.push({start, end, hovering});
+          }});
       }
-      if (line.type === GrDiffLine.Type.ADD || (
-        line.type === GrDiffLine.Type.BOTH &&
-          el.getAttribute('data-side') !== 'left')) {
-        ranges = ranges.concat(this._getRangesForLine(line, 'right'));
-      }
+    }
 
-      for (const range of ranges) {
-        GrAnnotation.annotateElement(el, range.start,
-            range.end - range.start,
-            range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
-      }
-    },
+    // If the change only changed the `hovering` property of a comment.
+    const match = record.path.match(HOVER_PATH_PATTERN);
+    if (match) {
+      // The #number indicates the key of that item in the array
+      // not the index, especially in polymer 1.
+      const {side, range, hovering} = this.get(match[1]);
 
-    /**
-     * Register a listener for layer updates.
-     *
-     * @param {function(number, number, string)} 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.
-     */
-    addListener(fn) {
-      this._listeners.push(fn);
-    },
+      this._updateRangesMap({
+        side, range, hovering, skipLayerUpdate: true,
+        operation: (forLine, start, end, hovering) => {
+          const index = forLine.findIndex(lineRange =>
+            lineRange.start === start && lineRange.end === end);
+          forLine[index].hovering = hovering;
+        }});
+    }
 
-    /**
-     * 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')
-     */
-    _notifyUpdateRange(start, end, side) {
-      for (const listener of this._listeners) {
-        listener(start, end, side);
-      }
-    },
-
-    /**
-     * Handle change in the ranges by updating the ranges maps and by
-     * emitting appropriate update notifications.
-     *
-     * @param {Object} record The change record.
-     */
-    _handleCommentRangesChange(record) {
-      if (!record) return;
-
-      // If the entire set of comments was changed.
-      if (record.path === 'commentRanges') {
-        this._rangesMap = {left: {}, right: {}};
-        for (const {side, range, hovering} of record.value) {
-          this._updateRangesMap(
-              side, range, hovering, (forLine, start, end, hovering) => {
-                forLine.push({start, end, hovering});
-              });
-        }
-      }
-
-      // If the change only changed the `hovering` property of a comment.
-      const match = record.path.match(HOVER_PATH_PATTERN);
-      if (match) {
-        // The #number indicates the key of that item in the array
-        // not the index, especially in polymer 1.
-        const {side, range, hovering} = this.get(match[1]);
-
-        this._updateRangesMap(
-            side, range, hovering, (forLine, start, end, hovering) => {
+    // If comments were spliced in or out.
+    if (record.path === 'commentRanges.splices') {
+      for (const indexSplice of record.value.indexSplices) {
+        const removed = indexSplice.removed;
+        for (const {side, range, hovering} of removed) {
+          this._updateRangesMap({
+            side, range, hovering, operation: (forLine, start, end) => {
               const index = forLine.findIndex(lineRange =>
                 lineRange.start === start && lineRange.end === end);
-              forLine[index].hovering = hovering;
-            });
-      }
-
-      // If comments were spliced in or out.
-      if (record.path === 'commentRanges.splices') {
-        for (const indexSplice of record.value.indexSplices) {
-          const removed = indexSplice.removed;
-          for (const {side, range, hovering} of removed) {
-            this._updateRangesMap(
-                side, range, hovering, (forLine, start, end) => {
-                  const index = forLine.findIndex(lineRange =>
-                    lineRange.start === start && lineRange.end === end);
-                  forLine.splice(index, 1);
-                });
-          }
-          const added = indexSplice.object.slice(
-              indexSplice.index, indexSplice.index + indexSplice.addedCount);
-          for (const {side, range, hovering} of added) {
-            this._updateRangesMap(
-                side, range, hovering, (forLine, start, end, hovering) => {
-                  forLine.push({start, end, hovering});
-                });
-          }
+              forLine.splice(index, 1);
+            }});
+        }
+        const added = indexSplice.object.slice(
+            indexSplice.index, indexSplice.index + indexSplice.addedCount);
+        for (const {side, range, hovering} of added) {
+          this._updateRangesMap({
+            side, range, hovering,
+            operation: (forLine, start, end, hovering) => {
+              forLine.push({start, end, hovering});
+            }});
         }
       }
-    },
+    }
+  }
 
-    _updateRangesMap(side, range, hovering, operation) {
-      const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
-      for (let line = range.start_line; line <= range.end_line; line++) {
-        const forLine = forSide[line] || (forSide[line] = []);
-        const start = line === range.start_line ? range.start_character : 0;
-        const end = line === range.end_line ? range.end_character : -1;
-        operation(forLine, start, end, hovering);
-      }
+  /**
+   * @param {!Object} options
+   * @property {!string} options.side
+   * @property {boolean} options.hovering
+   * @property {boolean} options.skipLayerUpdate
+   * @property {!Function} options.operation
+   * @property {!{
+   *  start_character: number,
+   *  start_line: number,
+   *  end_line: number,
+   *  end_character: number}} options.range
+   */
+  _updateRangesMap(options) {
+    const {side, range, hovering, operation, skipLayerUpdate} = options;
+    const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+    for (let line = range.start_line; line <= range.end_line; line++) {
+      const forLine = forSide[line] || (forSide[line] = []);
+      const start = line === range.start_line ? range.start_character : 0;
+      const end = line === range.end_line ? range.end_character : -1;
+      operation(forLine, start, end, hovering);
+    }
+    if (!skipLayerUpdate) {
       this._notifyUpdateRange(range.start_line, range.end_line, side);
-    },
+    }
+  }
 
-    _getRangesForLine(line, side) {
-      const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      const ranges = this.get(['_rangesMap', side, lineNum]) || [];
-      return ranges
-          .map(range => {
-            // Make a copy, so that the normalization below does not mess with
-            // our map.
-            range = Object.assign({}, range);
-            range.end = range.end === -1 ? line.text.length : range.end;
+  _getRangesForLine(line, side) {
+    const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
+    const ranges = this.get(['_rangesMap', side, lineNum]) || [];
+    return ranges
+        .map(range => {
+          // Make a copy, so that the normalization below does not mess with
+          // our map.
+          range = Object.assign({}, range);
+          range.end = range.end === -1 ? line.text.length : range.end;
 
-            // Normalize invalid ranges where the start is after the end but the
-            // start still makes sense. Set the end to the end of the line.
-            // @see Issue 5744
-            if (range.start >= range.end && range.start < line.text.length) {
-              range.end = line.text.length;
-              this.dispatchEvent(new CustomEvent('normalize-range', {
-                bubbles: true,
-                composed: true,
-                detail: {lineNum, side},
-              }));
-            }
+          // Normalize invalid ranges where the start is after the end but the
+          // start still makes sense. Set the end to the end of the line.
+          // @see Issue 5744
+          if (range.start >= range.end && range.start < line.text.length) {
+            range.end = line.text.length;
+            this.dispatchEvent(new CustomEvent('normalize-range', {
+              bubbles: true,
+              composed: true,
+              detail: {lineNum, side},
+            }));
+          }
 
-            return range;
-          })
-          // Sort the ranges so that hovering highlights are on top.
-          .sort((a, b) => a.hovering && !b.hovering ? 1 : 0);
-    },
-  });
-})();
+          return range;
+        })
+        // Sort the ranges so that hovering highlights are on top.
+        .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_html.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
new file mode 100644
index 0000000..3ed33d1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html``;
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..37d1707 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../gr-diff/gr-diff-line.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-ranged-comment-layer.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,309 +31,312 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-ranged-comment-layer', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-diff/gr-diff-line.js';
+import './gr-ranged-comment-layer.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+
+suite('gr-ranged-comment-layer', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    const initialCommentRanges = [
+      {
+        side: 'left',
+        range: {
+          end_character: 9,
+          end_line: 39,
+          start_character: 6,
+          start_line: 36,
+        },
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 22,
+          end_line: 12,
+          start_character: 10,
+          start_line: 10,
+        },
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 15,
+          end_line: 100,
+          start_character: 5,
+          start_line: 100,
+        },
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 2,
+          end_line: 55,
+          start_character: 32,
+          start_line: 55,
+        },
+      },
+    ];
+
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.commentRanges = initialCommentRanges;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('annotate', () => {
     let sandbox;
+    let el;
+    let line;
+    let annotateElementStub;
+    const lineNumberEl = document.createElement('td');
 
     setup(() => {
-      const initialCommentRanges = [
-        {
-          side: 'left',
-          range: {
-            end_character: 9,
-            end_line: 39,
-            start_character: 6,
-            start_line: 36,
-          },
-        },
-        {
-          side: 'right',
-          range: {
-            end_character: 22,
-            end_line: 12,
-            start_character: 10,
-            start_line: 10,
-          },
-        },
-        {
-          side: 'right',
-          range: {
-            end_character: 15,
-            end_line: 100,
-            start_character: 5,
-            start_line: 100,
-          },
-        },
-        {
-          side: 'right',
-          range: {
-            end_character: 2,
-            end_line: 55,
-            start_character: 32,
-            start_line: 55,
-          },
-        },
-      ];
-
       sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.commentRanges = initialCommentRanges;
+      annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+      el = document.createElement('div');
+      el.setAttribute('data-side', 'left');
+      line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    suite('annotate', () => {
-      let sandbox;
-      let el;
-      let line;
-      let annotateElementStub;
-      const lineNumberEl = document.createElement('td');
+    test('type=Remove no-comment', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 40;
 
-      setup(() => {
-        sandbox = sinon.sandbox.create();
-        annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
-        el = document.createElement('div');
-        el.setAttribute('data-side', 'left');
-        line = new GrDiffLine(GrDiffLine.Type.BOTH);
-        line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
-      });
+      element.annotate(el, lineNumberEl, line);
 
-      teardown(() => {
-        sandbox.restore();
-      });
-
-      test('type=Remove no-comment', () => {
-        line.type = GrDiffLine.Type.REMOVE;
-        line.beforeNumber = 40;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('type=Remove has-comment', () => {
-        line.type = GrDiffLine.Type.REMOVE;
-        line.beforeNumber = 36;
-        const expectedStart = 6;
-        const expectedLength = line.text.length - expectedStart;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-      });
-
-      test('type=Remove has-comment hovering', () => {
-        line.type = GrDiffLine.Type.REMOVE;
-        line.beforeNumber = 36;
-        element.set(['commentRanges', 0, 'hovering'], true);
-
-        const expectedStart = 6;
-        const expectedLength = line.text.length - expectedStart;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff rangeHighlight');
-      });
-
-      test('type=Both has-comment', () => {
-        line.type = GrDiffLine.Type.BOTH;
-        line.beforeNumber = 36;
-
-        const expectedStart = 6;
-        const expectedLength = line.text.length - expectedStart;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-      });
-
-      test('type=Both has-comment off side', () => {
-        line.type = GrDiffLine.Type.BOTH;
-        line.beforeNumber = 36;
-        el.setAttribute('data-side', 'right');
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isFalse(annotateElementStub.called);
-      });
-
-      test('type=Add has-comment', () => {
-        line.type = GrDiffLine.Type.ADD;
-        line.afterNumber = 12;
-        el.setAttribute('data-side', 'right');
-
-        const expectedStart = 0;
-        const expectedLength = 22;
-
-        element.annotate(el, lineNumberEl, line);
-
-        assert.isTrue(annotateElementStub.called);
-        const lastCall = annotateElementStub.lastCall;
-        assert.equal(lastCall.args[0], el);
-        assert.equal(lastCall.args[1], expectedStart);
-        assert.equal(lastCall.args[2], expectedLength);
-        assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-      });
+      assert.isFalse(annotateElementStub.called);
     });
 
-    test('_handleCommentRangesChange overwrite', () => {
-      element.set('commentRanges', []);
+    test('type=Remove has-comment', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 36;
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
 
-      assert.equal(Object.keys(element._rangesMap.left).length, 0);
-      assert.equal(Object.keys(element._rangesMap.right).length, 0);
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
     });
 
-    test('_handleCommentRangesChange hovering', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
-      const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+    test('type=Remove has-comment hovering', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 36;
+      element.set(['commentRanges', 0, 'hovering'], true);
 
-      element.set(['commentRanges', 1, 'hovering'], true);
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
 
-      assert.isTrue(notifyStub.called);
-      const lastCall = notifyStub.lastCall;
-      assert.equal(lastCall.args[0], 10);
-      assert.equal(lastCall.args[1], 12);
-      assert.equal(lastCall.args[2], 'right');
+      element.annotate(el, lineNumberEl, line);
 
-      assert.isTrue(updateRangesMapSpy.called);
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff rangeHighlight');
     });
 
-    test('_handleCommentRangesChange splice out', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
+    test('type=Both has-comment', () => {
+      line.type = GrDiffLine.Type.BOTH;
+      line.beforeNumber = 36;
 
-      element.splice('commentRanges', 1, 1);
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
 
-      assert.isTrue(notifyStub.called);
-      const lastCall = notifyStub.lastCall;
-      assert.equal(lastCall.args[0], 10);
-      assert.equal(lastCall.args[1], 12);
-      assert.equal(lastCall.args[2], 'right');
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
     });
 
-    test('_handleCommentRangesChange splice in', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
+    test('type=Both has-comment off side', () => {
+      line.type = GrDiffLine.Type.BOTH;
+      line.beforeNumber = 36;
+      el.setAttribute('data-side', 'right');
 
-      element.splice('commentRanges', 1, 0, {
-        side: 'left',
-        range: {
-          end_character: 15,
-          end_line: 275,
-          start_character: 5,
-          start_line: 250,
-        },
-      });
+      element.annotate(el, lineNumberEl, line);
 
-      assert.isTrue(notifyStub.called);
-      const lastCall = notifyStub.lastCall;
-      assert.equal(lastCall.args[0], 250);
-      assert.equal(lastCall.args[1], 275);
-      assert.equal(lastCall.args[2], 'left');
+      assert.isFalse(annotateElementStub.called);
     });
 
-    test('_handleCommentRangesChange mixed actions', () => {
-      const notifyStub = sinon.stub();
-      element.addListener(notifyStub);
-      const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+    test('type=Add has-comment', () => {
+      line.type = GrDiffLine.Type.ADD;
+      line.afterNumber = 12;
+      el.setAttribute('data-side', 'right');
 
-      element.set(['commentRanges', 1, 'hovering'], true);
-      assert.isTrue(updateRangesMapSpy.callCount === 1);
-      element.splice('commentRanges', 1, 1);
-      assert.isTrue(updateRangesMapSpy.callCount === 2);
-      element.splice('commentRanges', 1, 1);
-      assert.isTrue(updateRangesMapSpy.callCount === 3);
-      element.splice('commentRanges', 1, 0, {
-        side: 'left',
-        range: {
-          end_character: 15,
-          end_line: 275,
-          start_character: 5,
-          start_line: 250,
-        },
-      });
-      assert.isTrue(updateRangesMapSpy.callCount === 4);
-      element.set(['commentRanges', 2, 'hovering'], true);
-      assert.isTrue(updateRangesMapSpy.callCount === 5);
-    });
+      const expectedStart = 0;
+      const expectedLength = 22;
 
-    test('_computeCommentMap creates maps correctly', () => {
-      // There is only one ranged comment on the left, but it spans ll.36-39.
-      const leftKeys = [];
-      for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-      assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
-          leftKeys.sort());
+      element.annotate(el, lineNumberEl, line);
 
-      assert.equal(element._rangesMap.left[36].length, 1);
-      assert.equal(element._rangesMap.left[36][0].start, 6);
-      assert.equal(element._rangesMap.left[36][0].end, -1);
-
-      assert.equal(element._rangesMap.left[37].length, 1);
-      assert.equal(element._rangesMap.left[37][0].start, 0);
-      assert.equal(element._rangesMap.left[37][0].end, -1);
-
-      assert.equal(element._rangesMap.left[38].length, 1);
-      assert.equal(element._rangesMap.left[38][0].start, 0);
-      assert.equal(element._rangesMap.left[38][0].end, -1);
-
-      assert.equal(element._rangesMap.left[39].length, 1);
-      assert.equal(element._rangesMap.left[39][0].start, 0);
-      assert.equal(element._rangesMap.left[39][0].end, 9);
-
-      // The right has two ranged comments, one spanning ll.10-12 and the other
-      // on line 100.
-      const rightKeys = [];
-      for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
-      rightKeys.push('55', '100');
-      assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
-          rightKeys.sort());
-
-      assert.equal(element._rangesMap.right[10].length, 1);
-      assert.equal(element._rangesMap.right[10][0].start, 10);
-      assert.equal(element._rangesMap.right[10][0].end, -1);
-
-      assert.equal(element._rangesMap.right[11].length, 1);
-      assert.equal(element._rangesMap.right[11][0].start, 0);
-      assert.equal(element._rangesMap.right[11][0].end, -1);
-
-      assert.equal(element._rangesMap.right[12].length, 1);
-      assert.equal(element._rangesMap.right[12][0].start, 0);
-      assert.equal(element._rangesMap.right[12][0].end, 22);
-
-      assert.equal(element._rangesMap.right[100].length, 1);
-      assert.equal(element._rangesMap.right[100][0].start, 5);
-      assert.equal(element._rangesMap.right[100][0].end, 15);
-    });
-
-    test('_getRangesForLine normalizes invalid ranges', () => {
-      const line = {
-        afterNumber: 55,
-        text: '_getRangesForLine normalizes invalid ranges',
-      };
-      const ranges = element._getRangesForLine(line, 'right');
-      assert.equal(ranges.length, 1);
-      const range = ranges[0];
-      assert.isTrue(range.start < range.end, 'start and end are normalized');
-      assert.equal(range.end, line.text.length);
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
     });
   });
+
+  test('_handleCommentRangesChange overwrite', () => {
+    element.set('commentRanges', []);
+
+    assert.equal(Object.keys(element._rangesMap.left).length, 0);
+    assert.equal(Object.keys(element._rangesMap.right).length, 0);
+  });
+
+  test('_handleCommentRangesChange hovering', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+    const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+
+    element.set(['commentRanges', 1, 'hovering'], true);
+
+    // notify will be skipped for hovering
+    assert.isFalse(notifyStub.called);
+
+    assert.isTrue(updateRangesMapSpy.called);
+  });
+
+  test('_handleCommentRangesChange splice out', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+
+    element.splice('commentRanges', 1, 1);
+
+    assert.isTrue(notifyStub.called);
+    const lastCall = notifyStub.lastCall;
+    assert.equal(lastCall.args[0], 10);
+    assert.equal(lastCall.args[1], 12);
+    assert.equal(lastCall.args[2], 'right');
+  });
+
+  test('_handleCommentRangesChange splice in', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+
+    element.splice('commentRanges', 1, 0, {
+      side: 'left',
+      range: {
+        end_character: 15,
+        end_line: 275,
+        start_character: 5,
+        start_line: 250,
+      },
+    });
+
+    assert.isTrue(notifyStub.called);
+    const lastCall = notifyStub.lastCall;
+    assert.equal(lastCall.args[0], 250);
+    assert.equal(lastCall.args[1], 275);
+    assert.equal(lastCall.args[2], 'left');
+  });
+
+  test('_handleCommentRangesChange mixed actions', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+    const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
+
+    element.set(['commentRanges', 1, 'hovering'], true);
+    assert.isTrue(updateRangesMapSpy.callCount === 1);
+    element.splice('commentRanges', 1, 1);
+    assert.isTrue(updateRangesMapSpy.callCount === 2);
+    element.splice('commentRanges', 1, 1);
+    assert.isTrue(updateRangesMapSpy.callCount === 3);
+    element.splice('commentRanges', 1, 0, {
+      side: 'left',
+      range: {
+        end_character: 15,
+        end_line: 275,
+        start_character: 5,
+        start_line: 250,
+      },
+    });
+    assert.isTrue(updateRangesMapSpy.callCount === 4);
+    element.set(['commentRanges', 2, 'hovering'], true);
+    assert.isTrue(updateRangesMapSpy.callCount === 5);
+  });
+
+  test('_computeCommentMap creates maps correctly', () => {
+    // There is only one ranged comment on the left, but it spans ll.36-39.
+    const leftKeys = [];
+    for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
+    assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
+        leftKeys.sort());
+
+    assert.equal(element._rangesMap.left[36].length, 1);
+    assert.equal(element._rangesMap.left[36][0].start, 6);
+    assert.equal(element._rangesMap.left[36][0].end, -1);
+
+    assert.equal(element._rangesMap.left[37].length, 1);
+    assert.equal(element._rangesMap.left[37][0].start, 0);
+    assert.equal(element._rangesMap.left[37][0].end, -1);
+
+    assert.equal(element._rangesMap.left[38].length, 1);
+    assert.equal(element._rangesMap.left[38][0].start, 0);
+    assert.equal(element._rangesMap.left[38][0].end, -1);
+
+    assert.equal(element._rangesMap.left[39].length, 1);
+    assert.equal(element._rangesMap.left[39][0].start, 0);
+    assert.equal(element._rangesMap.left[39][0].end, 9);
+
+    // The right has two ranged comments, one spanning ll.10-12 and the other
+    // on line 100.
+    const rightKeys = [];
+    for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+    rightKeys.push('55', '100');
+    assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
+        rightKeys.sort());
+
+    assert.equal(element._rangesMap.right[10].length, 1);
+    assert.equal(element._rangesMap.right[10][0].start, 10);
+    assert.equal(element._rangesMap.right[10][0].end, -1);
+
+    assert.equal(element._rangesMap.right[11].length, 1);
+    assert.equal(element._rangesMap.right[11][0].start, 0);
+    assert.equal(element._rangesMap.right[11][0].end, -1);
+
+    assert.equal(element._rangesMap.right[12].length, 1);
+    assert.equal(element._rangesMap.right[12][0].start, 0);
+    assert.equal(element._rangesMap.right[12][0].end, 22);
+
+    assert.equal(element._rangesMap.right[100].length, 1);
+    assert.equal(element._rangesMap.right[100][0].start, 5);
+    assert.equal(element._rangesMap.right[100][0].end, 15);
+  });
+
+  test('_getRangesForLine normalizes invalid ranges', () => {
+    const line = {
+      afterNumber: 55,
+      text: '_getRangesForLine normalizes invalid ranges',
+    };
+    const ranges = element._getRangesForLine(line, 'right');
+    assert.equal(ranges.length, 1);
+    const range = ranges[0];
+    assert.isTrue(range.start < range.end, 'start and end are normalized');
+    assert.equal(range.end, line.text.length);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.html
deleted file mode 100644
index cefd241..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.html
+++ /dev/null
@@ -1,30 +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.
--->
-<dom-module id="gr-ranged-comment-theme">
-  <template>
-    <style>
-      .range {
-        background-color: var(--diff-highlight-range-color);
-        display: inline;
-      }
-      .rangeHighlight {
-        background-color: var(--diff-highlight-range-hover-color);
-        display: inline;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
new file mode 100644
index 0000000..49ed980
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
@@ -0,0 +1,41 @@
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
+  <template>
+    <style>
+      .range {
+        background-color: var(--diff-highlight-range-color);
+        display: inline;
+      }
+      .rangeHighlight {
+        background-color: var(--diff-highlight-range-hover-color);
+        display: inline;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
deleted file mode 100644
index aa4d2e1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ /dev/null
@@ -1,40 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-tooltip/gr-tooltip.html">
-
-<dom-module id="gr-selection-action-box">
-  <template>
-    <style include="shared-styles">
-      :host {
-        cursor: pointer;
-        font-family: var(--font-family);
-        position: absolute;
-        white-space: nowrap;
-      }
-    </style>
-    <gr-tooltip
-        id="tooltip"
-        text="Press c to comment"
-        position-below="[[positionBelow]]"></gr-tooltip>
-  </template>
-  <script src="gr-selection-action-box.js"></script>
-</dom-module>
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..f16db3b 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
@@ -14,111 +14,99 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-selection-action-box',
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-tooltip/gr-tooltip.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-selection-action-box_html.js';
 
-    /**
-     * Fired when the comment creation action was taken (hotkey, click).
-     *
-     * @event create-range-comment
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrSelectionActionBox extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-selection-action-box'; }
+  /**
+   * Fired when the comment creation action was taken (click).
+   *
+   * @event create-comment-requested
+   */
+
+  static get properties() {
+    return {
       keyEventTarget: {
         type: Object,
         value() { return document.body; },
       },
-      range: {
-        type: Object,
-        value: {
-          start_line: NaN,
-          start_character: NaN,
-          end_line: NaN,
-          end_character: NaN,
-        },
-      },
       positionBelow: Boolean,
-      side: {
-        type: String,
-        value: '',
-      },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+  /** @override */
+  created() {
+    super.created();
 
-    listeners: {
-      mousedown: '_handleMouseDown', // See https://crbug.com/gerrit/4767
-    },
+    // See https://crbug.com/gerrit/4767
+    this.addEventListener('mousedown',
+        e => this._handleMouseDown(e));
+  }
 
-    keyBindings: {
-      c: '_handleCKey',
-    },
+  placeAbove(el) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    this.style.top =
+        rect.top - parentRect.top - boxRect.height - 6 + 'px';
+    this.style.left =
+        rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+  }
 
-    placeAbove(el) {
-      Polymer.dom.flush();
-      const rect = this._getTargetBoundingRect(el);
-      const boxRect = this.$.tooltip.getBoundingClientRect();
-      const parentRect = this._getParentBoundingClientRect();
-      this.style.top =
-          rect.top - parentRect.top - boxRect.height - 6 + 'px';
-      this.style.left =
-          rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
-    },
+  placeBelow(el) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    this.style.top =
+    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();
-      const rect = this._getTargetBoundingRect(el);
-      const boxRect = this.$.tooltip.getBoundingClientRect();
-      const parentRect = this._getParentBoundingClientRect();
-      this.style.top =
-      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();
+  }
 
-    _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;
+    if (el instanceof Text) {
+      const range = document.createRange();
+      range.selectNode(el);
+      rect = range.getBoundingClientRect();
+      range.detach();
+    } else {
+      rect = el.getBoundingClientRect();
+    }
+    return rect;
+  }
 
-    _getTargetBoundingRect(el) {
-      let rect;
-      if (el instanceof Text) {
-        const range = document.createRange();
-        range.selectNode(el);
-        rect = range.getBoundingClientRect();
-        range.detach();
-      } else {
-        rect = el.getBoundingClientRect();
-      }
-      return rect;
-    },
+  _handleMouseDown(e) {
+    if (e.button !== 0) { return; } // 0 = main button
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('create-comment-requested', {
+      composed: true, bubbles: true,
+    }));
+  }
+}
 
-    _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();
-    },
-
-    _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_html.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
new file mode 100644
index 0000000..e7795b9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      cursor: pointer;
+      font-family: var(--font-family);
+      position: absolute;
+      white-space: nowrap;
+    }
+  </style>
+  <gr-tooltip
+    id="tooltip"
+    text="Press c to comment"
+    position-below="[[positionBelow]]"
+  ></gr-tooltip>
+`;
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..ff6fba7 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-selection-action-box.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -37,120 +34,102 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-selection-action-box', () => {
-    let container;
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-selection-action-box.js';
+suite('gr-selection-action-box', () => {
+  let container;
+  let element;
+  let sandbox;
+
+  setup(() => {
+    container = fixture('basic');
+    element = container.querySelector('gr-selection-action-box');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(element, 'dispatchEvent');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('ignores regular keys', () => {
+    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
+    assert.isFalse(element.dispatchEvent.called);
+  });
+
+  suite('mousedown reacts only to main button', () => {
+    let e;
 
     setup(() => {
-      container = fixture('basic');
-      element = container.querySelector('gr-selection-action-box');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(element, 'fire');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('ignores regular keys', () => {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
-      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;
-
-      setup(() => {
-        e = {
-          button: 0,
-          preventDefault: sandbox.stub(),
-          stopPropagation: sandbox.stub(),
-        };
-        sandbox.stub(element, '_fireCreateComment');
-      });
-
-      test('event handled if main button', () => {
-        element._handleMouseDown(e);
-        assert.isTrue(e.preventDefault.called);
-      });
-
-      test('event ignored if not main button', () => {
-        e.button = 1;
-        element._handleMouseDown(e);
-        assert.isFalse(e.preventDefault.called);
-      });
-    });
-
-    test('event fired contains playload', () => {
-      const side = 'left';
-      const range = {
-        start_line: 1,
-        start_character: 11,
-        end_line: 2,
-        end_character: 42,
+      e = {
+        button: 0,
+        preventDefault: sandbox.stub(),
+        stopPropagation: sandbox.stub(),
       };
-      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;
+    test('event handled if main button', () => {
+      element._handleMouseDown(e);
+      assert.isTrue(e.preventDefault.called);
+      assert.equal(
+          element.dispatchEvent.lastCall.args[0].type,
+          'create-comment-requested'
+      );
+    });
 
-      setup(() => {
-        target = container.querySelector('.target');
-        sandbox.stub(container, 'getBoundingClientRect').returns(
-            {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
-        sandbox.stub(element, '_getTargetBoundingRect').returns(
-            {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
-        sandbox.stub(element.$.tooltip, 'getBoundingClientRect').returns(
-            {width: 10, height: 10});
-      });
-
-      test('placeAbove for Element argument', () => {
-        element.placeAbove(target);
-        assert.equal(element.style.top, '25px');
-        assert.equal(element.style.left, '72px');
-      });
-
-      test('placeAbove for Text Node argument', () => {
-        element.placeAbove(target.firstChild);
-        assert.equal(element.style.top, '25px');
-        assert.equal(element.style.left, '72px');
-      });
-
-      test('placeBelow for Element argument', () => {
-        element.placeBelow(target);
-        assert.equal(element.style.top, '45px');
-        assert.equal(element.style.left, '72px');
-      });
-
-      test('placeBelow for Text Node argument', () => {
-        element.placeBelow(target.firstChild);
-        assert.equal(element.style.top, '45px');
-        assert.equal(element.style.left, '72px');
-      });
-
-      test('uses document.createRange', () => {
-        sandbox.spy(document, 'createRange');
-        element._getTargetBoundingRect.restore();
-        sandbox.spy(element, '_getTargetBoundingRect');
-        element.placeAbove(target.firstChild);
-        assert.isTrue(document.createRange.called);
-      });
+    test('event ignored if not main button', () => {
+      e.button = 1;
+      element._handleMouseDown(e);
+      assert.isFalse(e.preventDefault.called);
+      assert.isFalse(element.dispatchEvent.called);
     });
   });
+
+  suite('placeAbove', () => {
+    let target;
+
+    setup(() => {
+      target = container.querySelector('.target');
+      sandbox.stub(container, 'getBoundingClientRect').returns(
+          {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
+      sandbox.stub(element, '_getTargetBoundingRect').returns(
+          {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
+      sandbox.stub(element.$.tooltip, 'getBoundingClientRect').returns(
+          {width: 10, height: 10});
+    });
+
+    test('placeAbove for Element argument', () => {
+      element.placeAbove(target);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeAbove for Text Node argument', () => {
+      element.placeAbove(target.firstChild);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Element argument', () => {
+      element.placeBelow(target);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Text Node argument', () => {
+      element.placeBelow(target.firstChild);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('uses document.createRange', () => {
+      sandbox.spy(document, 'createRange');
+      element._getTargetBoundingRect.restore();
+      sandbox.spy(element, '_getTargetBoundingRect');
+      element.placeAbove(target.firstChild);
+      assert.isTrue(document.createRange.called);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
deleted file mode 100644
index dd6bfec..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-lib-loader/gr-lib-loader.html">
-
-<dom-module id="gr-syntax-layer">
-  <template>
-    <gr-lib-loader id="libLoader"></gr-lib-loader>
-  </template>
-  <script src="../../../scripts/util.js"></script>
-  <script src="../gr-diff/gr-diff-line.js"></script>
-  <script src="../gr-diff-highlight/gr-annotation.js"></script>
-  <script src="gr-syntax-layer.js"></script>
-</dom-module>
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 50cf6b4..f1e930f 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
@@ -14,128 +14,142 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const LANGUAGE_MAP = {
-    'application/dart': 'dart',
-    'application/json': 'json',
-    'application/x-powershell': 'powershell',
-    'application/typescript': 'typescript',
-    'application/xml': 'xml',
-    'application/xquery': 'xquery',
-    'application/x-erb': 'erb',
-    'text/css': 'css',
-    'text/html': 'html',
-    'text/javascript': 'js',
-    'text/jsx': 'jsx',
-    'text/x-c': 'cpp',
-    'text/x-c++src': 'cpp',
-    'text/x-clojure': 'clojure',
-    'text/x-cmake': 'cmake',
-    'text/x-coffeescript': 'coffeescript',
-    'text/x-common-lisp': 'lisp',
-    'text/x-crystal': 'crystal',
-    'text/x-csharp': 'csharp',
-    'text/x-csrc': 'cpp',
-    'text/x-d': 'd',
-    'text/x-diff': 'diff',
-    'text/x-django': 'django',
-    'text/x-dockerfile': 'dockerfile',
-    'text/x-ebnf': 'ebnf',
-    'text/x-elm': 'elm',
-    'text/x-erlang': 'erlang',
-    'text/x-fortran': 'fortran',
-    'text/x-fsharp': 'fsharp',
-    'text/x-go': 'go',
-    'text/x-groovy': 'groovy',
-    'text/x-haml': 'haml',
-    'text/x-handlebars': 'handlebars',
-    'text/x-haskell': 'haskell',
-    'text/x-haxe': 'haxe',
-    'text/x-ini': 'ini',
-    'text/x-java': 'java',
-    'text/x-julia': 'julia',
-    'text/x-kotlin': 'kotlin',
-    'text/x-latex': 'latex',
-    'text/x-less': 'less',
-    'text/x-lua': 'lua',
-    'text/x-mathematica': 'mathematica',
-    'text/x-nginx-conf': 'nginx',
-    'text/x-nsis': 'nsis',
-    'text/x-objectivec': 'objectivec',
-    'text/x-ocaml': 'ocaml',
-    'text/x-perl': 'perl',
-    'text/x-pgsql': 'pgsql', // postgresql
-    'text/x-php': 'php',
-    'text/x-properties': 'properties',
-    'text/x-protobuf': 'protobuf',
-    'text/x-puppet': 'puppet',
-    'text/x-python': 'python',
-    'text/x-q': 'q',
-    'text/x-ruby': 'ruby',
-    'text/x-rustsrc': 'rust',
-    'text/x-scala': 'scala',
-    'text/x-scss': 'scss',
-    'text/x-scheme': 'scheme',
-    'text/x-shell': 'shell',
-    'text/x-soy': 'soy',
-    'text/x-spreadsheet': 'excel',
-    'text/x-sh': 'bash',
-    'text/x-sql': 'sql',
-    'text/x-swift': 'swift',
-    'text/x-systemverilog': 'sv',
-    'text/x-tcl': 'tcl',
-    'text/x-torque': 'torque',
-    'text/x-twig': 'twig',
-    'text/x-vb': 'vb',
-    'text/x-verilog': 'v',
-    'text/x-vhdl': 'vhdl',
-    'text/x-yaml': 'yaml',
-    'text/vbscript': 'vbscript',
-  };
-  const ASYNC_DELAY = 10;
+import '../../shared/gr-lib-loader/gr-lib-loader.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-syntax-layer_html.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {util} from '../../../scripts/util.js';
 
-  const CLASS_WHITELIST = {
-    'gr-diff gr-syntax gr-syntax-attr': true,
-    'gr-diff gr-syntax gr-syntax-attribute': true,
-    'gr-diff gr-syntax gr-syntax-built_in': true,
-    'gr-diff gr-syntax gr-syntax-comment': true,
-    'gr-diff gr-syntax gr-syntax-doctag': true,
-    'gr-diff gr-syntax gr-syntax-function': true,
-    'gr-diff gr-syntax gr-syntax-keyword': true,
-    'gr-diff gr-syntax gr-syntax-link': true,
-    'gr-diff gr-syntax gr-syntax-literal': true,
-    'gr-diff gr-syntax gr-syntax-meta': true,
-    'gr-diff gr-syntax gr-syntax-meta-keyword': true,
-    'gr-diff gr-syntax gr-syntax-name': true,
-    'gr-diff gr-syntax gr-syntax-number': true,
-    'gr-diff gr-syntax gr-syntax-params': true,
-    'gr-diff gr-syntax gr-syntax-regexp': true,
-    'gr-diff gr-syntax gr-syntax-selector-attr': true,
-    'gr-diff gr-syntax gr-syntax-selector-class': true,
-    'gr-diff gr-syntax gr-syntax-selector-id': true,
-    'gr-diff gr-syntax gr-syntax-selector-pseudo': true,
-    'gr-diff gr-syntax gr-syntax-selector-tag': true,
-    'gr-diff gr-syntax gr-syntax-string': true,
-    'gr-diff gr-syntax gr-syntax-tag': true,
-    'gr-diff gr-syntax gr-syntax-template-tag': true,
-    'gr-diff gr-syntax gr-syntax-template-variable': true,
-    'gr-diff gr-syntax gr-syntax-title': true,
-    'gr-diff gr-syntax gr-syntax-type': true,
-    'gr-diff gr-syntax gr-syntax-variable': true,
-  };
+const LANGUAGE_MAP = {
+  'application/dart': 'dart',
+  'application/json': 'json',
+  'application/x-powershell': 'powershell',
+  'application/typescript': 'typescript',
+  'application/xml': 'xml',
+  'application/xquery': 'xquery',
+  'application/x-erb': 'erb',
+  'text/css': 'css',
+  'text/html': 'html',
+  'text/javascript': 'js',
+  'text/jsx': 'jsx',
+  'text/x-c': 'cpp',
+  'text/x-c++src': 'cpp',
+  'text/x-clojure': 'clojure',
+  'text/x-cmake': 'cmake',
+  'text/x-coffeescript': 'coffeescript',
+  'text/x-common-lisp': 'lisp',
+  'text/x-crystal': 'crystal',
+  'text/x-csharp': 'csharp',
+  'text/x-csrc': 'cpp',
+  'text/x-d': 'd',
+  'text/x-diff': 'diff',
+  'text/x-django': 'django',
+  'text/x-dockerfile': 'dockerfile',
+  'text/x-ebnf': 'ebnf',
+  'text/x-elm': 'elm',
+  'text/x-erlang': 'erlang',
+  'text/x-fortran': 'fortran',
+  'text/x-fsharp': 'fsharp',
+  'text/x-go': 'go',
+  'text/x-groovy': 'groovy',
+  'text/x-haml': 'haml',
+  'text/x-handlebars': 'handlebars',
+  'text/x-haskell': 'haskell',
+  'text/x-haxe': 'haxe',
+  'text/x-ini': 'ini',
+  'text/x-java': 'java',
+  'text/x-julia': 'julia',
+  'text/x-kotlin': 'kotlin',
+  'text/x-latex': 'latex',
+  'text/x-less': 'less',
+  'text/x-lua': 'lua',
+  'text/x-mathematica': 'mathematica',
+  'text/x-nginx-conf': 'nginx',
+  'text/x-nsis': 'nsis',
+  'text/x-objectivec': 'objectivec',
+  'text/x-ocaml': 'ocaml',
+  'text/x-perl': 'perl',
+  'text/x-pgsql': 'pgsql', // postgresql
+  'text/x-php': 'php',
+  'text/x-properties': 'properties',
+  'text/x-protobuf': 'protobuf',
+  'text/x-puppet': 'puppet',
+  'text/x-python': 'python',
+  'text/x-q': 'q',
+  'text/x-ruby': 'ruby',
+  'text/x-rustsrc': 'rust',
+  'text/x-scala': 'scala',
+  'text/x-scss': 'scss',
+  'text/x-scheme': 'scheme',
+  'text/x-shell': 'shell',
+  'text/x-soy': 'soy',
+  'text/x-spreadsheet': 'excel',
+  'text/x-sh': 'bash',
+  'text/x-sql': 'sql',
+  'text/x-swift': 'swift',
+  'text/x-systemverilog': 'sv',
+  'text/x-tcl': 'tcl',
+  'text/x-torque': 'torque',
+  'text/x-twig': 'twig',
+  'text/x-vb': 'vb',
+  'text/x-verilog': 'v',
+  'text/x-vhdl': 'vhdl',
+  'text/x-yaml': 'yaml',
+  'text/vbscript': 'vbscript',
+};
+const ASYNC_DELAY = 10;
 
-  const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-  const CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
-  const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
-  const GO_BACKSLASH_LITERAL = '\'\\\\\'';
-  const GLOBAL_LT_PATTERN = /</g;
+const CLASS_WHITELIST = {
+  'gr-diff gr-syntax gr-syntax-attr': true,
+  'gr-diff gr-syntax gr-syntax-attribute': true,
+  'gr-diff gr-syntax gr-syntax-built_in': true,
+  'gr-diff gr-syntax gr-syntax-comment': true,
+  'gr-diff gr-syntax gr-syntax-doctag': true,
+  'gr-diff gr-syntax gr-syntax-function': true,
+  'gr-diff gr-syntax gr-syntax-keyword': true,
+  'gr-diff gr-syntax gr-syntax-link': true,
+  'gr-diff gr-syntax gr-syntax-literal': true,
+  'gr-diff gr-syntax gr-syntax-meta': true,
+  'gr-diff gr-syntax gr-syntax-meta-keyword': true,
+  'gr-diff gr-syntax gr-syntax-name': true,
+  'gr-diff gr-syntax gr-syntax-number': true,
+  'gr-diff gr-syntax gr-syntax-params': true,
+  'gr-diff gr-syntax gr-syntax-regexp': true,
+  'gr-diff gr-syntax gr-syntax-selector-attr': true,
+  'gr-diff gr-syntax gr-syntax-selector-class': true,
+  'gr-diff gr-syntax gr-syntax-selector-id': true,
+  'gr-diff gr-syntax gr-syntax-selector-pseudo': true,
+  'gr-diff gr-syntax gr-syntax-selector-tag': true,
+  'gr-diff gr-syntax gr-syntax-string': true,
+  'gr-diff gr-syntax gr-syntax-tag': true,
+  'gr-diff gr-syntax gr-syntax-template-tag': true,
+  'gr-diff gr-syntax gr-syntax-template-variable': true,
+  'gr-diff gr-syntax gr-syntax-title': true,
+  'gr-diff gr-syntax gr-syntax-type': true,
+  'gr-diff gr-syntax gr-syntax-variable': true,
+};
 
-  Polymer({
-    is: 'gr-syntax-layer',
+const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
+const CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
+const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
+const GO_BACKSLASH_LITERAL = '\'\\\\\'';
+const GLOBAL_LT_PATTERN = /</g;
 
-    properties: {
+/** @extends Polymer.Element */
+class GrSyntaxLayer extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-syntax-layer'; }
+
+  static get properties() {
+    return {
       diff: {
         type: Object,
         observer: '_diffChanged',
@@ -172,358 +186,371 @@
         value: null,
       },
       _hljs: Object,
-    },
+    };
+  }
 
-    addListener(fn) {
-      this.push('_listeners', fn);
-    },
+  addListener(fn) {
+    this.push('_listeners', fn);
+  }
 
-    removeListener(fn) {
-      this._listeners = this._listeners.filter(f => f != fn);
-    },
+  removeListener(fn) {
+    this._listeners = this._listeners.filter(f => f != fn);
+  }
 
-    /**
-     * Annotation layer method to add syntax annotations to the given element
-     * for the given line.
-     *
-     * @param {!HTMLElement} el
-     * @param {!HTMLElement} lineNumberEl
-     * @param {!Object} line (GrDiffLine)
-     */
-    annotate(el, lineNumberEl, line) {
-      if (!this.enabled) { return; }
+  /**
+   * Annotation layer method to add syntax annotations to the given element
+   * for the given line.
+   *
+   * @param {!HTMLElement} el
+   * @param {!HTMLElement} lineNumberEl
+   * @param {!Object} line (GrDiffLine)
+   */
+  annotate(el, lineNumberEl, line) {
+    if (!this.enabled) { return; }
 
-      // Determine the side.
-      let side;
-      if (line.type === GrDiffLine.Type.REMOVE || (
-        line.type === GrDiffLine.Type.BOTH &&
-          el.getAttribute('data-side') !== 'right')) {
-        side = 'left';
-      } else if (line.type === GrDiffLine.Type.ADD || (
-        el.getAttribute('data-side') !== 'left')) {
-        side = 'right';
-      }
+    // Determine the side.
+    let side;
+    if (line.type === GrDiffLine.Type.REMOVE || (
+      line.type === GrDiffLine.Type.BOTH &&
+        el.getAttribute('data-side') !== 'right')) {
+      side = 'left';
+    } else if (line.type === GrDiffLine.Type.ADD || (
+      el.getAttribute('data-side') !== 'left')) {
+      side = 'right';
+    }
 
-      // Find the relevant syntax ranges, if any.
-      let ranges = [];
-      if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
-        ranges = this._baseRanges[line.beforeNumber - 1] || [];
-      } else if (side === 'right' &&
-          this._revisionRanges.length >= line.afterNumber) {
-        ranges = this._revisionRanges[line.afterNumber - 1] || [];
-      }
+    // Find the relevant syntax ranges, if any.
+    let ranges = [];
+    if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
+      ranges = this._baseRanges[line.beforeNumber - 1] || [];
+    } else if (side === 'right' &&
+        this._revisionRanges.length >= line.afterNumber) {
+      ranges = this._revisionRanges[line.afterNumber - 1] || [];
+    }
 
-      // Apply the ranges to the element.
-      for (const range of ranges) {
-        GrAnnotation.annotateElement(
-            el, range.start, range.length, range.className);
-      }
-    },
+    // Apply the ranges to the element.
+    for (const range of ranges) {
+      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];
-    },
+  _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
-     * as syntax info comes online.
-     *
-     * @return {Promise}
-     */
-    process() {
-      // Cancel any still running process() calls, because they append to the
-      // same _baseRanges and _revisionRanges fields.
-      this._cancel();
+  /**
+   * Start processing syntax for the loaded diff and notify layer listeners
+   * as syntax info comes online.
+   *
+   * @return {Promise}
+   */
+  process() {
+    // Cancel any still running process() calls, because they append to the
+    // same _baseRanges and _revisionRanges fields.
+    this._cancel();
 
-      // Discard existing ranges.
-      this._baseRanges = [];
-      this._revisionRanges = [];
+    // Discard existing ranges.
+    this._baseRanges = [];
+    this._revisionRanges = [];
 
-      if (!this.enabled || !this.diff.content.length) {
-        return Promise.resolve();
-      }
+    if (!this.enabled || !this.diff.content.length) {
+      return Promise.resolve();
+    }
 
-      if (this.diff.meta_a) {
-        this._baseLanguage = this._getLanguage(this.diff.meta_a);
-      }
-      if (this.diff.meta_b) {
-        this._revisionLanguage = this._getLanguage(this.diff.meta_b);
-      }
-      if (!this._baseLanguage && !this._revisionLanguage) {
-        return Promise.resolve();
-      }
+    if (this.diff.meta_a) {
+      this._baseLanguage = this._getLanguage(this.diff.meta_a);
+    }
+    if (this.diff.meta_b) {
+      this._revisionLanguage = this._getLanguage(this.diff.meta_b);
+    }
+    if (!this._baseLanguage && !this._revisionLanguage) {
+      return Promise.resolve();
+    }
 
-      const state = {
-        sectionIndex: 0,
-        lineIndex: 0,
-        baseContext: undefined,
-        revisionContext: undefined,
-        lineNums: {left: 1, right: 1},
-        lastNotify: {left: 1, right: 1},
-      };
+    const state = {
+      sectionIndex: 0,
+      lineIndex: 0,
+      baseContext: undefined,
+      revisionContext: undefined,
+      lineNums: {left: 1, right: 1},
+      lastNotify: {left: 1, right: 1},
+    };
 
-      this._processPromise = util.makeCancelable(this._loadHLJS()
-          .then(() => {
-            return new Promise(resolve => {
-              const nextStep = () => {
-                this._processHandle = null;
-                this._processNextLine(state);
+    const rangesCache = new Map();
 
-                // Move to the next line in the section.
-                state.lineIndex++;
+    this._processPromise = util.makeCancelable(this._loadHLJS()
+        .then(() => new Promise(resolve => {
+          const nextStep = () => {
+            this._processHandle = null;
+            this._processNextLine(state, rangesCache);
 
-                // If the section has been exhausted, move to the next one.
-                if (this._isSectionDone(state)) {
-                  state.lineIndex = 0;
-                  state.sectionIndex++;
-                }
+            // Move to the next line in the section.
+            state.lineIndex++;
 
-                // If all sections have been exhausted, finish.
-                if (state.sectionIndex >= this.diff.content.length) {
-                  resolve();
-                  this._notify(state);
-                  return;
-                }
+            // If the section has been exhausted, move to the next one.
+            if (this._isSectionDone(state)) {
+              state.lineIndex = 0;
+              state.sectionIndex++;
+            }
 
-                if (state.lineIndex % 100 === 0) {
-                  this._notify(state);
-                  this._processHandle = this.async(nextStep, ASYNC_DELAY);
-                } else {
-                  nextStep.call(this);
-                }
-              };
+            // If all sections have been exhausted, finish.
+            if (state.sectionIndex >= this.diff.content.length) {
+              resolve();
+              this._notify(state);
+              return;
+            }
 
-              this._processHandle = this.async(nextStep, 1);
-            });
-          }));
-      return this._processPromise
-          .finally(() => { this._processPromise = null; });
-    },
+            if (state.lineIndex % 100 === 0) {
+              this._notify(state);
+              this._processHandle = this.async(nextStep, ASYNC_DELAY);
+            } else {
+              nextStep.call(this);
+            }
+          };
 
-    /**
-     * Cancel any asynchronous syntax processing jobs.
-     */
-    _cancel() {
-      if (this._processHandle != null) {
-        this.cancelAsync(this._processHandle);
-        this._processHandle = null;
-      }
-      if (this._processPromise) {
-        this._processPromise.cancel();
-      }
-    },
+          this._processHandle = this.async(nextStep, 1);
+        })));
+    return this._processPromise
+        .finally(() => { this._processPromise = null; });
+  }
 
-    _diffChanged() {
-      this._cancel();
-      this._baseRanges = [];
-      this._revisionRanges = [];
-    },
+  /**
+   * Cancel any asynchronous syntax processing jobs.
+   */
+  _cancel() {
+    if (this._processHandle != null) {
+      this.cancelAsync(this._processHandle);
+      this._processHandle = null;
+    }
+    if (this._processPromise) {
+      this._processPromise.cancel();
+    }
+  }
 
-    /**
-     * Take a string of HTML with the (potentially nested) syntax markers
-     * Highlight.js emits and emit a list of text ranges and classes for the
-     * markers.
-     *
-     * @param {string} str The string of HTML.
-     * @return {!Array<!Object>} The list of ranges.
-     */
-    _rangesFromString(str) {
-      const div = document.createElement('div');
-      div.innerHTML = str;
-      return this._rangesFromElement(div, 0);
-    },
+  _diffChanged() {
+    this._cancel();
+    this._baseRanges = [];
+    this._revisionRanges = [];
+  }
 
-    _rangesFromElement(elem, offset) {
-      let result = [];
-      for (const node of elem.childNodes) {
-        const nodeLength = GrAnnotation.getLength(node);
-        // Note: HLJS may emit a span with class undefined when it thinks there
-        // may be a syntax error.
-        if (node.tagName === 'SPAN' && node.className !== 'undefined') {
-          if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
-            result.push({
-              start: offset,
-              length: nodeLength,
-              className: node.className,
-            });
-          }
-          if (node.children.length) {
-            result = result.concat(this._rangesFromElement(node, offset));
-          }
+  /**
+   * Take a string of HTML with the (potentially nested) syntax markers
+   * Highlight.js emits and emit a list of text ranges and classes for the
+   * 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, rangesCache) {
+    const cached = rangesCache.get(str);
+    if (cached) return cached;
+
+    const div = document.createElement('div');
+    div.innerHTML = str;
+    const ranges = this._rangesFromElement(div, 0);
+    rangesCache.set(str, ranges);
+    return ranges;
+  }
+
+  _rangesFromElement(elem, offset) {
+    let result = [];
+    for (const node of elem.childNodes) {
+      const nodeLength = GrAnnotation.getLength(node);
+      // Note: HLJS may emit a span with class undefined when it thinks there
+      // may be a syntax error.
+      if (node.tagName === 'SPAN' && node.className !== 'undefined') {
+        if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
+          result.push({
+            start: offset,
+            length: nodeLength,
+            className: node.className,
+          });
         }
-        offset += nodeLength;
+        if (node.children.length) {
+          result = result.concat(this._rangesFromElement(node, offset));
+        }
       }
-      return result;
-    },
+      offset += nodeLength;
+    }
+    return result;
+  }
 
-    /**
-     * For a given state, process the syntax for the next line (or pair of
-     * lines).
-     *
-     * @param {!Object} state The processing state for the layer.
-     */
-    _processNextLine(state) {
-      let baseLine;
-      let revisionLine;
+  /**
+   * For a given state, process the syntax for the next line (or pair of
+   * lines).
+   *
+   * @param {!Object} state The processing state for the layer.
+   */
+  _processNextLine(state, rangesCache) {
+    let baseLine;
+    let revisionLine;
 
-      const section = this.diff.content[state.sectionIndex];
-      if (section.ab) {
-        baseLine = section.ab[state.lineIndex];
-        revisionLine = section.ab[state.lineIndex];
+    const section = this.diff.content[state.sectionIndex];
+    if (section.ab) {
+      baseLine = section.ab[state.lineIndex];
+      revisionLine = section.ab[state.lineIndex];
+      state.lineNums.left++;
+      state.lineNums.right++;
+    } else {
+      if (section.a && section.a.length > state.lineIndex) {
+        baseLine = section.a[state.lineIndex];
         state.lineNums.left++;
+      }
+      if (section.b && section.b.length > state.lineIndex) {
+        revisionLine = section.b[state.lineIndex];
         state.lineNums.right++;
-      } else {
-        if (section.a && section.a.length > state.lineIndex) {
-          baseLine = section.a[state.lineIndex];
-          state.lineNums.left++;
-        }
-        if (section.b && section.b.length > state.lineIndex) {
-          revisionLine = section.b[state.lineIndex];
-          state.lineNums.right++;
-        }
       }
+    }
 
-      // To store the result of the syntax highlighter.
-      let result;
+    // To store the result of the syntax highlighter.
+    let result;
 
-      if (this._baseLanguage && baseLine !== undefined &&
-          this._hljs.getLanguage(this._baseLanguage)) {
-        baseLine = this._workaround(this._baseLanguage, baseLine);
-        result = this._hljs.highlight(this._baseLanguage, baseLine, true,
-            state.baseContext);
-        this.push('_baseRanges', this._rangesFromString(result.value));
-        state.baseContext = result.top;
-      }
+    if (this._baseLanguage && baseLine !== undefined &&
+        this._hljs.getLanguage(this._baseLanguage)) {
+      baseLine = this._workaround(this._baseLanguage, baseLine);
+      result = this._hljs.highlight(this._baseLanguage, baseLine, true,
+          state.baseContext);
+      this.push('_baseRanges',
+          this._rangesFromString(result.value, rangesCache));
+      state.baseContext = result.top;
+    }
 
-      if (this._revisionLanguage && revisionLine !== undefined &&
-          this._hljs.getLanguage(this._revisionLanguage)) {
-        revisionLine = this._workaround(this._revisionLanguage, revisionLine);
-        result = this._hljs.highlight(this._revisionLanguage, revisionLine,
-            true, state.revisionContext);
-        this.push('_revisionRanges', this._rangesFromString(result.value));
-        state.revisionContext = result.top;
-      }
-    },
+    if (this._revisionLanguage && revisionLine !== undefined &&
+        this._hljs.getLanguage(this._revisionLanguage)) {
+      revisionLine = this._workaround(this._revisionLanguage, revisionLine);
+      result = this._hljs.highlight(this._revisionLanguage, revisionLine,
+          true, state.revisionContext);
+      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
-     * cases before sending them into HLJS so that they parse correctly.
-     *
-     * Important notes:
-     * * These tests should be as constrained as possible to avoid interfering
-     *   with code it shouldn't AND to avoid executing regexes as much as
-     *   possible.
-     * * These tests should document the issue clearly enough that the test can
-     *   be condidently removed when the issue is solved in HLJS.
-     * * These tests should rewrite the line of code to have the same number of
-     *   characters. This method rewrites the string that gets parsed, but NOT
-     *   the string that gets displayed and highlighted. Thus, the positions
-     *   must be consistent.
-     *
-     * @param {!string} language The name of the HLJS language plugin in use.
-     * @param {!string} line The line of code to potentially rewrite.
-     * @return {string} A potentially-rewritten line of code.
-     */
-    _workaround(language, line) {
-      if (language === 'cpp') {
-        /**
-         * Prevent confusing < and << operators for the start of a meta string
-         * by converting them to a different operator.
-         * {@see Issue 4864}
-         * {@see https://github.com/isagalaev/highlight.js/issues/1341}
-         */
-        if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
-          line = line.replace(GLOBAL_LT_PATTERN, '|');
-        }
-
-        /**
-         * Rewrite CPP wchar_t characters literals to wchar_t string literals
-         * because HLJS only understands the string form.
-         * {@see Issue 5242}
-         * {#see https://github.com/isagalaev/highlight.js/issues/1412}
-         */
-        if (CPP_WCHAR_PATTERN.test(line)) {
-          line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
-        }
-
-        return line;
+  /**
+   * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
+   * cases before sending them into HLJS so that they parse correctly.
+   *
+   * Important notes:
+   * * These tests should be as constrained as possible to avoid interfering
+   *   with code it shouldn't AND to avoid executing regexes as much as
+   *   possible.
+   * * These tests should document the issue clearly enough that the test can
+   *   be condidently removed when the issue is solved in HLJS.
+   * * These tests should rewrite the line of code to have the same number of
+   *   characters. This method rewrites the string that gets parsed, but NOT
+   *   the string that gets displayed and highlighted. Thus, the positions
+   *   must be consistent.
+   *
+   * @param {!string} language The name of the HLJS language plugin in use.
+   * @param {!string} line The line of code to potentially rewrite.
+   * @return {string} A potentially-rewritten line of code.
+   */
+  _workaround(language, line) {
+    if (language === 'cpp') {
+      /**
+       * Prevent confusing < and << operators for the start of a meta string
+       * by converting them to a different operator.
+       * {@see Issue 4864}
+       * {@see https://github.com/isagalaev/highlight.js/issues/1341}
+       */
+      if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
+        line = line.replace(GLOBAL_LT_PATTERN, '|');
       }
 
       /**
-       * Prevent confusing the closing paren of a parameterized Java annotation
-       * being applied to a formal argument as the closing paren of the argument
-       * list. Rewrite the parens as spaces.
-       * {@see Issue 4776}
-       * {@see https://github.com/isagalaev/highlight.js/issues/1324}
+       * Rewrite CPP wchar_t characters literals to wchar_t string literals
+       * because HLJS only understands the string form.
+       * {@see Issue 5242}
+       * {#see https://github.com/isagalaev/highlight.js/issues/1412}
        */
-      if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
-        return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
-      }
-
-      /**
-       * HLJS misunderstands backslash character literals in Go.
-       * {@see Issue 5007}
-       * {#see https://github.com/isagalaev/highlight.js/issues/1411}
-       */
-      if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
-        return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
+      if (CPP_WCHAR_PATTERN.test(line)) {
+        line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
       }
 
       return line;
-    },
+    }
 
     /**
-     * Tells whether the state has exhausted its current section.
-     *
-     * @param {!Object} state
-     * @return {boolean}
+     * Prevent confusing the closing paren of a parameterized Java annotation
+     * being applied to a formal argument as the closing paren of the argument
+     * list. Rewrite the parens as spaces.
+     * {@see Issue 4776}
+     * {@see https://github.com/isagalaev/highlight.js/issues/1324}
      */
-    _isSectionDone(state) {
-      const section = this.diff.content[state.sectionIndex];
-      if (section.ab) {
-        return state.lineIndex >= section.ab.length;
-      } else {
-        return (!section.a || state.lineIndex >= section.a.length) &&
-            (!section.b || state.lineIndex >= section.b.length);
-      }
-    },
+    if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
+      return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
+    }
 
     /**
-     * For a given state, notify layer listeners of any processed line ranges
-     * that have not yet been notified.
-     *
-     * @param {!Object} state
+     * HLJS misunderstands backslash character literals in Go.
+     * {@see Issue 5007}
+     * {#see https://github.com/isagalaev/highlight.js/issues/1411}
      */
-    _notify(state) {
-      if (state.lineNums.left - state.lastNotify.left) {
-        this._notifyRange(
-            state.lastNotify.left,
-            state.lineNums.left,
-            'left');
-        state.lastNotify.left = state.lineNums.left;
-      }
-      if (state.lineNums.right - state.lastNotify.right) {
-        this._notifyRange(
-            state.lastNotify.right,
-            state.lineNums.right,
-            'right');
-        state.lastNotify.right = state.lineNums.right;
-      }
-    },
+    if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
+      return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
+    }
 
-    _notifyRange(start, end, side) {
-      for (const fn of this._listeners) {
-        fn(start, end, side);
-      }
-    },
+    return line;
+  }
 
-    _loadHLJS() {
-      return this.$.libLoader.getHLJS().then(hljs => {
-        this._hljs = hljs;
-      });
-    },
-  });
-})();
+  /**
+   * Tells whether the state has exhausted its current section.
+   *
+   * @param {!Object} state
+   * @return {boolean}
+   */
+  _isSectionDone(state) {
+    const section = this.diff.content[state.sectionIndex];
+    if (section.ab) {
+      return state.lineIndex >= section.ab.length;
+    } else {
+      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
+   * that have not yet been notified.
+   *
+   * @param {!Object} state
+   */
+  _notify(state) {
+    if (state.lineNums.left - state.lastNotify.left) {
+      this._notifyRange(
+          state.lastNotify.left,
+          state.lineNums.left,
+          'left');
+      state.lastNotify.left = state.lineNums.left;
+    }
+    if (state.lineNums.right - state.lastNotify.right) {
+      this._notifyRange(
+          state.lastNotify.right,
+          state.lineNums.right,
+          '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_html.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
new file mode 100644
index 0000000..433f814
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-lib-loader id="libLoader"></gr-lib-loader>
+`;
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..ccdbe8b 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
-<link rel="import" href="gr-syntax-layer.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,458 +31,473 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-syntax-layer tests', () => {
-    let sandbox;
-    let diff;
-    let element;
-    const lineNumberEl = document.createElement('td');
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
+import './gr-syntax-layer.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
 
-    function getMockHLJS() {
-      const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
-          'ipsum</span>';
-      return {
-        configure() {},
-        highlight(lang, line, ignore, state) {
-          return {
-            value: line.replace(/ipsum/, html),
-            top: state === undefined ? 1 : state + 1,
-          };
-        },
-        // Return something truthy because this method is used to check if the
-        // language is supported.
-        getLanguage(s) {
-          return {};
-        },
-      };
-    }
+suite('gr-syntax-layer tests', () => {
+  let sandbox;
+  let diff;
+  let element;
+  const lineNumberEl = document.createElement('td');
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      const mock = document.createElement('mock-diff-response');
-      diff = mock.diffResponse;
-      element.diff = diff;
-    });
+  function getMockHLJS() {
+    const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
+        'ipsum</span>';
+    return {
+      configure() {},
+      highlight(lang, line, ignore, state) {
+        return {
+          value: line.replace(/ipsum/, html),
+          top: state === undefined ? 1 : state + 1,
+        };
+      },
+      // Return something truthy because this method is used to check if the
+      // language is supported.
+      getLanguage(s) {
+        return {};
+      },
+    };
+  }
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    diff = getMockDiffResponse();
+    element.diff = diff;
+  });
 
-    test('annotate without range does nothing', () => {
-      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const el = document.createElement('div');
-      el.textContent = 'Etiam dui, blandit wisi.';
-      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 12;
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      element.annotate(el, lineNumberEl, line);
+  test('annotate without range does nothing', () => {
+    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = 'Etiam dui, blandit wisi.';
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
 
-      assert.isFalse(annotationSpy.called);
-    });
+    element.annotate(el, lineNumberEl, line);
 
-    test('annotate with range applies it', () => {
-      const str = 'Etiam dui, blandit wisi.';
-      const start = 6;
-      const length = 3;
-      const className = 'foobar';
+    assert.isFalse(annotationSpy.called);
+  });
 
-      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const el = document.createElement('div');
-      el.textContent = str;
-      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 12;
-      element._baseRanges[11] = [{
-        start,
-        length,
-        className,
-      }];
+  test('annotate with range applies it', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
 
-      element.annotate(el, lineNumberEl, line);
+    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
 
-      assert.isTrue(annotationSpy.called);
-      assert.equal(annotationSpy.lastCall.args[0], el);
-      assert.equal(annotationSpy.lastCall.args[1], start);
-      assert.equal(annotationSpy.lastCall.args[2], length);
-      assert.equal(annotationSpy.lastCall.args[3], className);
-      assert.isOk(el.querySelector('hl.' + className));
-    });
+    element.annotate(el, lineNumberEl, line);
 
-    test('annotate with range but disabled does nothing', () => {
-      const str = 'Etiam dui, blandit wisi.';
-      const start = 6;
-      const length = 3;
-      const className = 'foobar';
+    assert.isTrue(annotationSpy.called);
+    assert.equal(annotationSpy.lastCall.args[0], el);
+    assert.equal(annotationSpy.lastCall.args[1], start);
+    assert.equal(annotationSpy.lastCall.args[2], length);
+    assert.equal(annotationSpy.lastCall.args[3], className);
+    assert.isOk(el.querySelector('hl.' + className));
+  });
 
-      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const el = document.createElement('div');
-      el.textContent = str;
-      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 12;
-      element._baseRanges[11] = [{
-        start,
-        length,
-        className,
-      }];
-      element.enabled = false;
+  test('annotate with range but disabled does nothing', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
 
-      element.annotate(el, lineNumberEl, line);
+    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
+    element.enabled = false;
 
-      assert.isFalse(annotationSpy.called);
-    });
+    element.annotate(el, lineNumberEl, line);
 
-    test('process on empty diff does nothing', done => {
-      element.diff = {
-        meta_a: {content_type: 'application/json'},
-        meta_b: {content_type: 'application/json'},
-        content: [],
-      };
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
+    assert.isFalse(annotationSpy.called);
+  });
 
-      const processPromise = element.process();
+  test('process on empty diff does nothing', done => {
+    element.diff = {
+      meta_a: {content_type: 'application/json'},
+      meta_b: {content_type: 'application/json'},
+      content: [],
+    };
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
 
-      processPromise.then(() => {
-        assert.isFalse(processNextSpy.called);
-        assert.equal(element._baseRanges.length, 0);
-        assert.equal(element._revisionRanges.length, 0);
-        done();
-      });
-    });
+    const processPromise = element.process();
 
-    test('process for unsupported languages does nothing', done => {
-      element.diff = {
-        meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
-        meta_b: {content_type: 'application/not-a-real-language'},
-        content: [],
-      };
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
-
-      const processPromise = element.process();
-
-      processPromise.then(() => {
-        assert.isFalse(processNextSpy.called);
-        assert.equal(element._baseRanges.length, 0);
-        assert.equal(element._revisionRanges.length, 0);
-        done();
-      });
-    });
-
-    test('process while disabled does nothing', done => {
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
-      element.enabled = false;
-      const loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
-
-      const processPromise = element.process();
-
-      processPromise.then(() => {
-        assert.isFalse(processNextSpy.called);
-        assert.equal(element._baseRanges.length, 0);
-        assert.equal(element._revisionRanges.length, 0);
-        assert.isFalse(loadHLJSSpy.called);
-        done();
-      });
-    });
-
-    test('process highlight ipsum', done => {
-      element.diff.meta_a.content_type = 'application/json';
-      element.diff.meta_b.content_type = 'application/json';
-
-      const mockHLJS = getMockHLJS();
-      const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-      sandbox.stub(element.$.libLoader, 'getHLJS',
-          () => { return Promise.resolve(mockHLJS); });
-      const processNextSpy = sandbox.spy(element, '_processNextLine');
-      const processPromise = element.process();
-
-      processPromise.then(() => {
-        const linesA = diff.meta_a.lines;
-        const linesB = diff.meta_b.lines;
-
-        assert.isTrue(processNextSpy.called);
-        assert.equal(element._baseRanges.length, linesA);
-        assert.equal(element._revisionRanges.length, linesB);
-
-        assert.equal(highlightSpy.callCount, linesA + linesB);
-
-        // The first line of both sides have a range.
-        let ranges = [element._baseRanges[0], element._revisionRanges[0]];
-        for (const range of ranges) {
-          assert.equal(range.length, 1);
-          assert.equal(range[0].className,
-              'gr-diff gr-syntax gr-syntax-string');
-          assert.equal(range[0].start, 'lorem '.length);
-          assert.equal(range[0].length, 'ipsum'.length);
-        }
-
-        // There are no ranges from ll.1-12 on the left and ll.1-11 on the
-        // right.
-        ranges = element._baseRanges.slice(1, 12)
-            .concat(element._revisionRanges.slice(1, 11));
-
-        for (const range of ranges) {
-          assert.equal(range.length, 0);
-        }
-
-        // There should be another pair of ranges on l.13 for the left and
-        // l.12 for the right.
-        ranges = [element._baseRanges[13], element._revisionRanges[12]];
-
-        for (const range of ranges) {
-          assert.equal(range.length, 1);
-          assert.equal(range[0].className,
-              'gr-diff gr-syntax gr-syntax-string');
-          assert.equal(range[0].start, 32);
-          assert.equal(range[0].length, 'ipsum'.length);
-        }
-
-        // The next group should have a similar instance on either side.
-
-        let range = element._baseRanges[15];
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 34);
-        assert.equal(range[0].length, 'ipsum'.length);
-
-        range = element._revisionRanges[14];
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 35);
-        assert.equal(range[0].length, 'ipsum'.length);
-
-        done();
-      });
-    });
-
-    test('_diffChanged calls cancel', () => {
-      const cancelSpy = sandbox.spy(element, '_diffChanged');
-      element.diff = {content: []};
-      assert.isTrue(cancelSpy.called);
-    });
-
-    test('_rangesFromElement no ranges', () => {
-      const elem = document.createElement('span');
-      elem.textContent = 'Etiam dui, blandit wisi.';
-      const offset = 100;
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 0);
-    });
-
-    test('_rangesFromElement single range', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui, blandit';
-      const str2 = ' wisi.';
-      const className = 'gr-diff gr-syntax gr-syntax-string';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      const span = document.createElement('span');
-      span.textContent = str1;
-      span.className = className;
-      elem.appendChild(span);
-      elem.appendChild(document.createTextNode(str2));
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 1);
-      assert.equal(result[0].start, str0.length + offset);
-      assert.equal(result[0].length, str1.length);
-      assert.equal(result[0].className, className);
-    });
-
-    test('_rangesFromElement non-whitelist', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui, blandit';
-      const str2 = ' wisi.';
-      const className = 'not-in-the-whitelist';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      const span = document.createElement('span');
-      span.textContent = str1;
-      span.className = className;
-      elem.appendChild(span);
-      elem.appendChild(document.createTextNode(str2));
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 0);
-    });
-
-    test('_rangesFromElement milti range', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui,';
-      const str2 = ' blandit';
-      const str3 = ' wisi.';
-      const className = 'gr-diff gr-syntax gr-syntax-string';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      let span = document.createElement('span');
-      span.textContent = str1;
-      span.className = className;
-      elem.appendChild(span);
-      elem.appendChild(document.createTextNode(str2));
-      span = document.createElement('span');
-      span.textContent = str3;
-      span.className = className;
-      elem.appendChild(span);
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 2);
-
-      assert.equal(result[0].start, str0.length + offset);
-      assert.equal(result[0].length, str1.length);
-      assert.equal(result[0].className, className);
-
-      assert.equal(result[1].start,
-          str0.length + str1.length + str2.length + offset);
-      assert.equal(result[1].length, str3.length);
-      assert.equal(result[1].className, className);
-    });
-
-    test('_rangesFromElement nested range', () => {
-      const str0 = 'Etiam ';
-      const str1 = 'dui,';
-      const str2 = ' blandit';
-      const str3 = ' wisi.';
-      const className = 'gr-diff gr-syntax gr-syntax-string';
-      const offset = 100;
-
-      const elem = document.createElement('span');
-      elem.appendChild(document.createTextNode(str0));
-      const span1 = document.createElement('span');
-      span1.textContent = str1;
-      span1.className = className;
-      elem.appendChild(span1);
-      const span2 = document.createElement('span');
-      span2.textContent = str2;
-      span2.className = className;
-      span1.appendChild(span2);
-      elem.appendChild(document.createTextNode(str3));
-
-      const result = element._rangesFromElement(elem, offset);
-
-      assert.equal(result.length, 2);
-
-      assert.equal(result[0].start, str0.length + offset);
-      assert.equal(result[0].length, str1.length + str2.length);
-      assert.equal(result[0].className, className);
-
-      assert.equal(result[1].start, str0.length + str1.length + offset);
-      assert.equal(result[1].length, str2.length);
-      assert.equal(result[1].className, className);
-    });
-
-    test('_rangesFromString whitelist allows recursion', () => {
-      const str = [
-        '<span class="non-whtelisted-class">',
-        '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
-        '</span>'].join('');
-      const result = element._rangesFromString(str);
-      assert.notEqual(result.length, 0);
-    });
-
-    test('_isSectionDone', () => {
-      let state = {sectionIndex: 0, lineIndex: 0};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 0, lineIndex: 2};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 0, lineIndex: 4};
-      assert.isTrue(element._isSectionDone(state));
-
-      state = {sectionIndex: 1, lineIndex: 2};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 1, lineIndex: 3};
-      assert.isTrue(element._isSectionDone(state));
-
-      state = {sectionIndex: 3, lineIndex: 0};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 3, lineIndex: 3};
-      assert.isFalse(element._isSectionDone(state));
-
-      state = {sectionIndex: 3, lineIndex: 4};
-      assert.isTrue(element._isSectionDone(state));
-    });
-
-    test('workaround CPP LT directive', () => {
-      // Does nothing to regular line.
-      let line = 'int main(int argc, char** argv) { return 0; }';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Does nothing to include directive.
-      line = '#include <stdio>';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Converts left-shift operator in #define.
-      line = '#define GiB (1ull << 30)';
-      let expected = '#define GiB (1ull || 30)';
-      assert.equal(element._workaround('cpp', line), expected);
-
-      // Converts less-than operator in #if.
-      line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
-      expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
-      assert.equal(element._workaround('cpp', line), expected);
-    });
-
-    test('workaround Java param-annotation', () => {
-      // Does nothing to regular line.
-      let line = 'public static void foo(int bar) { }';
-      assert.equal(element._workaround('java', line), line);
-
-      // Does nothing to regular annotation.
-      line = 'public static void foo(@Nullable int bar) { }';
-      assert.equal(element._workaround('java', line), line);
-
-      // Converts parameterized annotation.
-      line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
-      const expected = 'public static void foo(@SuppressWarnings "unused" ' +
-          ' int bar) { }';
-      assert.equal(element._workaround('java', line), expected);
-    });
-
-    test('workaround CPP whcar_t character literals', () => {
-      // Does nothing to regular line.
-      let line = 'int main(int argc, char** argv) { return 0; }';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Does nothing to wchar_t string.
-      line = 'wchar_t* sz = L"abc 123";';
-      assert.equal(element._workaround('cpp', line), line);
-
-      // Converts wchar_t character literal to string.
-      line = 'wchar_t myChar = L\'#\'';
-      let expected = 'wchar_t myChar = L"."';
-      assert.equal(element._workaround('cpp', line), expected);
-
-      // Converts wchar_t character literal with escape sequence to string.
-      line = 'wchar_t myChar = L\'\\"\'';
-      expected = 'wchar_t myChar = L"\\."';
-      assert.equal(element._workaround('cpp', line), expected);
-    });
-
-    test('workaround go backslash character literals', () => {
-      // Does nothing to regular line.
-      let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
-      assert.equal(element._workaround('go', line), line);
-
-      // Does nothing to string with backslash literal
-      line = 'c := "\\\\"';
-      assert.equal(element._workaround('go', line), line);
-
-      // Converts backslash literal character to a string.
-      line = 'c := \'\\\\\'';
-      const expected = 'c := "\\\\"';
-      assert.equal(element._workaround('go', line), expected);
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      done();
     });
   });
+
+  test('process for unsupported languages does nothing', done => {
+    element.diff = {
+      meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
+      meta_b: {content_type: 'application/not-a-real-language'},
+      content: [],
+    };
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
+
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      done();
+    });
+  });
+
+  test('process while disabled does nothing', done => {
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
+    element.enabled = false;
+    const loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
+
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      assert.isFalse(loadHLJSSpy.called);
+      done();
+    });
+  });
+
+  test('process highlight ipsum', done => {
+    element.diff.meta_a.content_type = 'application/json';
+    element.diff.meta_b.content_type = 'application/json';
+
+    const mockHLJS = getMockHLJS();
+    const highlightSpy = sinon.spy(mockHLJS, 'highlight');
+    sandbox.stub(element.$.libLoader, 'getHLJS',
+        () => Promise.resolve(mockHLJS));
+    const processNextSpy = sandbox.spy(element, '_processNextLine');
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      const linesA = diff.meta_a.lines;
+      const linesB = diff.meta_b.lines;
+
+      assert.isTrue(processNextSpy.called);
+      assert.equal(element._baseRanges.length, linesA);
+      assert.equal(element._revisionRanges.length, linesB);
+
+      assert.equal(highlightSpy.callCount, linesA + linesB);
+
+      // The first line of both sides have a range.
+      let ranges = [element._baseRanges[0], element._revisionRanges[0]];
+      for (const range of ranges) {
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className,
+            'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 'lorem '.length);
+        assert.equal(range[0].length, 'ipsum'.length);
+      }
+
+      // There are no ranges from ll.1-12 on the left and ll.1-11 on the
+      // right.
+      ranges = element._baseRanges.slice(1, 12)
+          .concat(element._revisionRanges.slice(1, 11));
+
+      for (const range of ranges) {
+        assert.equal(range.length, 0);
+      }
+
+      // There should be another pair of ranges on l.13 for the left and
+      // l.12 for the right.
+      ranges = [element._baseRanges[13], element._revisionRanges[12]];
+
+      for (const range of ranges) {
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className,
+            'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 32);
+        assert.equal(range[0].length, 'ipsum'.length);
+      }
+
+      // The next group should have a similar instance on either side.
+
+      let range = element._baseRanges[15];
+      assert.equal(range.length, 1);
+      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 34);
+      assert.equal(range[0].length, 'ipsum'.length);
+
+      range = element._revisionRanges[14];
+      assert.equal(range.length, 1);
+      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 35);
+      assert.equal(range[0].length, 'ipsum'.length);
+
+      done();
+    });
+  });
+
+  test('_diffChanged calls cancel', () => {
+    const cancelSpy = sandbox.spy(element, '_diffChanged');
+    element.diff = {content: []};
+    assert.isTrue(cancelSpy.called);
+  });
+
+  test('_rangesFromElement no ranges', () => {
+    const elem = document.createElement('span');
+    elem.textContent = 'Etiam dui, blandit wisi.';
+    const offset = 100;
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 0);
+  });
+
+  test('_rangesFromElement single range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui, blandit';
+    const str2 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 1);
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length);
+    assert.equal(result[0].className, className);
+  });
+
+  test('_rangesFromElement non-whitelist', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui, blandit';
+    const str2 = ' wisi.';
+    const className = 'not-in-the-whitelist';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 0);
+  });
+
+  test('_rangesFromElement milti range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui,';
+    const str2 = ' blandit';
+    const str3 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    let span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+    span = document.createElement('span');
+    span.textContent = str3;
+    span.className = className;
+    elem.appendChild(span);
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 2);
+
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length);
+    assert.equal(result[0].className, className);
+
+    assert.equal(result[1].start,
+        str0.length + str1.length + str2.length + offset);
+    assert.equal(result[1].length, str3.length);
+    assert.equal(result[1].className, className);
+  });
+
+  test('_rangesFromElement nested range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui,';
+    const str2 = ' blandit';
+    const str3 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span1 = document.createElement('span');
+    span1.textContent = str1;
+    span1.className = className;
+    elem.appendChild(span1);
+    const span2 = document.createElement('span');
+    span2.textContent = str2;
+    span2.className = className;
+    span1.appendChild(span2);
+    elem.appendChild(document.createTextNode(str3));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 2);
+
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length + str2.length);
+    assert.equal(result[0].className, className);
+
+    assert.equal(result[1].start, str0.length + str1.length + offset);
+    assert.equal(result[1].length, str2.length);
+    assert.equal(result[1].className, className);
+  });
+
+  test('_rangesFromString whitelist allows recursion', () => {
+    const str = [
+      '<span class="non-whtelisted-class">',
+      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+      '</span>'].join('');
+    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));
+
+    state = {sectionIndex: 0, lineIndex: 2};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 0, lineIndex: 4};
+    assert.isTrue(element._isSectionDone(state));
+
+    state = {sectionIndex: 1, lineIndex: 2};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 1, lineIndex: 3};
+    assert.isTrue(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 0};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 3};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 4};
+    assert.isTrue(element._isSectionDone(state));
+  });
+
+  test('workaround CPP LT directive', () => {
+    // Does nothing to regular line.
+    let line = 'int main(int argc, char** argv) { return 0; }';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Does nothing to include directive.
+    line = '#include <stdio>';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Converts left-shift operator in #define.
+    line = '#define GiB (1ull << 30)';
+    let expected = '#define GiB (1ull || 30)';
+    assert.equal(element._workaround('cpp', line), expected);
+
+    // Converts less-than operator in #if.
+    line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
+    expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
+    assert.equal(element._workaround('cpp', line), expected);
+  });
+
+  test('workaround Java param-annotation', () => {
+    // Does nothing to regular line.
+    let line = 'public static void foo(int bar) { }';
+    assert.equal(element._workaround('java', line), line);
+
+    // Does nothing to regular annotation.
+    line = 'public static void foo(@Nullable int bar) { }';
+    assert.equal(element._workaround('java', line), line);
+
+    // Converts parameterized annotation.
+    line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
+    const expected = 'public static void foo(@SuppressWarnings "unused" ' +
+        ' int bar) { }';
+    assert.equal(element._workaround('java', line), expected);
+  });
+
+  test('workaround CPP whcar_t character literals', () => {
+    // Does nothing to regular line.
+    let line = 'int main(int argc, char** argv) { return 0; }';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Does nothing to wchar_t string.
+    line = 'wchar_t* sz = L"abc 123";';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Converts wchar_t character literal to string.
+    line = 'wchar_t myChar = L\'#\'';
+    let expected = 'wchar_t myChar = L"."';
+    assert.equal(element._workaround('cpp', line), expected);
+
+    // Converts wchar_t character literal with escape sequence to string.
+    line = 'wchar_t myChar = L\'\\"\'';
+    expected = 'wchar_t myChar = L"\\."';
+    assert.equal(element._workaround('cpp', line), expected);
+  });
+
+  test('workaround go backslash character literals', () => {
+    // Does nothing to regular line.
+    let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
+    assert.equal(element._workaround('go', line), line);
+
+    // Does nothing to string with backslash literal
+    line = 'c := "\\\\"';
+    assert.equal(element._workaround('go', line), line);
+
+    // Converts backslash literal character to a string.
+    line = 'c := \'\\\\\'';
+    const expected = 'c := "\\\\"';
+    assert.equal(element._workaround('go', line), expected);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
deleted file mode 100644
index e5ae06d..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.html
+++ /dev/null
@@ -1,110 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<dom-module id="gr-syntax-theme">
-  <template>
-    <style>
-      /**
-       * @overview Highlight.js emits the following classes that do not have
-       * styles here:
-       *    subst, symbol, class, function, doctag, meta-string, section, name,
-       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
-       *    attribute
-       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
-       */
-
-      .contentText {
-        color: var(--syntax-default-color);
-      }
-      .gr-syntax-attribute {
-        color: var(--syntax-attribute-color);
-      }
-      .gr-syntax-function {
-        color: var(--syntax-function-color);
-      }
-      .gr-syntax-meta {
-        color: var(--syntax-meta-color);
-      }
-      .gr-syntax-keyword,
-      .gr-syntax-name {
-        color: var(--syntax-keyword-color);
-      }
-      .gr-syntax-number {
-        color: var(--syntax-number-color);
-      }
-      .gr-syntax-selector-class {
-        color: var(--syntax-selector-class-color);
-      }
-      .gr-syntax-variable {
-        color: var(--syntax-variable-color);
-      }
-      .gr-syntax-template-variable {
-        color: var(--syntax-template-variable-color);
-      }
-      .gr-syntax-comment {
-        color: var(--syntax-comment-color);
-      }
-      .gr-syntax-string {
-        color: var(--syntax-string-color);
-      }
-      .gr-syntax-selector-id {
-        color: var(--syntax-selector-id-color);
-      }
-      .gr-syntax-built_in {
-        color: var(--syntax-built_in-color);
-      }
-      .gr-syntax-tag {
-        color: var(--syntax-tag-color);
-      }
-      .gr-syntax-link {
-        color: var(--syntax-link-color);
-      }
-      .gr-syntax-meta-keyword {
-        color: var(--syntax-meta-keyword-color);
-      }
-      .gr-syntax-type {
-        color: var(--syntax-type-color);
-      }
-      .gr-syntax-title {
-        color: var(--syntax-title-color);
-      }
-      .gr-syntax-attr {
-        color: var(--syntax-attr-color);
-      }
-      .gr-syntax-literal { /* XML/HTML Attribute */
-        color: var(--syntax-literal-color);
-      }
-      .gr-syntax-selector-pseudo {
-        color: var(--syntax-selector-pseudo-color);
-      }
-      .gr-syntax-regexp {
-        color: var(--syntax-regexp-color);
-      }
-      .gr-syntax-selector-attr {
-        color: var(--syntax-selector-attr-color);
-      }
-      .gr-syntax-template-tag {
-        color: var(--syntax-template-tag-color);
-      }
-      .gr-syntax-params {
-        color: var(--syntax-params-color);
-      }
-      .gr-syntax-doctag {
-        font-weight: var(--syntax-doctag-weight);
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
new file mode 100644
index 0000000..76a01de
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
+  <template>
+    <style>
+      /**
+       * @overview Highlight.js emits the following classes that do not have
+       * styles here:
+       *    subst, symbol, class, function, doctag, meta-string, section, name,
+       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
+       *    attribute
+       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
+       */
+
+      .contentText {
+        color: var(--syntax-default-color);
+      }
+      .gr-syntax-attribute {
+        color: var(--syntax-attribute-color);
+      }
+      .gr-syntax-function {
+        color: var(--syntax-function-color);
+      }
+      .gr-syntax-meta {
+        color: var(--syntax-meta-color);
+      }
+      .gr-syntax-keyword,
+      .gr-syntax-name {
+        color: var(--syntax-keyword-color);
+      }
+      .gr-syntax-number {
+        color: var(--syntax-number-color);
+      }
+      .gr-syntax-selector-class {
+        color: var(--syntax-selector-class-color);
+      }
+      .gr-syntax-variable {
+        color: var(--syntax-variable-color);
+      }
+      .gr-syntax-template-variable {
+        color: var(--syntax-template-variable-color);
+      }
+      .gr-syntax-comment {
+        color: var(--syntax-comment-color);
+      }
+      .gr-syntax-string {
+        color: var(--syntax-string-color);
+      }
+      .gr-syntax-selector-id {
+        color: var(--syntax-selector-id-color);
+      }
+      .gr-syntax-built_in {
+        color: var(--syntax-built_in-color);
+      }
+      .gr-syntax-tag {
+        color: var(--syntax-tag-color);
+      }
+      .gr-syntax-link {
+        color: var(--syntax-link-color);
+      }
+      .gr-syntax-meta-keyword {
+        color: var(--syntax-meta-keyword-color);
+      }
+      .gr-syntax-type {
+        color: var(--syntax-type-color);
+      }
+      .gr-syntax-title {
+        color: var(--syntax-title-color);
+      }
+      .gr-syntax-attr {
+        color: var(--syntax-attr-color);
+      }
+      .gr-syntax-literal { /* XML/HTML Attribute */
+        color: var(--syntax-literal-color);
+      }
+      .gr-syntax-selector-pseudo {
+        color: var(--syntax-selector-pseudo-color);
+      }
+      .gr-syntax-regexp {
+        color: var(--syntax-regexp-color);
+      }
+      .gr-syntax-selector-attr {
+        color: var(--syntax-selector-attr-color);
+      }
+      .gr-syntax-template-tag {
+        color: var(--syntax-template-tag-color);
+      }
+      .gr-syntax-params {
+        color: var(--syntax-params-color);
+      }
+      .gr-syntax-doctag {
+        font-weight: var(--syntax-doctag-weight);
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
deleted file mode 100644
index 5072b9d..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
+++ /dev/null
@@ -1,61 +0,0 @@
-<!--
-@license
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
-<link rel="import" href="../../../styles/gr-table-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-documentation-search">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
-    <gr-list-view
-        filter="[[_filter]]"
-        items=false
-        offset=0
-        loading="[[_loading]]"
-        path="[[_path]]">
-      <table id="list" class="genericList">
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="name topHeader"></th>
-          <th class="name topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-        <tbody class$="[[computeLoadingClass(_loading)]]">
-          <template is="dom-repeat" items="[[_documentationSearches]]">
-            <tr class="table">
-              <td class="name">
-                <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
-              </td>
-              <td></td>
-              <td></td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </gr-list-view>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-documentation-search.js"></script>
-</dom-module>
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..4d66699 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
@@ -14,16 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-documentation-search',
+import '../../../styles/gr-table-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-list-view/gr-list-view.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-documentation-search_html.js';
+import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
 
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
+/**
+ * @extends Polymer.Element
+ */
+class GrDocumentationSearch extends mixinBehaviors( [
+  ListViewBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-documentation-search'; }
+
+  static get properties() {
+    return {
+    /**
+     * URL params passed from the router.
+     */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -44,38 +64,38 @@
         type: String,
         value: '',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.ListViewBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this.dispatchEvent(
+        new CustomEvent('title-change', {title: 'Documentation Search'}));
+  }
 
-    attached() {
-      this.dispatchEvent(
-          new CustomEvent('title-change', {title: 'Documentation Search'}));
-    },
+  _paramsChanged(params) {
+    this._loading = true;
+    this._filter = this.getFilterValue(params);
 
-    _paramsChanged(params) {
-      this._loading = true;
-      this._filter = this.getFilterValue(params);
+    return this._getDocumentationSearches(this._filter);
+  }
 
-      return this._getDocumentationSearches(this._filter);
-    },
+  _getDocumentationSearches(filter) {
+    this._documentationSearches = [];
+    return this.$.restAPI.getDocumentationSearches(filter)
+        .then(searches => {
+          // Late response.
+          if (filter !== this._filter || !searches) { return; }
+          this._documentationSearches = searches;
+          this._loading = false;
+        });
+  }
 
-    _getDocumentationSearches(filter) {
-      this._documentationSearches = [];
-      return this.$.restAPI.getDocumentationSearches(filter)
-          .then(searches => {
-            // Late response.
-            if (filter !== this._filter || !searches) { return; }
-            this._documentationSearches = searches;
-            this._loading = false;
-          });
-    },
+  _computeSearchUrl(url) {
+    if (!url) { return ''; }
+    return this.getBaseUrl() + '/' + url;
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
new file mode 100644
index 0000000..b637b75
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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"
+    offset="0"
+    loading="[[_loading]]"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="name topHeader"></th>
+          <th class="name topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_documentationSearches]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
+            </td>
+            <td></td>
+            <td></td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..d0581ef 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
@@ -17,17 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<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-documentation-search.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,88 +32,93 @@
   </template>
 </test-fixture>
 
-<script>
-  let counter;
-  const documentationGenerator = () => {
-    return {
-      title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
-      url: 'Documentation/dev-rest-api.html',
-    };
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-documentation-search.js';
+import page from 'page/page.mjs';
+
+let counter;
+const documentationGenerator = () => {
+  return {
+    title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
+    url: 'Documentation/dev-rest-api.html',
   };
+};
 
-  suite('gr-documentation-search tests', () => {
-    let element;
-    let documentationSearches;
-    let sandbox;
-    let value;
+suite('gr-documentation-search tests', () => {
+  let element;
+  let documentationSearches;
+  let sandbox;
+  let value;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(page, 'show');
-      element = fixture('basic');
-      counter = 0;
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(page, 'show');
+    element = fixture('basic');
+    counter = 0;
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    suite('list with searches for documentation', () => {
-      setup(done => {
-        documentationSearches = _.times(26, documentationGenerator);
-        stub('gr-rest-api-interface', {
-          getDocumentationSearches() {
-            return Promise.resolve(documentationSearches);
-          },
-        });
-        element._paramsChanged(value).then(() => { flush(done); });
-      });
-
-      test('test for test repo in the list', done => {
-        flush(() => {
-          assert.equal(element._documentationSearches[0].title,
-              'Gerrit Code Review - REST API Developers Notes1');
-          assert.equal(element._documentationSearches[0].url,
-              'Documentation/dev-rest-api.html');
-          done();
-        });
-      });
-    });
-
-    suite('filter', () => {
-      setup(() => {
-        documentationSearches = _.times(25, documentationGenerator);
-        documentationSearchesFiltered = _.times(1, documentationSearches);
-      });
-
-      test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getDocumentationSearches', () => {
+  suite('list with searches for documentation', () => {
+    setup(done => {
+      documentationSearches = _.times(26, documentationGenerator);
+      stub('gr-rest-api-interface', {
+        getDocumentationSearches() {
           return Promise.resolve(documentationSearches);
-        });
-        const value = {
-          filter: 'test',
-        };
-        element._paramsChanged(value).then(() => {
-          assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
-              .calledWithExactly('test'));
-          done();
-        });
+        },
       });
+      element._paramsChanged(value).then(() => { flush(done); });
     });
 
-    suite('loading', () => {
-      test('correct contents are displayed', () => {
-        assert.isTrue(element._loading);
-        assert.equal(element.computeLoadingClass(element._loading), 'loading');
-        assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-        element._loading = false;
-        element._repos = _.times(25, documentationGenerator);
-
-        flushAsynchronousOperations();
-        assert.equal(element.computeLoadingClass(element._loading), '');
-        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    test('test for test repo in the list', done => {
+      flush(() => {
+        assert.equal(element._documentationSearches[0].title,
+            'Gerrit Code Review - REST API Developers Notes1');
+        assert.equal(element._documentationSearches[0].url,
+            'Documentation/dev-rest-api.html');
+        done();
       });
     });
   });
+
+  suite('filter', () => {
+    setup(() => {
+      documentationSearches = _.times(25, documentationGenerator);
+      _.times(1, documentationSearches);
+    });
+
+    test('_paramsChanged', done => {
+      sandbox.stub(
+          element.$.restAPI,
+          'getDocumentationSearches',
+          () => Promise.resolve(documentationSearches));
+      const value = {
+        filter: 'test',
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
+            .calledWithExactly('test'));
+        done();
+      });
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._repos = _.times(25, documentationGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
deleted file mode 100644
index 19a4e63..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.html
+++ /dev/null
@@ -1,45 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-default-editor">
-  <template>
-    <style include="shared-styles">
-      textarea {
-        border: none;
-        box-sizing: border-box;
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        line-height: var(--line-height-code);
-        min-height: 60vh;
-        resize: none;
-        white-space: pre;
-        width: 100%;
-      }
-      textarea:focus {
-        outline: none;
-      }
-    </style>
-    <textarea
-        id="textarea"
-        value="[[fileContent]]"
-        on-input="_handleTextareaInput"></textarea>
-  </template>
-  <script src="gr-default-editor.js"></script>
-</dom-module>
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..09f4abf 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
@@ -14,26 +14,38 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-default-editor',
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-default-editor_html.js';
 
-    /**
-     * Fired when the content of the editor changes.
-     *
-     * @event content-change
-     */
+/** @extends Polymer.Element */
+class GrDefaultEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-default-editor'; }
+  /**
+   * Fired when the content of the editor changes.
+   *
+   * @event content-change
+   */
+
+  static get properties() {
+    return {
       fileContent: String,
-    },
+    };
+  }
 
-    _handleTextareaInput(e) {
-      this.dispatchEvent(new CustomEvent(
-          'content-change',
-          {detail: {value: e.target.value}, bubbles: true, composed: true}));
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
new file mode 100644
index 0000000..7368289
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    textarea {
+      border: none;
+      box-sizing: border-box;
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: var(--line-height-code);
+      min-height: 60vh;
+      resize: none;
+      white-space: pre;
+      width: 100%;
+    }
+    textarea:focus {
+      outline: none;
+    }
+  </style>
+  <textarea
+    id="textarea"
+    value="[[fileContent]]"
+    on-input="_handleTextareaInput"
+  ></textarea>
+`;
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..229c6c3 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
@@ -16,17 +16,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-default-editor.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,25 +30,27 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-default-editor tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-default-editor.js';
+suite('gr-default-editor tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-      element.fileContent = '';
-    });
-
-    test('fires content-change event', done => {
-      const contentChangedHandler = e => {
-        assert.equal(e.detail.value, 'test');
-        done();
-      };
-      const textarea = element.$.textarea;
-      element.addEventListener('content-change', contentChangedHandler);
-      textarea.value = 'test';
-      textarea.dispatchEvent(new CustomEvent('input',
-          {target: textarea, bubbles: true, composed: true}));
-    });
+  setup(() => {
+    element = fixture('basic');
+    element.fileContent = '';
   });
+
+  test('fires content-change event', done => {
+    const contentChangedHandler = e => {
+      assert.equal(e.detail.value, 'test');
+      done();
+    };
+    const textarea = element.$.textarea;
+    element.addEventListener('content-change', contentChangedHandler);
+    textarea.value = 'test';
+    textarea.dispatchEvent(new CustomEvent('input',
+        {target: textarea, bubbles: true, composed: true}));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.html b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
deleted file mode 100644
index d526ccd..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.html
+++ /dev/null
@@ -1,33 +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>
-  (function(window) {
-    'use strict';
-
-    const GrEditConstants = window.GrEditConstants || {};
-
-    // Order corresponds to order in the UI.
-    GrEditConstants.Actions = {
-      OPEN: {label: 'Open', id: 'open'},
-      DELETE: {label: 'Delete', id: 'delete'},
-      RENAME: {label: 'Rename', id: 'rename'},
-      RESTORE: {label: 'Restore', id: 'restore'},
-    };
-
-    window.GrEditConstants = GrEditConstants;
-  })(window);
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.js b/polygerrit-ui/app/elements/edit/gr-edit-constants.js
new file mode 100644
index 0000000..7282a46
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.js
@@ -0,0 +1,27 @@
+/**
+ * @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.
+ */
+
+export const GrEditConstants = {
+// Order corresponds to order in the UI.
+  Actions: {
+    OPEN: {label: 'Add/Open/Upload', 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
deleted file mode 100644
index 52692a7..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
+++ /dev/null
@@ -1,171 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.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-edit-constants.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-edit-controls">
-  <template>
-    <style include="shared-styles">
-      :host {
-        align-items: center;
-        display: flex;
-        justify-content: flex-end;
-      }
-      .invisible {
-        display: none;
-      }
-      gr-button {
-        margin-left: var(--spacing-l);
-        text-decoration: none;
-      }
-      gr-dialog {
-        width: 50em;
-      }
-      gr-dialog .main {
-        width: 100%;
-      }
-      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);
-        width: 100%;
-      }
-      @media screen and (max-width: 50em) {
-        gr-dialog {
-          width: 100vw;
-        }
-      }
-    </style>
-    <template is="dom-repeat" items="[[_actions]]" as="action">
-      <gr-button
-          id$="[[action.id]]"
-          class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
-          link
-          on-click="_handleTap">[[action.label]]</gr-button>
-    </template>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-dialog
-          id="openDialog"
-          class="invisible dialog"
-          disabled$="[[!_isValidPath(_path)]]"
-          confirm-label="Open"
-          confirm-on-enter
-          on-confirm="_handleOpenConfirm"
-          on-cancel="_handleDialogCancel">
-        <div class="header" slot="header">
-          Open an existing or new file
-        </div>
-        <div class="main" slot="main">
-          <gr-autocomplete
-              placeholder="Enter an existing or new full file path."
-              query="[[_query]]"
-              text="{{_path}}"></gr-autocomplete>
-        </div>
-      </gr-dialog>
-      <gr-dialog
-          id="deleteDialog"
-          class="invisible dialog"
-          disabled$="[[!_isValidPath(_path)]]"
-          confirm-label="Delete"
-          confirm-on-enter
-          on-confirm="_handleDeleteConfirm"
-          on-cancel="_handleDialogCancel">
-        <div class="header" slot="header">Delete a file from the repo</div>
-        <div class="main" slot="main">
-          <gr-autocomplete
-              placeholder="Enter an existing full file path."
-              query="[[_query]]"
-              text="{{_path}}"></gr-autocomplete>
-        </div>
-      </gr-dialog>
-      <gr-dialog
-          id="renameDialog"
-          class="invisible dialog"
-          disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
-          confirm-label="Rename"
-          confirm-on-enter
-          on-confirm="_handleRenameConfirm"
-          on-cancel="_handleDialogCancel">
-        <div class="header" slot="header">Rename a file in the repo</div>
-        <div class="main" slot="main">
-          <gr-autocomplete
-              placeholder="Enter an existing full file path."
-              query="[[_query]]"
-              text="{{_path}}"></gr-autocomplete>
-          <iron-input
-              class="newPathIronInput"
-              bind-value="{{_newPath}}"
-              placeholder="Enter the new path.">
-            <input
-                class="newPathInput"
-                is="iron-input"
-                bind-value="{{_newPath}}"
-                placeholder="Enter the new path.">
-          </iron-input>
-        </div>
-      </gr-dialog>
-      <gr-dialog
-          id="restoreDialog"
-          class="invisible dialog"
-          confirm-label="Restore"
-          confirm-on-enter
-          on-confirm="_handleRestoreConfirm"
-          on-cancel="_handleDialogCancel">
-        <div class="header" slot="header">Restore this file?</div>
-        <div class="main" slot="main">
-          <iron-input
-              disabled
-              bind-value="{{_path}}">
-            <input
-                is="iron-input"
-                disabled
-                bind-value="{{_path}}">
-          </iron-input>
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-edit-controls.js"></script>
-</dom-module>
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..7ca849f 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
@@ -14,13 +14,40 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-edit-controls',
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dialog/gr-dialog.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-edit-controls_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {GrEditConstants} from '../gr-edit-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrEditControls extends mixinBehaviors( [
+  PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-edit-controls'; }
+
+  static get properties() {
+    return {
       change: Object,
       patchNum: String,
 
@@ -53,184 +80,228 @@
           return this._queryFiles.bind(this);
         },
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.PatchSetBehavior,
-    ],
+  _handleTap(e) {
+    e.preventDefault();
+    const action = dom(e).localTarget.id;
+    switch (action) {
+      case GrEditConstants.Actions.OPEN.id:
+        this.openOpenDialog();
+        return;
+      case GrEditConstants.Actions.DELETE.id:
+        this.openDeleteDialog();
+        return;
+      case GrEditConstants.Actions.RENAME.id:
+        this.openRenameDialog();
+        return;
+      case GrEditConstants.Actions.RESTORE.id:
+        this.openRestoreDialog();
+        return;
+    }
+  }
 
-    _handleTap(e) {
-      e.preventDefault();
-      const action = Polymer.dom(e).localTarget.id;
-      switch (action) {
-        case GrEditConstants.Actions.OPEN.id:
-          this.openOpenDialog();
-          return;
-        case GrEditConstants.Actions.DELETE.id:
-          this.openDeleteDialog();
-          return;
-        case GrEditConstants.Actions.RENAME.id:
-          this.openRenameDialog();
-          return;
-        case GrEditConstants.Actions.RESTORE.id:
-          this.openRestoreDialog();
-          return;
-      }
-    },
+  /**
+   * @param {string=} opt_path
+   */
+  openOpenDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.openDialog);
+  }
 
-    /**
-     * @param {string=} opt_path
-     */
-    openOpenDialog(opt_path) {
-      if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.openDialog);
-    },
+  /**
+   * @param {string=} opt_path
+   */
+  openDeleteDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.deleteDialog);
+  }
 
-    /**
-     * @param {string=} opt_path
-     */
-    openDeleteDialog(opt_path) {
-      if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.deleteDialog);
-    },
+  /**
+   * @param {string=} opt_path
+   */
+  openRenameDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.renameDialog);
+  }
 
-    /**
-     * @param {string=} opt_path
-     */
-    openRenameDialog(opt_path) {
-      if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.renameDialog);
-    },
+  /**
+   * @param {string=} opt_path
+   */
+  openRestoreDialog(opt_path) {
+    if (opt_path) { this._path = opt_path; }
+    return this._showDialog(this.$.restoreDialog);
+  }
 
-    /**
-     * @param {string=} opt_path
-     */
-    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.
+   *
+   * @param {string} path
+   * @return {boolean}
+   */
+  _isValidPath(path) {
+    // Double negation needed for strict boolean return type.
+    return !!path.length && !path.endsWith('/');
+  }
 
-    /**
-     * Given a path string, checks that it is a valid file path.
-     *
-     * @param {string} path
-     * @return {boolean}
-     */
-    _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);
+  }
 
-    _computeRenameDisabled(path, newPath) {
-      return this._isValidPath(path) && this._isValidPath(newPath);
-    },
+  /**
+   * Given a dom event, gets the dialog that lies along this event path.
+   *
+   * @param {!Event} e
+   * @return {!Element|undefined}
+   */
+  _getDialogFromEvent(e) {
+    return dom(e).path.find(element => {
+      if (!element.classList) { return false; }
+      return element.classList.contains('dialog');
+    });
+  }
 
-    /**
-     * Given a dom event, gets the dialog that lies along this event path.
-     *
-     * @param {!Event} e
-     * @return {!Element|undefined}
-     */
-    _getDialogFromEvent(e) {
-      return Polymer.dom(e).path.find(element => {
-        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
+    // ways (e.g. by clicking outside the dialog body). This call prevents
+    // multiple dialogs from being shown in the same overlay.
+    this._hideAllDialogs();
 
-    _showDialog(dialog) {
-      // Some dialogs may not fire their on-close event when closed in certain
-      // ways (e.g. by clicking outside the dialog body). This call prevents
-      // multiple dialogs from being shown in the same overlay.
-      this._hideAllDialogs();
+    return this.$.overlay.open().then(() => {
+      dialog.classList.toggle('invisible', false);
+      const autocomplete = dialog.querySelector('gr-autocomplete');
+      if (autocomplete) { autocomplete.focus(); }
+      this.async(() => { this.$.overlay.center(); }, 1);
+    });
+  }
 
-      return this.$.overlay.open().then(() => {
-        dialog.classList.toggle('invisible', false);
-        const autocomplete = dialog.querySelector('gr-autocomplete');
-        if (autocomplete) { autocomplete.focus(); }
-        this.async(() => { this.$.overlay.center(); }, 1);
-      });
-    },
+  _hideAllDialogs() {
+    const dialogs = dom(this.root).querySelectorAll('.dialog');
+    for (const dialog of dialogs) { this._closeDialog(dialog); }
+  }
 
-    _hideAllDialogs() {
-      const dialogs = Polymer.dom(this.root).querySelectorAll('.dialog');
-      for (const dialog of dialogs) { this._closeDialog(dialog); }
-    },
+  /**
+   * @param {Element|undefined} dialog
+   * @param {boolean=} clearInputs
+   */
+  _closeDialog(dialog, clearInputs) {
+    if (!dialog) { return; }
 
-    /**
-     * @param {Element|undefined} dialog
-     * @param {boolean=} clearInputs
-     */
-    _closeDialog(dialog, clearInputs) {
-      if (!dialog) { return; }
+    if (clearInputs) {
+      // Dialog may have autocompletes and plain inputs -- as these have
+      // different properties representing their bound text, it is easier to
+      // just make two separate queries.
+      dialog.querySelectorAll('gr-autocomplete')
+          .forEach(input => { input.text = ''; });
 
-      if (clearInputs) {
-        // Dialog may have autocompletes and plain inputs -- as these have
-        // different properties representing their bound text, it is easier to
-        // just make two separate queries.
-        dialog.querySelectorAll('gr-autocomplete')
-            .forEach(input => { input.text = ''; });
+      dialog.querySelectorAll('iron-input')
+          .forEach(input => { input.bindValue = ''; });
+    }
 
-        dialog.querySelectorAll('iron-input')
-            .forEach(input => { input.bindValue = ''; });
+    dialog.classList.toggle('invisible', true);
+    return this.$.overlay.close();
+  }
+
+  _handleDialogCancel(e) {
+    this._closeDialog(this._getDialogFromEvent(e));
+  }
+
+  _handleOpenConfirm(e) {
+    const url = GerritNav.getEditUrlForDiff(this.change, this._path,
+        this.patchNum);
+    GerritNav.navigateToRelativeUrl(url);
+    this._closeDialog(this._getDialogFromEvent(e), true);
+  }
+
+  _handleUploadConfirm(path, fileData) {
+    if (!this.change || !path || !fileData) {
+      this._closeDialog(this.$.openDialog, true);
+      return;
+    }
+    return this.$.restAPI.saveFileUploadChangeEdit(this.change._number, path,
+        fileData).then(res => {
+      if (!res.ok) { return; }
+      this._closeDialog(this.$.openDialog, true);
+      GerritNav.navigateToChange(this.change);
+    });
+  }
+
+  _handleDeleteConfirm(e) {
+    // Get the dialog before the api call as the event will change during bubbling
+    // which will make Polymer.dom(e).path an emtpy array in polymer 2
+    const dialog = this._getDialogFromEvent(e);
+    this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
+        .then(res => {
+          if (!res.ok) { return; }
+          this._closeDialog(dialog, true);
+          GerritNav.navigateToChange(this.change);
+        });
+  }
+
+  _handleRestoreConfirm(e) {
+    const dialog = this._getDialogFromEvent(e);
+    this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
+        .then(res => {
+          if (!res.ok) { return; }
+          this._closeDialog(dialog, true);
+          GerritNav.navigateToChange(this.change);
+        });
+  }
+
+  _handleRenameConfirm(e) {
+    const dialog = this._getDialogFromEvent(e);
+    return this.$.restAPI.renameFileInChangeEdit(this.change._number,
+        this._path, this._newPath).then(res => {
+      if (!res.ok) { return; }
+      this._closeDialog(dialog, true);
+      GerritNav.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' : '';
+  }
+
+  _handleDragAndDropUpload(event) {
+    // We prevent the default clicking.
+    event.preventDefault();
+    event.stopPropagation();
+
+    this._fileUpload(event);
+  }
+
+  _handleFileUploadChanged(event) {
+    this._fileUpload(event);
+  }
+
+  _fileUpload(event) {
+    const e = event.target.files || event.dataTransfer.files;
+    for (const file of e) {
+      if (!file) continue;
+
+      let path = this._path;
+      if (!path) {
+        path = file.name;
       }
 
-      dialog.classList.toggle('invisible', true);
-      return this.$.overlay.close();
-    },
+      const fr = new FileReader();
+      fr.file = file;
+      fr.onload = fileLoadEvent => {
+        if (!fileLoadEvent) return;
+        const fileData = fileLoadEvent.target.result;
+        this._handleUploadConfirm(path, fileData);
+      };
+      fr.readAsDataURL(file);
+    }
+  }
+}
 
-    _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
-      // which will make Polymer.dom(e).path an emtpy array in polymer 2
-      const dialog = this._getDialogFromEvent(e);
-      this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
-          .then(res => {
-            if (!res.ok) { return; }
-            this._closeDialog(dialog, true);
-            Gerrit.Nav.navigateToChange(this.change);
-          });
-    },
-
-    _handleRestoreConfirm(e) {
-      const dialog = this._getDialogFromEvent(e);
-      this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
-          .then(res => {
-            if (!res.ok) { return; }
-            this._closeDialog(dialog, true);
-            Gerrit.Nav.navigateToChange(this.change);
-          });
-    },
-
-    _handleRenameConfirm(e) {
-      const dialog = this._getDialogFromEvent(e);
-      return this.$.restAPI.renameFileInChangeEdit(this.change._number,
-          this._path, this._newPath).then(res => {
-        if (!res.ok) { return; }
-        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_html.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
new file mode 100644
index 0000000..02639c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: flex;
+      justify-content: flex-end;
+    }
+    .invisible {
+      display: none;
+    }
+    gr-button {
+      margin-left: var(--spacing-l);
+      text-decoration: none;
+    }
+    gr-dialog {
+      width: 50em;
+    }
+    gr-dialog .main {
+      width: 100%;
+    }
+    gr-dialog .main > iron-input {
+      width: 100%;
+    }
+    input {
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin: var(--spacing-m) 0;
+      padding: var(--spacing-s);
+      width: 100%;
+      box-sizing: content-box;
+    }
+    #fileUploadBrowse {
+      margin-left: 0;
+    }
+    #dragDropArea {
+      border: 2px dashed var(--border-color);
+      border-radius: var(--border-radius);
+      margin-top: var(--spacing-l);
+      padding: var(--spacing-xxl) var(--spacing-xxl);
+      text-align: center;
+    }
+    #dragDropArea > p {
+      font-weight: var(--font-weight-bold);
+      padding: var(--spacing-s);
+    }
+    @media screen and (max-width: 50em) {
+      gr-dialog {
+        width: 100vw;
+      }
+    }
+  </style>
+  <template is="dom-repeat" items="[[_actions]]" as="action">
+    <gr-button
+      id$="[[action.id]]"
+      class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
+      link=""
+      on-click="_handleTap"
+      >[[action.label]]</gr-button
+    >
+  </template>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-dialog
+      id="openDialog"
+      class="invisible dialog"
+      disabled$="[[!_isValidPath(_path)]]"
+      confirm-label="Confirm"
+      confirm-on-enter=""
+      on-confirm="_handleOpenConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">
+        Add a new file or open an existing file
+      </div>
+      <div class="main" slot="main">
+        <gr-autocomplete
+          placeholder="Enter an existing or new full file path."
+          query="[[_query]]"
+          text="{{_path}}"
+        ></gr-autocomplete>
+        <div id="dragDropArea" on-drop="_handleDragAndDropUpload">
+          <p>Drag and drop a file here</p>
+          <p>or</p>
+          <p>
+            <iron-input>
+              <input
+                is="iron-input"
+                id="fileUploadInput"
+                type="file"
+                on-change="_handleFileUploadChanged"
+                hidden
+              />
+            </iron-input>
+            <label for="fileUploadInput">
+              <gr-button id="fileUploadBrowse">Browse</gr-button>
+            </label>
+          </p>
+        </div>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="deleteDialog"
+      class="invisible dialog"
+      disabled$="[[!_isValidPath(_path)]]"
+      confirm-label="Delete"
+      confirm-on-enter=""
+      on-confirm="_handleDeleteConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">Delete a file from the repo</div>
+      <div class="main" slot="main">
+        <gr-autocomplete
+          placeholder="Enter an existing full file path."
+          query="[[_query]]"
+          text="{{_path}}"
+        ></gr-autocomplete>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="renameDialog"
+      class="invisible dialog"
+      disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
+      confirm-label="Rename"
+      confirm-on-enter=""
+      on-confirm="_handleRenameConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">Rename a file in the repo</div>
+      <div class="main" slot="main">
+        <gr-autocomplete
+          placeholder="Enter an existing full file path."
+          query="[[_query]]"
+          text="{{_path}}"
+        ></gr-autocomplete>
+        <iron-input
+          class="newPathIronInput"
+          bind-value="{{_newPath}}"
+          placeholder="Enter the new path."
+        >
+          <input
+            class="newPathInput"
+            is="iron-input"
+            bind-value="{{_newPath}}"
+            placeholder="Enter the new path."
+          />
+        </iron-input>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="restoreDialog"
+      class="invisible dialog"
+      confirm-label="Restore"
+      confirm-on-enter=""
+      on-confirm="_handleRestoreConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">Restore this file?</div>
+      <div class="main" slot="main">
+        <iron-input disabled="" bind-value="{{_path}}">
+          <input is="iron-input" disabled="" bind-value="{{_path}}" />
+        </iron-input>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..1267525 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
@@ -16,17 +16,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-edit-controls.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,7 +30,13 @@
   </template>
 </test-fixture>
 
-<script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-edit-controls.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
 suite('gr-edit-controls tests', () => {
   let element;
   let sandbox;
@@ -57,7 +59,10 @@
   teardown(() => { sandbox.restore(); });
 
   test('all actions exist', () => {
-    assert.equal(Polymer.dom(element.root).querySelectorAll('gr-button').length,
+    // We take 1 away from the total found, due to an extra button being
+    // added for the file uploads (browse).
+    assert.equal(
+        dom(element.root).querySelectorAll('gr-button').length - 1,
         element._actions.length);
   });
 
@@ -67,8 +72,8 @@
 
     setup(() => {
       navStubs = [
-        sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff'),
-        sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl'),
+        sandbox.stub(GerritNav, 'getEditUrlForDiff'),
+        sandbox.stub(GerritNav, 'navigateToRelativeUrl'),
       ];
       openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
     });
@@ -82,32 +87,37 @@
     });
 
     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);
         assert.isTrue(element.$.openDialog.disabled);
         assert.isFalse(queryStub.called);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        openAutoCcmplete._focused = true;
         openAutoCcmplete.noDebounce = true;
         openAutoCcmplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.$$('gr-button[primary]'));
+        MockInteractions.tap(element.$.openDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
         for (const stub of navStubs) { assert.isTrue(stub.called); }
-        assert.deepEqual(Gerrit.Nav.getEditUrlForDiff.lastCall.args,
+        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
             [element.change, 'src/test.cpp', element.patchNum]);
         assert.isTrue(closeDialogSpy.called);
       });
     });
 
     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;
         openAutoCcmplete.text = 'src/test.cpp';
         assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.$$('gr-button'));
+        MockInteractions.tap(element.$.openDialog.shadowRoot
+            .querySelector('gr-button'));
         for (const stub of navStubs) { assert.isFalse(stub.called); }
         assert.isTrue(closeDialogSpy.called);
         assert.equal(element._path, 'src/test.cpp');
@@ -121,7 +131,7 @@
     let deleteAutocomplete;
 
     setup(() => {
-      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      navStub = sandbox.stub(GerritNav, 'navigateToChange');
       deleteStub = sandbox.stub(element.$.restAPI, 'deleteFileInChangeEdit');
       deleteAutocomplete =
           element.$.deleteDialog.querySelector('gr-autocomplete');
@@ -129,15 +139,19 @@
 
     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);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        deleteAutocomplete._focused = true;
         deleteAutocomplete.noDebounce = true;
         deleteAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.$$('gr-button[primary]'));
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.isTrue(deleteStub.called);
@@ -152,15 +166,19 @@
 
     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);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        deleteAutocomplete._focused = true;
         deleteAutocomplete.noDebounce = true;
         deleteAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.$$('gr-button[primary]'));
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.isTrue(deleteStub.called);
@@ -173,13 +191,14 @@
     });
 
     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 =
             'src/test.cpp';
         assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.$$('gr-button'));
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button'));
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
         assert.equal(element._path, 'src/test.cpp');
@@ -191,12 +210,12 @@
     let navStub;
     let renameStub;
     let renameAutocomplete;
-    const inputSelector = Polymer.Element ?
+    const inputSelector = PolymerElement ?
       '.newPathIronInput' :
       '.newPathInput';
 
     setup(() => {
-      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      navStub = sandbox.stub(GerritNav, 'navigateToChange');
       renameStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
       renameAutocomplete =
           element.$.renameDialog.querySelector('gr-autocomplete');
@@ -204,10 +223,13 @@
 
     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);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        renameAutocomplete._focused = true;
         renameAutocomplete.noDebounce = true;
         renameAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
@@ -217,7 +239,8 @@
             'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.$$('gr-button[primary]'));
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.isTrue(renameStub.called);
@@ -232,10 +255,13 @@
 
     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);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        renameAutocomplete._focused = true;
         renameAutocomplete.noDebounce = true;
         renameAutocomplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
@@ -245,7 +271,8 @@
             'src/test.newPath';
 
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.$$('gr-button[primary]'));
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.isTrue(renameStub.called);
@@ -258,7 +285,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 =
@@ -266,7 +293,8 @@
         element.$.renameDialog.querySelector(inputSelector).bindValue =
             'src/test.newPath';
         assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.$$('gr-button'));
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button'));
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
         assert.equal(element._path, 'src/test.cpp');
@@ -280,20 +308,22 @@
     let restoreStub;
 
     setup(() => {
-      navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      navStub = sandbox.stub(GerritNav, 'navigateToChange');
       restoreStub = sandbox.stub(element.$.restAPI, 'restoreFileInChangeEdit');
     });
 
     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]'));
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.isTrue(restoreStub.called);
@@ -309,9 +339,10 @@
     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]'));
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.isTrue(restoreStub.called);
@@ -325,9 +356,10 @@
 
     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'));
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button'));
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
         assert.equal(element._path, 'src/test.cpp');
@@ -335,12 +367,45 @@
     });
   });
 
-  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');
+  suite('save file upload', () => {
+    let navStub;
+    let fileStub;
+
+    setup(() => {
+      navStub = sandbox.stub(GerritNav, 'navigateToChange');
+      fileStub = sandbox.stub(element.$.restAPI, 'saveFileUploadChangeEdit');
     });
+
+    test('_handleUploadConfirm', () => {
+      fileStub.returns(Promise.resolve({ok: true}));
+
+      element.change = {
+        _number: '1',
+        project: 'project',
+        revisions: {
+          abcd: {_number: 1},
+          efgh: {_number: 2},
+        },
+        current_revision: 'efgh',
+      };
+
+      element._handleUploadConfirm('test.php', 'base64').then(() => {
+        assert.equal(
+            navStub.lastCall.args,
+            '/c/project/+/1');
+      });
+    });
+  });
+
+  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.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
deleted file mode 100644
index f6c7803..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.html
+++ /dev/null
@@ -1,61 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
-<link rel="import" href="../gr-edit-constants.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-edit-file-controls">
-  <template>
-    <style include="shared-styles">
-      :host {
-        align-items: center;
-        display: flex;
-        justify-content: flex-end;
-      }
-      #actions {
-        margin-right: var(--spacing-l);
-      }
-      gr-button,
-      gr-dropdown {
-        --gr-button: {
-          height: 1.8em;
-        }
-      }
-      gr-dropdown {
-        --gr-dropdown-item: {
-          background-color: transparent;
-          border: none;
-          color: var(--link-color);
-          text-transform: uppercase;
-        }
-      }
-    </style>
-    <gr-dropdown
-        id="actions"
-        items="[[_fileActions]]"
-        down-arrow
-        vertical-offset="20"
-        on-tap-item="_handleActionTap"
-        link>Actions</gr-dropdown>
-  </template>
-  <script src="gr-edit-file-controls.js"></script>
-</dom-module>
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..8a24e23 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
@@ -14,19 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-edit-file-controls',
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-dropdown/gr-dropdown.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-edit-file-controls_html.js';
+import {GrEditConstants} from '../gr-edit-constants.js';
 
-    /**
-     * Fired when an action in the overflow menu is tapped.
-     *
-     * @event file-action-tap
-     */
+/** @extends Polymer.Element */
+class GrEditFileControls extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-edit-file-controls'; }
+  /**
+   * Fired when an action in the overflow menu is tapped.
+   *
+   * @event file-action-tap
+   */
+
+  static get properties() {
+    return {
       filePath: String,
       _allFileActions: {
         type: Array,
@@ -36,28 +49,30 @@
         type: Array,
         computed: '_computeFileActions(_allFileActions)',
       },
-    },
+    };
+  }
 
-    _handleActionTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this._dispatchFileAction(e.detail.id, this.filePath);
-    },
+  _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}));
-    },
+  _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.
-      return actions.map(action => {
-        return {
-          name: action.label,
-          id: action.id,
-        };
-      });
-    },
-  });
-})();
+  _computeFileActions(actions) {
+    // TODO(kaspern): conditionally disable some actions based on file status.
+    return actions.map(action => {
+      return {
+        name: action.label,
+        id: action.id,
+      };
+    });
+  }
+}
+
+customElements.define(GrEditFileControls.is, GrEditFileControls);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
new file mode 100644
index 0000000..ec0b8b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: flex;
+      justify-content: flex-end;
+    }
+    #actions {
+      margin-right: var(--spacing-l);
+    }
+    gr-button,
+    gr-dropdown {
+      --gr-button: {
+        height: 1.8em;
+      }
+    }
+    gr-dropdown {
+      --gr-dropdown-item: {
+        background-color: transparent;
+        border: none;
+        color: var(--link-color);
+        text-transform: uppercase;
+      }
+    }
+  </style>
+  <gr-dropdown
+    id="actions"
+    items="[[_fileActions]]"
+    down-arrow=""
+    vertical-offset="20"
+    on-tap-item="_handleActionTap"
+    link=""
+    >Actions</gr-dropdown
+  >
+`;
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..e11a2bd 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
@@ -16,18 +16,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../gr-edit-constants.html">
-<link rel="import" href="gr-edit-file-controls.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,7 +30,12 @@
   </template>
 </test-fixture>
 
-<script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-edit-constants.js';
+import './gr-edit-file-controls.js';
+import {GrEditConstants} from '../gr-edit-constants.js';
+
 suite('gr-edit-file-controls tests', () => {
   let element;
   let sandbox;
@@ -56,7 +56,8 @@
     actions._open();
     flushAsynchronousOperations();
 
-    MockInteractions.tap(actions.$$('li [data-id="open"]'));
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="open"]'));
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
         {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
@@ -68,7 +69,8 @@
     actions._open();
     flushAsynchronousOperations();
 
-    MockInteractions.tap(actions.$$('li [data-id="delete"]'));
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="delete"]'));
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
         {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
@@ -80,7 +82,8 @@
     actions._open();
     flushAsynchronousOperations();
 
-    MockInteractions.tap(actions.$$('li [data-id="restore"]'));
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="restore"]'));
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
         {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
@@ -92,7 +95,8 @@
     actions._open();
     flushAsynchronousOperations();
 
-    MockInteractions.tap(actions.$$('li [data-id="rename"]'));
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="rename"]'));
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
         {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
deleted file mode 100644
index ce90c69..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
+++ /dev/null
@@ -1,128 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
-<link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../gr-default-editor/gr-default-editor.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-editor-view">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--view-background-color);
-      }
-      gr-fixed-panel {
-        background-color: var(--edit-mode-background-color);
-        border-bottom: 1px var(--border-color) solid;
-        z-index: 1;
-      }
-      header,
-      .subHeader {
-        align-items: center;
-        display: flex;
-        justify-content: space-between;
-        padding: var(--spacing-m) var(--spacing-l);
-      }
-      header gr-editable-label {
-        font-size: var(--font-size-h3);
-        --label-style: {
-          text-overflow: initial;
-          white-space: initial;
-          word-break: break-all;
-        }
-        --input-style: {
-          margin-top: var(--spacing-l);
-        }
-      }
-      .textareaWrapper {
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        margin: var(--spacing-l);
-      }
-      .textareaWrapper .editButtons {
-        display: none;
-      }
-      .controlGroup {
-        align-items: center;
-        display: flex;
-        font-size: var(--font-size-h3);
-      }
-      .rightControls {
-        justify-content: flex-end;
-      }
-      @media screen and (max-width: 50em) {
-        header,
-        .subHeader {
-          display: block;
-        }
-        .rightControls {
-          float: right;
-        }
-      }
-    </style>
-    <gr-fixed-panel keep-on-scroll>
-      <header>
-        <span class="controlGroup">
-          <span>Edit mode</span>
-          <span class="separator"></span>
-          <gr-editable-label
-              label-text="File path"
-              value="[[_path]]"
-              placeholder="File path..."
-              on-changed="_handlePathChanged"></gr-editable-label>
-        </span>
-        <span class="controlGroup rightControls">
-          <gr-button
-              id="close"
-              link
-              on-click="_handleCloseTap">Close</gr-button>
-          <gr-button
-              id="save"
-              disabled$="[[_saveDisabled]]"
-              primary
-              link
-              on-click="_saveEdit">Save</gr-button>
-        </span>
-      </header>
-    </gr-fixed-panel>
-    <div class="textareaWrapper">
-      <gr-endpoint-decorator id="editorEndpoint" name="editor">
-        <gr-endpoint-param name="fileContent" value="[[_newContent]]"></gr-endpoint-param>
-        <gr-endpoint-param name="prefs" value="[[_prefs]]"></gr-endpoint-param>
-        <gr-endpoint-param name="fileType" value="[[_type]]"></gr-endpoint-param>
-        <gr-endpoint-param name="lineNum" value="[[_lineNum]]"></gr-endpoint-param>
-        <gr-default-editor id="file" file-content="[[_newContent]]"></gr-default-editor>
-      </gr-endpoint-decorator>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-editor-view.js"></script>
-</dom-module>
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 a21975d..d2ffa56 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
@@ -14,35 +14,64 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const RESTORED_MESSAGE = 'Content restored from a previous edit.';
-  const SAVING_MESSAGE = 'Saving changes...';
-  const SAVED_MESSAGE = 'All changes saved';
-  const SAVE_FAILED_MSG = 'Failed to save changes';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-editable-label/gr-editable-label.js';
+import '../../shared/gr-fixed-panel/gr-fixed-panel.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-storage/gr-storage.js';
+import '../gr-default-editor/gr-default-editor.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-editor-view_html.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
+const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+const SAVING_MESSAGE = 'Saving changes...';
+const SAVED_MESSAGE = 'All changes saved';
+const SAVE_FAILED_MSG = 'Failed to save changes';
 
-  Polymer({
-    is: 'gr-editor-view',
+const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
+/**
+ * @extends Polymer.Element
+ */
+class GrEditorView extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+  PatchSetBehavior,
+  PathListBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-editor-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired to notify the user of
+   *
+   * @event show-alert
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
+     * URL params passed from the router.
      */
-
-    /**
-     * Fired to notify the user of
-     *
-     * @event show-alert
-     */
-
-    properties: {
-      /**
-       * URL params passed from the router.
-       */
       params: {
         type: Object,
         observer: '_paramsChanged',
@@ -71,183 +100,188 @@
       },
       _prefs: Object,
       _lineNum: Number,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.PathListBehavior,
-    ],
-
-    listeners: {
-      'content-change': '_handleContentChange',
-    },
-
-    keyBindings: {
+  get keyBindings() {
+    return {
       'ctrl+s meta+s': '_handleSaveShortcut',
-    },
+    };
+  }
 
-    attached() {
-      this._getEditPrefs().then(prefs => { this._prefs = prefs; });
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('content-change',
+        e => this._handleContentChange(e));
+  }
 
-    get storageKey() {
-      return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this._getEditPrefs().then(prefs => { this._prefs = prefs; });
+  }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
+  get storageKey() {
+    return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
+  }
 
-    _getEditPrefs() {
-      return this.$.restAPI.getEditPreferences();
-    },
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
 
-    _paramsChanged(value) {
-      if (value.view !== Gerrit.Nav.View.EDIT) {
-        return;
-      }
+  _getEditPrefs() {
+    return this.$.restAPI.getEditPreferences();
+  }
 
-      this._changeNum = value.changeNum;
-      this._path = value.path;
-      this._patchNum = value.patchNum || this.EDIT_NAME;
-      this._lineNum = value.lineNum;
+  _paramsChanged(value) {
+    if (value.view !== GerritNav.View.EDIT) {
+      return;
+    }
 
-      // NOTE: This may be called before attachment (e.g. while parentElement is
-      // null). Fire title-change in an async so that, if attachment to the DOM
-      // has been queued, the event can bubble up to the handler in gr-app.
-      this.async(() => {
-        const title = `Editing ${this.computeTruncatedPath(this._path)}`;
-        this.fire('title-change', {title});
-      });
+    this._changeNum = value.changeNum;
+    this._path = value.path;
+    this._patchNum = value.patchNum || this.EDIT_NAME;
+    this._lineNum = value.lineNum;
 
-      const promises = [];
-
-      promises.push(this._getChangeDetail(this._changeNum));
-      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;
-      if (path === this._path) {
-        return Promise.resolve();
-      }
-      return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
-          this._path, path).then(res => {
-        if (!res.ok) { return; }
-
-        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 =
-            this.$.storage.getEditableContentItem(this.storageKey);
-
-      return this.$.restAPI.getFileContent(changeNum, path, patchNum)
-          .then(res => {
-            if (storedContent && storedContent.message &&
-                storedContent.message !== res.content) {
-              this.dispatchEvent(new CustomEvent('show-alert', {
-                detail: {message: RESTORED_MESSAGE},
-                bubbles: true,
-                composed: true,
-              }));
-
-              this._newContent = storedContent.message;
-            } else {
-              this._newContent = res.content || '';
-            }
-            this._content = res.content || '';
-
-            // A non-ok response may result if the file does not yet exist.
-            // The `type` field of the response is only valid when the file
-            // already exists.
-            if (res.ok && res.type) {
-              this._type = res.type;
-            } else {
-              this._type = '';
-            }
-          });
-    },
-
-    _saveEdit() {
-      this._saving = true;
-      this._showAlert(SAVING_MESSAGE);
-      this.$.storage.eraseEditableContentItem(this.storageKey);
-      return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
-          this._newContent).then(res => {
-        this._saving = false;
-        this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
-        if (!res.ok) { return; }
-
-        this._content = this._newContent;
-        this._successfulSave = true;
-      });
-    },
-
-    _showAlert(message) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
+    // NOTE: This may be called before attachment (e.g. while parentElement is
+    // null). Fire title-change in an async so that, if attachment to the DOM
+    // has been queued, the event can bubble up to the handler in gr-app.
+    this.async(() => {
+      const title = `Editing ${this.computeTruncatedPath(this._path)}`;
+      this.dispatchEvent(new CustomEvent('title-change', {
+        detail: {title},
+        composed: true, bubbles: true,
       }));
-    },
+    });
 
-    _computeSaveDisabled(content, newContent, saving) {
-      // Polymer 2: check for undefined
-      if ([
-        content,
-        newContent,
-        saving,
-      ].some(arg => arg === undefined)) {
-        return true;
-      }
+    const promises = [];
 
-      if (saving) {
-        return true;
-      }
-      return content === newContent;
-    },
+    promises.push(this._getChangeDetail(this._changeNum));
+    promises.push(
+        this._getFileData(this._changeNum, this._path, this._patchNum));
+    return Promise.all(promises);
+  }
 
-    _handleCloseTap() {
-      // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+  _getChangeDetail(changeNum) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      this._change = change;
+    });
+  }
+
+  _handlePathChanged(e) {
+    const path = e.detail;
+    if (path === this._path) {
+      return Promise.resolve();
+    }
+    return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
+        this._path, path).then(res => {
+      if (!res.ok) { return; }
+
+      this._successfulSave = true;
       this._viewEditInChangeView();
-    },
+    });
+  }
 
-    _handleContentChange(e) {
-      this.debounce('store', () => {
-        const content = e.detail.value;
-        if (content) {
-          this.set('_newContent', e.detail.value);
-          this.$.storage.setEditableContentItem(this.storageKey, content);
-        } else {
-          this.$.storage.eraseEditableContentItem(this.storageKey);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    },
+  _viewEditInChangeView() {
+    const patch = this._successfulSave ? this.EDIT_NAME : this._patchNum;
+    GerritNav.navigateToChange(this._change, patch, null,
+        patch !== this.EDIT_NAME);
+  }
 
-    _handleSaveShortcut(e) {
-      e.preventDefault();
-      if (!this._saveDisabled) {
-        this._saveEdit();
+  _getFileData(changeNum, path, patchNum) {
+    const storedContent =
+          this.$.storage.getEditableContentItem(this.storageKey);
+
+    return this.$.restAPI.getFileContent(changeNum, path, patchNum)
+        .then(res => {
+          if (storedContent && storedContent.message &&
+              storedContent.message !== res.content) {
+            this.dispatchEvent(new CustomEvent('show-alert', {
+              detail: {message: RESTORED_MESSAGE},
+              bubbles: true,
+              composed: true,
+            }));
+
+            this._newContent = storedContent.message;
+          } else {
+            this._newContent = res.content || '';
+          }
+          this._content = res.content || '';
+
+          // A non-ok response may result if the file does not yet exist.
+          // The `type` field of the response is only valid when the file
+          // already exists.
+          if (res.ok && res.type) {
+            this._type = res.type;
+          } else {
+            this._type = '';
+          }
+        });
+  }
+
+  _saveEdit() {
+    this._saving = true;
+    this._showAlert(SAVING_MESSAGE);
+    this.$.storage.eraseEditableContentItem(this.storageKey);
+    return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
+        this._newContent).then(res => {
+      this._saving = false;
+      this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
+      if (!res.ok) { return; }
+
+      this._content = this._newContent;
+      this._successfulSave = true;
+    });
+  }
+
+  _showAlert(message) {
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {message},
+      bubbles: true,
+      composed: true,
+    }));
+  }
+
+  _computeSaveDisabled(content, newContent, saving) {
+    // Polymer 2: check for undefined
+    if ([
+      content,
+      newContent,
+      saving,
+    ].some(arg => arg === undefined)) {
+      return true;
+    }
+
+    if (saving) {
+      return true;
+    }
+    return content === newContent;
+  }
+
+  _handleCloseTap() {
+    // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+    this._viewEditInChangeView();
+  }
+
+  _handleContentChange(e) {
+    this.debounce('store', () => {
+      const content = e.detail.value;
+      if (content) {
+        this.set('_newContent', e.detail.value);
+        this.$.storage.setEditableContentItem(this.storageKey, content);
+      } else {
+        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_html.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
new file mode 100644
index 0000000..9dc35b2
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--view-background-color);
+    }
+    gr-fixed-panel {
+      background-color: var(--edit-mode-background-color);
+      border-bottom: 1px var(--border-color) solid;
+      z-index: 1;
+    }
+    header,
+    .subHeader {
+      align-items: center;
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    header gr-editable-label {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+      --label-style: {
+        text-overflow: initial;
+        white-space: initial;
+        word-break: break-all;
+      }
+      --input-style: {
+        margin-top: var(--spacing-l);
+      }
+    }
+    .textareaWrapper {
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin: var(--spacing-l);
+    }
+    .textareaWrapper .editButtons {
+      display: none;
+    }
+    .controlGroup {
+      align-items: center;
+      display: flex;
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+    }
+    .rightControls {
+      justify-content: flex-end;
+    }
+    @media screen and (max-width: 50em) {
+      header,
+      .subHeader {
+        display: block;
+      }
+      .rightControls {
+        float: right;
+      }
+    }
+  </style>
+  <gr-fixed-panel keep-on-scroll="">
+    <header>
+      <span class="controlGroup">
+        <span>Edit mode</span>
+        <span class="separator"></span>
+        <gr-editable-label
+          label-text="File path"
+          value="[[_path]]"
+          placeholder="File path..."
+          on-changed="_handlePathChanged"
+        ></gr-editable-label>
+      </span>
+      <span class="controlGroup rightControls">
+        <gr-button id="close" link="" on-click="_handleCloseTap"
+          >Close</gr-button
+        >
+        <gr-button
+          id="save"
+          disabled$="[[_saveDisabled]]"
+          primary=""
+          link=""
+          on-click="_saveEdit"
+          >Save</gr-button
+        >
+      </span>
+    </header>
+  </gr-fixed-panel>
+  <div class="textareaWrapper">
+    <gr-endpoint-decorator id="editorEndpoint" name="editor">
+      <gr-endpoint-param
+        name="fileContent"
+        value="[[_newContent]]"
+      ></gr-endpoint-param>
+      <gr-endpoint-param name="prefs" value="[[_prefs]]"></gr-endpoint-param>
+      <gr-endpoint-param name="fileType" value="[[_type]]"></gr-endpoint-param>
+      <gr-endpoint-param
+        name="lineNum"
+        value="[[_lineNum]]"
+      ></gr-endpoint-param>
+      <gr-default-editor
+        id="file"
+        file-content="[[_newContent]]"
+      ></gr-default-editor>
+    </gr-endpoint-decorator>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+`;
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..e385854 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
@@ -16,17 +16,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-editor-view.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,7 +30,11 @@
   </template>
 </test-fixture>
 
-<script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-editor-view.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
 suite('gr-editor-view tests', () => {
   let element;
   let sandbox;
@@ -66,7 +66,7 @@
   suite('_paramsChanged', () => {
     test('incorrect view returns immediately', () => {
       element._paramsChanged(
-          Object.assign({}, mockParams, {view: Gerrit.Nav.View.DIFF}));
+          Object.assign({}, mockParams, {view: GerritNav.View.DIFF}));
       assert.notOk(element._changeNum);
     });
 
@@ -79,7 +79,7 @@
       });
 
       const promises = element._paramsChanged(
-          Object.assign({}, mockParams, {view: Gerrit.Nav.View.EDIT}));
+          Object.assign({}, mockParams, {view: GerritNav.View.EDIT}));
 
       flushAsynchronousOperations();
       assert.equal(element._changeNum, mockParams.changeNum);
@@ -299,7 +299,7 @@
 
   test('_viewEditInChangeView respects _patchNum', () => {
     navigateStub.restore();
-    const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+    const navStub = sandbox.stub(GerritNav, 'navigateToChange');
     element._patchNum = element.EDIT_NAME;
     element._viewEditInChangeView();
     assert.equal(navStub.lastCall.args[1], element.EDIT_NAME);
diff --git a/polygerrit-ui/app/elements/font-roboto-local-loader.js b/polygerrit-ui/app/elements/font-roboto-local-loader.js
new file mode 100644
index 0000000..7000d13
--- /dev/null
+++ b/polygerrit-ui/app/elements/font-roboto-local-loader.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Place all code related to font-roboto-local here
+import '@polymer/font-roboto-local/roboto.js';
+
diff --git a/polygerrit-ui/app/elements/gr-app-element.html b/polygerrit-ui/app/elements/gr-app-element.html
deleted file mode 100644
index 046e5ff..0000000
--- a/polygerrit-ui/app/elements/gr-app-element.html
+++ /dev/null
@@ -1,238 +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.
--->
-<script src="/bower_components/moment/moment.js"></script>
-<script src="../scripts/util.js"></script>
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../styles/shared-styles.html">
-<link rel="import" href="../styles/themes/app-theme.html">
-<link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
-<link rel="import" href="./documentation/gr-documentation-search/gr-documentation-search.html">
-<link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
-<link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
-<link rel="import" href="./change/gr-change-view/gr-change-view.html">
-<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
-<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
-<link rel="import" href="./core/gr-main-header/gr-main-header.html">
-<link rel="import" href="./core/gr-navigation/gr-navigation.html">
-<link rel="import" href="./core/gr-reporting/gr-reporting.html">
-<link rel="import" href="./core/gr-router/gr-router.html">
-<link rel="import" href="./core/gr-smart-search/gr-smart-search.html">
-<link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
-<link rel="import" href="./edit/gr-editor-view/gr-editor-view.html">
-<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="./plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
-<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
-<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
-<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
-<link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
-<link rel="import" href="./shared/gr-fixed-panel/gr-fixed-panel.html">
-<link rel="import" href="./shared/gr-lib-loader/gr-lib-loader.html">
-<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-app-element">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background-color: var(--view-background-color);
-        display: flex;
-        flex-direction: column;
-        min-height: 100%;
-      }
-      gr-fixed-panel {
-        /**
-         * This one should be greater that the z-index in gr-diff-view
-         * because gr-main-header contains overlay.
-         */
-        z-index: 10;
-      }
-      gr-main-header,
-      footer {
-        color: var(--primary-text-color);
-      }
-      gr-main-header {
-        background: var(--header-background, var(--header-background-color, #eee));
-        padding: var(--header-padding);
-        border-bottom: var(--header-border-bottom);
-        border-image: var(--header-border-image);
-        border-right: 0;
-        border-left: 0;
-        border-top: 0;
-        box-shadow: var(--header-box-shadow);
-      }
-      footer {
-        background: var(--footer-background, var(--footer-background-color, #eee));
-        border-top: var(--footer-border-top);
-        display: flex;
-        justify-content: space-between;
-        padding: var(--spacing-m) var(--spacing-l);
-        z-index: 100;
-      }
-      main {
-        flex: 1;
-        padding-bottom: var(--spacing-xxl);
-        position: relative;
-      }
-      .errorView {
-        align-items: center;
-        display: none;
-        flex-direction: column;
-        justify-content: center;
-        position: absolute;
-        top: 0;
-        right: 0;
-        bottom: 0;
-        left: 0;
-      }
-      .errorView.show {
-        display: flex;
-      }
-      .errorEmoji {
-        font-size: 2.6rem;
-      }
-      .errorText,
-      .errorMoreInfo {
-        margin-top: var(--spacing-m);
-      }
-      .errorText {
-        font-size: var(--font-size-h3);
-      }
-      .errorMoreInfo {
-        color: var(--deemphasized-text-color);
-      }
-      .feedback {
-        color: var(--error-text-color);
-      }
-    </style>
-    <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
-    <gr-fixed-panel id="header">
-      <gr-main-header
-          id="mainHeader"
-          search-query="{{params.query}}"
-          on-mobile-search="_mobileSearchToggle">
-      </gr-main-header>
-    </gr-fixed-panel>
-    <main>
-      <gr-smart-search
-          id="search"
-          search-query="{{params.query}}"
-          hidden="[[!mobileSearch]]">
-      </gr-smart-search>
-      <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
-        <gr-change-list-view
-            params="[[params]]"
-            account="[[_account]]"
-            view-state="{{_viewState.changeListView}}"></gr-change-list-view>
-      </template>
-      <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
-        <gr-dashboard-view
-            account="[[_account]]"
-            params="[[params]]"
-            view-state="{{_viewState.dashboardView}}"></gr-dashboard-view>
-      </template>
-      <template is="dom-if" if="[[_showChangeView]]" restamp="true">
-        <gr-change-view
-            params="[[params]]"
-            view-state="{{_viewState.changeView}}"
-            back-page="[[_lastSearchPage]]"></gr-change-view>
-      </template>
-      <template is="dom-if" if="[[_showEditorView]]" restamp="true">
-        <gr-editor-view
-            params="[[params]]"></gr-editor-view>
-      </template>
-      <template is="dom-if" if="[[_showDiffView]]" restamp="true">
-          <gr-diff-view
-              params="[[params]]"
-              change-view-state="{{_viewState.changeView}}"></gr-diff-view>
-        </template>
-      <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-        <gr-settings-view
-            params="[[params]]"
-            on-account-detail-update="_handleAccountDetailUpdate">
-        </gr-settings-view>
-      </template>
-      <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-        <gr-admin-view path="[[_path]]"
-            params=[[params]]></gr-admin-view>
-      </template>
-      <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
-        <gr-endpoint-decorator name="[[_pluginScreenName]]">
-          <gr-endpoint-param name="token" value="[[params.screen]]"></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-      <template is="dom-if" if="[[_showCLAView]]" restamp="true">
-        <gr-cla-view></gr-cla-view>
-      </template>
-      <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
-        <gr-documentation-search
-            params="[[params]]">
-        </gr-documentation-search>
-      </template>
-      <div id="errorView" class="errorView">
-        <div class="errorEmoji">[[_lastError.emoji]]</div>
-        <div class="errorText">[[_lastError.text]]</div>
-        <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
-      </div>
-    </main>
-    <footer r="contentinfo">
-      <div>
-        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
-        target="_blank">Gerrit Code Review</a>
-        ([[_version]])
-        <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
-      </div>
-      <div>
-        <template is="dom-if" if="[[_feedbackUrl]]">
-          <a class="feedback"
-              href$="[[_feedbackUrl]]"
-              rel="noopener"
-              target="_blank">Report bug</a> |
-        </template>
-        Press &ldquo;?&rdquo; for keyboard shortcuts
-        <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
-      </div>
-    </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>
-      <gr-registration-dialog
-          id="registrationDialog"
-          settings-url="[[_settingsUrl]]"
-          on-account-detail-update="_handleAccountDetailUpdate"
-          on-close="_handleRegistrationDialogClose">
-      </gr-registration-dialog>
-    </gr-overlay>
-    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-    <gr-error-manager id="errorManager"></gr-error-manager>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-reporting id="reporting"></gr-reporting>
-    <gr-router id="router"></gr-router>
-    <gr-plugin-host id="plugins"
-        config="[[_serverConfig]]">
-    </gr-plugin-host>
-    <gr-lib-loader id="libLoader"></gr-lib-loader>
-    <gr-external-style id="externalStyleForAll" name="app-theme"></gr-external-style>
-    <gr-external-style id="externalStyleForTheme" name="[[getThemeEndpoint()]]"></gr-external-style>
-  </template>
-  <script src="gr-app-element.js" crossorigin="anonymous"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index ce6b98b..6d232f8 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -14,22 +14,65 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../scripts/bundled-polymer.js';
+import '../styles/shared-styles.js';
+import '../styles/themes/app-theme.js';
+import './admin/gr-admin-view/gr-admin-view.js';
+import './documentation/gr-documentation-search/gr-documentation-search.js';
+import './change-list/gr-change-list-view/gr-change-list-view.js';
+import './change-list/gr-dashboard-view/gr-dashboard-view.js';
+import './change/gr-change-view/gr-change-view.js';
+import './core/gr-error-manager/gr-error-manager.js';
+import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js';
+import './core/gr-main-header/gr-main-header.js';
+import './core/gr-reporting/gr-reporting.js';
+import './core/gr-router/gr-router.js';
+import './core/gr-smart-search/gr-smart-search.js';
+import './diff/gr-diff-view/gr-diff-view.js';
+import './edit/gr-editor-view/gr-editor-view.js';
+import './plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import './plugins/gr-endpoint-param/gr-endpoint-param.js';
+import './plugins/gr-endpoint-slot/gr-endpoint-slot.js';
+import './plugins/gr-external-style/gr-external-style.js';
+import './plugins/gr-plugin-host/gr-plugin-host.js';
+import './settings/gr-cla-view/gr-cla-view.js';
+import './settings/gr-registration-dialog/gr-registration-dialog.js';
+import './settings/gr-settings-view/gr-settings-view.js';
+import './shared/gr-fixed-panel/gr-fixed-panel.js';
+import './shared/gr-lib-loader/gr-lib-loader.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-app-element_html.js';
+import {BaseUrlBehavior} from '../behaviors/base-url-behavior/base-url-behavior.js';
+import {KeyboardShortcutBehavior} from '../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {GerritNav} from './core/gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-app-element',
+/**
+ * @extends Polymer.Element
+ */
+class GrAppElement extends mixinBehaviors( [
+  BaseUrlBehavior,
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
+  static get is() { return 'gr-app-element'; }
+  /**
+   * Fired when the URL location changes.
+   *
+   * @event location-change
+   */
+
+  static get properties() {
+    return {
     /**
-     * Fired when the URL location changes.
-     *
-     * @event location-change
+     * @type {{ query: string, view: string, screen: string }}
      */
-
-    properties: {
-      /**
-       * @type {{ query: string, view: string, screen: string }}
-       */
       params: Object,
       keyEventTarget: {
         type: Object,
@@ -84,385 +127,456 @@
         type: Boolean,
         value: false,
       },
-    },
 
-    listeners: {
-      'page-error': '_handlePageError',
-      'title-change': '_handleTitleChange',
-      'location-change': '_handleLocationChange',
-      'rpc-log': '_handleRpcLog',
-    },
+      /**
+       * Other elements in app must open this URL when
+       * user login is required.
+       */
+      _loginUrl: {
+        type: String,
+        value: '/login',
+      },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_viewChanged(params.view)',
       '_paramsChanged(params.*)',
-    ],
+    ];
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+  keyboardShortcuts() {
+    return {
+      [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+      [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
+      [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+      [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+      [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+      [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
+    };
+  }
 
-    keyboardShortcuts() {
-      return {
-        [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
-        [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
-        [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
-        [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
-        [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));
+    this.addEventListener('shortcut-triggered',
+        e => this._handleShortcutTriggered(e));
+  }
 
-    created() {
-      this._bindKeyboardShortcuts();
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    this._updateLoginUrl();
+    this.$.reporting.appStarted();
+    this.$.router.start();
 
-    ready() {
-      this.$.reporting.appStarted(document.visibilityState === 'hidden');
-      this.$.router.start();
+    this.$.restAPI.getAccount().then(account => {
+      this._account = account;
+    });
+    this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
 
-      this.$.restAPI.getAccount().then(account => {
-        this._account = account;
+      if (config && config.gerrit && config.gerrit.report_bug_url) {
+        this._feedbackUrl = config.gerrit.report_bug_url;
+      }
+    });
+    this.$.restAPI.getVersion().then(version => {
+      this._version = version;
+      this._logWelcome();
+    });
+
+    if (window.localStorage.getItem('dark-theme')) {
+      // No need to add the style module to element again as it's imported
+      // by importHref already
+      this.$.libLoader.getDarkTheme();
+    }
+
+    // Note: this is evaluated here to ensure that it only happens after the
+    // router has been initialized. @see Issue 7837
+    this._settingsUrl = GerritNav.getUrlForSettings();
+
+    this._viewState = {
+      changeView: {
+        changeNum: null,
+        patchRange: null,
+        selectedFileIndex: 0,
+        showReplyDialog: false,
+        diffMode: null,
+        numFilesShown: null,
+        scrollTop: 0,
+      },
+      changeListView: {
+        query: null,
+        offset: 0,
+        selectedChangeIndex: 0,
+      },
+      dashboardView: {
+        selectedChangeIndex: 0,
+      },
+    };
+  }
+
+  _bindKeyboardShortcuts() {
+    this.bindShortcut(this.Shortcut.SEND_REPLY,
+        this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
+    this.bindShortcut(this.Shortcut.EMOJI_DROPDOWN,
+        this.DOC_ONLY, ':');
+
+    this.bindShortcut(
+        this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
+    this.bindShortcut(
+        this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w');
+
+    this.bindShortcut(
+        this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    this.bindShortcut(
+        this.Shortcut.CURSOR_PREV_CHANGE, 'k');
+    this.bindShortcut(
+        this.Shortcut.OPEN_CHANGE, 'o');
+    this.bindShortcut(
+        this.Shortcut.NEXT_PAGE, 'n', ']');
+    this.bindShortcut(
+        this.Shortcut.PREV_PAGE, 'p', '[');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
+    this.bindShortcut(
+        this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
+    this.bindShortcut(
+        this.Shortcut.EDIT_TOPIC, 't');
+
+    this.bindShortcut(
+        this.Shortcut.OPEN_REPLY_DIALOG, 'a');
+    this.bindShortcut(
+        this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+    this.bindShortcut(
+        this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    this.bindShortcut(
+        this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    this.bindShortcut(
+        this.Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
+    this.bindShortcut(
+        this.Shortcut.UP_TO_DASHBOARD, 'u');
+    this.bindShortcut(
+        this.Shortcut.UP_TO_CHANGE, 'u');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+
+    this.bindShortcut(
+        this.Shortcut.NEXT_LINE, 'j', 'down');
+    this.bindShortcut(
+        this.Shortcut.PREV_LINE, 'k', 'up');
+    if (this._isCursorManagerSupportMoveToVisibleLine()) {
+      this.bindShortcut(
+          this.Shortcut.VISIBLE_LINE, '.');
+    }
+    this.bindShortcut(
+        this.Shortcut.NEXT_CHUNK, 'n');
+    this.bindShortcut(
+        this.Shortcut.PREV_CHUNK, 'p');
+    this.bindShortcut(
+        this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    this.bindShortcut(
+        this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+    this.bindShortcut(
+        this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+    this.bindShortcut(
+        this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+    this.bindShortcut(
+        this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+        this.DOC_ONLY, 'shift+e');
+    this.bindShortcut(
+        this.Shortcut.LEFT_PANE, 'shift+left');
+    this.bindShortcut(
+        this.Shortcut.RIGHT_PANE, 'shift+right');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+    this.bindShortcut(
+        this.Shortcut.NEW_COMMENT, 'c');
+    this.bindShortcut(
+        this.Shortcut.SAVE_COMMENT,
+        'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
+    this.bindShortcut(
+        this.Shortcut.OPEN_DIFF_PREFS, ',');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
+
+    this.bindShortcut(
+        this.Shortcut.NEXT_FILE, ']');
+    this.bindShortcut(
+        this.Shortcut.PREV_FILE, '[');
+    this.bindShortcut(
+        this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+    this.bindShortcut(
+        this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+    this.bindShortcut(
+        this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    this.bindShortcut(
+        this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    this.bindShortcut(
+        this.Shortcut.OPEN_FILE, 'o', 'enter');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
+    this.bindShortcut(
+        this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_BLAME, 'b');
+
+    this.bindShortcut(
+        this.Shortcut.OPEN_FIRST_FILE, ']');
+    this.bindShortcut(
+        this.Shortcut.OPEN_LAST_FILE, '[');
+
+    this.bindShortcut(
+        this.Shortcut.SEARCH, '/');
+  }
+
+  _isCursorManagerSupportMoveToVisibleLine() {
+    // This method is a copy-paste from the
+    // method _isIntersectionObserverSupported of gr-cursor-manager.js
+    // It is better share this method with gr-cursor-manager,
+    // but doing it require a lot if changes instead of 1-line copied code
+    return 'IntersectionObserver' in window;
+  }
+
+  _accountChanged(account) {
+    if (!account) { return; }
+
+    // Preferences are cached when a user is logged in; warm them.
+    this.$.restAPI.getPreferences();
+    this.$.restAPI.getDiffPreferences();
+    this.$.restAPI.getEditPreferences();
+    this.$.errorManager.knownAccountId =
+        this._account && this._account._account_id || null;
+  }
+
+  _viewChanged(view) {
+    this.$.errorView.classList.remove('show');
+    this.set('_showChangeListView', view === GerritNav.View.SEARCH);
+    this.set('_showDashboardView', view === GerritNav.View.DASHBOARD);
+    this.set('_showChangeView', view === GerritNav.View.CHANGE);
+    this.set('_showDiffView', view === GerritNav.View.DIFF);
+    this.set('_showSettingsView', view === GerritNav.View.SETTINGS);
+    this.set('_showAdminView', view === GerritNav.View.ADMIN ||
+        view === GerritNav.View.GROUP || view === GerritNav.View.REPO);
+    this.set('_showCLAView', view === GerritNav.View.AGREEMENTS);
+    this.set('_showEditorView', view === GerritNav.View.EDIT);
+    const isPluginScreen = view === GerritNav.View.PLUGIN_SCREEN;
+    this.set('_showPluginScreen', false);
+    // Navigation within plugin screens does not restamp gr-endpoint-decorator
+    // because _showPluginScreen value does not change. To force restamp,
+    // change _showPluginScreen value between true and false.
+    if (isPluginScreen) {
+      this.async(() => this.set('_showPluginScreen', true), 1);
+    }
+    this.set('_showDocumentationSearch',
+        view === GerritNav.View.DOCUMENTATION_SEARCH);
+    if (this.params.justRegistered) {
+      this.$.registrationOverlay.open();
+      this.$.registrationDialog.loadData().then(() => {
+        this.$.registrationOverlay.refit();
       });
-      this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
+    }
+    this.$.header.unfloat();
+  }
 
-        if (config && config.gerrit && config.gerrit.report_bug_url) {
-          this._feedbackUrl = config.gerrit.report_bug_url;
-        }
-      });
-      this.$.restAPI.getVersion().then(version => {
-        this._version = version;
-        this._logWelcome();
-      });
+  _handleShortcutTriggered(event) {
+    const {event: e, goKey} = event.detail;
+    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+    let key = `${e.key}:${e.type}`;
+    if (goKey) key = 'g+' + key;
+    if (e.shiftKey) key = 'shift+' + key;
+    if (e.ctrlKey) key = 'ctrl+' + key;
+    if (e.metaKey) key = 'meta+' + key;
+    if (e.altKey) key = 'alt+' + key;
+    this.$.reporting.reportInteraction('shortcut-triggered', {
+      key,
+      from: event.path && event.path[0]
+        && event.path[0].nodeName || 'unknown',
+    });
+  }
 
-      if (window.localStorage.getItem('dark-theme')) {
-        // No need to add the style module to element again as it's imported
-        // by importHref already
-        this.$.libLoader.getDarkTheme();
-      }
+  _handlePageError(e) {
+    const props = [
+      '_showChangeListView',
+      '_showDashboardView',
+      '_showChangeView',
+      '_showDiffView',
+      '_showSettingsView',
+      '_showAdminView',
+    ];
+    for (const showProp of props) {
+      this.set(showProp, false);
+    }
 
-      // Note: this is evaluated here to ensure that it only happens after the
-      // router has been initialized. @see Issue 7837
-      this._settingsUrl = Gerrit.Nav.getUrlForSettings();
-
-      this._viewState = {
-        changeView: {
-          changeNum: null,
-          patchRange: null,
-          selectedFileIndex: 0,
-          showReplyDialog: false,
-          diffMode: null,
-          numFilesShown: null,
-          scrollTop: 0,
-        },
-        changeListView: {
-          query: null,
-          offset: 0,
-          selectedChangeIndex: 0,
-        },
-        dashboardView: {
-          selectedChangeIndex: 0,
-        },
-      };
-    },
-
-    _bindKeyboardShortcuts() {
-      this.bindShortcut(this.Shortcut.SEND_REPLY,
-          this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
-      this.bindShortcut(this.Shortcut.EMOJI_DROPDOWN,
-          this.DOC_ONLY, ':');
-
-      this.bindShortcut(
-          this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
-      this.bindShortcut(
-          this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w');
-
-      this.bindShortcut(
-          this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_PREV_CHANGE, 'k');
-      this.bindShortcut(
-          this.Shortcut.OPEN_CHANGE, 'o');
-      this.bindShortcut(
-          this.Shortcut.NEXT_PAGE, 'n', ']');
-      this.bindShortcut(
-          this.Shortcut.PREV_PAGE, 'p', '[');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
-      this.bindShortcut(
-          this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
-      this.bindShortcut(
-          this.Shortcut.EDIT_TOPIC, 't');
-
-      this.bindShortcut(
-          this.Shortcut.OPEN_REPLY_DIALOG, 'a');
-      this.bindShortcut(
-          this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
-      this.bindShortcut(
-          this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-      this.bindShortcut(
-          this.Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
-      this.bindShortcut(
-          this.Shortcut.UP_TO_DASHBOARD, 'u');
-      this.bindShortcut(
-          this.Shortcut.UP_TO_CHANGE, 'u');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
-
-      this.bindShortcut(
-          this.Shortcut.NEXT_LINE, 'j', 'down');
-      this.bindShortcut(
-          this.Shortcut.PREV_LINE, 'k', 'up');
-      this.bindShortcut(
-          this.Shortcut.NEXT_CHUNK, 'n');
-      this.bindShortcut(
-          this.Shortcut.PREV_CHUNK, 'p');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
-      this.bindShortcut(
-          this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-      this.bindShortcut(
-          this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-      this.bindShortcut(
-          this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
-      this.bindShortcut(
-          this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-          this.DOC_ONLY, 'shift+e');
-      this.bindShortcut(
-          this.Shortcut.LEFT_PANE, 'shift+left');
-      this.bindShortcut(
-          this.Shortcut.RIGHT_PANE, 'shift+right');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-      this.bindShortcut(
-          this.Shortcut.NEW_COMMENT, 'c');
-      this.bindShortcut(
-          this.Shortcut.SAVE_COMMENT,
-          'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
-      this.bindShortcut(
-          this.Shortcut.OPEN_DIFF_PREFS, ',');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
-
-      this.bindShortcut(
-          this.Shortcut.NEXT_FILE, ']');
-      this.bindShortcut(
-          this.Shortcut.PREV_FILE, '[');
-      this.bindShortcut(
-          this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-      this.bindShortcut(
-          this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-      this.bindShortcut(
-          this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-      this.bindShortcut(
-          this.Shortcut.OPEN_FILE, 'o', 'enter');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
-      this.bindShortcut(
-          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-      this.bindShortcut(
-          this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-
-      this.bindShortcut(
-          this.Shortcut.OPEN_FIRST_FILE, ']');
-      this.bindShortcut(
-          this.Shortcut.OPEN_LAST_FILE, '[');
-
-      this.bindShortcut(
-          this.Shortcut.SEARCH, '/');
-    },
-
-    _accountChanged(account) {
-      if (!account) { return; }
-
-      // Preferences are cached when a user is logged in; warm them.
-      this.$.restAPI.getPreferences();
-      this.$.restAPI.getDiffPreferences();
-      this.$.restAPI.getEditPreferences();
-      this.$.errorManager.knownAccountId =
-          this._account && this._account._account_id || null;
-    },
-
-    _viewChanged(view) {
-      this.$.errorView.classList.remove('show');
-      this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
-      this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
-      this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
-      this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
-      this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
-      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN ||
-          view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO);
-      this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
-      this.set('_showEditorView', view === Gerrit.Nav.View.EDIT);
-      const isPluginScreen = view === Gerrit.Nav.View.PLUGIN_SCREEN;
-      this.set('_showPluginScreen', false);
-      // Navigation within plugin screens does not restamp gr-endpoint-decorator
-      // because _showPluginScreen value does not change. To force restamp,
-      // change _showPluginScreen value between true and false.
-      if (isPluginScreen) {
-        this.async(() => this.set('_showPluginScreen', true), 1);
-      }
-      this.set('_showDocumentationSearch',
-          view === Gerrit.Nav.View.DOCUMENTATION_SEARCH);
-      if (this.params.justRegistered) {
-        this.$.registrationOverlay.open();
-        this.$.registrationDialog.loadData().then(() => {
-          this.$.registrationOverlay.refit();
-        });
-      }
-      this.$.header.unfloat();
-    },
-
-    _handlePageError(e) {
-      const props = [
-        '_showChangeListView',
-        '_showDashboardView',
-        '_showChangeView',
-        '_showDiffView',
-        '_showSettingsView',
-        '_showAdminView',
-      ];
-      for (const showProp of props) {
-        this.set(showProp, false);
-      }
-
-      this.$.errorView.classList.add('show');
-      const response = e.detail.response;
-      const err = {text: [response.status, response.statusText].join(' ')};
-      if (response.status === 404) {
-        err.emoji = '¯\\_(ツ)_/¯';
+    this.$.errorView.classList.add('show');
+    const response = e.detail.response;
+    const err = {text: [response.status, response.statusText].join(' ')};
+    if (response.status === 404) {
+      err.emoji = '¯\\_(ツ)_/¯';
+      this._lastError = err;
+    } else {
+      err.emoji = 'o_O';
+      response.text().then(text => {
+        err.moreInfo = text;
         this._lastError = err;
-      } else {
-        err.emoji = 'o_O';
-        response.text().then(text => {
-          err.moreInfo = text;
-          this._lastError = err;
-        });
-      }
-    },
+      });
+    }
+  }
 
-    _handleLocationChange(e) {
-      const hash = e.detail.hash.substring(1);
-      let pathname = e.detail.pathname;
-      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
-        pathname += '@' + hash;
-      }
-      this.set('_path', pathname);
-    },
+  _handleLocationChange(e) {
+    this._updateLoginUrl();
 
-    _paramsChanged(paramsRecord) {
-      const params = paramsRecord.base;
-      const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
-      if (viewsToCheck.includes(params.view)) {
-        this.set('_lastSearchPage', location.pathname);
-      }
-    },
+    const hash = e.detail.hash.substring(1);
+    let pathname = e.detail.pathname;
+    if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
+      pathname += '@' + hash;
+    }
+    this.set('_path', pathname);
+  }
 
-    _handleTitleChange(e) {
-      if (e.detail.title) {
-        document.title = e.detail.title + ' · Gerrit Code Review';
-      } else {
-        document.title = '';
-      }
-    },
+  _updateLoginUrl() {
+    const baseUrl = this.getBaseUrl();
+    if (baseUrl) {
+      // Strip the canonical path from the path since needing canonical in
+      // the path is uneeded and breaks the url.
+      this._loginUrl = baseUrl + '/login/' + encodeURIComponent(
+          '/' + window.location.pathname.substring(baseUrl.length) +
+          window.location.search +
+          window.location.hash);
+    } else {
+      this._loginUrl = '/login/' + encodeURIComponent(
+          window.location.pathname +
+          window.location.search +
+          window.location.hash);
+    }
+  }
 
-    _showKeyboardShortcuts(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      this.$.keyboardShortcuts.open();
-    },
+  _paramsChanged(paramsRecord) {
+    const params = paramsRecord.base;
+    const viewsToCheck = [GerritNav.View.SEARCH, GerritNav.View.DASHBOARD];
+    if (viewsToCheck.includes(params.view)) {
+      this.set('_lastSearchPage', location.pathname);
+    }
+  }
 
-    _handleKeyboardShortcutDialogClose() {
+  _handleTitleChange(e) {
+    if (e.detail.title) {
+      document.title = e.detail.title + ' · Gerrit Code Review';
+    } else {
+      document.title = '';
+    }
+  }
+
+  _showKeyboardShortcuts(e) {
+    // same shortcut should close the dialog if pressed again
+    // when dialog is open
+    if (this.$.keyboardShortcuts.opened) {
       this.$.keyboardShortcuts.close();
-    },
+      return;
+    }
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    this.$.keyboardShortcuts.open();
+  }
 
-    _handleAccountDetailUpdate(e) {
-      this.$.mainHeader.reload();
-      if (this.params.view === Gerrit.Nav.View.SETTINGS) {
-        this.$$('gr-settings-view').reloadAccountDetail();
-      }
-    },
+  _handleKeyboardShortcutDialogClose() {
+    this.$.keyboardShortcuts.close();
+  }
 
-    _handleRegistrationDialogClose(e) {
-      this.params.justRegistered = false;
-      this.$.registrationOverlay.close();
-    },
+  _handleAccountDetailUpdate(e) {
+    this.$.mainHeader.reload();
+    if (this.params.view === GerritNav.View.SETTINGS) {
+      this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail();
+    }
+  }
 
-    _goToOpenedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('open');
-    },
+  _handleRegistrationDialogClose(e) {
+    this.params.justRegistered = false;
+    this.$.registrationOverlay.close();
+  }
 
-    _goToUserDashboard() {
-      Gerrit.Nav.navigateToUserDashboard();
-    },
+  _goToOpenedChanges() {
+    GerritNav.navigateToStatusSearch('open');
+  }
 
-    _goToMergedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('merged');
-    },
+  _goToUserDashboard() {
+    GerritNav.navigateToUserDashboard();
+  }
 
-    _goToAbandonedChanges() {
-      Gerrit.Nav.navigateToStatusSearch('abandoned');
-    },
+  _goToMergedChanges() {
+    GerritNav.navigateToStatusSearch('merged');
+  }
 
-    _goToWatchedChanges() {
-      // The query is hardcoded, and doesn't respect custom menu entries
-      Gerrit.Nav.navigateToSearchQuery('is:watched is:open');
-    },
+  _goToAbandonedChanges() {
+    GerritNav.navigateToStatusSearch('abandoned');
+  }
 
-    _computePluginScreenName({plugin, screen}) {
-      if (!plugin || !screen) return '';
-      return `${plugin}-screen-${screen}`;
-    },
+  _goToWatchedChanges() {
+    // The query is hardcoded, and doesn't respect custom menu entries
+    GerritNav.navigateToSearchQuery('is:watched is:open');
+  }
 
-    _logWelcome() {
-      console.group('Runtime Info');
-      console.log('Gerrit UI (PolyGerrit)');
-      console.log(`Gerrit Server Version: ${this._version}`);
-      if (window.VERSION_INFO) {
-        console.log(`UI Version Info: ${window.VERSION_INFO}`);
-      }
-      if (this._feedbackUrl) {
-        console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
-      }
-      console.groupEnd();
-    },
+  _computePluginScreenName({plugin, screen}) {
+    if (!plugin || !screen) return '';
+    return `${plugin}-screen-${screen}`;
+  }
 
-    /**
-     * Intercept RPC log events emitted by REST API interfaces.
-     * Note: the REST API interface cannot use gr-reporting directly because
-     * that would create a cyclic dependency.
-     */
-    _handleRpcLog(e) {
-      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
-          e.detail.elapsed);
-    },
+  _logWelcome() {
+    console.group('Runtime Info');
+    console.log('Gerrit UI (PolyGerrit)');
+    console.log(`Gerrit Server Version: ${this._version}`);
+    if (window.VERSION_INFO) {
+      console.log(`UI Version Info: ${window.VERSION_INFO}`);
+    }
+    if (this._feedbackUrl) {
+      console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+    }
+    console.groupEnd();
+  }
 
-    _mobileSearchToggle(e) {
-      this.mobileSearch = !this.mobileSearch;
-    },
+  /**
+   * Intercept RPC log events emitted by REST API interfaces.
+   * Note: the REST API interface cannot use gr-reporting directly because
+   * that would create a cyclic dependency.
+   */
+  _handleRpcLog(e) {
+    this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+        e.detail.elapsed);
+  }
 
-    getThemeEndpoint() {
-      // For now, we only have dark mode and light mode
-      return window.localStorage.getItem('dark-theme') ?
-        'app-theme-dark' :
-        'app-theme-light';
-    },
-  });
-})();
+  _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-element_html.js b/polygerrit-ui/app/elements/gr-app-element_html.js
new file mode 100644
index 0000000..2ee48c1
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element_html.js
@@ -0,0 +1,234 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--background-color-tertiary);
+      display: flex;
+      flex-direction: column;
+      min-height: 100%;
+    }
+    gr-fixed-panel {
+      /**
+         * This one should be greater that the z-index in gr-diff-view
+         * because gr-main-header contains overlay.
+         */
+      z-index: 10;
+    }
+    gr-main-header,
+    footer {
+      color: var(--primary-text-color);
+    }
+    gr-main-header {
+      background: var(
+        --header-background,
+        var(--header-background-color, #eee)
+      );
+      padding: var(--header-padding);
+      border-bottom: var(--header-border-bottom);
+      border-image: var(--header-border-image);
+      border-right: 0;
+      border-left: 0;
+      border-top: 0;
+      box-shadow: var(--header-box-shadow);
+    }
+    footer {
+      background: var(
+        --footer-background,
+        var(--footer-background-color, #eee)
+      );
+      border-top: var(--footer-border-top);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) var(--spacing-l);
+      z-index: 100;
+    }
+    main {
+      flex: 1;
+      padding-bottom: var(--spacing-xxl);
+      position: relative;
+    }
+    .errorView {
+      align-items: center;
+      display: none;
+      flex-direction: column;
+      justify-content: center;
+      position: absolute;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      left: 0;
+    }
+    .errorView.show {
+      display: flex;
+    }
+    .errorEmoji {
+      font-size: 2.6rem;
+    }
+    .errorText,
+    .errorMoreInfo {
+      margin-top: var(--spacing-m);
+    }
+    .errorText {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+    }
+    .errorMoreInfo {
+      color: var(--deemphasized-text-color);
+    }
+    .feedback {
+      color: var(--error-text-color);
+    }
+  </style>
+  <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
+  <gr-fixed-panel id="header">
+    <gr-main-header
+      id="mainHeader"
+      search-query="{{params.query}}"
+      on-mobile-search="_mobileSearchToggle"
+      login-url="[[_loginUrl]]"
+    >
+    </gr-main-header>
+  </gr-fixed-panel>
+  <main>
+    <gr-smart-search
+      id="search"
+      search-query="{{params.query}}"
+      hidden="[[!mobileSearch]]"
+    >
+    </gr-smart-search>
+    <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
+      <gr-change-list-view
+        params="[[params]]"
+        account="[[_account]]"
+        view-state="{{_viewState.changeListView}}"
+      ></gr-change-list-view>
+    </template>
+    <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
+      <gr-dashboard-view
+        account="[[_account]]"
+        params="[[params]]"
+        view-state="{{_viewState.dashboardView}}"
+      ></gr-dashboard-view>
+    </template>
+    <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+      <gr-change-view
+        params="[[params]]"
+        view-state="{{_viewState.changeView}}"
+        back-page="[[_lastSearchPage]]"
+      ></gr-change-view>
+    </template>
+    <template is="dom-if" if="[[_showEditorView]]" restamp="true">
+      <gr-editor-view params="[[params]]"></gr-editor-view>
+    </template>
+    <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+      <gr-diff-view
+        params="[[params]]"
+        change-view-state="{{_viewState.changeView}}"
+      ></gr-diff-view>
+    </template>
+    <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
+      <gr-settings-view
+        params="[[params]]"
+        on-account-detail-update="_handleAccountDetailUpdate"
+      >
+      </gr-settings-view>
+    </template>
+    <template is="dom-if" if="[[_showAdminView]]" restamp="true">
+      <gr-admin-view path="[[_path]]" params="[[params]]"></gr-admin-view>
+    </template>
+    <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
+      <gr-endpoint-decorator name="[[_pluginScreenName]]">
+        <gr-endpoint-param
+          name="token"
+          value="[[params.screen]]"
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </template>
+    <template is="dom-if" if="[[_showCLAView]]" restamp="true">
+      <gr-cla-view></gr-cla-view>
+    </template>
+    <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
+      <gr-documentation-search params="[[params]]"> </gr-documentation-search>
+    </template>
+    <div id="errorView" class="errorView">
+      <div class="errorEmoji">[[_lastError.emoji]]</div>
+      <div class="errorText">[[_lastError.text]]</div>
+      <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
+    </div>
+  </main>
+  <footer r="contentinfo">
+    <div>
+      Powered by
+      <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank"
+        >Gerrit Code Review</a
+      >
+      ([[_version]])
+      <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
+    </div>
+    <div>
+      <template is="dom-if" if="[[_feedbackUrl]]">
+        <a
+          class="feedback"
+          href$="[[_feedbackUrl]]"
+          rel="noopener"
+          target="_blank"
+          >Report bug</a
+        >
+        |
+      </template>
+      Press “?” for keyboard shortcuts
+      <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
+    </div>
+  </footer>
+  <gr-overlay id="keyboardShortcuts" with-backdrop="">
+    <gr-keyboard-shortcuts-dialog
+      on-close="_handleKeyboardShortcutDialogClose"
+    ></gr-keyboard-shortcuts-dialog>
+  </gr-overlay>
+  <gr-overlay id="registrationOverlay" with-backdrop="">
+    <gr-registration-dialog
+      id="registrationDialog"
+      settings-url="[[_settingsUrl]]"
+      on-account-detail-update="_handleAccountDetailUpdate"
+      on-close="_handleRegistrationDialogClose"
+    >
+    </gr-registration-dialog>
+  </gr-overlay>
+  <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
+  <gr-error-manager
+    id="errorManager"
+    login-url="[[_loginUrl]]"
+  ></gr-error-manager>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-reporting id="reporting"></gr-reporting>
+  <gr-router id="router"></gr-router>
+  <gr-plugin-host id="plugins" config="[[_serverConfig]]"> </gr-plugin-host>
+  <gr-lib-loader id="libLoader"></gr-lib-loader>
+  <gr-external-style
+    id="externalStyleForAll"
+    name="app-theme"
+  ></gr-external-style>
+  <gr-external-style
+    id="externalStyleForTheme"
+    name="[[getThemeEndpoint()]]"
+  ></gr-external-style>
+`;
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.js
new file mode 100644
index 0000000..f0a9131
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.js
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @fileoverview This file is a backwards-compatibility shim.
+ * Before Polygerrit converted to ES Modules, it exposes some variables out onto
+ * the global namespace. Plugins can depend on these variables and we must
+ * expose these variables until plugins switch to direct import from polygerrit.
+ */
+
+import {GrDisplayNameUtils} from '../scripts/gr-display-name-utils/gr-display-name-utils.js';
+import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation.js';
+import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper.js';
+import {GrDiffLine} from './diff/gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from './diff/gr-diff/gr-diff-group.js';
+import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder.js';
+import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side.js';
+import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image.js';
+import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified.js';
+import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary.js';
+import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api.js';
+import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api.js';
+import {GrEditConstants} from './edit/gr-edit-constants.js';
+import {GrFileListConstants} from './change/gr-file-list-constants.js';
+import {GrDomHooksManager, GrDomHook} from './plugins/gr-dom-hooks/gr-dom-hooks.js';
+import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator.js';
+import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api.js';
+import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
+import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser.js';
+import {pluginEndpoints, GrPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser.js';
+import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface.js';
+import {GrRangeNormalizer} from './diff/gr-diff-highlight/gr-range-normalizer.js';
+import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
+import {util} from '../scripts/util.js';
+import moment from 'moment/src/moment.js';
+import page from 'page/page.mjs';
+import {Auth} from './shared/gr-rest-api-interface/gr-auth.js';
+import {EventEmitter} from './shared/gr-event-interface/gr-event-interface.js';
+import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api.js';
+import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context.js';
+import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api.js';
+import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api.js';
+import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js';
+import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js';
+import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper.js';
+import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api.js';
+import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api.js';
+import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api.js';
+import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api.js';
+import {pluginLoader, PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context.js';
+import {getBaseUrl, getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
+import {GerritNav} from './core/gr-navigation/gr-navigation.js';
+import {getRootElement} from '../scripts/rootElement.js';
+import {rangesEqual} from './diff/gr-diff/gr-diff-utils.js';
+import {RevisionInfo} from './shared/revision-info/revision-info.js';
+import {CoverageType} from '../types/types.js';
+import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll.js';
+
+export function initGlobalVariables() {
+  window.GrDisplayNameUtils = GrDisplayNameUtils;
+  window.GrAnnotation = GrAnnotation;
+  window.GrAttributeHelper = GrAttributeHelper;
+  window.GrDiffLine = GrDiffLine;
+  window.GrDiffGroup = GrDiffGroup;
+  window.GrDiffBuilder = GrDiffBuilder;
+  window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
+  window.GrDiffBuilderImage = GrDiffBuilderImage;
+  window.GrDiffBuilderUnified = GrDiffBuilderUnified;
+  window.GrDiffBuilderBinary = GrDiffBuilderBinary;
+  window.GrChangeActionsInterface = GrChangeActionsInterface;
+  window.GrChangeReplyInterface = GrChangeReplyInterface;
+  window.GrEditConstants = GrEditConstants;
+  window.GrFileListConstants = GrFileListConstants;
+  window.GrDomHooksManager = GrDomHooksManager;
+  window.GrDomHook = GrDomHook;
+  window.GrEtagDecorator = GrEtagDecorator;
+  window.GrThemeApi = GrThemeApi;
+  window.SiteBasedCache = SiteBasedCache;
+  window.FetchPromisesCache = FetchPromisesCache;
+  window.GrRestApiHelper = GrRestApiHelper;
+  window.GrLinkTextParser = GrLinkTextParser;
+  window.GrPluginEndpoints = GrPluginEndpoints;
+  window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
+  window.GrPopupInterface = GrPopupInterface;
+  window.GrRangeNormalizer = GrRangeNormalizer;
+  window.GrCountStringFormatter = GrCountStringFormatter;
+  window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
+  window.util = util;
+  window.moment = moment;
+  window.page = page;
+  window.Auth = Auth;
+  window.EventEmitter = EventEmitter;
+  window.GrAdminApi = GrAdminApi;
+  window.GrAnnotationActionsContext = GrAnnotationActionsContext;
+  window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
+  window.GrChangeMetadataApi = GrChangeMetadataApi;
+  window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
+  window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
+  window.GrEventHelper = GrEventHelper;
+  window.GrPluginRestApi = GrPluginRestApi;
+  window.GrRepoApi = GrRepoApi;
+  window.GrSettingsApi = GrSettingsApi;
+  window.GrStylesApi = GrStylesApi;
+  window.PluginLoader = PluginLoader;
+  window.GrPluginActionContext = GrPluginActionContext;
+
+  window._apiUtils = {
+    getPluginNameFromUrl,
+    send,
+    getRestAPI,
+    getBaseUrl,
+    PRELOADED_PROTOCOL,
+    PLUGIN_LOADING_TIMEOUT_MS,
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.Nav = GerritNav;
+  window.Gerrit.getRootElement = getRootElement;
+
+  window.Gerrit._pluginLoader = pluginLoader;
+  window.Gerrit._endpoints = pluginEndpoints;
+
+  window.Gerrit.slotToContent = slot => slot;
+  window.Gerrit.rangesEqual = rangesEqual;
+  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES =
+      SUGGESTIONS_PROVIDERS_USERS_TYPES;
+  window.Gerrit.RevisionInfo = RevisionInfo;
+  window.Gerrit.CoverageType = CoverageType;
+  Object.defineProperty(window.Gerrit, 'hiddenscroll', {
+    get: getHiddenScroll,
+    set: _setHiddenScroll,
+  });
+}
diff --git a/polygerrit-ui/app/elements/gr-app-init.js b/polygerrit-ui/app/elements/gr-app-init.js
new file mode 100644
index 0000000..14dbd87
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-init.js
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {initAppContext} from '../services/app-context-init.js';
+
+if (!window.Polymer) {
+  window.Polymer = {
+    lazyRegister: true,
+  };
+}
+window.Gerrit = window.Gerrit || {};
+
+initAppContext();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index f49d8aa..1483f7a 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -1,45 +1 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<script>
-  if (!window.Polymer) {
-    window.Polymer = {
-      passiveTouchGestures: true,
-      lazyRegister: true,
-    };
-  }
-  window.Gerrit = window.Gerrit || {};
-</script>
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/polymer-resin/standalone/polymer-resin.html">
-<!-- TODO(taoalpha): Remove once all legacyUndefinedCheck removed. -->
-<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
-<script>
-  security.polymer_resin.install({
-    allowedIdentifierPrefixes: [''],
-    reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
-    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
-  });
-</script>
-
-<link rel="import" href="./gr-app-element.html">
-<dom-module id="gr-app">
-  <template>
-    <gr-app-element id="app-element"></gr-app-element>
-  </template>
-  <script src="gr-app.js" crossorigin="anonymous"></script>
-</dom-module>
+<script src='./gr-app.js' type='module'></script>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index ac8ea1a..6bc79f7a 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,10 +14,53 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  Polymer({
-    is: 'gr-app',
-  });
-})();
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import './gr-app-init.js';
+import './font-roboto-local-loader.js';
+import '../scripts/bundled-polymer.js';
+
+/**
+ * setCancelSyntheticClickEvents is set to true by
+ * default which will cancel synthetic click events
+ * on older touch device.
+ * See https://github.com/Polymer/polymer/issues/5289
+ */
+import {setPassiveTouchGestures, setCancelSyntheticClickEvents} from '@polymer/polymer/lib/utils/settings.js';
+setCancelSyntheticClickEvents(false);
+setPassiveTouchGestures(true);
+
+import 'polymer-resin/standalone/polymer-resin.js';
+import {initGlobalVariables} from './gr-app-global-var-init.js';
+import './gr-app-element.js';
+import './change-list/gr-embed-dashboard/gr-embed-dashboard.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-app_html.js';
+import {SafeTypes} from '../behaviors/safe-types-behavior/safe-types-behavior.js';
+import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit.js';
+
+security.polymer_resin.install({
+  allowedIdentifierPrefixes: [''],
+  reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+  safeTypesBridge: SafeTypes.safeTypesBridge,
+});
+
+/** @extends Polymer.Element */
+class GrApp extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-app'; }
+}
+
+customElements.define(GrApp.is, GrApp);
+
+initGlobalVariables();
+initGerritPluginApi();
diff --git a/polygerrit-ui/app/elements/gr-app_html.js b/polygerrit-ui/app/elements/gr-app_html.js
new file mode 100644
index 0000000..3da1b69
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_html.js
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-app-element id="app-element"></gr-app-element>
+`;
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 9f1b7f8..6a13789 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -17,22 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  const link = document.createElement('link');
-  link.setAttribute('rel', 'import');
-  link.setAttribute('href', 'gr-app.html');
-  document.head.appendChild(link);
-</script>
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -40,75 +31,77 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-app tests', () => {
-    let sandbox;
-    let element;
+<script type="module">
+import '../test/common-test-setup.js';
+import './gr-app.js';
+import {GerritNav} from './core/gr-navigation/gr-navigation.js';
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-reporting', {
-        appStarted: sandbox.stub(),
-      });
-      stub('gr-account-dropdown', {
-        _getTopContent: sinon.stub(),
-      });
-      stub('gr-router', {
-        start: sandbox.stub(),
-      });
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve({}); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-        getConfig() {
-          return Promise.resolve({
-            plugin: {},
-            auth: {
-              auth_type: undefined,
-            },
-          });
-        },
-        getPreferences() { return Promise.resolve({my: []}); },
-        getDiffPreferences() { return Promise.resolve({}); },
-        getEditPreferences() { return Promise.resolve({}); },
-        getVersion() { return Promise.resolve(42); },
-        probePath() { return Promise.resolve(42); },
-      });
+suite('gr-app tests', () => {
+  let sandbox;
+  let element;
 
-      element = fixture('basic');
-      flush(done);
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-reporting', {
+      appStarted: sandbox.stub(),
+    });
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-router', {
+      start: sandbox.stub(),
+    });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve({}); },
+      getAccountCapabilities() { return Promise.resolve({}); },
+      getConfig() {
+        return Promise.resolve({
+          plugin: {},
+          auth: {
+            auth_type: undefined,
+          },
+        });
+      },
+      getPreferences() { return Promise.resolve({my: []}); },
+      getDiffPreferences() { return Promise.resolve({}); },
+      getEditPreferences() { return Promise.resolve({}); },
+      getVersion() { return Promise.resolve(42); },
+      probePath() { return Promise.resolve(42); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element = fixture('basic');
+    flush(done);
+  });
 
-    appElement = () => {
-      return element.$['app-element'];
-    };
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('reporting', () => {
-      assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
-    });
+  const appElement = () => element.$['app-element'];
 
-    test('reporting called before router start', () => {
-      const element = appElement();
-      const appStartedStub = element.$.reporting.appStarted;
-      const routerStartStub = element.$.router.start;
-      sinon.assert.callOrder(appStartedStub, routerStartStub);
-    });
+  test('reporting', () => {
+    assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
+  });
 
-    test('passes config to gr-plugin-host', () => {
-      const config = appElement().$.restAPI.getConfig;
-      return config.lastCall.returnValue.then(config => {
-        assert.deepEqual(appElement().$.plugins.config, config);
-      });
-    });
+  test('reporting called before router start', () => {
+    const element = appElement();
+    const appStartedStub = element.$.reporting.appStarted;
+    const routerStartStub = element.$.router.start;
+    sinon.assert.callOrder(appStartedStub, routerStartStub);
+  });
 
-    test('_paramsChanged sets search page', () => {
-      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.CHANGE}});
-      assert.notOk(appElement()._lastSearchPage);
-      appElement()._paramsChanged({base: {view: Gerrit.Nav.View.SEARCH}});
-      assert.ok(appElement()._lastSearchPage);
+  test('passes config to gr-plugin-host', () => {
+    const config = appElement().$.restAPI.getConfig;
+    return config.lastCall.returnValue.then(config => {
+      assert.deepEqual(appElement().$.plugins.config, config);
     });
   });
+
+  test('_paramsChanged sets search page', () => {
+    appElement()._paramsChanged({base: {view: GerritNav.View.CHANGE}});
+    assert.notOk(appElement()._lastSearchPage);
+    appElement()._paramsChanged({base: {view: GerritNav.View.SEARCH}});
+    assert.ok(appElement()._lastSearchPage);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.html b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.html
deleted file mode 100644
index 756c435..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.html
+++ /dev/null
@@ -1,18 +0,0 @@
-<!--
-@license
-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.
--->
-
-<script src="gr-admin-api.js"></script>
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..2dfb79f 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
@@ -14,29 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrAdminApi) { return; }
+/** @constructor */
+export function GrAdminApi(plugin) {
+  this.plugin = plugin;
+  plugin.on('admin-menu-links', this);
+  this._menuLinks = [];
+}
 
-  function GrAdminApi(plugin) {
-    this.plugin = plugin;
-    plugin.on('admin-menu-links', this);
-    this._menuLinks = [];
-  }
+/**
+ * @param {string} text
+ * @param {string} url
+ */
+GrAdminApi.prototype.addMenuLink = function(text, url, opt_capability) {
+  this._menuLinks.push({text, url, capability: opt_capability || null});
+};
 
-  /**
-   * @param {string} text
-   * @param {string} url
-   */
-  GrAdminApi.prototype.addMenuLink = function(text, url, opt_capability) {
-    this._menuLinks.push({text, url, capability: opt_capability || null});
-  };
-
-  GrAdminApi.prototype.getMenuLinks = function() {
-    return this._menuLinks.slice(0);
-  };
-
-  window.GrAdminApi = GrAdminApi;
-})(window);
+GrAdminApi.prototype.getMenuLinks = function() {
+  return this._menuLinks.slice(0);
+};
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..a865233 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,53 +18,55 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="gr-admin-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script>
-  suite('gr-admin-api tests', () => {
-    let sandbox;
-    let adminApi;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-      adminApi = plugin.admin();
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      adminApi = null;
-      sandbox.restore();
-    });
+suite('gr-admin-api tests', () => {
+  let sandbox;
+  let adminApi;
 
-    test('exists', () => {
-      assert.isOk(adminApi);
-    });
-
-    test('addMenuLink', () => {
-      adminApi.addMenuLink('text', 'url');
-      const links = adminApi.getMenuLinks();
-      assert.equal(links.length, 1);
-      assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
-    });
-
-    test('addMenuLinkWithCapability', () => {
-      adminApi.addMenuLink('text', 'url', 'capability');
-      const links = adminApi.getMenuLinks();
-      assert.equal(links.length, 1);
-      assert.deepEqual(links[0],
-          {text: 'text', url: 'url', capability: 'capability'});
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    adminApi = plugin.admin();
   });
+
+  teardown(() => {
+    adminApi = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(adminApi);
+  });
+
+  test('addMenuLink', () => {
+    adminApi.addMenuLink('text', 'url');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
+  });
+
+  test('addMenuLinkWithCapability', () => {
+    adminApi.addMenuLink('text', 'url', 'capability');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0],
+        {text: 'text', url: 'url', capability: 'capability'});
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
deleted file mode 100644
index ece8677..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-attribute-helper">
-  <script src="gr-attribute-helper.js"></script>
-</dom-module>
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..d5ebb65 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
@@ -14,90 +14,95 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  function GrAttributeHelper(element) {
-    this.element = element;
-    this._promises = {};
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-attribute-helper">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/** @constructor */
+export function GrAttributeHelper(element) {
+  this.element = element;
+  this._promises = {};
+}
+
+GrAttributeHelper.prototype._getChangedEventName = function(name) {
+  return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
+};
+
+/**
+ * Returns true if the property is defined on wrapped element.
+ *
+ * @param {string} name
+ * @return {boolean}
+ */
+GrAttributeHelper.prototype._elementHasProperty = function(name) {
+  return this.element[name] !== undefined;
+};
+
+GrAttributeHelper.prototype._reportValue = function(callback, value) {
+  try {
+    callback(value);
+  } catch (e) {
+    console.info(e);
   }
+};
 
-  GrAttributeHelper.prototype._getChangedEventName = function(name) {
-    return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
-  };
+/**
+ * Binds callback to property updates.
+ *
+ * @param {string} name Property name.
+ * @param {function(?)} callback
+ * @return {function()} Unbind function.
+ */
+GrAttributeHelper.prototype.bind = function(name, callback) {
+  const attributeChangedEventName = this._getChangedEventName(name);
+  const changedHandler = e => this._reportValue(callback, e.detail.value);
+  const unbind = () => this.element.removeEventListener(
+      attributeChangedEventName, changedHandler);
+  this.element.addEventListener(
+      attributeChangedEventName, changedHandler);
+  if (this._elementHasProperty(name)) {
+    this._reportValue(callback, this.element[name]);
+  }
+  return unbind;
+};
 
-  /**
-   * Returns true if the property is defined on wrapped element.
-   *
-   * @param {string} name
-   * @return {boolean}
-   */
-  GrAttributeHelper.prototype._elementHasProperty = function(name) {
-    return this.element[name] !== undefined;
-  };
+/**
+ * Get value of the property from wrapped object. Waits for the property
+ * to be initialized if it isn't defined.
+ *
+ * @param {string} name Property name.
+ * @return {!Promise<?>}
+ */
+GrAttributeHelper.prototype.get = function(name) {
+  if (this._elementHasProperty(name)) {
+    return Promise.resolve(this.element[name]);
+  }
+  if (!this._promises[name]) {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    const unbind = this.bind(name, value => {
+      resolve(value);
+      unbind();
+    });
+    this._promises[name] = promise;
+  }
+  return this._promises[name];
+};
 
-  GrAttributeHelper.prototype._reportValue = function(callback, value) {
-    try {
-      callback(value);
-    } catch (e) {
-      console.info(e);
-    }
-  };
-
-  /**
-   * Binds callback to property updates.
-   *
-   * @param {string} name Property name.
-   * @param {function(?)} callback
-   * @return {function()} Unbind function.
-   */
-  GrAttributeHelper.prototype.bind = function(name, callback) {
-    const attributeChangedEventName = this._getChangedEventName(name);
-    const changedHandler = e => this._reportValue(callback, e.detail.value);
-    const unbind = () => this.element.removeEventListener(
-        attributeChangedEventName, changedHandler);
-    this.element.addEventListener(
-        attributeChangedEventName, changedHandler);
-    if (this._elementHasProperty(name)) {
-      this._reportValue(callback, this.element[name]);
-    }
-    return unbind;
-  };
-
-  /**
-   * Get value of the property from wrapped object. Waits for the property
-   * to be initialized if it isn't defined.
-   *
-   * @param {string} name Property name.
-   * @return {!Promise<?>}
-   */
-  GrAttributeHelper.prototype.get = function(name) {
-    if (this._elementHasProperty(name)) {
-      return Promise.resolve(this.element[name]);
-    }
-    if (!this._promises[name]) {
-      let resolve;
-      const promise = new Promise(r => resolve = r);
-      const unbind = this.bind(name, value => {
-        resolve(value);
-        unbind();
-      });
-      this._promises[name] = promise;
-    }
-    return this._promises[name];
-  };
-
-  /**
-   * Sets value and dispatches event to force notify.
-   *
-   * @param {string} name Property name.
-   * @param {?} value
-   */
-  GrAttributeHelper.prototype.set = function(name, value) {
-    this.element[name] = value;
-    this.element.dispatchEvent(
-        new CustomEvent(this._getChangedEventName(name), {detail: {value}}));
-  };
-
-  window.GrAttributeHelper = GrAttributeHelper;
-})(window);
+/**
+ * Sets value and dispatches event to force notify.
+ *
+ * @param {string} name Property name.
+ * @param {?} value
+ */
+GrAttributeHelper.prototype.set = function(name, value) {
+  this.element[name] = value;
+  this.element.dispatchEvent(
+      new CustomEvent(this._getChangedEventName(name), {detail: {value}}));
+};
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..50f9002 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,28 +18,27 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-attribute-helper.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <dom-element id="some-element">
-  <script>
-    Polymer({
-      is: 'some-element',
-      properties: {
-        fooBar: {
-          type: Object,
-          notify: true,
-        },
-      },
-    });
-  </script>
+  <script type="module">
+import '../../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({
+  is: 'some-element',
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+</script>
+
 </dom-element>
 
 <test-fixture id="basic">
@@ -48,53 +47,55 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-attribute-helper tests', () => {
-    let element;
-    let instance;
-    let sandbox;
+<script type="module">
+import {GrAttributeHelper} from './gr-attribute-helper.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      instance = new GrAttributeHelper(element);
-    });
+suite('gr-attribute-helper tests', () => {
+  let element;
+  let instance;
+  let sandbox;
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('resolved on value change from undefined', () => {
-      const promise = instance.get('fooBar').then(value => {
-        assert.equal(value, 'foo! bar!');
-      });
-      element.fooBar = 'foo! bar!';
-      return promise;
-    });
-
-    test('resolves to current attribute value', () => {
-      element.fooBar = 'foo-foo-bar';
-      const promise = instance.get('fooBar').then(value => {
-        assert.equal(value, 'foo-foo-bar');
-      });
-      element.fooBar = 'no bar';
-      return promise;
-    });
-
-    test('bind', () => {
-      const stub = sandbox.stub();
-      element.fooBar = 'bar foo';
-      const unbind = instance.bind('fooBar', stub);
-      element.fooBar = 'partridge in a foo tree';
-      element.fooBar = 'five gold bars';
-      assert.equal(stub.callCount, 3);
-      assert.deepEqual(stub.args[0], ['bar foo']);
-      assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
-      assert.deepEqual(stub.args[2], ['five gold bars']);
-      stub.reset();
-      unbind();
-      instance.fooBar = 'ladies dancing';
-      assert.isFalse(stub.called);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    instance = new GrAttributeHelper(element);
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('resolved on value change from undefined', () => {
+    const promise = instance.get('fooBar').then(value => {
+      assert.equal(value, 'foo! bar!');
+    });
+    element.fooBar = 'foo! bar!';
+    return promise;
+  });
+
+  test('resolves to current attribute value', () => {
+    element.fooBar = 'foo-foo-bar';
+    const promise = instance.get('fooBar').then(value => {
+      assert.equal(value, 'foo-foo-bar');
+    });
+    element.fooBar = 'no bar';
+    return promise;
+  });
+
+  test('bind', () => {
+    const stub = sandbox.stub();
+    element.fooBar = 'bar foo';
+    const unbind = instance.bind('fooBar', stub);
+    element.fooBar = 'partridge in a foo tree';
+    element.fooBar = 'five gold bars';
+    assert.equal(stub.callCount, 3);
+    assert.deepEqual(stub.args[0], ['bar foo']);
+    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
+    assert.deepEqual(stub.args[2], ['five gold bars']);
+    stub.reset();
+    unbind();
+    instance.fooBar = 'ladies dancing';
+    assert.isFalse(stub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
deleted file mode 100644
index dd532e1..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.html
+++ /dev/null
@@ -1,22 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-change-metadata-api">
-  <script src="gr-change-metadata-api.js"></script>
-</dom-module>
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..8be50b1 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
@@ -14,26 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  function GrChangeMetadataApi(plugin) {
-    this._hook = null;
-    this.plugin = plugin;
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-api">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/** @constructor */
+export function GrChangeMetadataApi(plugin) {
+  this._hook = null;
+  this.plugin = plugin;
+}
+
+GrChangeMetadataApi.prototype._createHook = function() {
+  this._hook = this.plugin.hook('change-metadata-item');
+};
+
+GrChangeMetadataApi.prototype.onLabelsChanged = function(callback) {
+  if (!this._hook) {
+    this._createHook();
   }
-
-  GrChangeMetadataApi.prototype._createHook = function() {
-    this._hook = this.plugin.hook('change-metadata-item');
-  };
-
-  GrChangeMetadataApi.prototype.onLabelsChanged = function(callback) {
-    if (!this._hook) {
-      this._createHook();
-    }
-    this._hook.onAttached(element =>
-      this.plugin.attributeHelper(element).bind('labels', callback));
-    return this;
-  };
-
-  window.GrChangeMetadataApi = GrChangeMetadataApi;
-})(window);
+  this._hook.onAttached(element =>
+    this.plugin.attributeHelper(element).bind('labels', callback));
+  return this;
+};
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
deleted file mode 100644
index 8b9000f..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-dom-hooks">
-  <script src="gr-dom-hooks.js"></script>
-</dom-module>
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 c0f111a..b998733 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
@@ -14,132 +14,155 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  function GrDomHooksManager(plugin) {
-    this._plugin = plugin;
-    this._hooks = {};
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-dom-hooks">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/** @constructor */
+export function GrDomHooksManager(plugin) {
+  this._plugin = plugin;
+  this._hooks = {};
+}
+
+GrDomHooksManager.prototype._getHookName = function(endpointName,
+    opt_moduleName) {
+  if (opt_moduleName) {
+    return endpointName + ' ' + opt_moduleName;
+  } else {
+    // lowercase in case plugin's name contains uppercase letters
+    // TODO: this still can not prevent if plugin has invalid char
+    // other than uppercase, but is the first step
+    // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
+    const pluginName = this._plugin.getPluginName() || 'unknown_plugin';
+    return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
   }
+};
 
-  GrDomHooksManager.prototype._getHookName = function(endpointName,
-      opt_moduleName) {
-    if (opt_moduleName) {
-      return endpointName + ' ' + opt_moduleName;
-    } else {
-      // lowercase in case plugin's name contains uppercase letters
-      // TODO: this still can not prevent if plugin has invalid char
-      // other than uppercase, but is the first step
-      // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
-      const pluginName = this._plugin.getPluginName() || 'unknown_plugin';
-      return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
-    }
-  };
-
-  GrDomHooksManager.prototype.getDomHook = function(endpointName,
-      opt_moduleName) {
-    const hookName = this._getHookName(endpointName, opt_moduleName);
-    if (!this._hooks[hookName]) {
-      this._hooks[hookName] = new GrDomHook(hookName, opt_moduleName);
-    }
-    return this._hooks[hookName];
-  };
-
-  function GrDomHook(hookName, opt_moduleName) {
-    this._instances = [];
-    this._callbacks = [];
-    if (opt_moduleName) {
-      this._moduleName = opt_moduleName;
-    } else {
-      this._moduleName = hookName;
-      this._createPlaceholder(hookName);
-    }
+GrDomHooksManager.prototype.getDomHook = function(endpointName,
+    opt_moduleName) {
+  const hookName = this._getHookName(endpointName, opt_moduleName);
+  if (!this._hooks[hookName]) {
+    this._hooks[hookName] = new GrDomHook(hookName, opt_moduleName);
   }
+  return this._hooks[hookName];
+};
 
-  GrDomHook.prototype._createPlaceholder = function(hookName) {
-    Polymer({
-      is: hookName,
-      properties: {
-        plugin: Object,
-        content: Object,
-      },
+/** @constructor */
+export function GrDomHook(hookName, opt_moduleName) {
+  this._instances = [];
+  this._attachCallbacks = [];
+  this._detachCallbacks = [];
+  if (opt_moduleName) {
+    this._moduleName = opt_moduleName;
+  } else {
+    this._moduleName = hookName;
+    this._createPlaceholder(hookName);
+  }
+}
+
+GrDomHook.prototype._createPlaceholder = function(hookName) {
+  Polymer({
+    is: hookName,
+    properties: {
+      plugin: Object,
+      content: Object,
+    },
+  });
+};
+
+GrDomHook.prototype.handleInstanceDetached = function(instance) {
+  const index = this._instances.indexOf(instance);
+  if (index !== -1) {
+    this._instances.splice(index, 1);
+  }
+  this._detachCallbacks.forEach(callback => callback(instance));
+};
+
+GrDomHook.prototype.handleInstanceAttached = function(instance) {
+  this._instances.push(instance);
+  this._attachCallbacks.forEach(callback => callback(instance));
+};
+
+/**
+ * Get instance of last DOM hook element attached into the endpoint.
+ * Returns a Promise, that's resolved when attachment is done.
+ *
+ * @return {!Promise<!Element>}
+ */
+GrDomHook.prototype.getLastAttached = function() {
+  if (this._instances.length) {
+    return Promise.resolve(this._instances.slice(-1)[0]);
+  }
+  if (!this._lastAttachedPromise) {
+    let resolve;
+    const promise = new Promise(r => resolve = r);
+    this._attachCallbacks.push(resolve);
+    this._lastAttachedPromise = promise.then(element => {
+      this._lastAttachedPromise = null;
+      const index = this._attachCallbacks.indexOf(resolve);
+      if (index !== -1) {
+        this._attachCallbacks.splice(index, 1);
+      }
+      return element;
     });
-  };
+  }
+  return this._lastAttachedPromise;
+};
 
-  GrDomHook.prototype.handleInstanceDetached = function(instance) {
-    const index = this._instances.indexOf(instance);
-    if (index !== -1) {
-      this._instances.splice(index, 1);
-    }
-  };
+/**
+ * Get all DOM hook elements.
+ */
+GrDomHook.prototype.getAllAttached = function() {
+  return this._instances;
+};
 
-  GrDomHook.prototype.handleInstanceAttached = function(instance) {
-    this._instances.push(instance);
-    this._callbacks.forEach(callback => callback(instance));
-  };
+/**
+ * Install a new callback to invoke when a new instance of DOM hook element
+ * is attached.
+ *
+ * @param {function(Element)} callback
+ */
+GrDomHook.prototype.onAttached = function(callback) {
+  this._attachCallbacks.push(callback);
+  return this;
+};
 
-  /**
-   * Get instance of last DOM hook element attached into the endpoint.
-   * Returns a Promise, that's resolved when attachment is done.
-   *
-   * @return {!Promise<!Element>}
-   */
-  GrDomHook.prototype.getLastAttached = function() {
-    if (this._instances.length) {
-      return Promise.resolve(this._instances.slice(-1)[0]);
-    }
-    if (!this._lastAttachedPromise) {
-      let resolve;
-      const promise = new Promise(r => resolve = r);
-      this._callbacks.push(resolve);
-      this._lastAttachedPromise = promise.then(element => {
-        this._lastAttachedPromise = null;
-        const index = this._callbacks.indexOf(resolve);
-        if (index !== -1) {
-          this._callbacks.splice(index, 1);
-        }
-        return element;
-      });
-    }
-    return this._lastAttachedPromise;
-  };
+/**
+ * Install a new callback to invoke when an instance of DOM hook element
+ * is detached.
+ *
+ * @param {function(Element)} callback
+ */
+GrDomHook.prototype.onDetached = function(callback) {
+  this._detachCallbacks.push(callback);
+  return this;
+};
 
-  /**
-   * Get all DOM hook elements.
-   */
-  GrDomHook.prototype.getAllAttached = function() {
-    return this._instances;
-  };
+/**
+ * Name of DOM hook element that will be installed into the endpoint.
+ */
+GrDomHook.prototype.getModuleName = function() {
+  return this._moduleName;
+};
 
-  /**
-   * Install a new callback to invoke when a new instance of DOM hook element
-   * is attached.
-   *
-   * @param {function(Element)} callback
-   */
-  GrDomHook.prototype.onAttached = function(callback) {
-    this._callbacks.push(callback);
-    return this;
-  };
-
-  /**
-   * Name of DOM hook element that will be installed into the endpoint.
-   */
-  GrDomHook.prototype.getModuleName = function() {
-    return this._moduleName;
-  };
-
-  GrDomHook.prototype.getPublicAPI = function() {
-    const result = {};
-    const exposedMethods = [
-      'onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName',
-    ];
-    for (const p of exposedMethods) {
-      result[p] = this[p].bind(this);
-    }
-    return result;
-  };
-
-  window.GrDomHook = GrDomHook;
-  window.GrDomHooksManager = GrDomHooksManager;
-})(window);
+GrDomHook.prototype.getPublicAPI = function() {
+  const result = {};
+  const exposedMethods = [
+    'onAttached',
+    'onDetached',
+    'getLastAttached',
+    'getAllAttached',
+    'getModuleName',
+  ];
+  for (const p of exposedMethods) {
+    result[p] = this[p].bind(this);
+  }
+  return result;
+};
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..17a22e9 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,16 +18,11 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dom-hooks.html"/>
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,112 +30,137 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dom-hooks tests', () => {
-    const PUBLIC_METHODS =
-        ['onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName'];
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    let instance;
-    let sandbox;
-    let hook;
-    let hookInternal;
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrDomHooksManager(plugin);
+suite('gr-dom-hooks tests', () => {
+  const PUBLIC_METHODS =[
+    'onAttached',
+    'onDetached',
+    'getLastAttached',
+    'getAllAttached',
+    'getModuleName',
+  ];
+
+  let instance;
+  let sandbox;
+  let hook;
+  let hookInternal;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrDomHooksManager(plugin);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('placeholder', () => {
+    setup(()=>{
+      sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
+      hookInternal = instance.getDomHook('foo-bar');
+      hook = hookInternal.getPublicAPI();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('public hook API has only public methods', () => {
+      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
     });
 
-    suite('placeholder', () => {
-      setup(()=>{
-        sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
-        hookInternal = instance.getDomHook('foo-bar');
-        hook = hookInternal.getPublicAPI();
-      });
-
-      test('public hook API has only public methods', () => {
-        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
-      });
-
-      test('registers placeholder class', () => {
-        assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
-            'testplugin-autogenerated-foo-bar'));
-      });
-
-      test('getModuleName()', () => {
-        const hookName = Object.keys(instance._hooks).pop();
-        assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
-        assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
-      });
+    test('registers placeholder class', () => {
+      assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
+          'testplugin-autogenerated-foo-bar'));
     });
 
-    suite('custom element', () => {
-      setup(() => {
-        hookInternal = instance.getDomHook('foo-bar', 'my-el');
-        hook = hookInternal.getPublicAPI();
-      });
-
-      test('public hook API has only public methods', () => {
-        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
-      });
-
-      test('getModuleName()', () => {
-        const hookName = Object.keys(instance._hooks).pop();
-        assert.equal(hookName, 'foo-bar my-el');
-        assert.equal(hook.getModuleName(), 'my-el');
-      });
-
-      test('onAttached', () => {
-        const onAttachedSpy = sandbox.spy();
-        hook.onAttached(onAttachedSpy);
-        const [el1, el2] = [
-          document.createElement(hook.getModuleName()),
-          document.createElement(hook.getModuleName()),
-        ];
-        hookInternal.handleInstanceAttached(el1);
-        hookInternal.handleInstanceAttached(el2);
-        assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
-        assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
-      });
-
-      test('getAllAttached', () => {
-        const [el1, el2] = [
-          document.createElement(hook.getModuleName()),
-          document.createElement(hook.getModuleName()),
-        ];
-        el1.textContent = 'one';
-        el2.textContent = 'two';
-        hookInternal.handleInstanceAttached(el1);
-        hookInternal.handleInstanceAttached(el2);
-        assert.deepEqual([el1, el2], hook.getAllAttached());
-        hookInternal.handleInstanceDetached(el1);
-        assert.deepEqual([el2], hook.getAllAttached());
-      });
-
-      test('getLastAttached', () => {
-        const beforeAttachedPromise = hook.getLastAttached().then(
-            el => assert.strictEqual(el1, el));
-        const [el1, el2] = [
-          document.createElement(hook.getModuleName()),
-          document.createElement(hook.getModuleName()),
-        ];
-        el1.textContent = 'one';
-        el2.textContent = 'two';
-        hookInternal.handleInstanceAttached(el1);
-        hookInternal.handleInstanceAttached(el2);
-        const afterAttachedPromise = hook.getLastAttached().then(
-            el => assert.strictEqual(el2, el));
-        return Promise.all([
-          beforeAttachedPromise,
-          afterAttachedPromise,
-        ]);
-      });
+    test('getModuleName()', () => {
+      const hookName = Object.keys(instance._hooks).pop();
+      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
+      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
     });
   });
+
+  suite('custom element', () => {
+    setup(() => {
+      hookInternal = instance.getDomHook('foo-bar', 'my-el');
+      hook = hookInternal.getPublicAPI();
+    });
+
+    test('public hook API has only public methods', () => {
+      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+    });
+
+    test('getModuleName()', () => {
+      const hookName = Object.keys(instance._hooks).pop();
+      assert.equal(hookName, 'foo-bar my-el');
+      assert.equal(hook.getModuleName(), 'my-el');
+    });
+
+    test('onAttached', () => {
+      const onAttachedSpy = sandbox.spy();
+      hook.onAttached(onAttachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('onDetached', () => {
+      const onDetachedSpy = sandbox.spy();
+      hook.onDetached(onDetachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hookInternal.handleInstanceDetached(el1);
+      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
+      hookInternal.handleInstanceDetached(el2);
+      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('getAllAttached', () => {
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      assert.deepEqual([el1, el2], hook.getAllAttached());
+      hookInternal.handleInstanceDetached(el1);
+      assert.deepEqual([el2], hook.getAllAttached());
+    });
+
+    test('getLastAttached', () => {
+      const beforeAttachedPromise = hook.getLastAttached().then(
+          el => assert.strictEqual(el1, el));
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      const afterAttachedPromise = hook.getLastAttached().then(
+          el => assert.strictEqual(el2, el));
+      return Promise.all([
+        beforeAttachedPromise,
+        afterAttachedPromise,
+      ]);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
deleted file mode 100644
index ab892ac..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
+++ /dev/null
@@ -1,26 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-endpoint-decorator">
-  <template strip-whitespace>
-    <slot></slot>
-  </template>
-  <script src="gr-endpoint-decorator.js"></script>
-</dom-module>
\ No newline at end of file
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 b38107e..b40ea15 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
@@ -14,15 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const INIT_PROPERTIES_TIMEOUT_MS = 10000;
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {importHref} from '../../../scripts/import-href.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-endpoint-decorator_html.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-  Polymer({
-    is: 'gr-endpoint-decorator',
+const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
-    properties: {
+/** @extends Polymer.Element */
+class GrEndpointDecorator extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-endpoint-decorator'; }
+
+  static get properties() {
+    return {
       name: String,
       /** @type {!Map} */
       _domHooks: {
@@ -40,118 +55,138 @@
         type: Map,
         value() { return new Map(); },
       },
-    },
+    };
+  }
 
-    detached() {
-      for (const [el, domHook] of this._domHooks) {
-        domHook.handleInstanceDetached(el);
-      }
-      Gerrit._endpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    for (const [el, domHook] of this._domHooks) {
+      domHook.handleInstanceDetached(el);
+    }
+    pluginEndpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
+  }
 
-    /**
-     * @suppress {checkTypes}
-     */
-    _import(url) {
-      return new Promise((resolve, reject) => {
-        (this.importHref || Polymer.importHref)(url, resolve, reject);
-      });
-    },
+  /**
+   * @suppress {checkTypes}
+   */
+  _import(url) {
+    return new Promise((resolve, reject) => {
+      importHref(url, resolve, reject);
+    });
+  }
 
-    _initDecoration(name, plugin) {
-      const el = document.createElement(name);
-      return this._initProperties(el, plugin,
-          this.getContentChildren().find(
-              el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
-          .then(el => this._appendChild(el));
-    },
+  _initDecoration(name, plugin, slot) {
+    const el = document.createElement(name);
+    return this._initProperties(el, plugin,
+        this.getContentChildren().find(
+            el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
+        .then(el => {
+          const slotEl = slot ?
+            dom(this).querySelector(`gr-endpoint-slot[name=${slot}]`) :
+            null;
+          if (slot && slotEl) {
+            slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
+          } else {
+            this._appendChild(el);
+          }
+          return el;
+        });
+  }
 
-    _initReplacement(name, plugin) {
-      this.getContentChildNodes()
-          .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
-          .forEach(node => node.remove());
-      const el = document.createElement(name);
-      return this._initProperties(el, plugin).then(
-          el => this._appendChild(el));
-    },
+  _initReplacement(name, plugin) {
+    this.getContentChildNodes()
+        .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
+        .forEach(node => node.remove());
+    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'));
-    },
+  _getEndpointParams() {
+    return Array.from(
+        dom(this).querySelectorAll('gr-endpoint-param'));
+  }
 
-    /**
-     * @param {!Element} el
-     * @param {!Object} plugin
-     * @param {!Element=} opt_content
-     * @return {!Promise<Element>}
-     */
-    _initProperties(el, plugin, opt_content) {
-      el.plugin = plugin;
-      if (opt_content) {
-        el.content = opt_content;
-      }
-      const expectProperties = this._getEndpointParams().map(paramEl => {
-        const helper = plugin.attributeHelper(paramEl);
-        const paramName = paramEl.getAttribute('name');
-        return helper.get('value').then(
-            value => helper.bind('value',
-                value => plugin.attributeHelper(el).set(paramName, value))
-        );
-      });
-      let timeoutId;
-      const timeout = new Promise(
-          resolve => timeoutId = setTimeout(() => {
-            console.warn(
-                'Timeout waiting for endpoint properties initialization: ' +
-              `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
-          }, INIT_PROPERTIES_TIMEOUT_MS));
-      return Promise.race([timeout, Promise.all(expectProperties)])
-          .then(() => {
-            clearTimeout(timeoutId);
-            return el;
-          });
-    },
-
-    _appendChild(el) {
-      return Polymer.dom(this.root).appendChild(el);
-    },
-
-    _initModule({moduleName, plugin, type, domHook}) {
-      const name = plugin.getPluginName() + '.' + moduleName;
-      if (this._initializedPlugins.get(name)) {
-        return;
-      }
-      let initPromise;
-      switch (type) {
-        case 'decorate':
-          initPromise = this._initDecoration(moduleName, plugin);
-          break;
-        case 'replace':
-          initPromise = this._initReplacement(moduleName, plugin);
-          break;
-      }
-      if (!initPromise) {
-        console.warn('Unable to initialize module ' + name);
-      }
-      this._initializedPlugins.set(name, true);
-      initPromise.then(el => {
-        domHook.handleInstanceAttached(el);
-        this._domHooks.set(el, domHook);
-      });
-    },
-
-    ready() {
-      this._endpointCallBack = this._initModule.bind(this);
-      Gerrit._endpoints.onNewEndpoint(this.name, this._endpointCallBack);
-      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)
+  /**
+   * @param {!Element} el
+   * @param {!Object} plugin
+   * @param {!Element=} opt_content
+   * @return {!Promise<Element>}
+   */
+  _initProperties(el, plugin, opt_content) {
+    el.plugin = plugin;
+    if (opt_content) {
+      el.content = opt_content;
+    }
+    const expectProperties = this._getEndpointParams().map(paramEl => {
+      const helper = plugin.attributeHelper(paramEl);
+      const paramName = paramEl.getAttribute('name');
+      return helper.get('value').then(
+          value => helper.bind('value',
+              value => plugin.attributeHelper(el).set(paramName, value))
       );
-    },
-  });
-})();
+    });
+    let timeoutId;
+    const timeout = new Promise(
+        resolve => timeoutId = setTimeout(() => {
+          console.warn(
+              'Timeout waiting for endpoint properties initialization: ' +
+            `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
+        }, INIT_PROPERTIES_TIMEOUT_MS));
+    return Promise.race([timeout, Promise.all(expectProperties)])
+        .then(() => {
+          clearTimeout(timeoutId);
+          return el;
+        });
+  }
+
+  _appendChild(el) {
+    return dom(this.root).appendChild(el);
+  }
+
+  _initModule({moduleName, plugin, type, domHook, slot}) {
+    const name = plugin.getPluginName() + '.' + moduleName;
+    if (this._initializedPlugins.get(name)) {
+      return;
+    }
+    let initPromise;
+    switch (type) {
+      case 'decorate':
+        initPromise = this._initDecoration(moduleName, plugin, slot);
+        break;
+      case 'replace':
+        initPromise = this._initReplacement(moduleName, plugin);
+        break;
+    }
+    if (!initPromise) {
+      console.warn('Unable to initialize module ' + name);
+    }
+    this._initializedPlugins.set(name, true);
+    initPromise.then(el => {
+      domHook.handleInstanceAttached(el);
+      this._domHooks.set(el, domHook);
+    });
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._endpointCallBack = this._initModule.bind(this);
+    pluginEndpoints.onNewEndpoint(this.name, this._endpointCallBack);
+    if (this.name) {
+      pluginLoader.awaitPluginsLoaded()
+          .then(() => Promise.all(
+              pluginEndpoints.getPlugins(this.name).map(
+                  pluginUrl => this._import(pluginUrl)))
+          )
+          .then(() =>
+            pluginEndpoints
+                .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_html.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
new file mode 100644
index 0000000..c4310fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html` <slot></slot> `;
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..890a457 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,22 +18,21 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-endpoint-decorator.html">
-<link rel="import" href="../gr-endpoint-param/gr-endpoint-param.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
     <div>
       <gr-endpoint-decorator name="first">
         <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
+        <p>
+          <span>test slot</span>
+          <gr-endpoint-slot name="test"></gr-endpoint-slot>
+        </p>
       </gr-endpoint-decorator>
       <gr-endpoint-decorator name="second">
         <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
@@ -45,145 +44,185 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-endpoint-decorator', () => {
-    let container;
-    let sandbox;
-    let plugin;
-    let decorationHook;
-    let replacementHook;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-endpoint-decorator.js';
+import '../gr-endpoint-param/gr-endpoint-param.js';
+import '../gr-endpoint-slot/gr-endpoint-slot.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-endpoint-decorator', {
-        _import: sandbox.stub().returns(Promise.resolve()),
-      });
-      Gerrit._testOnly_resetPlugins();
-      container = fixture('basic');
-      Gerrit.install(p => plugin = p, '0.1', 'http://some/plugin/url.html');
-      // Decoration
-      decorationHook = plugin.registerCustomComponent('first', 'some-module');
-      // Replacement
-      replacementHook = plugin.registerCustomComponent(
-          'second', 'other-module', {replace: true});
-      // Mimic all plugins loaded.
-      Gerrit._loadPlugins([]);
-      flush(done);
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-endpoint-decorator', () => {
+  let container;
+  let sandbox;
+  let plugin;
+  let decorationHook;
+  let decorationHookWithSlot;
+  let replacementHook;
+
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-endpoint-decorator', {
+      _import: sandbox.stub().returns(Promise.resolve()),
     });
+    resetPlugins();
+    container = fixture('basic');
+    pluginApi.install(p => plugin = p, '0.1',
+        'http://some/plugin/url.html');
+    // Decoration
+    decorationHook = plugin.registerCustomComponent('first', 'some-module');
+    decorationHookWithSlot = plugin.registerCustomComponent(
+        'first',
+        'some-module-2',
+        {slot: 'test'}
+    );
+    // Replacement
+    replacementHook = plugin.registerCustomComponent(
+        'second', 'other-module', {replace: true});
+    // Mimic all plugins loaded.
+    pluginLoader.loadPlugins([]);
+    flush(done);
+  });
 
-    teardown(() => {
-      sandbox.restore();
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('imports plugin-provided modules into endpoints', () => {
+    const endpoints =
+        Array.from(container.querySelectorAll('gr-endpoint-decorator'));
+    assert.equal(endpoints.length, 3);
+    endpoints.forEach(element => {
+      assert.isTrue(
+          element._import.calledWith(new URL('http://some/plugin/url.html')));
     });
+  });
 
-    test('imports plugin-provided modules into endpoints', () => {
-      const endpoints =
-          Array.from(container.querySelectorAll('gr-endpoint-decorator'));
-      assert.equal(endpoints.length, 3);
-      endpoints.forEach(element => {
-        assert.isTrue(
-            element._import.calledWith(new URL('http://some/plugin/url.html')));
-      });
-    });
+  test('decoration', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = Array.from(dom(element.root).children).filter(
+        element => element.nodeName === 'SOME-MODULE');
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'barbar');
+    return decorationHook.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
+        .then(() => {
+          element.remove();
+          assert.equal(decorationHook.getAllAttached().length, 0);
+        });
+  });
 
-    test('decoration', () => {
+  test('decoration with slot', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = [...dom(element).querySelectorAll('p > some-module-2')];
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'barbar');
+    return decorationHookWithSlot.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
+        .then(() => {
+          element.remove();
+          assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
+        });
+  });
+
+  test('replacement', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="second"]');
+    const module = Array.from(dom(element.root).children).find(
+        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);
+        });
+  });
+
+  test('late registration', done => {
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
       const element =
-          container.querySelector('gr-endpoint-decorator[name="first"]');
-      const modules = Array.from(Polymer.dom(element.root).children).filter(
-          element => element.nodeName === 'SOME-MODULE');
-      assert.equal(modules.length, 1);
-      const [module] = modules;
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
       assert.isOk(module);
-      assert.equal(module['someparam'], 'barbar');
-      return decorationHook.getLastAttached().then(element => {
-        assert.strictEqual(element, module);
-      }).then(() => {
-        element.remove();
-        assert.equal(decorationHook.getAllAttached().length, 0);
-      });
+      done();
     });
+  });
 
-    test('replacement', () => {
+  test('two modules', done => {
+    plugin.registerCustomComponent('banana', 'mod-one');
+    plugin.registerCustomComponent('banana', 'mod-two');
+    flush(() => {
       const element =
-          container.querySelector('gr-endpoint-decorator[name="second"]');
-      const module = Array.from(Polymer.dom(element.root).children).find(
-          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);
-      });
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const module1 = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'MOD-ONE');
+      assert.isOk(module1);
+      const module2 = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'MOD-TWO');
+      assert.isOk(module2);
+      done();
     });
+  });
 
-    test('late registration', done => {
-      plugin.registerCustomComponent('banana', 'noob-noob');
+  test('late param setup', done => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const param = dom(element).querySelector('gr-endpoint-param');
+    param['value'] = undefined;
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      let module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      // Module waits for param to be defined.
+      assert.isNotOk(module);
+      const value = {abc: 'def'};
+      param.value = value;
       flush(() => {
-        const element =
-            container.querySelector('gr-endpoint-decorator[name="banana"]');
-        const module = Array.from(Polymer.dom(element.root).children).find(
+        module = Array.from(dom(element.root).children).find(
             element => element.nodeName === 'NOOB-NOOB');
         assert.isOk(module);
-        done();
-      });
-    });
-
-    test('two modules', done => {
-      plugin.registerCustomComponent('banana', 'mod-one');
-      plugin.registerCustomComponent('banana', 'mod-two');
-      flush(() => {
-        const element =
-            container.querySelector('gr-endpoint-decorator[name="banana"]');
-        const module1 = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'MOD-ONE');
-        assert.isOk(module1);
-        const module2 = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'MOD-TWO');
-        assert.isOk(module2);
-        done();
-      });
-    });
-
-    test('late param setup', done => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const param = Polymer.dom(element).querySelector('gr-endpoint-param');
-      param['value'] = undefined;
-      plugin.registerCustomComponent('banana', 'noob-noob');
-      flush(() => {
-        let module = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'NOOB-NOOB');
-        // Module waits for param to be defined.
-        assert.isNotOk(module);
-        const value = {abc: 'def'};
-        param.value = value;
-        flush(() => {
-          module = Array.from(Polymer.dom(element.root).children).find(
-              element => element.nodeName === 'NOOB-NOOB');
-          assert.isOk(module);
-          assert.strictEqual(module['someParam'], value);
-          done();
-        });
-      });
-    });
-
-    test('param is bound', done => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const param = Polymer.dom(element).querySelector('gr-endpoint-param');
-      const value1 = {abc: 'def'};
-      const value2 = {def: 'abc'};
-      param.value = value1;
-      plugin.registerCustomComponent('banana', 'noob-noob');
-      flush(() => {
-        const module = Array.from(Polymer.dom(element.root).children).find(
-            element => element.nodeName === 'NOOB-NOOB');
-        assert.strictEqual(module['someParam'], value1);
-        param.value = value2;
-        assert.strictEqual(module['someParam'], value2);
+        assert.strictEqual(module['someParam'], value);
         done();
       });
     });
   });
+
+  test('param is bound', done => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const param = dom(element).querySelector('gr-endpoint-param');
+    const value1 = {abc: 'def'};
+    const value2 = {def: 'abc'};
+    param.value = value1;
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      const module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      assert.strictEqual(module['someParam'], value1);
+      param.value = value2;
+      assert.strictEqual(module['someParam'], value2);
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
deleted file mode 100644
index 6a5b558..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-endpoint-param">
-  <script src="gr-endpoint-param.js"></script>
-</dom-module>
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..9574391 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
@@ -14,34 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-endpoint-param',
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrEndpointParam extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-endpoint-param'; }
+
+  static get properties() {
+    return {
       name: String,
       value: {
         type: Object,
         notify: true,
         observer: '_valueChanged',
       },
-    },
+    };
+  }
 
-    _valueChanged(newValue, oldValue) {
-      /* In polymer 2 the following change was made:
-      "Property change notifications (property-changed events) aren't fired when
-      the value changes as a result of a binding from the host"
-      (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
-      To workaround this problem, we fire the event from the observer.
-      In some cases this fire the event twice, but our code is
-      ready for it.
-      */
-      const detail = {
-        value: newValue,
-      };
-      this.dispatchEvent(new CustomEvent('value-changed', {detail}));
-    },
-  });
-})();
+  _valueChanged(newValue, oldValue) {
+    /* In polymer 2 the following change was made:
+    "Property change notifications (property-changed events) aren't fired when
+    the value changes as a result of a binding from the host"
+    (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
+    To workaround this problem, we fire the event from the observer.
+    In some cases this fire the event twice, but our code is
+    ready for it.
+    */
+    const detail = {
+      value: newValue,
+    };
+    this.dispatchEvent(new CustomEvent('value-changed', {detail}));
+  }
+}
+
+customElements.define(GrEndpointParam.is, GrEndpointParam);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
new file mode 100644
index 0000000..9ee9c3d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+class GrEndpointSlot extends PolymerElement {
+  static get is() { return 'gr-endpoint-slot'; }
+
+  static get properties() {
+    return {
+      name: String,
+    };
+  }
+}
+
+customElements.define(GrEndpointSlot.is, GrEndpointSlot);
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
deleted file mode 100644
index 15db861..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
+++ /dev/null
@@ -1,23 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-
-<dom-module id="gr-event-helper">
-  <script src="gr-event-helper.js"></script>
-</dom-module>
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..63d40fc 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
@@ -14,92 +14,98 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  function GrEventHelper(element) {
-    this.element = element;
-    this._unsubscribers = [];
-  }
+const $_documentContainer = document.createElement('template');
 
-  /**
-   * Add a callback to arbitrary event.
-   * The callback may return false to prevent event bubbling.
-   *
-   * @param {string} event Event name
-   * @param {function(Event):boolean} callback
-   * @return {function()} Unsubscribe function.
-   */
-  GrEventHelper.prototype.on = function(event, callback) {
-    return this._listen(this.element, callback, {event});
-  };
+$_documentContainer.innerHTML = `<dom-module id="gr-event-helper">
+  
+</dom-module>`;
 
-  /**
-   * Alias of onClick
-   *
-   * @see onClick
-   */
-  GrEventHelper.prototype.onTap = function(callback) {
-    return this._listen(this.element, callback);
-  };
+document.head.appendChild($_documentContainer.content);
 
-  /**
-   * Add a callback to element click or touch.
-   * The callback may return false to prevent event bubbling.
-   *
-   * @param {function(Event):boolean} callback
-   * @return {function()} Unsubscribe function.
-   */
-  GrEventHelper.prototype.onClick = function(callback) {
-    return this._listen(this.element, callback);
-  };
+/** @constructor */
+export function GrEventHelper(element) {
+  this.element = element;
+  this._unsubscribers = [];
+}
 
-  /**
-   * Alias of captureClick
-   *
-   * @see captureClick
-   */
-  GrEventHelper.prototype.captureTap = function(callback) {
-    return this._listen(this.element.parentElement, callback, {capture: true});
-  };
+/**
+ * Add a callback to arbitrary event.
+ * The callback may return false to prevent event bubbling.
+ *
+ * @param {string} event Event name
+ * @param {function(Event):boolean} callback
+ * @return {function()} Unsubscribe function.
+ */
+GrEventHelper.prototype.on = function(event, callback) {
+  return this._listen(this.element, callback, {event});
+};
 
-  /**
-   * Add a callback to element click or touch ahead of normal flow.
-   * Callback is installed on parent during capture phase.
-   * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
-   * The callback may return false to cancel regular event listeners.
-   *
-   * @param {function(Event):boolean} callback
-   * @return {function()} Unsubscribe function.
-   */
-  GrEventHelper.prototype.captureClick = function(callback) {
-    return this._listen(this.element.parentElement, callback, {capture: true});
-  };
+/**
+ * Alias of onClick
+ *
+ * @see onClick
+ */
+GrEventHelper.prototype.onTap = function(callback) {
+  return this._listen(this.element, callback);
+};
 
-  GrEventHelper.prototype._listen = function(container, callback, opt_options) {
-    const capture = opt_options && opt_options.capture;
-    const event = opt_options && opt_options.event || 'click';
-    const handler = e => {
-      if (e.path.indexOf(this.element) !== -1) {
-        let mayContinue = true;
-        try {
-          mayContinue = callback(e);
-        } catch (e) {
-          console.warn(`Plugin error handing event: ${e}`);
-        }
-        if (mayContinue === false) {
-          e.stopImmediatePropagation();
-          e.stopPropagation();
-          e.preventDefault();
-        }
+/**
+ * Add a callback to element click or touch.
+ * The callback may return false to prevent event bubbling.
+ *
+ * @param {function(Event):boolean} callback
+ * @return {function()} Unsubscribe function.
+ */
+GrEventHelper.prototype.onClick = function(callback) {
+  return this._listen(this.element, callback);
+};
+
+/**
+ * Alias of captureClick
+ *
+ * @see captureClick
+ */
+GrEventHelper.prototype.captureTap = function(callback) {
+  return this._listen(this.element.parentElement, callback, {capture: true});
+};
+
+/**
+ * Add a callback to element click or touch ahead of normal flow.
+ * Callback is installed on parent during capture phase.
+ * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
+ * The callback may return false to cancel regular event listeners.
+ *
+ * @param {function(Event):boolean} callback
+ * @return {function()} Unsubscribe function.
+ */
+GrEventHelper.prototype.captureClick = function(callback) {
+  return this._listen(this.element.parentElement, callback, {capture: true});
+};
+
+GrEventHelper.prototype._listen = function(container, callback, opt_options) {
+  const capture = opt_options && opt_options.capture;
+  const event = opt_options && opt_options.event || 'click';
+  const handler = e => {
+    if (e.path.indexOf(this.element) !== -1) {
+      let mayContinue = true;
+      try {
+        mayContinue = callback(e);
+      } catch (e) {
+        console.warn(`Plugin error handing event: ${e}`);
       }
-    };
-    container.addEventListener(event, handler, capture);
-    const unsubscribe = () =>
-      container.removeEventListener(event, handler, capture);
-    this._unsubscribers.push(unsubscribe);
-    return unsubscribe;
+      if (mayContinue === false) {
+        e.stopImmediatePropagation();
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    }
   };
+  container.addEventListener(event, handler, capture);
+  const unsubscribe = () =>
+    container.removeEventListener(event, handler, capture);
+  this._unsubscribers.push(unsubscribe);
+  return unsubscribe;
+};
 
-  window.GrEventHelper = GrEventHelper;
-})(window);
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..a27c817 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,33 +18,28 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-event-helper.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <dom-element id="some-element">
-  <script>
-    Polymer({
-      is: 'some-element',
+  <script type="module">
+import '../../../test/common-test-setup.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({
+  is: 'some-element',
 
-      properties: {
-        fooBar: {
-          type: Object,
-          notify: true,
-        },
-      },
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+</script>
 
-      behaviors: [
-        Gerrit.FireBehavior,
-      ],
-    });
-  </script>
 </dom-element>
 
 <test-fixture id="basic">
@@ -53,84 +48,91 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-event-helper tests', () => {
-    let element;
-    let instance;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+import {GrEventHelper} from './gr-event-helper.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      instance = new GrEventHelper(element);
-    });
+suite('gr-event-helper tests', () => {
+  let element;
+  let instance;
+  let sandbox;
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('onTap()', done => {
-      instance.onTap(() => {
-        done();
-      });
-      MockInteractions.tap(element);
-    });
-
-    test('onTap() cancel', () => {
-      const tapStub = sandbox.stub();
-      Polymer.Gestures.addListener(element.parentElement, 'tap', tapStub);
-      instance.onTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('onClick() cancel', () => {
-      const tapStub = sandbox.stub();
-      element.parentElement.addEventListener('click', tapStub);
-      instance.onTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('captureTap()', done => {
-      instance.captureTap(() => {
-        done();
-      });
-      MockInteractions.tap(element);
-    });
-
-    test('captureClick()', done => {
-      instance.captureClick(() => {
-        done();
-      });
-      MockInteractions.tap(element);
-    });
-
-    test('captureTap() cancels tap()', () => {
-      const tapStub = sandbox.stub();
-      Polymer.Gestures.addListener(element.parentElement, 'tap', tapStub);
-      instance.captureTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('captureClick() cancels click()', () => {
-      const tapStub = sandbox.stub();
-      element.addEventListener('click', tapStub);
-      instance.captureTap(() => false);
-      MockInteractions.tap(element);
-      flushAsynchronousOperations();
-      assert.isFalse(tapStub.called);
-    });
-
-    test('on()', done => {
-      instance.on('foo', () => {
-        done();
-      });
-      element.fire('foo');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    instance = new GrEventHelper(element);
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('onTap()', done => {
+    instance.onTap(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('onTap() cancel', () => {
+    const tapStub = sandbox.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('onClick() cancel', () => {
+    const tapStub = sandbox.stub();
+    element.parentElement.addEventListener('click', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('captureTap()', done => {
+    instance.captureTap(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('captureClick()', done => {
+    instance.captureClick(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('captureTap() cancels tap()', () => {
+    const tapStub = sandbox.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('captureClick() cancels click()', () => {
+    const tapStub = sandbox.stub();
+    element.addEventListener('click', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('on()', done => {
+    instance.on('foo', () => {
+      done();
+    });
+    element.dispatchEvent(
+        new CustomEvent('foo', {
+          composed: true, bubbles: true,
+        }));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
deleted file mode 100644
index 6a55349..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
+++ /dev/null
@@ -1,26 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-external-style">
-  <template>
-    <slot></slot>
-  </template>
-  <script src="gr-external-style.js"></script>
-</dom-module>
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..68b1494 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
@@ -14,13 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-external-style',
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {importHref} from '../../../scripts/import-href.js';
+import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-external-style_html.js';
+import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrExternalStyle extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-external-style'; }
+
+  static get properties() {
+    return {
       name: String,
       _urlsImported: {
         type: Array,
@@ -30,52 +45,63 @@
         type: Array,
         value() { return []; },
       },
-    },
+    };
+  }
 
-    /**
-     * @suppress {checkTypes}
-     */
-    _import(url) {
-      if (this._urlsImported.includes(url)) { return Promise.resolve(); }
-      this._urlsImported.push(url);
-      return new Promise((resolve, reject) => {
-        (this.importHref || Polymer.importHref)(url, resolve, reject);
-      });
-    },
+  _importHref(url, resolve, reject) {
+    // It is impossible to mock es6-module imported function.
+    // The _importHref function is mocked in test.
+    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');
-      s.setAttribute('include', name);
-      const cs = document.createElement('custom-style');
-      cs.appendChild(s);
-      // When using Shadow DOM <custom-style> must be added to the <body>.
-      // Within <gr-external-style> itself the styles would have no effect.
-      const topEl = document.getElementsByTagName('body')[0];
-      topEl.insertBefore(cs, topEl.firstChild);
-      Polymer.updateStyles();
-    },
+  /**
+   * @suppress {checkTypes}
+   */
+  _import(url) {
+    if (this._urlsImported.includes(url)) { return Promise.resolve(); }
+    this._urlsImported.push(url);
+    return new Promise((resolve, reject) => {
+      this._importHref(url, resolve, reject);
+    });
+  }
 
-    _importAndApply() {
-      Promise.all(Gerrit._endpoints.getPlugins(this.name).map(
-          pluginUrl => this._import(pluginUrl))
-      ).then(() => {
-        const moduleNames = Gerrit._endpoints.getModules(this.name);
-        for (const name of moduleNames) {
-          this._applyStyle(name);
-        }
-      });
-    },
+  _applyStyle(name) {
+    if (this._stylesApplied.includes(name)) { return; }
+    this._stylesApplied.push(name);
 
-    attached() {
-      this._importAndApply();
-    },
+    const s = document.createElement('style');
+    s.setAttribute('include', name);
+    const cs = document.createElement('custom-style');
+    cs.appendChild(s);
+    // When using Shadow DOM <custom-style> must be added to the <body>.
+    // Within <gr-external-style> itself the styles would have no effect.
+    const topEl = document.getElementsByTagName('body')[0];
+    topEl.insertBefore(cs, topEl.firstChild);
+    updateStyles();
+  }
 
-    ready() {
-      Gerrit.awaitPluginsLoaded().then(() => this._importAndApply());
-    },
-  });
-})();
+  _importAndApply() {
+    Promise.all(pluginEndpoints.getPlugins(this.name).map(
+        pluginUrl => this._import(pluginUrl))
+    ).then(() => {
+      const moduleNames = pluginEndpoints.getModules(this.name);
+      for (const name of moduleNames) {
+        this._applyStyle(name);
+      }
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._importAndApply();
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    pluginLoader.awaitPluginsLoaded().then(() => this._importAndApply());
+  }
+}
+
+customElements.define(GrExternalStyle.is, GrExternalStyle);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
new file mode 100644
index 0000000..c4310fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html` <slot></slot> `;
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..8f85348 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,107 +18,116 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-external-style.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <gr-external-style name="foo"></gr-external-style>
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-external-style integration tests', () => {
-    const TEST_URL = 'http://some/plugin/url.html';
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-external-style.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    let sandbox;
-    let element;
-    let plugin;
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    const installPlugin = () => {
-      if (plugin) { return; }
-      Gerrit.install(p => {
-        plugin = p;
-      }, '0.1', TEST_URL);
-    };
+suite('gr-external-style integration tests', () => {
+  const TEST_URL = 'http://some/plugin/url.html';
 
-    const createElement = () => {
-      element = fixture('basic');
-      sandbox.spy(element, '_applyStyle');
-    };
+  let sandbox;
+  let element;
+  let plugin;
+  let importHrefStub;
 
-    /**
-     * Installs the plugin, creates the element, registers style module.
-     */
-    const lateRegister = () => {
-      installPlugin();
-      createElement();
-      plugin.registerStyleModule('foo', 'some-module');
-    };
+  const installPlugin = () => {
+    if (plugin) { return; }
+    pluginApi.install(p => {
+      plugin = p;
+    }, '0.1', TEST_URL);
+  };
 
-    /**
-     * Installs the plugin, registers style module, creates the element.
-     */
-    const earlyRegister = () => {
-      installPlugin();
-      plugin.registerStyleModule('foo', 'some-module');
-      createElement();
-    };
+  const createElement = () => {
+    element = fixture('basic');
+    sandbox.spy(element, '_applyStyle');
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-external-style', {
-        importHref: (url, resolve) => resolve(),
-      });
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+  /**
+   * Installs the plugin, creates the element, registers style module.
+   */
+  const lateRegister = () => {
+    installPlugin();
+    createElement();
+    plugin.registerStyleModule('foo', 'some-module');
+  };
+
+  /**
+   * Installs the plugin, registers style module, creates the element.
+   */
+  const earlyRegister = () => {
+    installPlugin();
+    plugin.registerStyleModule('foo', 'some-module');
+    createElement();
+  };
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    importHrefStub = sandbox.stub().callsArg(1);
+    stub('gr-external-style', {
+      _importHref: (url, resolve, reject) => {
+        importHrefStub(url, resolve, reject);
+      },
     });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('imports plugin-provided module', async () => {
-      lateRegister();
-      await new Promise(flush);
-      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
-    });
-
-    test('applies plugin-provided styles', async () => {
-      lateRegister();
-      await new Promise(flush);
-      assert.isTrue(element._applyStyle.calledWith('some-module'));
-    });
-
-    test('does not double import', async () => {
-      earlyRegister();
-      await new Promise(flush);
-      plugin.registerStyleModule('foo', 'some-module');
-      await new Promise(flush);
-      const urlsImported =
-          element._urlsImported.filter(url => url.toString() === TEST_URL);
-      assert.strictEqual(urlsImported.length, 1);
-    });
-
-    test('does not double apply', async () => {
-      earlyRegister();
-      await new Promise(flush);
-      plugin.registerStyleModule('foo', 'some-module');
-      await new Promise(flush);
-      const stylesApplied =
-          element._stylesApplied.filter(name => name === 'some-module');
-      assert.strictEqual(stylesApplied.length, 1);
-    });
-
-    test('loads and applies preloaded modules', async () => {
-      earlyRegister();
-      await new Promise(flush);
-      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
-      assert.isTrue(element._applyStyle.calledWith('some-module'));
-    });
+    sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('imports plugin-provided module', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
+  });
+
+  test('applies plugin-provided styles', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+
+  test('does not double import', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    const urlsImported =
+        element._urlsImported.filter(url => url.toString() === TEST_URL);
+    assert.strictEqual(urlsImported.length, 1);
+  });
+
+  test('does not double apply', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    const stylesApplied =
+        element._stylesApplied.filter(name => name === 'some-module');
+    assert.strictEqual(stylesApplied.length, 1);
+  });
+
+  test('loads and applies preloaded modules', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
deleted file mode 100644
index f277899..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
+++ /dev/null
@@ -1,24 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-plugin-host">
-  <script src="gr-plugin-host.js"></script>
-</dom-module>
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..d1b2106 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
@@ -14,52 +14,63 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-plugin-host',
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrPluginHost extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-plugin-host'; }
+
+  static get properties() {
+    return {
       config: {
         type: Object,
         observer: '_configChanged',
       },
-    },
+    };
+  }
 
-    _configChanged(config) {
-      const plugins = config.plugin;
-      const htmlPlugins = (plugins.html_resource_paths || []);
-      const jsPlugins =
-          this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
-      const shouldLoadTheme = config.default_theme &&
-            !Gerrit._isPluginPreloaded('preloaded:gerrit-theme');
-      const themeToLoad =
-            shouldLoadTheme ? [config.default_theme] : [];
+  _configChanged(config) {
+    const plugins = config.plugin;
+    const htmlPlugins = (plugins.html_resource_paths || []);
+    const jsPlugins =
+        this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
+    const shouldLoadTheme = config.default_theme &&
+          !pluginLoader.isPluginPreloaded('preloaded:gerrit-theme');
+    const themeToLoad =
+          shouldLoadTheme ? [config.default_theme] : [];
 
-      // Theme should be loaded first if has one to have better UX
-      const pluginsPending =
-          themeToLoad.concat(jsPlugins, htmlPlugins);
+    // Theme should be loaded first if has one to have better UX
+    const pluginsPending =
+        themeToLoad.concat(jsPlugins, htmlPlugins);
 
-      const pluginOpts = {};
+    const pluginOpts = {};
 
-      if (shouldLoadTheme) {
-        // Theme needs to be loaded synchronous.
-        pluginOpts[config.default_theme] = {sync: true};
-      }
+    if (shouldLoadTheme) {
+      // Theme needs to be loaded synchronous.
+      pluginOpts[config.default_theme] = {sync: true};
+    }
 
-      Gerrit._loadPlugins(pluginsPending, pluginOpts);
-    },
+    pluginLoader.loadPlugins(pluginsPending, pluginOpts);
+  }
 
-    /**
-     * Omit .js plugins that have .html counterparts.
-     * For example, if plugin provides foo.js and foo.html, skip foo.js.
-     */
-    _handleMigrations(jsPlugins, htmlPlugins) {
-      return jsPlugins.filter(url => {
-        const counterpart = url.replace(/\.js$/, '.html');
-        return !htmlPlugins.includes(counterpart);
-      });
-    },
-  });
-})();
+  /**
+   * Omit .js plugins that have .html counterparts.
+   * For example, if plugin provides foo.js and foo.html, skip foo.js.
+   */
+  _handleMigrations(jsPlugins, htmlPlugins) {
+    return jsPlugins.filter(url => {
+      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..2c8a8c0 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,15 +18,11 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-plugin-host.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,61 +30,65 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-host tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-plugin-host.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(document.body, 'appendChild');
-      sandbox.stub(element, 'importHref');
-    });
+suite('gr-plugin-host tests', () => {
+  let element;
+  let sandbox;
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('load plugins should be called', () => {
-      sandbox.stub(Gerrit, '_loadPlugins');
-      element.config = {
-        plugin: {
-          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-          js_resource_paths: ['plugins/42'],
-        },
-      };
-      assert.isTrue(Gerrit._loadPlugins.calledOnce);
-      assert.isTrue(Gerrit._loadPlugins.calledWith([
-        'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-      ], {}));
-    });
-
-    test('theme plugins should be loaded if enabled', () => {
-      sandbox.stub(Gerrit, '_loadPlugins');
-      element.config = {
-        default_theme: 'gerrit-theme.html',
-        plugin: {
-          html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-          js_resource_paths: ['plugins/42'],
-        },
-      };
-      assert.isTrue(Gerrit._loadPlugins.calledOnce);
-      assert.isTrue(Gerrit._loadPlugins.calledWith([
-        'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-      ], {'gerrit-theme.html': {sync: true}}));
-    });
-
-    test('skip theme if preloaded', () => {
-      sandbox.stub(Gerrit, '_isPluginPreloaded')
-          .withArgs('preloaded:gerrit-theme').returns(true);
-      sandbox.stub(Gerrit, '_loadPlugins');
-      element.config = {
-        default_theme: '/oof',
-        plugin: {},
-      };
-      assert.isTrue(Gerrit._loadPlugins.calledOnce);
-      assert.isTrue(Gerrit._loadPlugins.calledWith([], {}));
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(document.body, 'appendChild');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('load plugins should be called', () => {
+    sandbox.stub(pluginLoader, 'loadPlugins');
+    element.config = {
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
+    assert.isTrue(pluginLoader.loadPlugins.calledWith([
+      'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {}));
+  });
+
+  test('theme plugins should be loaded if enabled', () => {
+    sandbox.stub(pluginLoader, 'loadPlugins');
+    element.config = {
+      default_theme: 'gerrit-theme.html',
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
+    assert.isTrue(pluginLoader.loadPlugins.calledWith([
+      'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {'gerrit-theme.html': {sync: true}}));
+  });
+
+  test('skip theme if preloaded', () => {
+    sandbox.stub(pluginLoader, 'isPluginPreloaded')
+        .withArgs('preloaded:gerrit-theme')
+        .returns(true);
+    sandbox.stub(pluginLoader, 'loadPlugins');
+    element.config = {
+      default_theme: '/oof',
+      plugin: {},
+    };
+    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
+    assert.isTrue(pluginLoader.loadPlugins.calledWith([], {}));
+  });
+});
 </script>
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
deleted file mode 100644
index 402d988..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
+++ /dev/null
@@ -1,29 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-
-<dom-module id="gr-plugin-popup">
-  <template>
-    <style include="shared-styles"></style>
-    <gr-overlay id="overlay" with-backdrop>
-      <slot></slot>
-    </gr-overlay>
-  </template>
-  <script src="gr-plugin-popup.js"></script>
-</dom-module>
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..db44cea 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
@@ -14,22 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
+
+import '../../shared/gr-overlay/gr-overlay.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-popup_html.js';
+
 (function(window) {
   'use strict';
 
-  Polymer({
-    is: 'gr-plugin-popup',
+  /** @extends Polymer.Element */
+  class GrPluginPopup extends GestureEventListeners(
+      LegacyElementMixin(
+          PolymerElement)) {
+    static get template() { return htmlTemplate; }
+
+    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_html.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
new file mode 100644
index 0000000..5d2cae7
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
index 1f1e81e..2e65365 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,15 +18,11 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-plugin-popup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,37 +30,40 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-popup tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-plugin-popup.js';
+suite('gr-plugin-popup tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      stub('gr-overlay', {
-        open: sandbox.stub().returns(Promise.resolve()),
-        close: sandbox.stub(),
-      });
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('exists', () => {
-      assert.isOk(element);
-    });
-
-    test('open uses open() from gr-overlay', () => {
-      return element.open().then(() => {
-        assert.isTrue(element.$.overlay.open.called);
-      });
-    });
-
-    test('close uses close() from gr-overlay', () => {
-      element.close();
-      assert.isTrue(element.$.overlay.close.called);
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    stub('gr-overlay', {
+      open: sandbox.stub().returns(Promise.resolve()),
+      close: sandbox.stub(),
     });
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(element);
+  });
+
+  test('open uses open() from gr-overlay', done => {
+    element.open().then(() => {
+      assert.isTrue(element.$.overlay.open.called);
+      done();
+    });
+  });
+
+  test('close uses close() from gr-overlay', () => {
+    element.close();
+    assert.isTrue(element.$.overlay.close.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
deleted file mode 100644
index 26ece30..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
+++ /dev/null
@@ -1,24 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="gr-plugin-popup.html">
-
-<dom-module id="gr-popup-interface">
-  <script src="gr-popup-interface.js"></script>
-</dom-module>
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..3363d72 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
@@ -14,63 +14,70 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * Plugin popup API.
-   * Provides method for opening and closing popups from plugin.
-   * opt_moduleName is a name of custom element that will be automatically
-   * inserted on popup opening.
-   *
-   * @param {!Object} plugin
-   * @param {opt_moduleName=} string
-   */
-  function GrPopupInterface(plugin, opt_moduleName) {
-    this.plugin = plugin;
-    this._openingPromise = null;
-    this._popup = null;
-    this._moduleName = opt_moduleName || null;
+import './gr-plugin-popup.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-popup-interface">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/**
+ * Plugin popup API.
+ * Provides method for opening and closing popups from plugin.
+ * opt_moduleName is a name of custom element that will be automatically
+ * inserted on popup opening.
+ *
+ * @constructor
+ * @param {!Object} plugin
+ * @param {opt_moduleName=} string
+ */
+export function GrPopupInterface(plugin, opt_moduleName) {
+  this.plugin = plugin;
+  this._openingPromise = null;
+  this._popup = null;
+  this._moduleName = opt_moduleName || null;
+}
+
+GrPopupInterface.prototype._getElement = function() {
+  return dom(this._popup);
+};
+
+/**
+ * Opens the popup, inserts it into DOM over current UI.
+ * Creates the popup if not previously created. Creates popup content element,
+ * if it was provided with constructor.
+ *
+ * @returns {!Promise<!Object>}
+ */
+GrPopupInterface.prototype.open = function() {
+  if (!this._openingPromise) {
+    this._openingPromise =
+        this.plugin.hook('plugin-overlay').getLastAttached()
+            .then(hookEl => {
+              const popup = document.createElement('gr-plugin-popup');
+              if (this._moduleName) {
+                const el = dom(popup).appendChild(
+                    document.createElement(this._moduleName));
+                el.plugin = this.plugin;
+              }
+              this._popup = dom(hookEl).appendChild(popup);
+              flush();
+              return this._popup.open().then(() => this);
+            });
   }
+  return this._openingPromise;
+};
 
-  GrPopupInterface.prototype._getElement = function() {
-    return Polymer.dom(this._popup);
-  };
-
-  /**
-   * Opens the popup, inserts it into DOM over current UI.
-   * Creates the popup if not previously created. Creates popup content element,
-   * if it was provided with constructor.
-   *
-   * @returns {!Promise<!Object>}
-   */
-  GrPopupInterface.prototype.open = function() {
-    if (!this._openingPromise) {
-      this._openingPromise =
-          this.plugin.hook('plugin-overlay').getLastAttached()
-              .then(hookEl => {
-                const popup = document.createElement('gr-plugin-popup');
-                if (this._moduleName) {
-                  const el = Polymer.dom(popup).appendChild(
-                      document.createElement(this._moduleName));
-                  el.plugin = this.plugin;
-                }
-                this._popup = Polymer.dom(hookEl).appendChild(popup);
-                Polymer.dom.flush();
-                return this._popup.open().then(() => this);
-              });
-    }
-    return this._openingPromise;
-  };
-
-  /**
-   * Hides the popup.
-   */
-  GrPopupInterface.prototype.close = function() {
-    if (!this._popup) { return; }
-    this._popup.close();
-    this._openingPromise = null;
-  };
-
-  window.GrPopupInterface = GrPopupInterface;
-})(window);
+/**
+ * Hides the popup.
+ */
+GrPopupInterface.prototype.close = function() {
+  if (!this._popup) { return; }
+  this._popup.close();
+  this._openingPromise = null;
+};
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..62ab0e7 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,15 +18,11 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-popup-interface.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="container">
   <template>
@@ -38,78 +34,94 @@
   <template>
     <div id="barfoo">some test module</div>
   </template>
-  <script>Polymer({is: 'gr-user-test-popup'});</script>
+  <script type="module">
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({is: 'gr-user-test-popup'});
+</script>
 </dom-module>
 
-<script>
-  suite('gr-popup-interface tests', () => {
-    let container;
-    let instance;
-    let plugin;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrPopupInterface} from './gr-popup-interface.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
+const pluginApi = _testOnly_initGerritPluginApi();
+suite('gr-popup-interface tests', () => {
+  let container;
+  let instance;
+  let plugin;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    container = fixture('container');
+    sandbox.stub(plugin, 'hook').returns({
+      getLastAttached() {
+        return Promise.resolve(container);
+      },
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('manual', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      container = fixture('container');
-      sandbox.stub(plugin, 'hook').returns({
-        getLastAttached() {
-          return Promise.resolve(container);
-        },
+      instance = new GrPopupInterface(plugin);
+    });
+
+    test('open', done => {
+      instance.open().then(api => {
+        assert.strictEqual(api, instance);
+        const manual = document.createElement('div');
+        manual.id = 'foobar';
+        manual.innerHTML = 'manual content';
+        api._getElement().appendChild(manual);
+        flushAsynchronousOperations();
+        assert.equal(
+            container.querySelector('#foobar').textContent, 'manual content');
+        done();
       });
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('manual', () => {
-      setup(() => {
-        instance = new GrPopupInterface(plugin);
-      });
-
-      test('open', () => {
-        return instance.open().then(api => {
-          assert.strictEqual(api, instance);
-          const manual = document.createElement('div');
-          manual.id = 'foobar';
-          manual.innerHTML = 'manual content';
-          api._getElement().appendChild(manual);
-          flushAsynchronousOperations();
-          assert.equal(
-              container.querySelector('#foobar').textContent, 'manual content');
-        });
-      });
-
-      test('close', () => {
-        return instance.open().then(api => {
-          assert.isTrue(api._getElement().node.opened);
-          api.close();
-          assert.isFalse(api._getElement().node.opened);
-        });
-      });
-    });
-
-    suite('components', () => {
-      setup(() => {
-        instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
-      });
-
-      test('open', () => {
-        return instance.open().then(api => {
-          assert.isNotNull(
-              Polymer.dom(container).querySelector('gr-user-test-popup'));
-        });
-      });
-
-      test('close', () => {
-        return instance.open().then(api => {
-          assert.isTrue(api._getElement().node.opened);
-          api.close();
-          assert.isFalse(api._getElement().node.opened);
-        });
+    test('close', done => {
+      instance.open().then(api => {
+        assert.isTrue(api._getElement().node.opened);
+        api.close();
+        assert.isFalse(api._getElement().node.opened);
+        done();
       });
     });
   });
+
+  suite('components', () => {
+    setup(() => {
+      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+    });
+
+    test('open', done => {
+      instance.open().then(api => {
+        assert.isNotNull(
+            dom(container).querySelector('gr-user-test-popup'));
+        done();
+      });
+    });
+
+    test('close', done => {
+      instance.open().then(api => {
+        assert.isTrue(api._getElement().node.opened);
+        api.close();
+        assert.isFalse(api._getElement().node.opened);
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
deleted file mode 100644
index 593c1e0..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.html
+++ /dev/null
@@ -1,35 +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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../admin/gr-repo-command/gr-repo-command.html">
-
-<dom-module id="gr-plugin-repo-command">
-  <template>
-    <gr-repo-command title="[[title]]">
-    </gr-repo-command>
-  </template>
-  <script>
-    Polymer({
-      is: 'gr-plugin-repo-command',
-      properties: {
-        title: String,
-        repoName: String,
-        config: Object,
-      },
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
new file mode 100644
index 0000000..f9a2bdf
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import '../../admin/gr-repo-command/gr-repo-command.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+Polymer({
+  _template: html`
+    <gr-repo-command title="[[title]]">
+    </gr-repo-command>
+`,
+
+  is: 'gr-plugin-repo-command',
+
+  properties: {
+    title: String,
+    repoName: String,
+    config: Object,
+  },
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
deleted file mode 100644
index 8e6c053..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.html
+++ /dev/null
@@ -1,24 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="gr-plugin-repo-command.html">
-
-<dom-module id="gr-repo-api">
-  <script src="gr-repo-api.js"></script>
-</dom-module>
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..36d822c 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
@@ -14,50 +14,53 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Prevent redefinition.
-  if (window.GrRepoApi) { return; }
+import './gr-plugin-repo-command.js';
+const $_documentContainer = document.createElement('template');
 
-  function GrRepoApi(plugin) {
-    this._hook = null;
-    this.plugin = plugin;
+$_documentContainer.innerHTML = `<dom-module id="gr-repo-api">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/** @constructor */
+export function GrRepoApi(plugin) {
+  this._hook = null;
+  this.plugin = plugin;
+}
+
+GrRepoApi.prototype._createHook = function(title) {
+  this._hook = this.plugin.hook('repo-command').onAttached(element => {
+    const pluginCommand =
+          document.createElement('gr-plugin-repo-command');
+    pluginCommand.title = title;
+    element.appendChild(pluginCommand);
+  });
+};
+
+GrRepoApi.prototype.createCommand = function(title, callback) {
+  if (this._hook) {
+    console.warn('Already set up.');
+    return this._hook;
   }
-
-  GrRepoApi.prototype._createHook = function(title) {
-    this._hook = this.plugin.hook('repo-command').onAttached(element => {
-      const pluginCommand =
-            document.createElement('gr-plugin-repo-command');
-      pluginCommand.title = title;
-      element.appendChild(pluginCommand);
-    });
-  };
-
-  GrRepoApi.prototype.createCommand = function(title, callback) {
-    if (this._hook) {
-      console.warn('Already set up.');
-      return this._hook;
+  this._createHook(title);
+  this._hook.onAttached(element => {
+    if (callback(element.repoName, element.config) === false) {
+      element.hidden = true;
     }
-    this._createHook(title);
-    this._hook.onAttached(element => {
-      if (callback(element.repoName, element.config) === false) {
-        element.hidden = true;
-      }
-    });
-    return this;
-  };
+  });
+  return this;
+};
 
-  GrRepoApi.prototype.onTap = function(callback) {
-    if (!this._hook) {
-      console.warn('Call createCommand first.');
-      return this;
-    }
-    this._hook.onAttached(element => {
-      this.plugin.eventHelper(element).on('command-tap', callback);
-    });
+GrRepoApi.prototype.onTap = function(callback) {
+  if (!this._hook) {
+    console.warn('Call createCommand first.');
     return this;
-  };
-
-  window.GrRepoApi = GrRepoApi;
-})(window);
+  }
+  this._hook.onAttached(element => {
+    this.plugin.eventHelper(element).on('command-tap', callback);
+  });
+  return this;
+};
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..32ae959 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,16 +18,11 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="gr-repo-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,48 +31,58 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-api tests', () => {
-    let sandbox;
-    let repoApi;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-      repoApi = plugin.project();
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      repoApi = null;
-      sandbox.restore();
-    });
+suite('gr-repo-api tests', () => {
+  let sandbox;
+  let repoApi;
 
-    test('exists', () => {
-      assert.isOk(repoApi);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    repoApi = plugin.project();
+  });
 
-    test('works', done => {
-      const attachedStub = sandbox.stub();
-      const tapStub = sandbox.stub();
-      repoApi
-          .createCommand('foo', attachedStub)
-          .onTap(tapStub);
-      const element = fixture('basic');
-      flush(() => {
-        assert.isTrue(attachedStub.called);
-        const pluginCommand = element.$$('gr-plugin-repo-command');
-        assert.isOk(pluginCommand);
-        const command = pluginCommand.$$('gr-repo-command');
-        assert.isOk(command);
-        assert.equal(command.title, 'foo');
-        assert.isFalse(tapStub.called);
-        MockInteractions.tap(command.$$('gr-button'));
-        assert.isTrue(tapStub.called);
-        done();
-      });
+  teardown(() => {
+    repoApi = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(repoApi);
+  });
+
+  test('works', done => {
+    const attachedStub = sandbox.stub();
+    const tapStub = sandbox.stub();
+    repoApi
+        .createCommand('foo', attachedStub)
+        .onTap(tapStub);
+    const element = fixture('basic');
+    flush(() => {
+      assert.isTrue(attachedStub.called);
+      const pluginCommand = element.shadowRoot
+          .querySelector('gr-plugin-repo-command');
+      assert.isOk(pluginCommand);
+      const command = pluginCommand.shadowRoot
+          .querySelector('gr-repo-command');
+      assert.isOk(command);
+      assert.equal(command.title, 'foo');
+      assert.isFalse(tapStub.called);
+      MockInteractions.tap(command.shadowRoot
+          .querySelector('gr-button'));
+      assert.isTrue(tapStub.called);
+      done();
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
deleted file mode 100644
index 20cc71b..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.html
+++ /dev/null
@@ -1,27 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Settings
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT 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="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../settings/gr-settings-view/gr-settings-item.html">
-<link rel="import" href="../../settings/gr-settings-view/gr-settings-menu-item.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-settings-api">
-  <script src="gr-settings-api.js"></script>
-</dom-module>
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..4fb971f 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
@@ -14,52 +14,60 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  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);
-    this.plugin = plugin;
+import '../../settings/gr-settings-view/gr-settings-item.js';
+import '../../settings/gr-settings-view/gr-settings-menu-item.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-settings-api">
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/** @constructor */
+export 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);
+  this.plugin = plugin;
+}
+
+GrSettingsApi.prototype.title = function(title) {
+  this._title = title;
+  return this;
+};
+
+GrSettingsApi.prototype.token = function(token) {
+  this._token = token;
+  return this;
+};
+
+GrSettingsApi.prototype.module = function(moduleName) {
+  this._moduleName = moduleName;
+  return this;
+};
+
+GrSettingsApi.prototype.build = function() {
+  if (!this._moduleName) {
+    throw new Error('Settings screen custom element not defined!');
   }
+  const token = `x/${this.plugin.getPluginName()}/${this._token}`;
+  this.plugin.hook('settings-menu-item').onAttached(el => {
+    const menuItem = document.createElement('gr-settings-menu-item');
+    menuItem.title = this._title;
+    menuItem.href = `#${token}`;
+    el.appendChild(menuItem);
+  });
 
-  GrSettingsApi.prototype.title = function(title) {
-    this._title = title;
-    return this;
-  };
-
-  GrSettingsApi.prototype.token = function(token) {
-    this._token = token;
-    return this;
-  };
-
-  GrSettingsApi.prototype.module = function(moduleName) {
-    this._moduleName = moduleName;
-    return this;
-  };
-
-  GrSettingsApi.prototype.build = function() {
-    if (!this._moduleName) {
-      throw new Error('Settings screen custom element not defined!');
-    }
-    const token = `x/${this.plugin.getPluginName()}/${this._token}`;
-    this.plugin.hook('settings-menu-item').onAttached(el => {
-      const menuItem = document.createElement('gr-settings-menu-item');
-      menuItem.title = this._title;
-      menuItem.href = `#${token}`;
-      el.appendChild(menuItem);
-    });
-
-    return this.plugin.hook('settings-screen').onAttached(el => {
-      const item = document.createElement('gr-settings-item');
-      item.title = this._title;
-      item.anchor = token;
-      item.appendChild(document.createElement(this._moduleName));
-      el.appendChild(item);
-    });
-  };
-
-  window.GrSettingsApi = GrSettingsApi;
-})(window);
+  return this.plugin.hook('settings-screen').onAttached(el => {
+    const item = document.createElement('gr-settings-item');
+    item.title = this._title;
+    item.anchor = token;
+    item.appendChild(document.createElement(this._moduleName));
+    el.appendChild(item);
+  });
+};
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..5057992 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,16 +18,11 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="gr-settings-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -38,48 +33,57 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-settings-api tests', () => {
-    let sandbox;
-    let settingsApi;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-      settingsApi = plugin.settings();
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      settingsApi = null;
-      sandbox.restore();
-    });
+suite('gr-settings-api tests', () => {
+  let sandbox;
+  let settingsApi;
 
-    test('exists', () => {
-      assert.isOk(settingsApi);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    settingsApi = plugin.settings();
+  });
 
-    test('works', done => {
-      settingsApi
-          .title('foo')
-          .token('bar')
-          .module('some-settings-screen')
-          .build();
-      const element = fixture('basic');
-      flush(() => {
-        const [menuItemEl, itemEl] = element;
-        const menuItem = menuItemEl.$$('gr-settings-menu-item');
-        assert.isOk(menuItem);
-        assert.equal(menuItem.title, 'foo');
-        assert.equal(menuItem.href, '#x/testplugin/bar');
-        const item = itemEl.$$('gr-settings-item');
-        assert.isOk(item);
-        assert.equal(item.title, 'foo');
-        assert.equal(item.anchor, 'x/testplugin/bar');
-        done();
-      });
+  teardown(() => {
+    settingsApi = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(settingsApi);
+  });
+
+  test('works', done => {
+    settingsApi
+        .title('foo')
+        .token('bar')
+        .module('some-settings-screen')
+        .build();
+    const element = fixture('basic');
+    flush(() => {
+      const [menuItemEl, itemEl] = element;
+      const menuItem = menuItemEl.shadowRoot
+          .querySelector('gr-settings-menu-item');
+      assert.isOk(menuItem);
+      assert.equal(menuItem.title, 'foo');
+      assert.equal(menuItem.href, '#x/testplugin/bar');
+      const item = itemEl.shadowRoot
+          .querySelector('gr-settings-item');
+      assert.isOk(item);
+      assert.equal(item.title, 'foo');
+      assert.equal(item.anchor, 'x/testplugin/bar');
+      done();
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html
deleted file mode 100644
index 74b87c8..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.html
+++ /dev/null
@@ -1,18 +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.
--->
-
-<script src="gr-styles-api.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..3da60db 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
@@ -14,70 +14,61 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrStylesApi) { return; }
+let styleObjectCount = 0;
 
-  let styleObjectCount = 0;
+/** @constructor */
+function GrStyleObject(rulesStr) {
+  this._rulesStr = rulesStr;
+  this._className = `__pg_js_api_class_${styleObjectCount}`;
+  styleObjectCount++;
+}
 
-  function GrStyleObject(rulesStr) {
-    this._rulesStr = rulesStr;
-    this._className = `__pg_js_api_class_${styleObjectCount}`;
-    styleObjectCount++;
+/**
+ * Creates a new unique CSS class and injects it in a root node of the element
+ * if it hasn't been added yet. A root node is an document or is the
+ * associated shadowRoot. This class can be added to any element with the same
+ * root node.
+ *
+ * @param {HTMLElement} element The element to get class name for.
+ * @return {string} Appropriate class name for the element is returned
+ */
+GrStyleObject.prototype.getClassName = function(element) {
+  let rootNode = Polymer.Settings.useShadow
+    ? element.getRootNode() : document.body;
+  if (rootNode === document) {
+    rootNode = document.head;
   }
-
-  /**
-   * Creates a new unique CSS class and injects it in a root node of the element
-   * if it hasn't been added yet. A root node is an document or is the
-   * associated shadowRoot. This class can be added to any element with the same
-   * root node.
-   *
-   * @param {HTMLElement} element The element to get class name for.
-   * @return {string} Appropriate class name for the element is returned
-   */
-  GrStyleObject.prototype.getClassName = function(element) {
-    let rootNode = Polymer.Settings.useShadow
-      ? element.getRootNode() : document.body;
-    if (rootNode === document) {
-      rootNode = document.head;
-    }
-    if (!rootNode.__pg_js_api_style_tags) {
-      rootNode.__pg_js_api_style_tags = {};
-    }
-    if (!rootNode.__pg_js_api_style_tags[this._className]) {
-      const styleTag = document.createElement('style');
-      styleTag.innerHTML = `.${this._className} { ${this._rulesStr} }`;
-      rootNode.appendChild(styleTag);
-      rootNode.__pg_js_api_style_tags[this._className] = true;
-    }
-    return this._className;
-  };
-
-  /**
-   * Apply shared style to the element.
-   *
-   * @param {HTMLElement} element The element to apply style for
-   */
-  GrStyleObject.prototype.apply = function(element) {
-    element.classList.add(this.getClassName(element));
-  };
-
-
-  function GrStylesApi() {
+  if (!rootNode.__pg_js_api_style_tags) {
+    rootNode.__pg_js_api_style_tags = {};
   }
+  if (!rootNode.__pg_js_api_style_tags[this._className]) {
+    const styleTag = document.createElement('style');
+    styleTag.innerHTML = `.${this._className} { ${this._rulesStr} }`;
+    rootNode.appendChild(styleTag);
+    rootNode.__pg_js_api_style_tags[this._className] = true;
+  }
+  return this._className;
+};
 
-  /**
-   * Creates a new GrStyleObject with specified style properties.
-   *
-   * @param {string} String with style properties.
-   * @return {GrStyleObject}
-   */
-  GrStylesApi.prototype.css = function(ruleStr) {
-    return new GrStyleObject(ruleStr);
-  };
+/**
+ * Apply shared style to the element.
+ *
+ * @param {HTMLElement} element The element to apply style for
+ */
+GrStyleObject.prototype.apply = function(element) {
+  element.classList.add(this.getClassName(element));
+};
 
+export function GrStylesApi() {
+}
 
-  window.GrStylesApi = GrStylesApi;
-})(window);
+/**
+ * Creates a new GrStyleObject with specified style properties.
+ *
+ * @param {string} ruleStr with style properties.
+ * @return {GrStyleObject}
+ */
+GrStylesApi.prototype.css = function(ruleStr) {
+  return new GrStyleObject(ruleStr);
+};
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..d6bae9b 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,51 +18,58 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="gr-styles-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <dom-module id="gr-style-test-element">
   <template>
     <div id="wrapper"></div>
   </template>
-  <script>Polymer({is: 'gr-style-test-element'});</script>
+  <script type="module">
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+Polymer({is: 'gr-style-test-element'});
+</script>
 </dom-module>
 
-<script>
-  suite('gr-styles-api tests', () => {
-    let sandbox;
-    let stylesApi;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-      stylesApi = plugin.styles();
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      stylesApi = null;
-      sandbox.restore();
-    });
+suite('gr-styles-api tests', () => {
+  let sandbox;
+  let stylesApi;
 
-    test('exists', () => {
-      assert.isOk(stylesApi);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    stylesApi = plugin.styles();
+  });
 
-    test('css', () => {
-      const styleObject = stylesApi.css('background: red');
-      assert.isDefined(styleObject);
-    });
+  teardown(() => {
+    stylesApi = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(stylesApi);
+  });
+
+  test('css', () => {
+    const styleObject = stylesApi.css('background: red');
+    assert.isDefined(styleObject);
   });
 
   suite('GrStyleObject tests', () => {
@@ -74,9 +81,9 @@
     setup(() => {
       sandbox = sinon.sandbox.create();
       let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
+      pluginApi.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
+      pluginLoader.loadPlugins([]);
       stylesApi = plugin.styles();
       displayInlineStyle = stylesApi.css('display: inline');
       displayNoneStyle = stylesApi.css('display: none');
@@ -98,14 +105,13 @@
       const element1 = document.createElement('div');
       const element2 = document.createElement('div');
       const element3 = document.createElement('div');
-      Polymer.dom(parentElement).appendChild(element1);
-      Polymer.dom(parentElement).appendChild(element2);
-      Polymer.dom(element2).appendChild(element3);
+      dom(parentElement).appendChild(element1);
+      dom(parentElement).appendChild(element2);
+      dom(element2).appendChild(element3);
 
       return [element1, element2, element3];
     }
 
-
     test('getClassName  - body level elements', () => {
       const bodyLevelElements = createNestedElements(document.body);
 
@@ -114,7 +120,7 @@
 
     test('getClassName  - elements inside polymer element', () => {
       const polymerElement = document.createElement('gr-style-test-element');
-      Polymer.dom(document.body).appendChild(polymerElement);
+      dom(document.body).appendChild(polymerElement);
       const contentElements = createNestedElements(polymerElement.$.wrapper);
 
       testGetClassName(contentElements);
@@ -147,7 +153,7 @@
 
     test('apply - elements inside polymer element', () => {
       const polymerElement = document.createElement('gr-style-test-element');
-      Polymer.dom(document.body).appendChild(polymerElement);
+      dom(document.body).appendChild(polymerElement);
       const contentElements = createNestedElements(polymerElement.$.wrapper);
 
       testApply(contentElements);
@@ -161,7 +167,6 @@
       assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
     }
 
-
     function assertAllElementsHaveDefaultStyle(elements) {
       for (const element of elements) {
         assert.equal(getComputedStyle(element).getPropertyValue('display'),
@@ -179,4 +184,5 @@
       }
     }
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
deleted file mode 100644
index f0eacd2..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
+++ /dev/null
@@ -1,46 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-custom-plugin-header">
-  <template>
-    <style>
-      img {
-        width: 1em;
-        height: 1em;
-        vertical-align: middle;
-      }
-      .title {
-        margin-left: var(--spacing-xs);
-      }
-    </style>
-    <span>
-      <img src="[[logoUrl]]" hidden$="[[!logoUrl]]">
-      <span class="title">[[title]]</span>
-    </span>
-  </template>
-  <script>
-    Polymer({
-      is: 'gr-custom-plugin-header',
-      properties: {
-        logoUrl: String,
-        title: String,
-      },
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
new file mode 100644
index 0000000..411a7c8
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+Polymer({
+  _template: html`
+    <style>
+      img {
+        width: 1em;
+        height: 1em;
+        vertical-align: middle;
+      }
+      .title {
+        margin-left: var(--spacing-xs);
+      }
+    </style>
+    <span>
+      <img src="[[logoUrl]]" hidden\$="[[!logoUrl]]">
+      <span class="title">[[title]]</span>
+    </span>
+`,
+
+  is: 'gr-custom-plugin-header',
+
+  properties: {
+    logoUrl: String,
+    title: String,
+  },
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
deleted file mode 100644
index d6e67fe..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
+++ /dev/null
@@ -1,24 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="gr-custom-plugin-header.html">
-
-<dom-module id="gr-theme-api">
-  <script src="gr-theme-api.js"></script>
-</dom-module>
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..c987af3 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
@@ -14,26 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Prevent redefinition.
-  if (window.GrThemeApi) { return; }
+import './gr-custom-plugin-header.js';
+const $_documentContainer = document.createElement('template');
 
-  function GrThemeApi(plugin) {
-    this.plugin = plugin;
-  }
+$_documentContainer.innerHTML = `<dom-module id="gr-theme-api">
+  
+</dom-module>`;
 
-  GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
-    this.plugin.hook('header-title', {replace: true}).onAttached(
-        element => {
-          const customHeader =
-                document.createElement('gr-custom-plugin-header');
-          customHeader.logoUrl = logoUrl;
-          customHeader.title = title;
-          element.appendChild(customHeader);
-        });
-  };
+document.head.appendChild($_documentContainer.content);
 
-  window.GrThemeApi = GrThemeApi;
-})(window);
+/** @constructor */
+export function GrThemeApi(plugin) {
+  this.plugin = plugin;
+}
+
+GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
+  this.plugin.hook('header-title', {replace: true}).onAttached(
+      element => {
+        const customHeader =
+              document.createElement('gr-custom-plugin-header');
+        customHeader.logoUrl = logoUrl;
+        customHeader.title = title;
+        element.appendChild(customHeader);
+      });
+};
+
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..9e2e190 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,16 +18,11 @@
 
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="gr-theme-api.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="header-title">
   <template>
@@ -37,48 +32,56 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-theme-api tests', () => {
-    let sandbox;
-    let theme;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-theme-api tests', () => {
+  let sandbox;
+  let theme;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    theme = plugin.theme();
+  });
+
+  teardown(() => {
+    theme = null;
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(theme);
+  });
+
+  suite('header-title', () => {
+    let customHeader;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      theme = plugin.theme();
-    });
-
-    teardown(() => {
-      theme = null;
-      sandbox.restore();
-    });
-
-    test('exists', () => {
-      assert.isOk(theme);
-    });
-
-    suite('header-title', () => {
-      let customHeader;
-
-      setup(() => {
-        fixture('header-title');
-        stub('gr-custom-plugin-header', {
-          ready() { customHeader = this; },
-        });
-        Gerrit._loadPlugins([]);
+      fixture('header-title');
+      stub('gr-custom-plugin-header', {
+        /** @override */
+        ready() { customHeader = this; },
       });
+      pluginLoader.loadPlugins([]);
+    });
 
-      test('sets logo and title', done => {
-        theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
-        flush(() => {
-          assert.isNotNull(customHeader);
-          assert.equal(customHeader.logoUrl, 'foo.jpg');
-          assert.equal(customHeader.title, 'bar');
-          done();
-        });
+    test('sets logo and title', done => {
+      theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
+      flush(() => {
+        assert.isNotNull(customHeader);
+        assert.equal(customHeader.logoUrl, 'foo.jpg');
+        assert.equal(customHeader.title, 'bar');
+        done();
       });
     });
   });
+});
 </script>
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
deleted file mode 100644
index 662c6f1..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ /dev/null
@@ -1,138 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-
-<dom-module id="gr-account-info">
-  <template>
-    <style include="shared-styles">
-      gr-avatar {
-        height: 120px;
-        width: 120px;
-        margin-right: var(--spacing-xs);
-        vertical-align: -.25em;
-      }
-      div section.hide {
-        display: none;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <div class="gr-form-styles">
-      <section>
-        <span class="title"></span>
-        <span class="value">
-          <gr-avatar account="[[_account]]"
-              image-size="120"></gr-avatar>
-        </span>
-      </section>
-      <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
-        <span class="title"></span>
-        <span class="value">
-          <a href$="[[_avatarChangeUrl]]">
-            Change avatar
-          </a>
-        </span>
-      </section>
-      <section>
-        <span class="title">ID</span>
-        <span class="value">[[_account._account_id]]</span>
-      </section>
-      <section>
-        <span class="title">Email</span>
-        <span class="value">[[_account.email]]</span>
-      </section>
-      <section>
-        <span class="title">Registered</span>
-        <span class="value">
-          <gr-date-formatter
-              has-tooltip
-              date-str="[[_account.registered_on]]"></gr-date-formatter>
-        </span>
-      </section>
-      <section id="usernameSection">
-        <span class="title">Username</span>
-        <span
-            hidden$="[[usernameMutable]]"
-            class="value">[[_username]]</span>
-        <span
-            hidden$="[[!usernameMutable]]"
-            class="value">
-          <iron-input
-              disabled="[[_saving]]"
-              on-keydown="_handleKeydown"
-              bind-value="{{_username}}">
-            <input
-                is="iron-input"
-                id="usernameInput"
-                disabled="[[_saving]]"
-                on-keydown="_handleKeydown"
-                bind-value="{{_username}}">
-          </iron-input>
-        </span>
-      </section>
-      <section id="nameSection">
-        <span class="title">Full name</span>
-        <span
-            hidden$="[[nameMutable]]"
-            class="value">[[_account.name]]</span>
-        <span
-            hidden$="[[!nameMutable]]"
-            class="value">
-          <iron-input
-              disabled="[[_saving]]"
-              on-keydown="_handleKeydown"
-              bind-value="{{_account.name}}">
-            <input
-                is="iron-input"
-                id="nameInput"
-                disabled="[[_saving]]"
-                on-keydown="_handleKeydown"
-                bind-value="{{_account.name}}">
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Status (e.g. "Vacation")</span>
-        <span class="value">
-          <iron-input
-              disabled="[[_saving]]"
-              on-keydown="_handleKeydown"
-              bind-value="{{_account.status}}">
-            <input
-                is="iron-input"
-                id="statusInput"
-                disabled="[[_saving]]"
-                on-keydown="_handleKeydown"
-                bind-value="{{_account.status}}">
-          </iron-input>
-        </span>
-      </section>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-account-info.js"></script>
-</dom-module>
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..7e312d3 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
@@ -14,19 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-avatar/gr-avatar.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-info_html.js';
 
-  Polymer({
-    is: 'gr-account-info',
+/**
+ * @extends Polymer.Element
+ */
+class GrAccountInfo extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when account details are changed.
-     *
-     * @event account-detail-update
-     */
+  static get is() { return 'gr-account-info'; }
+  /**
+   * Fired when account details are changed.
+   *
+   * @event account-detail-update
+   */
 
-    properties: {
+  static get properties() {
+    return {
       usernameMutable: {
         type: Boolean,
         notify: true,
@@ -41,11 +56,12 @@
         type: Boolean,
         notify: true,
         computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
-            '_hasUsernameChange, _hasStatusChange)',
+          '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
       },
 
       _hasNameChange: Boolean,
       _hasUsernameChange: Boolean,
+      _hasDisplayNameChange: Boolean,
       _hasStatusChange: Boolean,
       _loading: {
         type: Boolean,
@@ -66,134 +82,153 @@
         type: String,
         value: '',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_nameChanged(_account.name)',
       '_statusChanged(_account.status)',
-    ],
+      '_displayNameChanged(_account.display_name)',
+    ];
+  }
 
-    loadData() {
-      const promises = [];
+  loadData() {
+    const promises = [];
 
-      this._loading = true;
+    this._loading = true;
 
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-      }));
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+    }));
 
-      promises.push(this.$.restAPI.getAccount().then(account => {
-        this._hasNameChange = false;
-        this._hasUsernameChange = false;
-        this._hasStatusChange = false;
-        // Provide predefined value for username to trigger computation of
-        // username mutability.
-        account.username = account.username || '';
-        this._account = account;
-        this._username = account.username;
-      }));
+    promises.push(this.$.restAPI.getAccount().then(account => {
+      this._hasNameChange = false;
+      this._hasUsernameChange = false;
+      this._hasDisplayNameChange = false;
+      this._hasStatusChange = false;
+      // Provide predefined value for username to trigger computation of
+      // username mutability.
+      account.username = account.username || '';
+      this._account = account;
+      this._username = account.username;
+    }));
 
-      promises.push(this.$.restAPI.getAvatarChangeUrl().then(url => {
-        this._avatarChangeUrl = url;
-      }));
+    promises.push(this.$.restAPI.getAvatarChangeUrl().then(url => {
+      this._avatarChangeUrl = url;
+    }));
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
-      });
-    },
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+    });
+  }
 
-    save() {
-      if (!this.hasUnsavedChanges) {
-        return Promise.resolve();
-      }
+  save() {
+    if (!this.hasUnsavedChanges) {
+      return Promise.resolve();
+    }
 
-      this._saving = true;
-      // Set only the fields that have changed.
-      // Must be done in sequence to avoid race conditions (@see Issue 5721)
-      return this._maybeSetName()
-          .then(this._maybeSetUsername.bind(this))
-          .then(this._maybeSetStatus.bind(this))
-          .then(() => {
-            this._hasNameChange = false;
-            this._hasStatusChange = false;
-            this._saving = false;
-            this.fire('account-detail-update');
-          });
-    },
+    this._saving = true;
+    // Set only the fields that have changed.
+    // Must be done in sequence to avoid race conditions (@see Issue 5721)
+    return this._maybeSetName()
+        .then(() => this._maybeSetUsername())
+        .then(() => this._maybeSetDisplayName())
+        .then(() => this._maybeSetStatus())
+        .then(() => {
+          this._hasNameChange = false;
+          this._hasDisplayNameChange = false;
+          this._hasStatusChange = false;
+          this._saving = false;
+          this.dispatchEvent(new CustomEvent('account-detail-update', {
+            composed: true, bubbles: true,
+          }));
+        });
+  }
 
-    _maybeSetName() {
-      return this._hasNameChange && this.nameMutable ?
-        this.$.restAPI.setAccountName(this._account.name) :
-        Promise.resolve();
-    },
+  _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();
-    },
+  _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();
-    },
+  _maybeSetDisplayName() {
+    return this._hasDisplayNameChange ?
+      this.$.restAPI.setAccountDisplayName(this._account.display_name) :
+      Promise.resolve();
+  }
 
-    _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
-      return nameChanged || usernameChanged || statusChanged;
-    },
+  _maybeSetStatus() {
+    return this._hasStatusChange ?
+      this.$.restAPI.setAccountStatus(this._account.status) :
+      Promise.resolve();
+  }
 
-    _computeUsernameMutable(config, username) {
-      // Polymer 2: check for undefined
-      if ([
-        config,
-        username,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
+  _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged,
+      displayNameChanged) {
+    return nameChanged || usernameChanged || statusChanged
+        || displayNameChanged;
+  }
 
-      // Username may not be changed once it is set.
-      return config.auth.editable_account_fields.includes('USER_NAME') &&
-          !username;
-    },
+  _computeUsernameMutable(config, username) {
+    // Polymer 2: check for undefined
+    if ([
+      config,
+      username,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-    _computeNameMutable(config) {
-      return config.auth.editable_account_fields.includes('FULL_NAME');
-    },
+    // Username may not be changed once it is set.
+    return config.auth.editable_account_fields.includes('USER_NAME') &&
+        !username;
+  }
 
-    _statusChanged() {
-      if (this._loading) { return; }
-      this._hasStatusChange = true;
-    },
+  _computeNameMutable(config) {
+    return config.auth.editable_account_fields.includes('FULL_NAME');
+  }
 
-    _usernameChanged() {
-      if (this._loading || !this._account) { return; }
-      this._hasUsernameChange =
-          (this._account.username || '') !== (this._username || '');
-    },
+  _statusChanged() {
+    if (this._loading) { return; }
+    this._hasStatusChange = true;
+  }
 
-    _nameChanged() {
-      if (this._loading) { return; }
-      this._hasNameChange = true;
-    },
+  _displayNameChanged() {
+    if (this._loading) { return; }
+    this._hasDisplayNameChange = true;
+  }
 
-    _handleKeydown(e) {
-      if (e.keyCode === 13) { // Enter
-        e.stopPropagation();
-        this.save();
-      }
-    },
+  _usernameChanged() {
+    if (this._loading || !this._account) { return; }
+    this._hasUsernameChange =
+        (this._account.username || '') !== (this._username || '');
+  }
 
-    _hideAvatarChangeUrl(avatarChangeUrl) {
-      if (!avatarChangeUrl) {
-        return 'hide';
-      }
+  _nameChanged() {
+    if (this._loading) { return; }
+    this._hasNameChange = true;
+  }
 
-      return '';
-    },
-  });
-})();
+  _handleKeydown(e) {
+    if (e.keyCode === 13) { // Enter
+      e.stopPropagation();
+      this.save();
+    }
+  }
+
+  _hideAvatarChangeUrl(avatarChangeUrl) {
+    if (!avatarChangeUrl) {
+      return 'hide';
+    }
+
+    return '';
+  }
+}
+
+customElements.define(GrAccountInfo.is, GrAccountInfo);
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
new file mode 100644
index 0000000..fd94821
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-avatar {
+      height: 120px;
+      width: 120px;
+      margin-right: var(--spacing-xs);
+      vertical-align: -0.25em;
+    }
+    div section.hide {
+      display: none;
+    }
+  </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>
+      <span class="value">
+        <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
+      </span>
+    </section>
+    <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
+      <span class="title"></span>
+      <span class="value">
+        <a href$="[[_avatarChangeUrl]]">
+          Change avatar
+        </a>
+      </span>
+    </section>
+    <section>
+      <span class="title">ID</span>
+      <span class="value">[[_account._account_id]]</span>
+    </section>
+    <section>
+      <span class="title">Email</span>
+      <span class="value">[[_account.email]]</span>
+    </section>
+    <section>
+      <span class="title">Registered</span>
+      <span class="value">
+        <gr-date-formatter
+          has-tooltip=""
+          date-str="[[_account.registered_on]]"
+        ></gr-date-formatter>
+      </span>
+    </section>
+    <section id="usernameSection">
+      <span class="title">Username</span>
+      <span hidden$="[[usernameMutable]]" class="value">[[_username]]</span>
+      <span hidden$="[[!usernameMutable]]" class="value">
+        <iron-input on-keydown="_handleKeydown" bind-value="{{_username}}">
+          <input
+            is="iron-input"
+            id="usernameInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_username}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section id="nameSection">
+      <span class="title">Full name</span>
+      <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
+      <span hidden$="[[!nameMutable]]" class="value">
+        <iron-input on-keydown="_handleKeydown" bind-value="{{_account.name}}">
+          <input
+            is="iron-input"
+            id="nameInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_account.name}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Display name</span>
+      <span class="value">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_account.display_name}}"
+        >
+          <input
+            is="iron-input"
+            id="displayNameInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_account.display_name}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Status (e.g. "Vacation")</span>
+      <span class="value">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_account.status}}"
+        >
+          <input
+            is="iron-input"
+            id="statusInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_account.status}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..53641d9 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,309 +31,312 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-info tests', () => {
-    let element;
-    let account;
-    let config;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-account-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-account-info tests', () => {
+  let element;
+  let account;
+  let config;
+  let sandbox;
 
-    function valueOf(title) {
-      const sections = Polymer.dom(element.root).querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title) {
+    const sections = dom(element.root).querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      account = {
-        _account_id: 123,
-        name: 'user name',
-        email: 'user@email',
-        username: 'user username',
-        registered: '2000-01-01 00:00:00.000000000',
-      };
-      config = {auth: {editable_account_fields: []}};
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    account = {
+      _account_id: 123,
+      name: 'user name',
+      email: 'user@email',
+      username: 'user username',
+      registered: '2000-01-01 00:00:00.000000000',
+    };
+    config = {auth: {editable_account_fields: []}};
 
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(account); },
-        getConfig() { return Promise.resolve(config); },
-        getPreferences() {
-          return Promise.resolve({time_format: 'HHMM_12'});
-        },
-      });
-      element = fixture('basic');
-      // Allow the element to render.
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(account); },
+      getConfig() { return Promise.resolve(config); },
+      getPreferences() {
+        return Promise.resolve({time_format: 'HHMM_12'});
+      },
     });
+    element = fixture('basic');
+    // Allow the element to render.
+    element.loadData().then(() => { flush(done); });
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('basic account info render', () => {
-      assert.isFalse(element._loading);
+  test('basic account info render', () => {
+    assert.isFalse(element._loading);
 
-      assert.equal(valueOf('ID').textContent, account._account_id);
-      assert.equal(valueOf('Email').textContent, account.email);
-      assert.equal(valueOf('Username').textContent, account.username);
-    });
+    assert.equal(valueOf('ID').textContent, account._account_id);
+    assert.equal(valueOf('Email').textContent, account.email);
+    assert.equal(valueOf('Username').textContent, account.username);
+  });
 
-    test('full name render (immutable)', () => {
-      const section = element.$.nameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
+  test('full name render (immutable)', () => {
+    const section = element.$.nameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
 
-      assert.isFalse(element.nameMutable);
-      assert.isFalse(displaySpan.hasAttribute('hidden'));
-      assert.equal(displaySpan.textContent, account.name);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
-    });
+    assert.isFalse(element.nameMutable);
+    assert.isFalse(displaySpan.hasAttribute('hidden'));
+    assert.equal(displaySpan.textContent, account.name);
+    assert.isTrue(inputSpan.hasAttribute('hidden'));
+  });
 
-    test('full name render (mutable)', () => {
+  test('full name render (mutable)', () => {
+    element.set('_serverConfig',
+        {auth: {editable_account_fields: ['FULL_NAME']}});
+
+    const section = element.$.nameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isTrue(element.nameMutable);
+    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    assert.equal(element.$.nameInput.bindValue, account.name);
+    assert.isFalse(inputSpan.hasAttribute('hidden'));
+  });
+
+  test('username render (immutable)', () => {
+    const section = element.$.usernameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isFalse(element.usernameMutable);
+    assert.isFalse(displaySpan.hasAttribute('hidden'));
+    assert.equal(displaySpan.textContent, account.username);
+    assert.isTrue(inputSpan.hasAttribute('hidden'));
+  });
+
+  test('username render (mutable)', () => {
+    element.set('_serverConfig',
+        {auth: {editable_account_fields: ['USER_NAME']}});
+    element.set('_account.username', '');
+    element.set('_username', '');
+
+    const section = element.$.usernameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isTrue(element.usernameMutable);
+    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    assert.equal(element.$.usernameInput.bindValue, account.username);
+    assert.isFalse(inputSpan.hasAttribute('hidden'));
+  });
+
+  suite('account info edit', () => {
+    let nameChangedSpy;
+    let usernameChangedSpy;
+    let statusChangedSpy;
+    let nameStub;
+    let usernameStub;
+    let statusStub;
+
+    setup(() => {
+      nameChangedSpy = sandbox.spy(element, '_nameChanged');
+      usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
+      statusChangedSpy = sandbox.spy(element, '_statusChanged');
       element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
 
-      const section = element.$.nameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
+      nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+          name => Promise.resolve());
+      usernameStub = sandbox.stub(element.$.restAPI, 'setAccountUsername',
+          username => Promise.resolve());
+      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+          status => Promise.resolve());
+    });
 
+    test('name', done => {
       assert.isTrue(element.nameMutable);
-      assert.isTrue(displaySpan.hasAttribute('hidden'));
-      assert.equal(element.$.nameInput.bindValue, account.name);
-      assert.isFalse(inputSpan.hasAttribute('hidden'));
-    });
+      assert.isFalse(element.hasUnsavedChanges);
 
-    test('username render (immutable)', () => {
-      const section = element.$.usernameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
+      element.set('_account.name', 'new name');
 
-      assert.isFalse(element.usernameMutable);
-      assert.isFalse(displaySpan.hasAttribute('hidden'));
-      assert.equal(displaySpan.textContent, account.username);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
-    });
+      assert.isTrue(nameChangedSpy.called);
+      assert.isFalse(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
 
-    test('username render (mutable)', () => {
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['USER_NAME']}});
-      element.set('_account.username', '');
-      element.set('_username', '');
-
-      const section = element.$.usernameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
-
-      assert.isTrue(element.usernameMutable);
-      assert.isTrue(displaySpan.hasAttribute('hidden'));
-      assert.equal(element.$.usernameInput.bindValue, account.username);
-      assert.isFalse(inputSpan.hasAttribute('hidden'));
-    });
-
-    suite('account info edit', () => {
-      let nameChangedSpy;
-      let usernameChangedSpy;
-      let statusChangedSpy;
-      let nameStub;
-      let usernameStub;
-      let statusStub;
-
-      setup(() => {
-        nameChangedSpy = sandbox.spy(element, '_nameChanged');
-        usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
-        statusChangedSpy = sandbox.spy(element, '_statusChanged');
-        element.set('_serverConfig',
-            {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
-
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            name => Promise.resolve());
-        usernameStub = sandbox.stub(element.$.restAPI, 'setAccountUsername',
-            username => Promise.resolve());
-        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            status => Promise.resolve());
-      });
-
-      test('name', done => {
-        assert.isTrue(element.nameMutable);
-        assert.isFalse(element.hasUnsavedChanges);
-
-        element.set('_account.name', 'new name');
-
-        assert.isTrue(nameChangedSpy.called);
-        assert.isFalse(statusChangedSpy.called);
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isFalse(usernameStub.called);
-          assert.isTrue(nameStub.called);
-          assert.isFalse(statusStub.called);
-          nameStub.lastCall.returnValue.then(() => {
-            assert.equal(nameStub.lastCall.args[0], 'new name');
-            done();
-          });
-        });
-      });
-
-      test('username', done => {
-        element.set('_account.username', '');
-        element._hasUsernameChange = false;
-        assert.isTrue(element.usernameMutable);
-
-        element.set('_username', 'new username');
-
-        assert.isTrue(usernameChangedSpy.called);
-        assert.isFalse(statusChangedSpy.called);
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isTrue(usernameStub.called);
-          assert.isFalse(nameStub.called);
-          assert.isFalse(statusStub.called);
-          usernameStub.lastCall.returnValue.then(() => {
-            assert.equal(usernameStub.lastCall.args[0], 'new username');
-            done();
-          });
-        });
-      });
-
-      test('status', done => {
-        assert.isFalse(element.hasUnsavedChanges);
-
-        element.set('_account.status', 'new status');
-
-        assert.isFalse(nameChangedSpy.called);
-        assert.isTrue(statusChangedSpy.called);
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isFalse(usernameStub.called);
-          assert.isTrue(statusStub.called);
-          assert.isFalse(nameStub.called);
-          statusStub.lastCall.returnValue.then(() => {
-            assert.equal(statusStub.lastCall.args[0], 'new status');
-            done();
-          });
-        });
-      });
-    });
-
-    suite('edit name and status', () => {
-      let nameChangedSpy;
-      let statusChangedSpy;
-      let nameStub;
-      let statusStub;
-
-      setup(() => {
-        nameChangedSpy = sandbox.spy(element, '_nameChanged');
-        statusChangedSpy = sandbox.spy(element, '_statusChanged');
-        element.set('_serverConfig',
-            {auth: {editable_account_fields: ['FULL_NAME']}});
-
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            name => Promise.resolve());
-        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            status => Promise.resolve());
-        sandbox.stub(element.$.restAPI, 'setAccountUsername',
-            username => Promise.resolve());
-      });
-
-      test('set name and status', done => {
-        assert.isTrue(element.nameMutable);
-        assert.isFalse(element.hasUnsavedChanges);
-
-        element.set('_account.name', 'new name');
-
-        assert.isTrue(nameChangedSpy.called);
-
-        element.set('_account.status', 'new status');
-
-        assert.isTrue(statusChangedSpy.called);
-
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isTrue(statusStub.called);
-          assert.isTrue(nameStub.called);
-
+      element.save().then(() => {
+        assert.isFalse(usernameStub.called);
+        assert.isTrue(nameStub.called);
+        assert.isFalse(statusStub.called);
+        nameStub.lastCall.returnValue.then(() => {
           assert.equal(nameStub.lastCall.args[0], 'new name');
-
-          assert.equal(statusStub.lastCall.args[0], 'new status');
-
           done();
         });
       });
     });
 
-    suite('set status but read name', () => {
-      let statusChangedSpy;
-      let statusStub;
+    test('username', done => {
+      element.set('_account.username', '');
+      element._hasUsernameChange = false;
+      assert.isTrue(element.usernameMutable);
 
-      setup(() => {
-        statusChangedSpy = sandbox.spy(element, '_statusChanged');
-        element.set('_serverConfig',
-            {auth: {editable_account_fields: []}});
+      element.set('_username', 'new username');
 
-        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            status => Promise.resolve());
-      });
+      assert.isTrue(usernameChangedSpy.called);
+      assert.isFalse(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
 
-      test('read full name but set status', done => {
-        const section = element.$.nameSection;
-        const displaySpan = section.querySelectorAll('.value')[0];
-        const inputSpan = section.querySelectorAll('.value')[1];
-
-        assert.isFalse(element.nameMutable);
-
-        assert.isFalse(element.hasUnsavedChanges);
-
-        assert.isFalse(displaySpan.hasAttribute('hidden'));
-        assert.equal(displaySpan.textContent, account.name);
-        assert.isTrue(inputSpan.hasAttribute('hidden'));
-
-        element.set('_account.status', 'new status');
-
-        assert.isTrue(statusChangedSpy.called);
-
-        assert.isTrue(element.hasUnsavedChanges);
-
-        element.save().then(() => {
-          assert.isTrue(statusStub.called);
-          statusStub.lastCall.returnValue.then(() => {
-            assert.equal(statusStub.lastCall.args[0], 'new status');
-            done();
-          });
+      element.save().then(() => {
+        assert.isTrue(usernameStub.called);
+        assert.isFalse(nameStub.called);
+        assert.isFalse(statusStub.called);
+        usernameStub.lastCall.returnValue.then(() => {
+          assert.equal(usernameStub.lastCall.args[0], 'new username');
+          done();
         });
       });
     });
 
-    test('_usernameChanged compares usernames with loose equality', () => {
-      element._account = {};
-      element._username = '';
-      element._hasUsernameChange = false;
-      element._loading = false;
-      // _usernameChanged is an observer, but call it here after setting
-      // _hasUsernameChange in the test to force recomputation.
-      element._usernameChanged();
-      flushAsynchronousOperations();
+    test('status', done => {
+      assert.isFalse(element.hasUnsavedChanges);
 
-      assert.isFalse(element._hasUsernameChange);
+      element.set('_account.status', 'new status');
 
-      element.set('_username', 'test');
-      flushAsynchronousOperations();
+      assert.isFalse(nameChangedSpy.called);
+      assert.isTrue(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
 
-      assert.isTrue(element._hasUsernameChange);
-    });
-
-    test('_hideAvatarChangeUrl', () => {
-      assert.equal(element._hideAvatarChangeUrl(''), 'hide');
-
-      assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+      element.save().then(() => {
+        assert.isFalse(usernameStub.called);
+        assert.isTrue(statusStub.called);
+        assert.isFalse(nameStub.called);
+        statusStub.lastCall.returnValue.then(() => {
+          assert.equal(statusStub.lastCall.args[0], 'new status');
+          done();
+        });
+      });
     });
   });
+
+  suite('edit name and status', () => {
+    let nameChangedSpy;
+    let statusChangedSpy;
+    let nameStub;
+    let statusStub;
+
+    setup(() => {
+      nameChangedSpy = sandbox.spy(element, '_nameChanged');
+      statusChangedSpy = sandbox.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+          name => Promise.resolve());
+      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+          status => Promise.resolve());
+      sandbox.stub(element.$.restAPI, 'setAccountUsername',
+          username => Promise.resolve());
+    });
+
+    test('set name and status', done => {
+      assert.isTrue(element.nameMutable);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      element.set('_account.name', 'new name');
+
+      assert.isTrue(nameChangedSpy.called);
+
+      element.set('_account.status', 'new status');
+
+      assert.isTrue(statusChangedSpy.called);
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isTrue(statusStub.called);
+        assert.isTrue(nameStub.called);
+
+        assert.equal(nameStub.lastCall.args[0], 'new name');
+
+        assert.equal(statusStub.lastCall.args[0], 'new status');
+
+        done();
+      });
+    });
+  });
+
+  suite('set status but read name', () => {
+    let statusChangedSpy;
+    let statusStub;
+
+    setup(() => {
+      statusChangedSpy = sandbox.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: []}});
+
+      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+          status => Promise.resolve());
+    });
+
+    test('read full name but set status', done => {
+      const section = element.$.nameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isFalse(element.nameMutable);
+
+      assert.isFalse(element.hasUnsavedChanges);
+
+      assert.isFalse(displaySpan.hasAttribute('hidden'));
+      assert.equal(displaySpan.textContent, account.name);
+      assert.isTrue(inputSpan.hasAttribute('hidden'));
+
+      element.set('_account.status', 'new status');
+
+      assert.isTrue(statusChangedSpy.called);
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isTrue(statusStub.called);
+        statusStub.lastCall.returnValue.then(() => {
+          assert.equal(statusStub.lastCall.args[0], 'new status');
+          done();
+        });
+      });
+    });
+  });
+
+  test('_usernameChanged compares usernames with loose equality', () => {
+    element._account = {};
+    element._username = '';
+    element._hasUsernameChange = false;
+    element._loading = false;
+    // _usernameChanged is an observer, but call it here after setting
+    // _hasUsernameChange in the test to force recomputation.
+    element._usernameChanged();
+    flushAsynchronousOperations();
+
+    assert.isFalse(element._hasUsernameChange);
+
+    element.set('_username', 'test');
+    flushAsynchronousOperations();
+
+    assert.isTrue(element._hasUsernameChange);
+  });
+
+  test('_hideAvatarChangeUrl', () => {
+    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
+
+    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+  });
+});
 </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
deleted file mode 100644
index 852161c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
+++ /dev/null
@@ -1,62 +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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-agreements-list">
-  <template>
-    <style include="shared-styles">
-      #agreements .nameColumn {
-        min-width: 15em;
-        width: auto;
-      }
-      #agreements .descriptionColumn {
-        width: auto;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <div class="gr-form-styles">
-      <table id="agreements">
-        <thead>
-          <tr>
-            <th class="nameColumn">Name</th>
-            <th class="descriptionColumn">Description</th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_agreements]]">
-            <tr>
-              <td class="nameColumn">
-                <a href$="[[getUrlBase(item.url)]]" rel="external">
-                  [[item.name]]
-                </a>
-              </td>
-              <td class="descriptionColumn">[[item.description]]</td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <a href$="[[getUrl()]]">New Contributor Agreement</a>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-agreements-list.js"></script>
-</dom-module>
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..390baf6 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
@@ -14,36 +14,55 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  Polymer({
-    is: 'gr-agreements-list',
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-agreements-list_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrAgreementsList extends mixinBehaviors( [
+  BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-agreements-list'; }
+
+  static get properties() {
+    return {
       _agreements: Array,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+  }
 
-    attached() {
-      this.loadData();
-    },
+  loadData() {
+    return this.$.restAPI.getAccountAgreements().then(agreements => {
+      this._agreements = agreements;
+    });
+  }
 
-    loadData() {
-      return this.$.restAPI.getAccountAgreements().then(agreements => {
-        this._agreements = agreements;
-      });
-    },
+  getUrl() {
+    return this.getBaseUrl() + '/settings/new-agreement';
+  }
 
-    getUrl() {
-      return this.getBaseUrl() + '/settings/new-agreement';
-    },
+  getUrlBase(item) {
+    return this.getBaseUrl() + '/' + item;
+  }
+}
 
-    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_html.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
new file mode 100644
index 0000000..1cd9ce2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #agreements .nameColumn {
+      min-width: 15em;
+      width: auto;
+    }
+    #agreements .descriptionColumn {
+      width: auto;
+    }
+  </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>
+        <tr>
+          <th class="nameColumn">Name</th>
+          <th class="descriptionColumn">Description</th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[_agreements]]">
+          <tr>
+            <td class="nameColumn">
+              <a href$="[[getUrlBase(item.url)]]" rel="external">
+                [[item.name]]
+              </a>
+            </td>
+            <td class="descriptionColumn">[[item.description]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+    <a href$="[[getUrl()]]">New Contributor Agreement</a>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..3a2b86d 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-agreements-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,37 +31,40 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-agreements-list tests', () => {
-    let element;
-    let agreements;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-agreements-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-agreements-list tests', () => {
+  let element;
+  let agreements;
 
-    setup(done => {
-      agreements = [{
-        url: 'some url',
-        description: 'Agreements 1 description',
-        name: 'Agreements 1',
-      }];
+  setup(done => {
+    agreements = [{
+      url: 'some url',
+      description: 'Agreements 1 description',
+      name: 'Agreements 1',
+    }];
 
-      stub('gr-rest-api-interface', {
-        getAccountAgreements() { return Promise.resolve(agreements); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountAgreements() { return Promise.resolve(agreements); },
     });
 
-    test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 1);
-
-      const nameCells = Array.from(rows).map(row =>
-        row.querySelectorAll('td')[0].textContent.trim()
-      );
-
-      assert.equal(nameCells[0], 'Agreements 1');
-    });
+    element.loadData().then(() => { flush(done); });
   });
+
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 1);
+
+    const nameCells = Array.from(rows).map(row =>
+      row.querySelectorAll('td')[0].textContent.trim()
+    );
+
+    assert.equal(nameCells[0], 'Agreements 1');
+  });
+});
 </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
deleted file mode 100644
index 88a53ee..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
+++ /dev/null
@@ -1,84 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-
-<dom-module id="gr-change-table-editor">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      #changeCols {
-        width: auto;
-      }
-      #changeCols .visibleHeader {
-        text-align: center;
-      }
-      .checkboxContainer {
-        cursor: pointer;
-        text-align: center;
-      }
-      .checkboxContainer input {
-        cursor: pointer;
-      }
-      .checkboxContainer:hover {
-        outline: 1px solid var(--border-color);
-      }
-    </style>
-    <div class="gr-form-styles">
-      <table id="changeCols">
-        <thead>
-          <tr>
-            <th class="nameHeader">Column</th>
-            <th class="visibleHeader">Visible</th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr>
-            <td>Number</td>
-            <td class="checkboxContainer"
-                on-click="_handleCheckboxContainerClick">
-              <input
-                  type="checkbox"
-                  name="number"
-                  on-click="_handleNumberCheckboxClick"
-                  checked$="[[showNumber]]">
-            </td>
-          </tr>
-          <template is="dom-repeat" items="[[columnNames]]">
-            <tr>
-              <td>[[item]]</td>
-              <td class="checkboxContainer"
-                  on-click="_handleCheckboxContainerClick">
-                <input
-                    type="checkbox"
-                    name="[[item]]"
-                    on-click="_handleTargetClick"
-                    checked$="[[!isColumnHidden(item, displayedColumns)]]">
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </div>
-  </template>
-  <script src="gr-change-table-editor.js"></script>
-</dom-module>
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..61e8e93 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
@@ -14,13 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-table-editor_html.js';
+import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
 
-  Polymer({
-    is: 'gr-change-table-editor',
+/**
+ * @extends Polymer.Element
+ */
+class GrChangeTableEditor extends mixinBehaviors( [
+  ChangeTableBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-change-table-editor'; }
+
+  static get properties() {
+    return {
       displayedColumns: {
         type: Array,
         notify: true,
@@ -29,49 +50,47 @@
         type: Boolean,
         notify: true,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.ChangeTableBehavior,
-    ],
+  /**
+   * Get the list of enabled column names from whichever checkboxes are
+   * checked (excluding the number checkbox).
+   *
+   * @return {!Array<string>}
+   */
+  _getDisplayedColumns() {
+    return Array.from(dom(this.root)
+        .querySelectorAll('.checkboxContainer input:not([name=number])'))
+        .filter(checkbox => checkbox.checked)
+        .map(checkbox => checkbox.name);
+  }
 
-    /**
-     * Get the list of enabled column names from whichever checkboxes are
-     * checked (excluding the number checkbox).
-     *
-     * @return {!Array<string>}
-     */
-    _getDisplayedColumns() {
-      return Array.from(Polymer.dom(this.root)
-          .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
+   * contains.
+   */
+  _handleCheckboxContainerClick(e) {
+    const checkbox = dom(e.target).querySelector('input');
+    if (!checkbox) { return; }
+    checkbox.click();
+  }
 
-    /**
-     * Handle a click on a checkbox container and relay the click to the checkbox it
-     * contains.
-     */
-    _handleCheckboxContainerClick(e) {
-      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
+   * accordingly.
+   */
+  _handleNumberCheckboxClick(e) {
+    this.showNumber = dom(e).rootTarget.checked;
+  }
 
-    /**
-     * Handle a click on the number checkbox and update the showNumber property
-     * accordingly.
-     */
-    _handleNumberCheckboxClick(e) {
-      this.showNumber = Polymer.dom(e).rootTarget.checked;
-    },
+  /**
+   * Handle a click on a displayed column checkboxes (excluding number) and
+   * update the displayedColumns property accordingly.
+   */
+  _handleTargetClick(e) {
+    this.set('displayedColumns', this._getDisplayedColumns());
+  }
+}
 
-    /**
-     * Handle a click on a displayed column checkboxes (excluding number) and
-     * update the displayedColumns property accordingly.
-     */
-    _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_html.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
new file mode 100644
index 0000000..d63e627
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    #changeCols .visibleHeader {
+      text-align: center;
+    }
+    .checkboxContainer {
+      cursor: pointer;
+      text-align: center;
+    }
+    .checkboxContainer input {
+      cursor: pointer;
+    }
+    .checkboxContainer:hover {
+      outline: 1px solid var(--border-color);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="changeCols">
+      <thead>
+        <tr>
+          <th class="nameHeader">Column</th>
+          <th class="visibleHeader">Visible</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <td>Number</td>
+          <td
+            class="checkboxContainer"
+            on-click="_handleCheckboxContainerClick"
+          >
+            <input
+              type="checkbox"
+              name="number"
+              on-click="_handleNumberCheckboxClick"
+              checked$="[[showNumber]]"
+            />
+          </td>
+        </tr>
+        <template is="dom-repeat" items="[[columnNames]]">
+          <tr>
+            <td>[[item]]</td>
+            <td
+              class="checkboxContainer"
+              on-click="_handleCheckboxContainerClick"
+            >
+              <input
+                type="checkbox"
+                name="[[item]]"
+                on-click="_handleTargetClick"
+                checked$="[[!isColumnHidden(item, displayedColumns)]]"
+              />
+            </td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </div>
+`;
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..79d1390 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-table-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,127 +31,141 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-table-editor tests', () => {
-    let element;
-    let columns;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-table-editor.js';
+suite('gr-change-table-editor tests', () => {
+  let element;
+  let columns;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
 
-      columns = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-      ];
+    columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+    ];
 
-      element.set('displayedColumns', columns);
-      element.showNumber = false;
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('renders', () => {
-      const rows = element.$$('tbody').querySelectorAll('tr');
-      let tds;
-
-      // The `+ 1` is for the number column, which isn't included in the change
-      // table behavior's list.
-      assert.equal(rows.length, element.columnNames.length + 1);
-      for (let i = 0; i < columns.length; i++) {
-        tds = rows[i + 1].querySelectorAll('td');
-        assert.equal(tds[0].textContent, columns[i]);
-      }
-    });
-
-    test('hide item', () => {
-      const checkbox = element.$$('table tr:nth-child(2) input');
-      const isChecked = checkbox.checked;
-      const displayedLength = element.displayedColumns.length;
-      assert.isTrue(isChecked);
-
-      MockInteractions.tap(checkbox);
-      flushAsynchronousOperations();
-
-      assert.equal(element.displayedColumns.length, displayedLength - 1);
-    });
-
-    test('show item', () => {
-      element.set('displayedColumns', [
-        'Status',
-        'Owner',
-        'Assignee',
-        'Repo',
-        'Branch',
-        'Updated',
-      ]);
-      flushAsynchronousOperations();
-      const checkbox = element.$$('table tr:nth-child(2) input');
-      const isChecked = checkbox.checked;
-      const displayedLength = element.displayedColumns.length;
-      assert.isFalse(isChecked);
-      assert.equal(element.$$('table').style.display, '');
-
-      MockInteractions.tap(checkbox);
-      flushAsynchronousOperations();
-
-      assert.equal(element.displayedColumns.length,
-          displayedLength + 1);
-    });
-
-    test('_getDisplayedColumns', () => {
-      assert.deepEqual(element._getDisplayedColumns(), columns);
-      MockInteractions.tap(
-          element.$$('.checkboxContainer input[name=Assignee]'));
-      assert.deepEqual(element._getDisplayedColumns(),
-          columns.filter(c => c !== 'Assignee'));
-    });
-
-    test('_handleCheckboxContainerClick relayes taps to checkboxes', () => {
-      sandbox.stub(element, '_handleNumberCheckboxClick');
-      sandbox.stub(element, '_handleTargetClick');
-
-      MockInteractions.tap(
-          element.$$('table tr:first-of-type .checkboxContainer'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-      assert.isFalse(element._handleTargetClick.called);
-
-      MockInteractions.tap(
-          element.$$('table tr:last-of-type .checkboxContainer'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-      assert.isTrue(element._handleTargetClick.calledOnce);
-    });
-
-    test('_handleNumberCheckboxClick', () => {
-      sandbox.spy(element, '_handleNumberCheckboxClick');
-
-      MockInteractions
-          .tap(element.$$('.checkboxContainer input[name=number]'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-      assert.isTrue(element.showNumber);
-
-      MockInteractions
-          .tap(element.$$('.checkboxContainer input[name=number]'));
-      assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
-      assert.isFalse(element.showNumber);
-    });
-
-    test('_handleTargetClick', () => {
-      sandbox.spy(element, '_handleTargetClick');
-      assert.include(element.displayedColumns, 'Assignee');
-      MockInteractions
-          .tap(element.$$('.checkboxContainer input[name=Assignee]'));
-      assert.isTrue(element._handleTargetClick.calledOnce);
-      assert.notInclude(element.displayedColumns, 'Assignee');
-    });
+    element.set('displayedColumns', columns);
+    element.showNumber = false;
+    flushAsynchronousOperations();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('tbody').querySelectorAll('tr');
+    let tds;
+
+    // The `+ 1` is for the number column, which isn't included in the change
+    // table behavior's list.
+    assert.equal(rows.length, element.columnNames.length + 1);
+    for (let i = 0; i < columns.length; i++) {
+      tds = rows[i + 1].querySelectorAll('td');
+      assert.equal(tds[0].textContent, columns[i]);
+    }
+  });
+
+  test('hide item', () => {
+    const checkbox = element.shadowRoot
+        .querySelector('table tr:nth-child(2) input');
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isTrue(isChecked);
+
+    MockInteractions.tap(checkbox);
+    flushAsynchronousOperations();
+
+    assert.equal(element.displayedColumns.length, displayedLength - 1);
+  });
+
+  test('show item', () => {
+    element.set('displayedColumns', [
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+    ]);
+    flushAsynchronousOperations();
+    const checkbox = element.shadowRoot
+        .querySelector('table tr:nth-child(2) input');
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isFalse(isChecked);
+    assert.equal(element.shadowRoot
+        .querySelector('table').style.display, '');
+
+    MockInteractions.tap(checkbox);
+    flushAsynchronousOperations();
+
+    assert.equal(element.displayedColumns.length,
+        displayedLength + 1);
+  });
+
+  test('_getDisplayedColumns', () => {
+    assert.deepEqual(element._getDisplayedColumns(), columns);
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('.checkboxContainer input[name=Assignee]'));
+    assert.deepEqual(element._getDisplayedColumns(),
+        columns.filter(c => c !== 'Assignee'));
+  });
+
+  test('_handleCheckboxContainerClick relayes taps to checkboxes', () => {
+    sandbox.stub(element, '_handleNumberCheckboxClick');
+    sandbox.stub(element, '_handleTargetClick');
+
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('table tr:first-of-type .checkboxContainer'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isFalse(element._handleTargetClick.called);
+
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('table tr:last-of-type .checkboxContainer'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isTrue(element._handleTargetClick.calledOnce);
+  });
+
+  test('_handleNumberCheckboxClick', () => {
+    sandbox.spy(element, '_handleNumberCheckboxClick');
+
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=number]'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isTrue(element.showNumber);
+
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=number]'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
+    assert.isFalse(element.showNumber);
+  });
+
+  test('_handleTargetClick', () => {
+    sandbox.spy(element, '_handleTargetClick');
+    assert.include(element.displayedColumns, 'Assignee');
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=Assignee]'));
+    assert.isTrue(element._handleTargetClick.calledOnce);
+    assert.notInclude(element.displayedColumns, 'Assignee');
+  });
+});
 </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
deleted file mode 100644
index c29153e..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
+++ /dev/null
@@ -1,115 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-cla-view">
-  <template>
-    <style include="shared-styles">
-      h1 {
-        margin-bottom: var(--spacing-m);
-      }
-      h3 {
-        margin-bottom: var(--spacing-m);
-      }
-      .agreementsUrl {
-        border: 1px solid #b0bdcc;
-        margin-bottom: var(--spacing-xl);
-        margin-left: var(--spacing-xl);
-        margin-right: var(--spacing-xl);
-        padding: var(--spacing-s);
-      }
-      #claNewAgreementsLabel {
-        font-weight: var(--font-weight-bold);
-      }
-      #claNewAgreement {
-        display: none;
-      }
-      #claNewAgreement.show {
-        display: block;
-      }
-      .contributorAgreementButton {
-        font-weight: var(--font-weight-bold);
-      }
-      .alreadySubmittedText {
-        color: var(--error-text-color);
-        margin: 0 var(--spacing-xxl);
-        padding: var(--spacing-m);
-      }
-      .alreadySubmittedText.hide,
-      .hideAgreementsTextBox {
-        display: none;
-      }
-      main {
-        margin: var(--spacing-xxl) auto;
-        max-width: 50em;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <main>
-      <h1>New Contributor Agreement</h1>
-      <h3>Select an agreement type:</h3>
-      <template is="dom-repeat" items="[[_serverConfig.auth.contributor_agreements]]">
-        <span class="contributorAgreementButton">
-          <input id$="claNewAgreementsInput[[item.name]]"
-              name="claNewAgreementsRadio"
-              type="radio"
-              data-name$="[[item.name]]"
-              data-url$="[[item.url]]"
-              on-click="_handleShowAgreement"
-              disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]">
-          <label id="claNewAgreementsLabel">[[item.name]]</label>
-        </span>
-        <div class$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]">
-          Agreement already submitted.
-        </div>
-        <div class="agreementsUrl">
-          [[item.description]]
-        </div>
-      </template>
-      <div id="claNewAgreement" class$="[[_computeShowAgreementsClass(_showAgreements)]]">
-        <h3 class="smallHeading">Review the agreement:</h3>
-        <div id="agreementsUrl" class="agreementsUrl">
-          <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
-            Please review the agreement.</a>
-        </div>
-        <div class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]">
-          <h3 class="smallHeading">Complete the agreement:</h3>
-          <iron-input bind-value="{{_agreementsText}}"
-                      placeholder="Enter 'I agree' here">
-            <input id="input-agreements"
-                   is="iron-input"
-                   bind-value="{{_agreementsText}}"
-                   placeholder="Enter 'I agree' here">
-          </iron-input>
-          <gr-button on-click="_handleSaveAgreements" disabled="[[_disableAgreementsText(_agreementsText)]]">
-            Submit
-          </gr-button>
-        </div>
-      </div>
-    </main>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-cla-view.js"></script>
-</dom-module>
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..957eb48 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
@@ -14,13 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  Polymer({
-    is: 'gr-cla-view',
+import '@polymer/iron-input/iron-input.js';
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-cla-view_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrClaView extends mixinBehaviors( [
+  BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-cla-view'; }
+
+  static get properties() {
+    return {
       _groups: Object,
       /** @type {?} */
       _serverConfig: Object,
@@ -32,124 +53,124 @@
         value: false,
       },
       _agreementsUrl: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
 
-    attached() {
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'New Contributor Agreement'},
+      composed: true, bubbles: true,
+    }));
+  }
+
+  loadData() {
+    const promises = [];
+    promises.push(this.$.restAPI.getConfig(true).then(config => {
+      this._serverConfig = config;
+    }));
+
+    promises.push(this.$.restAPI.getAccountGroups().then(groups => {
+      this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
+    }));
+
+    promises.push(this.$.restAPI.getAccountAgreements().then(agreements => {
+      this._signedAgreements = agreements || [];
+    }));
+
+    return Promise.all(promises);
+  }
+
+  _getAgreementsUrl(configUrl) {
+    let url;
+    if (!configUrl) {
+      return '';
+    }
+    if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
+      url = configUrl;
+    } else {
+      url = this.getBaseUrl() + '/' + configUrl;
+    }
+
+    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...');
+
+    const name = this._agreementName;
+    return this.$.restAPI.saveAccountAgreement({name}).then(res => {
+      let message = 'Agreement failed to be submitted, please try again';
+      if (res.status === 200) {
+        message = 'Agreement has been successfully submited.';
+      }
+      this._createToast(message);
       this.loadData();
+      this._agreementsText = '';
+      this._showAgreements = false;
+    });
+  }
 
-      this.fire('title-change', {title: 'New Contributor Agreement'});
-    },
+  _createToast(message) {
+    this.dispatchEvent(new CustomEvent(
+        'show-alert', {detail: {message}, bubbles: true, composed: true}));
+  }
 
-    loadData() {
-      const promises = [];
-      promises.push(this.$.restAPI.getConfig(true).then(config => {
-        this._serverConfig = config;
-      }));
+  _computeShowAgreementsClass(agreements) {
+    return agreements ? 'show' : '';
+  }
 
-      promises.push(this.$.restAPI.getAccountGroups().then(groups => {
-        this._groups = groups.sort((a, b) => {
-          return a.name.localeCompare(b.name);
-        });
-      }));
-
-      promises.push(this.$.restAPI.getAccountAgreements().then(agreements => {
-        this._signedAgreements = agreements || [];
-      }));
-
-      return Promise.all(promises);
-    },
-
-    _getAgreementsUrl(configUrl) {
-      let url;
-      if (!configUrl) {
-        return '';
+  _disableAgreements(item, groups, signedAgreements) {
+    if (!groups) return false;
+    for (const group of groups) {
+      if ((item && item.auto_verify_group &&
+          item.auto_verify_group.id === group.id) ||
+          signedAgreements.find(i => i.name === item.name)) {
+        return true;
       }
-      if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
-        url = configUrl;
-      } else {
-        url = this.getBaseUrl() + '/' + configUrl;
+    }
+    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
+  // then hides the text box and submit button.
+  _computeHideAgreementClass(name, config) {
+    if (!config) return '';
+    for (const key in config) {
+      if (!config.hasOwnProperty(key)) {
+        continue;
       }
-
-      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...');
-
-      const name = this._agreementName;
-      return this.$.restAPI.saveAccountAgreement({name}).then(res => {
-        let message = 'Agreement failed to be submitted, please try again';
-        if (res.status === 200) {
-          message = 'Agreement has been successfully submited.';
-        }
-        this._createToast(message);
-        this.loadData();
-        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;
-      for (const group of groups) {
-        if ((item && item.auto_verify_group &&
-            item.auto_verify_group.id === group.id) ||
-            signedAgreements.find(i => i.name === item.name)) {
-          return true;
-        }
-      }
-      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
-    // then hides the text box and submit button.
-    _computeHideAgreementClass(name, config) {
-      if (!config) return '';
-      for (const key in config) {
-        if (!config.hasOwnProperty(key)) {
+      for (const prop in config[key]) {
+        if (!config[key].hasOwnProperty(prop)) {
           continue;
         }
-        for (const prop in config[key]) {
-          if (!config[key].hasOwnProperty(prop)) {
-            continue;
-          }
-          if (name === config[key].name &&
-              !config[key].auto_verify_group) {
-            return 'hideAgreementsTextBox';
-          }
+        if (name === config[key].name &&
+            !config[key].auto_verify_group) {
+          return 'hideAgreementsTextBox';
         }
       }
-    },
-  });
-})();
+    }
+  }
+}
+
+customElements.define(GrClaView.is, GrClaView);
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
new file mode 100644
index 0000000..2d371e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    h1 {
+      margin-bottom: var(--spacing-m);
+    }
+    h3 {
+      margin-bottom: var(--spacing-m);
+    }
+    .agreementsUrl {
+      border: 1px solid #b0bdcc;
+      margin-bottom: var(--spacing-xl);
+      margin-left: var(--spacing-xl);
+      margin-right: var(--spacing-xl);
+      padding: var(--spacing-s);
+    }
+    #claNewAgreementsLabel {
+      font-weight: var(--font-weight-bold);
+    }
+    #claNewAgreement {
+      display: none;
+    }
+    #claNewAgreement.show {
+      display: block;
+    }
+    .contributorAgreementButton {
+      font-weight: var(--font-weight-bold);
+    }
+    .alreadySubmittedText {
+      color: var(--error-text-color);
+      margin: 0 var(--spacing-xxl);
+      padding: var(--spacing-m);
+    }
+    .alreadySubmittedText.hide,
+    .hideAgreementsTextBox {
+      display: none;
+    }
+    main {
+      margin: var(--spacing-xxl) auto;
+      max-width: 50em;
+    }
+  </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>
+    <template
+      is="dom-repeat"
+      items="[[_serverConfig.auth.contributor_agreements]]"
+    >
+      <span class="contributorAgreementButton">
+        <input
+          id$="claNewAgreementsInput[[item.name]]"
+          name="claNewAgreementsRadio"
+          type="radio"
+          data-name$="[[item.name]]"
+          data-url$="[[item.url]]"
+          on-click="_handleShowAgreement"
+          disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]"
+        />
+        <label id="claNewAgreementsLabel">[[item.name]]</label>
+      </span>
+      <div
+        class$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]"
+      >
+        Agreement already submitted.
+      </div>
+      <div class="agreementsUrl">
+        [[item.description]]
+      </div>
+    </template>
+    <div
+      id="claNewAgreement"
+      class$="[[_computeShowAgreementsClass(_showAgreements)]]"
+    >
+      <h3 class="smallHeading">Review the agreement:</h3>
+      <div id="agreementsUrl" class="agreementsUrl">
+        <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
+          Please review the agreement.</a
+        >
+      </div>
+      <div
+        class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]"
+      >
+        <h3 class="smallHeading">Complete the agreement:</h3>
+        <iron-input
+          bind-value="{{_agreementsText}}"
+          placeholder="Enter 'I agree' here"
+        >
+          <input
+            id="input-agreements"
+            is="iron-input"
+            bind-value="{{_agreementsText}}"
+            placeholder="Enter 'I agree' here"
+          />
+        </iron-input>
+        <gr-button
+          on-click="_handleSaveAgreements"
+          disabled="[[_disableAgreementsText(_agreementsText)]]"
+        >
+          Submit
+        </gr-button>
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..bc3c10c 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-cla-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,161 +31,164 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-cla-view tests', () => {
-    let element;
-    const signedAgreements = [{
-      name: 'CLA',
-      description: 'Contributor License Agreement',
-      url: 'static/cla.html',
-    }];
-    const auth = {
-      name: 'Individual',
-      description: 'test-description',
-      url: 'static/cla_individual.html',
-      auto_verify_group: {
-        url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-        options: {
-          visible_to_all: true,
-        },
-        group_id: 20,
-        owner: 'CLA Accepted - Individual',
-        owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-        created_on: '2017-07-31 15:11:04.000000000',
-        id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-        name: 'CLA Accepted - Individual',
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-cla-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-cla-view tests', () => {
+  let element;
+  const signedAgreements = [{
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla.html',
+  }];
+  const auth = {
+    name: 'Individual',
+    description: 'test-description',
+    url: 'static/cla_individual.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      options: {
+        visible_to_all: true,
       },
-    };
-
-    const auth2 = {
-      name: 'Individual2',
-      description: 'test-description2',
-      url: 'static/cla_individual2.html',
-      auto_verify_group: {
-        url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-        options: {},
-        group_id: 21,
-        owner: 'CLA Accepted - Individual2',
-        owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-        created_on: '2017-07-31 15:25:42.000000000',
-        id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-        name: 'CLA Accepted - Individual2',
-      },
-    };
-
-    const auth3 = {
-      name: 'CLA',
-      description: 'Contributor License Agreement',
-      url: 'static/cla_individual.html',
-    };
-
-    const config = {
-      auth: {
-        use_contributor_agreements: true,
-        contributor_agreements: [
-          {
-            name: 'Individual',
-            description: 'test-description',
-            url: 'static/cla_individual.html',
-          },
-          {
-            name: 'CLA',
-            description: 'Contributor License Agreement',
-            url: 'static/cla.html',
-          }],
-      },
-    };
-    const config2 = {
-      auth: {
-        use_contributor_agreements: true,
-        contributor_agreements: [
-          {
-            name: 'Individual2',
-            description: 'test-description2',
-            url: 'static/cla_individual2.html',
-          },
-        ],
-      },
-    };
-    const groups = [{
-      options: {visible_to_all: true},
+      group_id: 20,
+      owner: 'CLA Accepted - Individual',
+      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      created_on: '2017-07-31 15:11:04.000000000',
       id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      group_id: 3,
       name: 'CLA Accepted - Individual',
     },
-    ];
+  };
 
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve(config); },
-        getAccountGroups() { return Promise.resolve(groups); },
-        getAccountAgreements() { return Promise.resolve(signedAgreements); },
-      });
-      element = fixture('basic');
-      element.loadData().then(() => { flush(done); });
-    });
+  const auth2 = {
+    name: 'Individual2',
+    description: 'test-description2',
+    url: 'static/cla_individual2.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      options: {},
+      group_id: 21,
+      owner: 'CLA Accepted - Individual2',
+      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      created_on: '2017-07-31 15:25:42.000000000',
+      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      name: 'CLA Accepted - Individual2',
+    },
+  };
 
-    test('renders as expected with signed agreement', () => {
-      const agreementSections = Polymer.dom(element.root)
-          .querySelectorAll('.contributorAgreementButton');
-      const agreementSubmittedTexts = Polymer.dom(element.root)
-          .querySelectorAll('.alreadySubmittedText');
-      assert.equal(agreementSections.length, 2);
-      assert.isFalse(agreementSections[0].querySelector('input').disabled);
-      assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
-          'none');
-      assert.isTrue(agreementSections[1].querySelector('input').disabled);
-      assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
-          'none');
-    });
+  const auth3 = {
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla_individual.html',
+  };
 
-    test('_disableAgreements', () => {
-      // In the auto verify group and have not yet signed agreement
-      assert.isTrue(
-          element._disableAgreements(auth, groups, signedAgreements));
-      // Not in the auto verify group and have not yet signed agreement
-      assert.isFalse(
-          element._disableAgreements(auth2, groups, signedAgreements));
-      // Not in the auto verify group, have signed agreement
-      assert.isTrue(
-          element._disableAgreements(auth3, groups, signedAgreements));
-      // Make sure the undefined check works
-      assert.isFalse(
-          element._disableAgreements(auth, undefined, signedAgreements));
-    });
+  const config = {
+    auth: {
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual',
+          description: 'test-description',
+          url: 'static/cla_individual.html',
+        },
+        {
+          name: 'CLA',
+          description: 'Contributor License Agreement',
+          url: 'static/cla.html',
+        }],
+    },
+  };
+  const config2 = {
+    auth: {
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual2',
+          description: 'test-description2',
+          url: 'static/cla_individual2.html',
+        },
+      ],
+    },
+  };
+  const groups = [{
+    options: {visible_to_all: true},
+    id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+    group_id: 3,
+    name: 'CLA Accepted - Individual',
+  },
+  ];
 
-    test('_hideAgreements', () => {
-      // Not in the auto verify group and have not yet signed agreement
-      assert.equal(
-          element._hideAgreements(auth, groups, signedAgreements), '');
-      // In the auto verify group
-      assert.equal(
-          element._hideAgreements(auth2, groups, signedAgreements), 'hide');
-      // Not in the auto verify group, have signed agreement
-      assert.equal(
-          element._hideAgreements(auth3, groups, signedAgreements), '');
+  setup(done => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve(config); },
+      getAccountGroups() { return Promise.resolve(groups); },
+      getAccountAgreements() { return Promise.resolve(signedAgreements); },
     });
-
-    test('_disableAgreementsText', () => {
-      assert.isFalse(element._disableAgreementsText('I AGREE'));
-      assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
-    });
-
-    test('_computeHideAgreementClass', () => {
-      assert.equal(
-          element._computeHideAgreementClass(
-              auth.name, config.auth.contributor_agreements),
-          'hideAgreementsTextBox');
-      assert.isUndefined(
-          element._computeHideAgreementClass(
-              auth.name, config2.auth.contributor_agreements));
-    });
-
-    test('_getAgreementsUrl', () => {
-      assert.equal(element._getAgreementsUrl(
-          'http://test.org/test.html'), 'http://test.org/test.html');
-      assert.equal(element._getAgreementsUrl(
-          'test_cla.html'), '/test_cla.html');
-    });
+    element = fixture('basic');
+    element.loadData().then(() => { flush(done); });
   });
+
+  test('renders as expected with signed agreement', () => {
+    const agreementSections = dom(element.root)
+        .querySelectorAll('.contributorAgreementButton');
+    const agreementSubmittedTexts = dom(element.root)
+        .querySelectorAll('.alreadySubmittedText');
+    assert.equal(agreementSections.length, 2);
+    assert.isFalse(agreementSections[0].querySelector('input').disabled);
+    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
+        'none');
+    assert.isTrue(agreementSections[1].querySelector('input').disabled);
+    assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
+        'none');
+  });
+
+  test('_disableAgreements', () => {
+    // In the auto verify group and have not yet signed agreement
+    assert.isTrue(
+        element._disableAgreements(auth, groups, signedAgreements));
+    // Not in the auto verify group and have not yet signed agreement
+    assert.isFalse(
+        element._disableAgreements(auth2, groups, signedAgreements));
+    // Not in the auto verify group, have signed agreement
+    assert.isTrue(
+        element._disableAgreements(auth3, groups, signedAgreements));
+    // Make sure the undefined check works
+    assert.isFalse(
+        element._disableAgreements(auth, undefined, signedAgreements));
+  });
+
+  test('_hideAgreements', () => {
+    // Not in the auto verify group and have not yet signed agreement
+    assert.equal(
+        element._hideAgreements(auth, groups, signedAgreements), '');
+    // In the auto verify group
+    assert.equal(
+        element._hideAgreements(auth2, groups, signedAgreements), 'hide');
+    // Not in the auto verify group, have signed agreement
+    assert.equal(
+        element._hideAgreements(auth3, groups, signedAgreements), '');
+  });
+
+  test('_disableAgreementsText', () => {
+    assert.isFalse(element._disableAgreementsText('I AGREE'));
+    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
+  });
+
+  test('_computeHideAgreementClass', () => {
+    assert.equal(
+        element._computeHideAgreementClass(
+            auth.name, config.auth.contributor_agreements),
+        'hideAgreementsTextBox');
+    assert.isUndefined(
+        element._computeHideAgreementClass(
+            auth.name, config2.auth.contributor_agreements));
+  });
+
+  test('_getAgreementsUrl', () => {
+    assert.equal(element._getAgreementsUrl(
+        'http://test.org/test.html'), 'http://test.org/test.html');
+    assert.equal(element._getAgreementsUrl(
+        'test_cla.html'), '/test_cla.html');
+  });
+});
 </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
deleted file mode 100644
index 53a30c3..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
+++ /dev/null
@@ -1,157 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<dom-module id="gr-edit-preferences">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles"></style>
-    <div id="editPreferences" class="gr-form-styles">
-      <section>
-        <span class="title">Tab width</span>
-        <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{editPrefs.tab_size}}"
-              on-keypress="_handleEditPrefsChanged"
-              on-change="_handleEditPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{editPrefs.tab_size}}"
-                on-keypress="_handleEditPrefsChanged"
-                on-change="_handleEditPrefsChanged">
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Columns</span>
-        <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{editPrefs.line_length}}"
-              on-keypress="_handleEditPrefsChanged"
-              on-change="_handleEditPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{editPrefs.line_length}}"
-                on-keypress="_handleEditPrefsChanged"
-                on-change="_handleEditPrefsChanged">
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Indent unit</span>
-        <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{editPrefs.indent_unit}}"
-              on-keypress="_handleEditPrefsChanged"
-              on-change="_handleEditPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{editPrefs.indent_unit}}"
-                on-keypress="_handleEditPrefsChanged"
-                on-change="_handleEditPrefsChanged">
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Syntax highlighting</span>
-        <span class="value">
-          <input
-              id="editSyntaxHighlighting"
-              type="checkbox"
-              checked$="[[editPrefs.syntax_highlighting]]"
-              on-change="_handleEditSyntaxHighlightingChanged">
-        </span>
-      </section>
-      <section>
-        <span class="title">Show tabs</span>
-        <span class="value">
-          <input
-              id="editShowTabs"
-              type="checkbox"
-              checked$="[[editPrefs.show_tabs]]"
-              on-change="_handleEditShowTabsChanged">
-        </span>
-      </section>
-      <section>
-        <span class="title">Match brackets</span>
-        <span class="value">
-          <input
-              id="showMatchBrackets"
-              type="checkbox"
-              checked$="[[editPrefs.match_brackets]]"
-              on-change="_handleMatchBracketsChanged">
-        </span>
-      </section>
-      <section>
-        <span class="title">Line wrapping</span>
-        <span class="value">
-          <input
-              id="editShowLineWrapping"
-              type="checkbox"
-              checked$="[[editPrefs.line_wrapping]]"
-              on-change="_handleEditLineWrappingChanged">
-        </span>
-      </section>
-      <section>
-        <span class="title">Indent with tabs</span>
-        <span class="value">
-          <input
-              id="showIndentWithTabs"
-              type="checkbox"
-              checked$="[[editPrefs.indent_with_tabs]]"
-              on-change="_handleIndentWithTabsChanged">
-        </span>
-      </section>
-      <section>
-        <span class="title">Auto close brackets</span>
-        <span class="value">
-          <input
-              id="showAutoCloseBrackets"
-              type="checkbox"
-              checked$="[[editPrefs.auto_close_brackets]]"
-              on-change="_handleAutoCloseBracketsChanged">
-        </span>
-      </section>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-edit-preferences.js"></script>
-</dom-module>
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..2a7ac06 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
@@ -14,13 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-edit-preferences',
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-edit-preferences_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrEditPreferences extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-edit-preferences'; }
+
+  static get properties() {
+    return {
       hasUnsavedChanges: {
         type: Boolean,
         notify: true,
@@ -29,54 +44,56 @@
 
       /** @type {?} */
       editPrefs: Object,
-    },
+    };
+  }
 
-    loadData() {
-      return this.$.restAPI.getEditPreferences().then(prefs => {
-        this.editPrefs = prefs;
-      });
-    },
+  loadData() {
+    return this.$.restAPI.getEditPreferences().then(prefs => {
+      this.editPrefs = prefs;
+    });
+  }
 
-    _handleEditPrefsChanged() {
-      this.hasUnsavedChanges = true;
-    },
+  _handleEditPrefsChanged() {
+    this.hasUnsavedChanges = true;
+  }
 
-    _handleEditSyntaxHighlightingChanged() {
-      this.set('editPrefs.syntax_highlighting',
-          this.$.editSyntaxHighlighting.checked);
-      this._handleEditPrefsChanged();
-    },
+  _handleEditSyntaxHighlightingChanged() {
+    this.set('editPrefs.syntax_highlighting',
+        this.$.editSyntaxHighlighting.checked);
+    this._handleEditPrefsChanged();
+  }
 
-    _handleEditShowTabsChanged() {
-      this.set('editPrefs.show_tabs', this.$.editShowTabs.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();
-    },
+  _handleMatchBracketsChanged() {
+    this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
+    this._handleEditPrefsChanged();
+  }
 
-    _handleEditLineWrappingChanged() {
-      this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.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();
-    },
+  _handleIndentWithTabsChanged() {
+    this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
+    this._handleEditPrefsChanged();
+  }
 
-    _handleAutoCloseBracketsChanged() {
-      this.set('editPrefs.auto_close_brackets',
-          this.$.showAutoCloseBrackets.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;
-      });
-    },
-  });
-})();
+  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_html.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
new file mode 100644
index 0000000..f2c476a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{editPrefs.tab_size}}"
+          on-keypress="_handleEditPrefsChanged"
+          on-change="_handleEditPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{editPrefs.tab_size}}"
+            on-keypress="_handleEditPrefsChanged"
+            on-change="_handleEditPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Columns</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{editPrefs.line_length}}"
+          on-keypress="_handleEditPrefsChanged"
+          on-change="_handleEditPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{editPrefs.line_length}}"
+            on-keypress="_handleEditPrefsChanged"
+            on-change="_handleEditPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Indent unit</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{editPrefs.indent_unit}}"
+          on-keypress="_handleEditPrefsChanged"
+          on-change="_handleEditPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{editPrefs.indent_unit}}"
+            on-keypress="_handleEditPrefsChanged"
+            on-change="_handleEditPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Syntax highlighting</span>
+      <span class="value">
+        <input
+          id="editSyntaxHighlighting"
+          type="checkbox"
+          checked$="[[editPrefs.syntax_highlighting]]"
+          on-change="_handleEditSyntaxHighlightingChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Show tabs</span>
+      <span class="value">
+        <input
+          id="editShowTabs"
+          type="checkbox"
+          checked$="[[editPrefs.show_tabs]]"
+          on-change="_handleEditShowTabsChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Match brackets</span>
+      <span class="value">
+        <input
+          id="showMatchBrackets"
+          type="checkbox"
+          checked$="[[editPrefs.match_brackets]]"
+          on-change="_handleMatchBracketsChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Line wrapping</span>
+      <span class="value">
+        <input
+          id="editShowLineWrapping"
+          type="checkbox"
+          checked$="[[editPrefs.line_wrapping]]"
+          on-change="_handleEditLineWrappingChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Indent with tabs</span>
+      <span class="value">
+        <input
+          id="showIndentWithTabs"
+          type="checkbox"
+          checked$="[[editPrefs.indent_with_tabs]]"
+          on-change="_handleIndentWithTabsChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Auto close brackets</span>
+      <span class="value">
+        <input
+          id="showAutoCloseBrackets"
+          type="checkbox"
+          checked$="[[editPrefs.auto_close_brackets]]"
+          on-change="_handleAutoCloseBracketsChanged"
+        />
+      </span>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..3cc7bfe 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-edit-preferences.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,94 +31,96 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-edit-preferences tests', () => {
-    let element;
-    let sandbox;
-    let editPreferences;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-edit-preferences.js';
+suite('gr-edit-preferences tests', () => {
+  let element;
+  let sandbox;
+  let editPreferences;
 
-    function valueOf(title, fieldsetid) {
-      const sections = element.$[fieldsetid].querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent.trim() === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    setup(() => {
-      editPreferences = {
-        auto_close_brackets: false,
-        cursor_blink_rate: 0,
-        hide_line_numbers: false,
-        hide_top_menu: false,
-        indent_unit: 2,
-        indent_with_tabs: false,
-        key_map_type: 'DEFAULT',
-        line_length: 100,
-        line_wrapping: false,
-        match_brackets: true,
-        show_base: false,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        tab_size: 8,
-        theme: 'DEFAULT',
-      };
+  setup(() => {
+    editPreferences = {
+      auto_close_brackets: false,
+      cursor_blink_rate: 0,
+      hide_line_numbers: false,
+      hide_top_menu: false,
+      indent_unit: 2,
+      indent_with_tabs: false,
+      key_map_type: 'DEFAULT',
+      line_length: 100,
+      line_wrapping: false,
+      match_brackets: true,
+      show_base: false,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+      theme: 'DEFAULT',
+    };
 
-      stub('gr-rest-api-interface', {
-        getEditPreferences() {
-          return Promise.resolve(editPreferences);
-        },
-      });
-
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      return element.loadData();
+    stub('gr-rest-api-interface', {
+      getEditPreferences() {
+        return Promise.resolve(editPreferences);
+      },
     });
 
-    teardown(() => { sandbox.restore(); });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    return element.loadData();
+  });
 
-    test('renders', () => {
-      // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Tab width', 'editPreferences')
-          .firstElementChild.bindValue, editPreferences.tab_size);
-      assert.equal(valueOf('Columns', 'editPreferences')
-          .firstElementChild.bindValue, editPreferences.line_length);
-      assert.equal(valueOf('Indent unit', 'editPreferences')
-          .firstElementChild.bindValue, editPreferences.indent_unit);
-      assert.equal(valueOf('Syntax highlighting', 'editPreferences')
-          .firstElementChild.checked, editPreferences.syntax_highlighting);
-      assert.equal(valueOf('Show tabs', 'editPreferences')
-          .firstElementChild.checked, editPreferences.show_tabs);
-      assert.equal(valueOf('Match brackets', 'editPreferences')
-          .firstElementChild.checked, editPreferences.match_brackets);
-      assert.equal(valueOf('Line wrapping', 'editPreferences')
-          .firstElementChild.checked, editPreferences.line_wrapping);
-      assert.equal(valueOf('Indent with tabs', 'editPreferences')
-          .firstElementChild.checked, editPreferences.indent_with_tabs);
-      assert.equal(valueOf('Auto close brackets', 'editPreferences')
-          .firstElementChild.checked, editPreferences.auto_close_brackets);
+  teardown(() => { sandbox.restore(); });
 
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Tab width', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.tab_size);
+    assert.equal(valueOf('Columns', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.line_length);
+    assert.equal(valueOf('Indent unit', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.indent_unit);
+    assert.equal(valueOf('Syntax highlighting', 'editPreferences')
+        .firstElementChild.checked, editPreferences.syntax_highlighting);
+    assert.equal(valueOf('Show tabs', 'editPreferences')
+        .firstElementChild.checked, editPreferences.show_tabs);
+    assert.equal(valueOf('Match brackets', 'editPreferences')
+        .firstElementChild.checked, editPreferences.match_brackets);
+    assert.equal(valueOf('Line wrapping', 'editPreferences')
+        .firstElementChild.checked, editPreferences.line_wrapping);
+    assert.equal(valueOf('Indent with tabs', 'editPreferences')
+        .firstElementChild.checked, editPreferences.indent_with_tabs);
+    assert.equal(valueOf('Auto close brackets', 'editPreferences')
+        .firstElementChild.checked, editPreferences.auto_close_brackets);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sandbox.stub(element.$.restAPI, 'saveEditPreferences')
+        .returns(Promise.resolve());
+    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
+        .firstElementChild;
+    showTabsCheckbox.checked = false;
+    element._handleEditShowTabsChanged();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    return element.save().then(() => {
       assert.isFalse(element.hasUnsavedChanges);
     });
-
-    test('save changes', () => {
-      sandbox.stub(element.$.restAPI, 'saveEditPreferences')
-          .returns(Promise.resolve());
-      const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
-          .firstElementChild;
-      showTabsCheckbox.checked = false;
-      element._handleEditShowTabsChanged();
-
-      assert.isTrue(element.hasUnsavedChanges);
-
-      // Save the change.
-      return element.save().then(() => {
-        assert.isFalse(element.hasUnsavedChanges);
-      });
-    });
   });
+});
 </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
deleted file mode 100644
index caaf18b..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
+++ /dev/null
@@ -1,98 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-email-editor">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      th {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-      }
-      #emailTable .emailColumn {
-        min-width: 32.5em;
-        width: auto;
-      }
-      #emailTable .preferredHeader {
-        text-align: center;
-        width: 6em;
-      }
-      #emailTable .preferredControl {
-        cursor: pointer;
-        height: auto;
-        text-align: center;
-      }
-      #emailTable .preferredControl .preferredRadio {
-        height: auto;
-      }
-      .preferredControl:hover {
-        outline: 1px solid var(--border-color);
-      }
-    </style>
-    <div class="gr-form-styles">
-      <table id="emailTable">
-        <thead>
-          <tr>
-            <th class="emailColumn">Email</th>
-            <th class="preferredHeader">Preferred</th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_emails]]">
-            <tr>
-              <td class="emailColumn">[[item.email]]</td>
-              <td class="preferredControl" on-click="_handlePreferredControlClick">
-                <iron-input
-                    class="preferredRadio"
-                    type="radio"
-                    on-change="_handlePreferredChange"
-                    name="preferred"
-                    bind-value="[[item.email]]"
-                    checked$="[[item.preferred]]">
-                  <input
-                      is="iron-input"
-                      class="preferredRadio"
-                      type="radio"
-                      on-change="_handlePreferredChange"
-                      name="preferred"
-                      value="[[item.email]]"
-                      checked$="[[item.preferred]]">
-                </iron-input>
-              </td>
-              <td>
-                <gr-button
-                    data-index$="[[index]]"
-                    on-click="_handleDeleteButton"
-                    disabled="[[item.preferred]]"
-                    class="remove-button">Delete</gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-email-editor.js"></script>
-</dom-module>
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..fc97079 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
@@ -14,13 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-email-editor',
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-email-editor_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrEmailEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-email-editor'; }
+
+  static get properties() {
+    return {
       hasUnsavedChanges: {
         type: Boolean,
         notify: true,
@@ -37,59 +52,61 @@
         type: String,
         value: null,
       },
-    },
+    };
+  }
 
-    loadData() {
-      return this.$.restAPI.getAccountEmails().then(emails => {
-        this._emails = emails;
-      });
-    },
+  loadData() {
+    return this.$.restAPI.getAccountEmails().then(emails => {
+      this._emails = emails;
+    });
+  }
 
-    save() {
-      const promises = [];
+  save() {
+    const promises = [];
 
-      for (const emailObj of this._emailsToRemove) {
-        promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
+    for (const emailObj of this._emailsToRemove) {
+      promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
+    }
+
+    if (this._newPreferred) {
+      promises.push(this.$.restAPI.setPreferredAccountEmail(
+          this._newPreferred));
+    }
+
+    return Promise.all(promises).then(() => {
+      this._emailsToRemove = [];
+      this._newPreferred = null;
+      this.hasUnsavedChanges = false;
+    });
+  }
+
+  _handleDeleteButton(e) {
+    const index = parseInt(dom(e).localTarget
+        .getAttribute('data-index'), 10);
+    const email = this._emails[index];
+    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;
+    for (let i = 0; i < this._emails.length; i++) {
+      if (preferred === this._emails[i].email) {
+        this.set(['_emails', i, 'preferred'], true);
+        this._newPreferred = preferred;
+        this.hasUnsavedChanges = true;
+      } else if (this._emails[i].preferred) {
+        this.set(['_emails', i, 'preferred'], false);
       }
+    }
+  }
+}
 
-      if (this._newPreferred) {
-        promises.push(this.$.restAPI.setPreferredAccountEmail(
-            this._newPreferred));
-      }
-
-      return Promise.all(promises).then(() => {
-        this._emailsToRemove = [];
-        this._newPreferred = null;
-        this.hasUnsavedChanges = false;
-      });
-    },
-
-    _handleDeleteButton(e) {
-      const index = parseInt(Polymer.dom(e).localTarget
-          .getAttribute('data-index'), 10);
-      const email = this._emails[index];
-      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;
-      for (let i = 0; i < this._emails.length; i++) {
-        if (preferred === this._emails[i].email) {
-          this.set(['_emails', i, 'preferred'], true);
-          this._newPreferred = preferred;
-          this.hasUnsavedChanges = true;
-        } else if (this._emails[i].preferred) {
-          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_html.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
new file mode 100644
index 0000000..977e95d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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);
+      text-align: left;
+    }
+    #emailTable .emailColumn {
+      min-width: 32.5em;
+      width: auto;
+    }
+    #emailTable .preferredHeader {
+      text-align: center;
+      width: 6em;
+    }
+    #emailTable .preferredControl {
+      cursor: pointer;
+      height: auto;
+      text-align: center;
+    }
+    #emailTable .preferredControl .preferredRadio {
+      height: auto;
+    }
+    .preferredControl:hover {
+      outline: 1px solid var(--border-color);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="emailTable">
+      <thead>
+        <tr>
+          <th class="emailColumn">Email</th>
+          <th class="preferredHeader">Preferred</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[_emails]]">
+          <tr>
+            <td class="emailColumn">[[item.email]]</td>
+            <td
+              class="preferredControl"
+              on-click="_handlePreferredControlClick"
+            >
+              <iron-input
+                class="preferredRadio"
+                type="radio"
+                on-change="_handlePreferredChange"
+                name="preferred"
+                bind-value="[[item.email]]"
+                checked$="[[item.preferred]]"
+              >
+                <input
+                  is="iron-input"
+                  class="preferredRadio"
+                  type="radio"
+                  on-change="_handlePreferredChange"
+                  name="preferred"
+                  value="[[item.email]]"
+                  checked$="[[item.preferred]]"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                data-index$="[[index]]"
+                on-click="_handleDeleteButton"
+                disabled="[[item.preferred]]"
+                class="remove-button"
+                >Delete</gr-button
+              >
+            </td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..ad2553d 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-email-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,116 +31,122 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-email-editor tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-email-editor.js';
+suite('gr-email-editor tests', () => {
+  let element;
 
-    setup(done => {
-      const emails = [
-        {email: 'email@one.com'},
-        {email: 'email@two.com', preferred: true},
-        {email: 'email@three.com'},
-      ];
+  setup(done => {
+    const emails = [
+      {email: 'email@one.com'},
+      {email: 'email@two.com', preferred: true},
+      {email: 'email@three.com'},
+    ];
 
-      stub('gr-rest-api-interface', {
-        getAccountEmails() { return Promise.resolve(emails); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(flush(done));
+    stub('gr-rest-api-interface', {
+      getAccountEmails() { return Promise.resolve(emails); },
     });
 
-    test('renders', () => {
-      const rows = element.$$('table').querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 3);
+    element.loadData().then(flush(done));
+  });
 
-      assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
-      assert.isNotOk(rows[0].querySelector('gr-button').disabled);
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
 
-      assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
-      assert.isOk(rows[1].querySelector('gr-button').disabled);
+    assert.equal(rows.length, 3);
 
-      assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
-      assert.isNotOk(rows[2].querySelector('gr-button').disabled);
+    assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
+    assert.isNotOk(rows[0].querySelector('gr-button').disabled);
 
-      assert.isFalse(element.hasUnsavedChanges);
-    });
+    assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
+    assert.isOk(rows[1].querySelector('gr-button').disabled);
 
-    test('edit preferred', () => {
-      const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
-      const radios = element.$$('table').querySelectorAll('input[type=radio]');
+    assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
+    assert.isNotOk(rows[2].querySelector('gr-button').disabled);
 
-      assert.isFalse(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
-      assert.isNotOk(radios[0].checked);
-      assert.isOk(radios[1].checked);
-      assert.isFalse(preferredChangedSpy.called);
+    assert.isFalse(element.hasUnsavedChanges);
+  });
 
-      radios[0].click();
+  test('edit preferred', () => {
+    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+    const radios = element.shadowRoot
+        .querySelector('table').querySelectorAll('input[type=radio]');
 
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
-      assert.isOk(radios[0].checked);
-      assert.isNotOk(radios[1].checked);
-      assert.isTrue(preferredChangedSpy.called);
-    });
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+    assert.isNotOk(radios[0].checked);
+    assert.isOk(radios[1].checked);
+    assert.isFalse(preferredChangedSpy.called);
 
-    test('delete email', () => {
-      const buttons = element.$$('table').querySelectorAll('gr-button');
+    radios[0].click();
 
-      assert.isFalse(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+    assert.isOk(radios[0].checked);
+    assert.isNotOk(radios[1].checked);
+    assert.isTrue(preferredChangedSpy.called);
+  });
 
-      buttons[2].click();
+  test('delete email', () => {
+    const buttons = element.shadowRoot
+        .querySelector('table').querySelectorAll('gr-button');
 
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 1);
-      assert.equal(element._emails.length, 2);
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
 
-      assert.equal(element._emailsToRemove[0].email, 'email@three.com');
-    });
+    buttons[2].click();
 
-    test('save changes', done => {
-      const deleteEmailStub =
-          sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-      const setPreferredStub = sinon.stub(element.$.restAPI,
-          'setPreferredAccountEmail');
-      const rows = element.$$('table').querySelectorAll('tbody tr');
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 1);
+    assert.equal(element._emails.length, 2);
 
-      assert.isFalse(element.hasUnsavedChanges);
-      assert.isNotOk(element._newPreferred);
-      assert.equal(element._emailsToRemove.length, 0);
-      assert.equal(element._emails.length, 3);
+    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+  });
 
-      // Delete the first email and set the last as preferred.
-      rows[0].querySelector('gr-button').click();
-      rows[2].querySelector('input[type=radio]').click();
+  test('save changes', done => {
+    const deleteEmailStub =
+        sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+    const setPreferredStub = sinon.stub(element.$.restAPI,
+        'setPreferredAccountEmail');
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
 
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.equal(element._newPreferred, 'email@three.com');
-      assert.equal(element._emailsToRemove.length, 1);
-      assert.equal(element._emailsToRemove[0].email, 'email@one.com');
-      assert.equal(element._emails.length, 2);
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
 
-      // Save the changes.
-      element.save().then(() => {
-        assert.equal(deleteEmailStub.callCount, 1);
-        assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+    // Delete the first email and set the last as preferred.
+    rows[0].querySelector('gr-button').click();
+    rows[2].querySelector('input[type=radio]').click();
 
-        assert.isTrue(setPreferredStub.called);
-        assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.equal(element._newPreferred, 'email@three.com');
+    assert.equal(element._emailsToRemove.length, 1);
+    assert.equal(element._emailsToRemove[0].email, 'email@one.com');
+    assert.equal(element._emails.length, 2);
 
-        done();
-      });
+    // Save the changes.
+    element.save().then(() => {
+      assert.equal(deleteEmailStub.callCount, 1);
+      assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+
+      assert.isTrue(setPreferredStub.called);
+      assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+
+      done();
     });
   });
+});
 </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
deleted file mode 100644
index cf73d99..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
+++ /dev/null
@@ -1,143 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.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="../../../styles/shared-styles.html">
-
-<dom-module id="gr-gpg-editor">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      .keyHeader {
-        width: 9em;
-      }
-      .userIdHeader {
-        width: 15em;
-      }
-      #viewKeyOverlay {
-        padding: var(--spacing-xxl);
-        width: 50em;
-      }
-      .publicKey {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        overflow-x: scroll;
-        overflow-wrap: break-word;
-        width: 30em;
-      }
-      .closeButton {
-        bottom: 2em;
-        position: absolute;
-        right: 2em;
-      }
-      #existing {
-        margin-bottom: var(--spacing-l);
-      }
-    </style>
-    <div class="gr-form-styles">
-      <fieldset id="existing">
-        <table>
-          <thead>
-            <tr>
-              <th class="idColumn">ID</th>
-              <th class="fingerPrintColumn">Fingerprint</th>
-              <th class="userIdHeader">User IDs</th>
-              <th class="keyHeader">Public Key</th>
-              <th></th>
-              <th></th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[_keys]]" as="key">
-              <tr>
-                <td class="idColumn">[[key.id]]</td>
-                <td class="fingerPrintColumn">[[key.fingerprint]]</td>
-                <td class="userIdHeader">
-                  <template is="dom-repeat" items="[[key.user_ids]]">
-                    [[item]]
-                  </template>
-                </td>
-                <td class="keyHeader">
-                  <gr-button
-                      on-click="_showKey"
-                      data-index$="[[index]]"
-                      link>Click to View</gr-button>
-                </td>
-                <td>
-                  <gr-copy-clipboard
-                      has-tooltip
-                      button-title="Copy GPG public key to clipboard"
-                      hide-input
-                      text="[[key.key]]">
-                  </gr-copy-clipboard>
-                </td>
-                <td>
-                  <gr-button
-                      data-index$="[[index]]"
-                      on-click="_handleDeleteKey">Delete</gr-button>
-                </td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-        <gr-overlay id="viewKeyOverlay" with-backdrop>
-          <fieldset>
-            <section>
-              <span class="title">Status</span>
-              <span class="value">[[_keyToView.status]]</span>
-            </section>
-            <section>
-              <span class="title">Key</span>
-              <span class="value">[[_keyToView.key]]</span>
-            </section>
-          </fieldset>
-          <gr-button
-              class="closeButton"
-              on-click="_closeOverlay">Close</gr-button>
-        </gr-overlay>
-        <gr-button
-            on-click="save"
-            disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
-      </fieldset>
-      <fieldset>
-        <section>
-          <span class="title">New GPG key</span>
-          <span class="value">
-            <iron-autogrow-textarea
-                id="newKey"
-                autocomplete="on"
-                bind-value="{{_newKey}}"
-                placeholder="New GPG Key"></iron-autogrow-textarea>
-          </span>
-        </section>
-        <gr-button
-            id="addButton"
-            disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-            on-click="_handleAddKey">Add new GPG key</gr-button>
-      </fieldset>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-gpg-editor.js"></script>
-</dom-module>
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..90631c7 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
@@ -14,13 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-gpg-editor',
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-gpg-editor_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrGpgEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-gpg-editor'; }
+
+  static get properties() {
+    return {
       hasUnsavedChanges: {
         type: Boolean,
         value: false,
@@ -37,69 +55,72 @@
         type: Array,
         value() { return []; },
       },
-    },
+    };
+  }
 
-    loadData() {
-      this._keys = [];
-      return this.$.restAPI.getAccountGPGKeys().then(keys => {
-        if (!keys) {
-          return;
-        }
-        this._keys = Object.keys(keys)
-            .map(key => {
-              const gpgKey = keys[key];
-              gpgKey.id = key;
-              return gpgKey;
-            });
-      });
-    },
-
-    save() {
-      const promises = this._keysToRemove.map(key => {
-        this.$.restAPI.deleteAccountGPGKey(key.id);
-      });
-
-      return Promise.all(promises).then(() => {
-        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;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this.push('_keysToRemove', this._keys[index]);
-      this.splice('_keys', index, 1);
-      this.hasUnsavedChanges = true;
-    },
-
-    _handleAddKey() {
-      this.$.addButton.disabled = true;
-      this.$.newKey.disabled = true;
-      return this.$.restAPI.addAccountGPGKey({add: [this._newKey.trim()]})
-          .then(key => {
-            this.$.newKey.disabled = false;
-            this._newKey = '';
-            this.loadData();
-          }).catch(() => {
-            this.$.addButton.disabled = false;
-            this.$.newKey.disabled = false;
+  loadData() {
+    this._keys = [];
+    return this.$.restAPI.getAccountGPGKeys().then(keys => {
+      if (!keys) {
+        return;
+      }
+      this._keys = Object.keys(keys)
+          .map(key => {
+            const gpgKey = keys[key];
+            gpgKey.id = key;
+            return gpgKey;
           });
-    },
+    });
+  }
 
-    _computeAddButtonDisabled(newKey) {
-      return !newKey.length;
-    },
-  });
-})();
+  save() {
+    const promises = this._keysToRemove.map(key => {
+      this.$.restAPI.deleteAccountGPGKey(key.id);
+    });
+
+    return Promise.all(promises).then(() => {
+      this._keysToRemove = [];
+      this.hasUnsavedChanges = false;
+    });
+  }
+
+  _showKey(e) {
+    const el = 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 = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this.push('_keysToRemove', this._keys[index]);
+    this.splice('_keys', index, 1);
+    this.hasUnsavedChanges = true;
+  }
+
+  _handleAddKey() {
+    this.$.addButton.disabled = true;
+    this.$.newKey.disabled = true;
+    return this.$.restAPI.addAccountGPGKey({add: [this._newKey.trim()]})
+        .then(key => {
+          this.$.newKey.disabled = false;
+          this._newKey = '';
+          this.loadData();
+        })
+        .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_html.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
new file mode 100644
index 0000000..19b8d0c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    .userIdHeader {
+      width: 15em;
+    }
+    #viewKeyOverlay {
+      padding: var(--spacing-xxl);
+      width: 50em;
+    }
+    .publicKey {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      overflow-x: scroll;
+      overflow-wrap: break-word;
+      width: 30em;
+    }
+    .closeButton {
+      bottom: 2em;
+      position: absolute;
+      right: 2em;
+    }
+    #existing {
+      margin-bottom: var(--spacing-l);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset id="existing">
+      <table>
+        <thead>
+          <tr>
+            <th class="idColumn">ID</th>
+            <th class="fingerPrintColumn">Fingerprint</th>
+            <th class="userIdHeader">User IDs</th>
+            <th class="keyHeader">Public Key</th>
+            <th></th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_keys]]" as="key">
+            <tr>
+              <td class="idColumn">[[key.id]]</td>
+              <td class="fingerPrintColumn">[[key.fingerprint]]</td>
+              <td class="userIdHeader">
+                <template is="dom-repeat" items="[[key.user_ids]]">
+                  [[item]]
+                </template>
+              </td>
+              <td class="keyHeader">
+                <gr-button on-click="_showKey" data-index$="[[index]]" link=""
+                  >Click to View</gr-button
+                >
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  has-tooltip=""
+                  button-title="Copy GPG public key to clipboard"
+                  hide-input=""
+                  text="[[key.key]]"
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button data-index$="[[index]]" on-click="_handleDeleteKey"
+                  >Delete</gr-button
+                >
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+      <gr-overlay id="viewKeyOverlay" with-backdrop="">
+        <fieldset>
+          <section>
+            <span class="title">Status</span>
+            <span class="value">[[_keyToView.status]]</span>
+          </section>
+          <section>
+            <span class="title">Key</span>
+            <span class="value">[[_keyToView.key]]</span>
+          </section>
+        </fieldset>
+        <gr-button class="closeButton" on-click="_closeOverlay"
+          >Close</gr-button
+        >
+      </gr-overlay>
+      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
+        >Save changes</gr-button
+      >
+    </fieldset>
+    <fieldset>
+      <section>
+        <span class="title">New GPG key</span>
+        <span class="value">
+          <iron-autogrow-textarea
+            id="newKey"
+            autocomplete="on"
+            bind-value="{{_newKey}}"
+            placeholder="New GPG Key"
+          ></iron-autogrow-textarea>
+        </span>
+      </section>
+      <gr-button
+        id="addButton"
+        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
+        on-click="_handleAddKey"
+        >Add new GPG key</gr-button
+      >
+    </fieldset>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..4a0af5b 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-gpg-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,162 +31,165 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-gpg-editor tests', () => {
-    let element;
-    let keys;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-gpg-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-gpg-editor tests', () => {
+  let element;
+  let keys;
 
-    setup(done => {
-      const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-      const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-      keys = {
-        AFC8A49B: {
-          fingerprint: fingerprint1,
-          user_ids: [
-            'John Doe john.doe@example.com',
-          ],
-          key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-               '\nVersion: BCPG v1.52\n\t<key 1>',
-          status: 'TRUSTED',
-          problems: [],
-        },
-        AED9B59C: {
-          fingerprint: fingerprint2,
-          user_ids: [
-            'Gerrit gerrit@example.com',
-          ],
-          key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-               '\nVersion: BCPG v1.52\n\t<key 2>',
-          status: 'TRUSTED',
-          problems: [],
-        },
-      };
+  setup(done => {
+    const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+    const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+    keys = {
+      AFC8A49B: {
+        fingerprint: fingerprint1,
+        user_ids: [
+          'John Doe john.doe@example.com',
+        ],
+        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+             '\nVersion: BCPG v1.52\n\t<key 1>',
+        status: 'TRUSTED',
+        problems: [],
+      },
+      AED9B59C: {
+        fingerprint: fingerprint2,
+        user_ids: [
+          'Gerrit gerrit@example.com',
+        ],
+        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+             '\nVersion: BCPG v1.52\n\t<key 2>',
+        status: 'TRUSTED',
+        problems: [],
+      },
+    };
 
-      stub('gr-rest-api-interface', {
-        getAccountGPGKeys() { return Promise.resolve(keys); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountGPGKeys() { return Promise.resolve(keys); },
     });
 
-    test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 2);
+    element.loadData().then(() => { flush(done); });
+  });
 
-      let cells = rows[0].querySelectorAll('td');
-      assert.equal(cells[0].textContent, 'AFC8A49B');
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
 
-      cells = rows[1].querySelectorAll('td');
-      assert.equal(cells[0].textContent, 'AED9B59C');
-    });
+    assert.equal(rows.length, 2);
 
-    test('remove key', done => {
-      const lastKey = keys[Object.keys(keys)[1]];
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AFC8A49B');
 
-      const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey',
-          () => { return Promise.resolve(); });
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AED9B59C');
+  });
 
+  test('remove key', done => {
+    const lastKey = keys[Object.keys(keys)[1]];
+
+    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey',
+        () => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(6) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keys.length, 1);
+    assert.equal(element._keysToRemove.length, 1);
+    assert.equal(element._keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    element.save().then(() => {
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
-
-      // Get the delete button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(6) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keys.length, 1);
-      assert.equal(element._keysToRemove.length, 1);
-      assert.equal(element._keysToRemove[0], lastKey);
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isFalse(saveStub.called);
-
-      element.save().then(() => {
-        assert.isTrue(saveStub.called);
-        assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
-        assert.equal(element._keysToRemove.length, 0);
-        assert.isFalse(element.hasUnsavedChanges);
-        done();
-      });
-    });
-
-    test('show key', () => {
-      const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-      // Get the show button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(4) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
-      assert.isTrue(openSpy.called);
-    });
-
-    test('add key', done => {
-      const newKeyString =
-          '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-          '\nVersion: BCPG v1.52\n\t<key 3>';
-      const newKeyObject = {
-        ADE8A59B: {
-          fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
-          user_ids: [
-            'John john@example.com',
-          ],
-          key: newKeyString,
-          status: 'TRUSTED',
-          problems: [],
-        },
-      };
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-          () => { return Promise.resolve(newKeyObject); });
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isTrue(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 2);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-    });
-
-    test('add invalid key', done => {
-      const newKeyString = 'not even close to valid';
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-          () => { return Promise.reject(new Error('error')); });
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isFalse(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 2);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+      done();
     });
   });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+    // Get the show button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(4) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', done => {
+    const newKeyString =
+        '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+        '\nVersion: BCPG v1.52\n\t<key 3>';
+    const newKeyObject = {
+      ADE8A59B: {
+        fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
+        user_ids: [
+          'John john@example.com',
+        ],
+        key: newKeyString,
+        status: 'TRUSTED',
+        problems: [],
+      },
+    };
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
+        () => Promise.resolve(newKeyObject));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+  });
+
+  test('add invalid key', done => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
+        () => Promise.reject(new Error('error')));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+  });
+});
 </script>
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
deleted file mode 100644
index ca500c8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ /dev/null
@@ -1,67 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-group-list">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-        #groups .nameColumn {
-          min-width: 11em;
-          width: auto;
-        }
-        .descriptionHeader {
-          min-width: 21.5em;
-        }
-        .visibleCell {
-          text-align: center;
-          width: 6em;
-        }
-      </style>
-    <div class="gr-form-styles">
-      <table id="groups">
-        <thead>
-          <tr>
-            <th class="nameHeader">Name</th>
-            <th class="descriptionHeader">Description</th>
-            <th class="visibleCell">Visible to all</th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_groups]]">
-            <tr>
-              <td class="nameColumn">
-                <a href$="[[_computeGroupPath(item)]]">
-                  [[item.name]]
-                </a>
-              </td>
-              <td>[[item.description]]</td>
-              <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-group-list.js"></script>
-</dom-module>
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..1cc1369 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
@@ -14,34 +14,48 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-group-list',
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-group-list_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrGroupList extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-group-list'; }
+
+  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);
-        });
-      });
-    },
+  loadData() {
+    return this.$.restAPI.getAccountGroups().then(groups => {
+      this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
+    });
+  }
 
-    _computeVisibleToAll(group) {
-      return group.options.visible_to_all ? 'Yes' : 'No';
-    },
+  _computeVisibleToAll(group) {
+    return group.options.visible_to_all ? 'Yes' : 'No';
+  }
 
-    _computeGroupPath(group) {
-      if (!group || !group.id) { return; }
+  _computeGroupPath(group) {
+    if (!group || !group.id) { return; }
 
-      // 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));
-    },
-  });
-})();
+    // Group ID is already encoded from the API
+    // Decode it here to match with our router encoding behavior
+    return GerritNav.getUrlForGroup(decodeURIComponent(group.id));
+  }
+}
+
+customElements.define(GrGroupList.is, GrGroupList);
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
new file mode 100644
index 0000000..d5350aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+      width: auto;
+    }
+    .descriptionHeader {
+      min-width: 21.5em;
+    }
+    .visibleCell {
+      text-align: center;
+      width: 6em;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="groups">
+      <thead>
+        <tr>
+          <th class="nameHeader">Name</th>
+          <th class="descriptionHeader">Description</th>
+          <th class="visibleCell">Visible to all</th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[_groups]]">
+          <tr>
+            <td class="nameColumn">
+              <a href$="[[_computeGroupPath(item)]]">
+                [[item.name]]
+              </a>
+            </td>
+            <td>[[item.description]]</td>
+            <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 0422d1b..2fdc7b3 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-group-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,89 +31,94 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-group-list tests', () => {
-    let sandbox;
-    let element;
-    let groups;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-group-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      groups = [{
-        url: 'some url',
-        options: {},
-        description: 'Group 1 description',
-        group_id: 1,
-        owner: 'Administrators',
-        owner_id: '123',
-        id: 'abc',
-        name: 'Group 1',
-      }, {
-        options: {visible_to_all: true},
-        id: '456',
-        name: 'Group 2',
-      }, {
-        options: {},
-        id: '789',
-        name: 'Group 3',
-      }];
+suite('gr-group-list tests', () => {
+  let sandbox;
+  let element;
+  let groups;
 
-      stub('gr-rest-api-interface', {
-        getAccountGroups() { return Promise.resolve(groups); },
-      });
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    groups = [{
+      url: 'some url',
+      options: {},
+      description: 'Group 1 description',
+      group_id: 1,
+      owner: 'Administrators',
+      owner_id: '123',
+      id: 'abc',
+      name: 'Group 1',
+    }, {
+      options: {visible_to_all: true},
+      id: '456',
+      name: 'Group 2',
+    }, {
+      options: {},
+      id: '789',
+      name: 'Group 3',
+    }];
 
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountGroups() { return Promise.resolve(groups); },
     });
 
-    teardown(() => { sandbox.restore(); });
+    element = fixture('basic');
 
-    test('renders', () => {
-      const rows = Array.from(
-          Polymer.dom(element.root).querySelectorAll('tbody tr'));
-
-      assert.equal(rows.length, 3);
-
-      const nameCells = rows.map(row =>
-        row.querySelectorAll('td a')[0].textContent.trim()
-      );
-
-      assert.equal(nameCells[0], 'Group 1');
-      assert.equal(nameCells[1], 'Group 2');
-      assert.equal(nameCells[2], 'Group 3');
-    });
-
-    test('_computeVisibleToAll', () => {
-      assert.equal(element._computeVisibleToAll(groups[0]), 'No');
-      assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
-    });
-
-    test('_computeGroupPath', () => {
-      let urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
-          () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-      let group = {
-        id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-      };
-      assert.equal(element._computeGroupPath(group),
-          '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-      group = {
-        name: 'admin',
-      };
-      assert.isUndefined(element._computeGroupPath(group));
-
-      urlStub.restore();
-
-      urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForGroup',
-          () => '/admin/groups/user/test');
-
-      group = {
-        id: 'user%2Ftest',
-      };
-      assert.equal(element._computeGroupPath(group),
-          '/admin/groups/user/test');
-    });
+    element.loadData().then(() => { flush(done); });
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('renders', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
+
+    assert.equal(rows.length, 3);
+
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td a')[0].textContent.trim()
+    );
+
+    assert.equal(nameCells[0], 'Group 1');
+    assert.equal(nameCells[1], 'Group 2');
+    assert.equal(nameCells[2], 'Group 3');
+  });
+
+  test('_computeVisibleToAll', () => {
+    assert.equal(element._computeVisibleToAll(groups[0]), 'No');
+    assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
+  });
+
+  test('_computeGroupPath', () => {
+    let urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    let group = {
+      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+    };
+    assert.equal(element._computeGroupPath(group),
+        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    group = {
+      name: 'admin',
+    };
+    assert.isUndefined(element._computeGroupPath(group));
+
+    urlStub.restore();
+
+    urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
+        () => '/admin/groups/user/test');
+
+    group = {
+      id: 'user%2Ftest',
+    };
+    assert.equal(element._computeGroupPath(group),
+        '/admin/groups/user/test');
+  });
+});
 </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
deleted file mode 100644
index 0cb9695..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ /dev/null
@@ -1,104 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.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="../../../styles/shared-styles.html">
-
-<dom-module id="gr-http-password">
-  <template>
-    <style include="shared-styles">
-      .password {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-      }
-      #generatedPasswordOverlay {
-        padding: var(--spacing-xxl);
-        width: 50em;
-      }
-      #generatedPasswordDisplay {
-        margin: var(--spacing-l) 0;
-      }
-      #generatedPasswordDisplay .title {
-        width: unset;
-      }
-      #generatedPasswordDisplay .value {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-      }
-      #passwordWarning {
-        font-style: italic;
-        text-align: center;
-      }
-      .closeButton {
-        bottom: 2em;
-        position: absolute;
-        right: 2em;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <div class="gr-form-styles">
-      <div hidden$="[[_passwordUrl]]">
-        <section>
-          <span class="title">Username</span>
-          <span class="value">[[_username]]</span>
-        </section>
-        <gr-button
-            id="generateButton"
-            on-click="_handleGenerateTap">Generate new password</gr-button>
-      </div>
-      <span hidden$="[[!_passwordUrl]]">
-        <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
-          Obtain password</a>
-        (opens in a new tab)
-      </span>
-    </div>
-    <gr-overlay
-        id="generatedPasswordOverlay"
-        on-iron-overlay-closed="_generatedPasswordOverlayClosed"
-        with-backdrop>
-      <div class="gr-form-styles">
-        <section id="generatedPasswordDisplay">
-          <span class="title">New Password:</span>
-          <span class="value">[[_generatedPassword]]</span>
-          <gr-copy-clipboard
-              has-tooltip
-              button-title="Copy password to clipboard"
-              hide-input
-              text="[[_generatedPassword]]">
-          </gr-copy-clipboard>
-        </section>
-        <section id="passwordWarning">
-          This password will not be displayed again.<br>
-          If you lose it, you will need to generate a new one.
-        </section>
-        <gr-button
-            link
-            class="closeButton"
-            on-click="_closeOverlay">Close</gr-button>
-      </div>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-http-password.js"></script>
-</dom-module>
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..02657f8 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
@@ -14,50 +14,70 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-http-password',
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-http-password_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrHttpPassword extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-http-password'; }
+
+  static get properties() {
+    return {
       _username: String,
       _generatedPassword: String,
       _passwordUrl: String,
-    },
+    };
+  }
 
-    attached() {
-      this.loadData();
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this.loadData();
+  }
 
-    loadData() {
-      const promises = [];
+  loadData() {
+    const promises = [];
 
-      promises.push(this.$.restAPI.getAccount().then(account => {
-        this._username = account.username;
-      }));
+    promises.push(this.$.restAPI.getAccount().then(account => {
+      this._username = account.username;
+    }));
 
-      promises.push(this.$.restAPI.getConfig().then(info => {
-        this._passwordUrl = info.auth.http_password_url || null;
-      }));
+    promises.push(this.$.restAPI.getConfig().then(info => {
+      this._passwordUrl = info.auth.http_password_url || null;
+    }));
 
-      return Promise.all(promises);
-    },
+    return Promise.all(promises);
+  }
 
-    _handleGenerateTap() {
-      this._generatedPassword = 'Generating...';
-      this.$.generatedPasswordOverlay.open();
-      this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
-        this._generatedPassword = newPassword;
-      });
-    },
+  _handleGenerateTap() {
+    this._generatedPassword = 'Generating...';
+    this.$.generatedPasswordOverlay.open();
+    this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
+      this._generatedPassword = newPassword;
+    });
+  }
 
-    _closeOverlay() {
-      this.$.generatedPasswordOverlay.close();
-    },
+  _closeOverlay() {
+    this.$.generatedPasswordOverlay.close();
+  }
 
-    _generatedPasswordOverlayClosed() {
-      this._generatedPassword = '';
-    },
-  });
-})();
+  _generatedPasswordOverlayClosed() {
+    this._generatedPassword = '';
+  }
+}
+
+customElements.define(GrHttpPassword.is, GrHttpPassword);
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
new file mode 100644
index 0000000..0474b99
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .password {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    #generatedPasswordOverlay {
+      padding: var(--spacing-xxl);
+      width: 50em;
+    }
+    #generatedPasswordDisplay {
+      margin: var(--spacing-l) 0;
+    }
+    #generatedPasswordDisplay .title {
+      width: unset;
+    }
+    #generatedPasswordDisplay .value {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    #passwordWarning {
+      font-style: italic;
+      text-align: center;
+    }
+    .closeButton {
+      bottom: 2em;
+      position: absolute;
+      right: 2em;
+    }
+  </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>
+        <span class="title">Username</span>
+        <span class="value">[[_username]]</span>
+      </section>
+      <gr-button id="generateButton" on-click="_handleGenerateTap"
+        >Generate new password</gr-button
+      >
+    </div>
+    <span hidden$="[[!_passwordUrl]]">
+      <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
+        Obtain password</a
+      >
+      (opens in a new tab)
+    </span>
+  </div>
+  <gr-overlay
+    id="generatedPasswordOverlay"
+    on-iron-overlay-closed="_generatedPasswordOverlayClosed"
+    with-backdrop=""
+  >
+    <div class="gr-form-styles">
+      <section id="generatedPasswordDisplay">
+        <span class="title">New Password:</span>
+        <span class="value">[[_generatedPassword]]</span>
+        <gr-copy-clipboard
+          has-tooltip=""
+          button-title="Copy password to clipboard"
+          hide-input=""
+          text="[[_generatedPassword]]"
+        >
+        </gr-copy-clipboard>
+      </section>
+      <section id="passwordWarning">
+        This password will not be displayed again.<br />
+        If you lose it, you will need to generate a new one.
+      </section>
+      <gr-button link="" class="closeButton" on-click="_closeOverlay"
+        >Close</gr-button
+      >
+    </div>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..26fa84d 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-http-password.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,62 +31,61 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-http-password tests', () => {
-    let element;
-    let account;
-    let config;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-http-password.js';
+suite('gr-http-password tests', () => {
+  let element;
+  let account;
+  let config;
 
-    setup(done => {
-      account = {username: 'user name'};
-      config = {auth: {}};
+  setup(done => {
+    account = {username: 'user name'};
+    config = {auth: {}};
 
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(account); },
-        getConfig() { return Promise.resolve(config); },
-      });
-
-      element = fixture('basic');
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(account); },
+      getConfig() { return Promise.resolve(config); },
     });
 
-    test('generate password', () => {
-      const button = element.$.generateButton;
-      const nextPassword = 'the new password';
-      let generateResolve;
-      const generateStub = sinon.stub(element.$.restAPI,
-          'generateAccountHttpPassword', () => {
-            return new Promise(resolve => {
-              generateResolve = resolve;
-            });
-          });
+    element = fixture('basic');
+    element.loadData().then(() => { flush(done); });
+  });
 
-      assert.isNotOk(element._generatedPassword);
+  test('generate password', () => {
+    const button = element.$.generateButton;
+    const nextPassword = 'the new password';
+    let generateResolve;
+    const generateStub = sinon.stub(element.$.restAPI,
+        'generateAccountHttpPassword', () => new Promise(resolve => {
+          generateResolve = resolve;
+        }));
 
-      MockInteractions.tap(button);
+    assert.isNotOk(element._generatedPassword);
 
-      assert.isTrue(generateStub.called);
-      assert.equal(element._generatedPassword, 'Generating...');
+    MockInteractions.tap(button);
 
-      generateResolve(nextPassword);
+    assert.isTrue(generateStub.called);
+    assert.equal(element._generatedPassword, 'Generating...');
 
-      generateStub.lastCall.returnValue.then(() => {
-        assert.equal(element._generatedPassword, nextPassword);
-      });
-    });
+    generateResolve(nextPassword);
 
-    test('without http_password_url', () => {
-      assert.isNull(element._passwordUrl);
-    });
-
-    test('with http_password_url', done => {
-      config.auth.http_password_url = 'http://example.com/';
-      element.loadData().then(() => {
-        assert.isNotNull(element._passwordUrl);
-        assert.equal(element._passwordUrl, config.auth.http_password_url);
-        done();
-      });
+    generateStub.lastCall.returnValue.then(() => {
+      assert.equal(element._generatedPassword, nextPassword);
     });
   });
 
+  test('without http_password_url', () => {
+    assert.isNull(element._passwordUrl);
+  });
+
+  test('with http_password_url', done => {
+    config.auth.http_password_url = 'http://example.com/';
+    element.loadData().then(() => {
+      assert.isNotNull(element._passwordUrl);
+      assert.equal(element._passwordUrl, config.auth.http_password_url);
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
deleted file mode 100644
index ee855cc..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
+++ /dev/null
@@ -1,106 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
-<link rel="import" href="../../shared/gr-button/gr-button.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">
-
-<dom-module id="gr-identities">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      tr th.emailAddressHeader,
-      tr th.identityHeader {
-        width: 15em;
-        padding: 0 10px;
-      }
-      tr td.statusColumn,
-      tr td.emailAddressColumn,
-      tr td.identityColumn {
-        word-break: break-word;
-      }
-      tr td.emailAddressColumn,
-      tr td.identityColumn {
-        padding: 4px 10px;
-        width: 15em;
-      }
-      .deleteButton {
-        float: right;
-      }
-      .deleteButton:not(.show) {
-        display: none;
-      }
-      .space {
-        margin-bottom: var(--spacing-l);
-      }
-    </style>
-    <div class="gr-form-styles">
-      <fieldset class="space">
-        <table>
-          <thead>
-            <tr>
-              <th class="statusHeader">Status</th>
-              <th class="emailAddressHeader">Email Address</th>
-              <th class="identityHeader">Identity</th>
-              <th class="deleteHeader"></th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[_identities]]" filter="filterIdentities">
-              <tr>
-                <td class="statusColumn">
-                  [[_computeIsTrusted(item.trusted)]]
-                </td>
-                <td class="emailAddressColumn">[[item.email_address]]</td>
-                <td class="identityColumn">[[_computeIdentity(item.identity)]]</td>
-                <td class="deleteColumn">
-                  <gr-button
-                      class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                      on-click="_handleDeleteItem">
-                    Delete
-                  </gr-button>
-                </td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-      </fieldset>
-      <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
-        <fieldset>
-          <a href$="[[_computeLinkAnotherIdentity()]]">
-            <gr-button id="linkAnotherIdentity" link>Link Another Identity</gr-button>
-          </a>
-        </fieldset>
-      </template>
-    </div>
-    <gr-overlay id="overlay" with-backdrop>
-      <gr-confirm-delete-item-dialog
-          class="confirmDialog"
-          on-confirm="_handleDeleteItemConfirm"
-          on-cancel="_handleConfirmDialogCancel"
-          item="[[_idName]]"
-          item-type="id"></gr-confirm-delete-item-dialog>
-    </gr-overlay>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-identities.js"></script>
-</dom-module>
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..74c5eed 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -14,18 +14,40 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const AUTH = [
-    'OPENID',
-    'OAUTH',
-  ];
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-identities_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  Polymer({
-    is: 'gr-identities',
+const AUTH = [
+  'OPENID',
+  'OAUTH',
+];
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrIdentities extends mixinBehaviors( [
+  BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-identities'; }
+
+  static get properties() {
+    return {
       _identities: Object,
       _idName: String,
       serverConfig: Object,
@@ -33,68 +55,66 @@
         type: Boolean,
         computed: '_computeShowLinkAnotherIdentity(serverConfig)',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
+  loadData() {
+    return this.$.restAPI.getExternalIds().then(id => {
+      this._identities = id;
+    });
+  }
 
-    loadData() {
-      return this.$.restAPI.getExternalIds().then(id => {
-        this._identities = id;
-      });
-    },
+  _computeIdentity(id) {
+    return id && id.startsWith('mailto:') ? '' : id;
+  }
 
-    _computeIdentity(id) {
-      return id && id.startsWith('mailto:') ? '' : id;
-    },
+  _computeHideDeleteClass(canDelete) {
+    return canDelete ? 'show' : '';
+  }
 
-    _computeHideDeleteClass(canDelete) {
-      return canDelete ? 'show' : '';
-    },
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    return this.$.restAPI.deleteAccountIdentity([this._idName])
+        .then(() => { this.loadData(); });
+  }
 
-    _handleDeleteItemConfirm() {
-      this.$.overlay.close();
-      return this.$.restAPI.deleteAccountIdentity([this._idName])
-          .then(() => { this.loadData(); });
-    },
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
 
-    _handleConfirmDialogCancel() {
-      this.$.overlay.close();
-    },
+  _handleDeleteItem(e) {
+    const name = e.model.get('item.identity');
+    if (!name) { return; }
+    this._idName = name;
+    this.$.overlay.open();
+  }
 
-    _handleDeleteItem(e) {
-      const name = e.model.get('item.identity');
-      if (!name) { return; }
-      this._idName = name;
-      this.$.overlay.open();
-    },
+  _computeIsTrusted(item) {
+    return item ? '' : 'Untrusted';
+  }
 
-    _computeIsTrusted(item) {
-      return item ? '' : 'Untrusted';
-    },
+  filterIdentities(item) {
+    return !item.identity.startsWith('username:');
+  }
 
-    filterIdentities(item) {
-      return !item.identity.startsWith('username:');
-    },
+  _computeShowLinkAnotherIdentity(config) {
+    if (config && config.auth &&
+        config.auth.git_basic_auth_policy) {
+      return AUTH.includes(
+          config.auth.git_basic_auth_policy.toUpperCase());
+    }
 
-    _computeShowLinkAnotherIdentity(config) {
-      if (config && config.auth &&
-          config.auth.git_basic_auth_policy) {
-        return AUTH.includes(
-            config.auth.git_basic_auth_policy.toUpperCase());
-      }
+    return false;
+  }
 
-      return false;
-    },
+  _computeLinkAnotherIdentity() {
+    const baseUrl = this.getBaseUrl() || '';
+    let pathname = window.location.pathname;
+    if (baseUrl) {
+      pathname = '/' + pathname.substring(baseUrl.length);
+    }
+    return baseUrl + '/login/' + encodeURIComponent(pathname) + '?link';
+  }
+}
 
-    _computeLinkAnotherIdentity() {
-      const baseUrl = this.getBaseUrl() || '';
-      let pathname = window.location.pathname;
-      if (baseUrl) {
-        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_html.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
new file mode 100644
index 0000000..bf50124
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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 {
+      width: 15em;
+      padding: 0 10px;
+    }
+    tr td.statusColumn,
+    tr td.emailAddressColumn,
+    tr td.identityColumn {
+      word-break: break-word;
+    }
+    tr td.emailAddressColumn,
+    tr td.identityColumn {
+      padding: 4px 10px;
+      width: 15em;
+    }
+    .deleteButton {
+      float: right;
+    }
+    .deleteButton:not(.show) {
+      display: none;
+    }
+    .space {
+      margin-bottom: var(--spacing-l);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset class="space">
+      <table>
+        <thead>
+          <tr>
+            <th class="statusHeader">Status</th>
+            <th class="emailAddressHeader">Email Address</th>
+            <th class="identityHeader">Identity</th>
+            <th class="deleteHeader"></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template
+            is="dom-repeat"
+            items="[[_identities]]"
+            filter="filterIdentities"
+          >
+            <tr>
+              <td class="statusColumn">
+                [[_computeIsTrusted(item.trusted)]]
+              </td>
+              <td class="emailAddressColumn">[[item.email_address]]</td>
+              <td class="identityColumn">
+                [[_computeIdentity(item.identity)]]
+              </td>
+              <td class="deleteColumn">
+                <gr-button
+                  class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
+                  on-click="_handleDeleteItem"
+                >
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </fieldset>
+    <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
+      <fieldset>
+        <a href$="[[_computeLinkAnotherIdentity()]]">
+          <gr-button id="linkAnotherIdentity" link=""
+            >Link Another Identity</gr-button
+          >
+        </a>
+      </fieldset>
+    </template>
+  </div>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-confirm-delete-item-dialog
+      class="confirmDialog"
+      on-confirm="_handleDeleteItemConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      item="[[_idName]]"
+      item-type="id"
+    ></gr-confirm-delete-item-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..0965826 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-identities.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,157 +31,160 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-identities tests', () => {
-    let element;
-    let sandbox;
-    const ids = [
-      {
-        identity: 'username:john',
-        email_address: 'john.doe@example.com',
-        trusted: true,
-      }, {
-        identity: 'gerrit:gerrit',
-        email_address: 'gerrit@example.com',
-      }, {
-        identity: 'mailto:gerrit2@example.com',
-        email_address: 'gerrit2@example.com',
-        trusted: true,
-        can_delete: true,
-      },
-    ];
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-identities.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-identities tests', () => {
+  let element;
+  let sandbox;
+  const ids = [
+    {
+      identity: 'username:john',
+      email_address: 'john.doe@example.com',
+      trusted: true,
+    }, {
+      identity: 'gerrit:gerrit',
+      email_address: 'gerrit@example.com',
+    }, {
+      identity: 'mailto:gerrit2@example.com',
+      email_address: 'gerrit2@example.com',
+      trusted: true,
+      can_delete: true,
+    },
+  ];
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
+  setup(done => {
+    sandbox = sinon.sandbox.create();
 
-      stub('gr-rest-api-interface', {
-        getExternalIds() { return Promise.resolve(ids); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getExternalIds() { return Promise.resolve(ids); },
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    element = fixture('basic');
 
-    test('renders', () => {
-      const rows = Array.from(
-          Polymer.dom(element.root).querySelectorAll('tbody tr'));
+    element.loadData().then(() => { flush(done); });
+  });
 
-      assert.equal(rows.length, 2);
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      const nameCells = rows.map(row =>
-        row.querySelectorAll('td')[2].textContent
-      );
+  test('renders', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
 
-      assert.equal(nameCells[0], 'gerrit:gerrit');
-      assert.equal(nameCells[1], '');
-    });
+    assert.equal(rows.length, 2);
 
-    test('renders email', () => {
-      const rows = Array.from(
-          Polymer.dom(element.root).querySelectorAll('tbody tr'));
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[2].textContent
+    );
 
-      assert.equal(rows.length, 2);
+    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
+    assert.equal(nameCells[1].trim(), '');
+  });
 
-      const nameCells = rows.map(row =>
-        row.querySelectorAll('td')[1].textContent
-      );
+  test('renders email', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
 
-      assert.equal(nameCells[0], 'gerrit@example.com');
-      assert.equal(nameCells[1], 'gerrit2@example.com');
-    });
+    assert.equal(rows.length, 2);
 
-    test('_computeIdentity', () => {
-      assert.equal(
-          element._computeIdentity(ids[0].identity), 'username:john');
-      assert.equal(element._computeIdentity(ids[2].identity), '');
-    });
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[1].textContent
+    );
 
-    test('filterIdentities', () => {
-      assert.isFalse(element.filterIdentities(ids[0]));
+    assert.equal(nameCells[0], 'gerrit@example.com');
+    assert.equal(nameCells[1], 'gerrit2@example.com');
+  });
 
-      assert.isTrue(element.filterIdentities(ids[1]));
-    });
+  test('_computeIdentity', () => {
+    assert.equal(
+        element._computeIdentity(ids[0].identity), 'username:john');
+    assert.equal(element._computeIdentity(ids[2].identity), '');
+  });
 
-    test('delete id', done => {
-      element._idName = 'mailto:gerrit2@example.com';
-      const loadDataStub = sandbox.stub(element, 'loadData');
-      element._handleDeleteItemConfirm().then(() => {
-        assert.isTrue(loadDataStub.called);
-        done();
-      });
-    });
+  test('filterIdentities', () => {
+    assert.isFalse(element.filterIdentities(ids[0]));
 
-    test('_handleDeleteItem opens modal', () => {
-      const deleteBtn =
-          Polymer.dom(element.root).querySelector('.deleteButton');
-      const deleteItem = sandbox.stub(element, '_handleDeleteItem');
-      MockInteractions.tap(deleteBtn);
-      assert.isTrue(deleteItem.called);
-    });
+    assert.isTrue(element.filterIdentities(ids[1]));
+  });
 
-    test('_computeShowLinkAnotherIdentity', () => {
-      let serverConfig;
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OAUTH',
-        },
-      };
-      assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OpenID',
-        },
-      };
-      assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP_LDAP',
-        },
-      };
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'LDAP',
-        },
-      };
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP',
-        },
-      };
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-      serverConfig = {};
-      assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-    });
-
-    test('_showLinkAnotherIdentity', () => {
-      element.serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OAUTH',
-        },
-      };
-
-      assert.isTrue(element._showLinkAnotherIdentity);
-
-      element.serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'LDAP',
-        },
-      };
-
-      assert.isFalse(element._showLinkAnotherIdentity);
+  test('delete id', done => {
+    element._idName = 'mailto:gerrit2@example.com';
+    const loadDataStub = sandbox.stub(element, 'loadData');
+    element._handleDeleteItemConfirm().then(() => {
+      assert.isTrue(loadDataStub.called);
+      done();
     });
   });
+
+  test('_handleDeleteItem opens modal', () => {
+    const deleteBtn =
+        dom(element.root).querySelector('.deleteButton');
+    const deleteItem = sandbox.stub(element, '_handleDeleteItem');
+    MockInteractions.tap(deleteBtn);
+    assert.isTrue(deleteItem.called);
+  });
+
+  test('_computeShowLinkAnotherIdentity', () => {
+    let serverConfig;
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OpenID',
+      },
+    };
+    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP_LDAP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {};
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+  });
+
+  test('_showLinkAnotherIdentity', () => {
+    element.serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+
+    assert.isTrue(element._showLinkAnotherIdentity);
+
+    element.serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+
+    assert.isFalse(element._showLinkAnotherIdentity);
+  });
+});
 </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
deleted file mode 100644
index 1485628..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ /dev/null
@@ -1,127 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-
-<dom-module id="gr-menu-editor">
-  <template>
-    <style include="shared-styles">
-      .buttonColumn {
-        width: 2em;
-      }
-      .moveUpButton,
-      .moveDownButton {
-        width: 100%
-      }
-      tbody tr:first-of-type td .moveUpButton,
-      tbody tr:last-of-type td .moveDownButton {
-        display: none;
-      }
-      td.urlCell {
-        word-break: break-word;
-      }
-      .newUrlInput {
-        min-width: 23em;
-      }
-    </style>
-    <style include="gr-form-styles"></style>
-    <div class="gr-form-styles">
-      <table>
-        <thead>
-          <tr>
-            <th class="nameHeader">Name</th>
-            <th class="url-header">URL</th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[menuItems]]">
-            <tr>
-              <td>[[item.name]]</td>
-              <td class="urlCell">[[item.url]]</td>
-              <td class="buttonColumn">
-                <gr-button
-                    link
-                    data-index$="[[index]]"
-                    on-click="_handleMoveUpButton"
-                    class="moveUpButton">↑</gr-button>
-              </td>
-              <td class="buttonColumn">
-                <gr-button
-                    link
-                    data-index$="[[index]]"
-                    on-click="_handleMoveDownButton"
-                    class="moveDownButton">↓</gr-button>
-              </td>
-              <td>
-                <gr-button
-                    link
-                    data-index$="[[index]]"
-                    on-click="_handleDeleteButton"
-                    class="remove-button">Delete</gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-        <tfoot>
-          <tr>
-            <th>
-              <iron-input
-                  placeholder="New Title"
-                  on-keydown="_handleInputKeydown"
-                  bind-value="{{_newName}}">
-                <input
-                    is="iron-input"
-                    placeholder="New Title"
-                    on-keydown="_handleInputKeydown"
-                    bind-value="{{_newName}}">
-              </iron-input>
-            </th>
-            <th>
-              <iron-input
-                  class="newUrlInput"
-                  placeholder="New URL"
-                  on-keydown="_handleInputKeydown"
-                  bind-value="{{_newUrl}}">
-                <input
-                    class="newUrlInput"
-                    is="iron-input"
-                    placeholder="New URL"
-                    on-keydown="_handleInputKeydown"
-                    bind-value="{{_newUrl}}">
-              </iron-input>
-            </th>
-            <th></th>
-            <th></th>
-            <th>
-              <gr-button
-                  link
-                  disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-                  on-click="_handleAddButton">Add</gr-button>
-            </th>
-          </tr>
-        </tfoot>
-      </table>
-    </div>
-  </template>
-  <script src="gr-menu-editor.js"></script>
-</dom-module>
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..42982fd 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
@@ -14,61 +14,80 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-menu-editor',
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import '../../../styles/gr-form-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-menu-editor_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrMenuEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-menu-editor'; }
+
+  static get properties() {
+    return {
       menuItems: Array,
       _newName: String,
       _newUrl: String,
-    },
+    };
+  }
 
-    _handleMoveUpButton(e) {
-      const index = Number(Polymer.dom(e).localTarget.dataset.index);
-      if (index === 0) { return; }
-      const row = this.menuItems[index];
-      const prev = this.menuItems[index - 1];
-      this.splice('menuItems', index - 1, 2, row, prev);
-    },
+  _handleMoveUpButton(e) {
+    const index = Number(dom(e).localTarget.dataset.index);
+    if (index === 0) { return; }
+    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);
-      if (index === this.menuItems.length - 1) { return; }
-      const row = this.menuItems[index];
-      const next = this.menuItems[index + 1];
-      this.splice('menuItems', index, 2, next, row);
-    },
+  _handleMoveDownButton(e) {
+    const index = Number(dom(e).localTarget.dataset.index);
+    if (index === this.menuItems.length - 1) { return; }
+    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);
-    },
+  _handleDeleteButton(e) {
+    const index = Number(dom(e).localTarget.dataset.index);
+    this.splice('menuItems', index, 1);
+  }
 
-    _handleAddButton() {
-      if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
+  _handleAddButton() {
+    if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
 
-      this.splice('menuItems', this.menuItems.length, 0, {
-        name: this._newName,
-        url: this._newUrl,
-        target: '_blank',
-      });
+    this.splice('menuItems', this.menuItems.length, 0, {
+      name: this._newName,
+      url: this._newUrl,
+      target: '_blank',
+    });
 
-      this._newName = '';
-      this._newUrl = '';
-    },
+    this._newName = '';
+    this._newUrl = '';
+  }
 
-    _computeAddDisabled(newName, newUrl) {
-      return !newName.length || !newUrl.length;
-    },
+  _computeAddDisabled(newName, newUrl) {
+    return !newName.length || !newUrl.length;
+  }
 
-    _handleInputKeydown(e) {
-      if (e.keyCode === 13) {
-        e.stopPropagation();
-        this._handleAddButton();
-      }
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
new file mode 100644
index 0000000..ceb8958
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .buttonColumn {
+      width: 2em;
+    }
+    .moveUpButton,
+    .moveDownButton {
+      width: 100%;
+    }
+    tbody tr:first-of-type td .moveUpButton,
+    tbody tr:last-of-type td .moveDownButton {
+      display: none;
+    }
+    td.urlCell {
+      word-break: break-word;
+    }
+    .newUrlInput {
+      min-width: 23em;
+    }
+  </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>
+        <tr>
+          <th class="nameHeader">Name</th>
+          <th class="url-header">URL</th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[menuItems]]">
+          <tr>
+            <td>[[item.name]]</td>
+            <td class="urlCell">[[item.url]]</td>
+            <td class="buttonColumn">
+              <gr-button
+                link=""
+                data-index$="[[index]]"
+                on-click="_handleMoveUpButton"
+                class="moveUpButton"
+                >↑</gr-button
+              >
+            </td>
+            <td class="buttonColumn">
+              <gr-button
+                link=""
+                data-index$="[[index]]"
+                on-click="_handleMoveDownButton"
+                class="moveDownButton"
+                >↓</gr-button
+              >
+            </td>
+            <td>
+              <gr-button
+                link=""
+                data-index$="[[index]]"
+                on-click="_handleDeleteButton"
+                class="remove-button"
+                >Delete</gr-button
+              >
+            </td>
+          </tr>
+        </template>
+      </tbody>
+      <tfoot>
+        <tr>
+          <th>
+            <iron-input
+              placeholder="New Title"
+              on-keydown="_handleInputKeydown"
+              bind-value="{{_newName}}"
+            >
+              <input
+                is="iron-input"
+                placeholder="New Title"
+                on-keydown="_handleInputKeydown"
+                bind-value="{{_newName}}"
+              />
+            </iron-input>
+          </th>
+          <th>
+            <iron-input
+              class="newUrlInput"
+              placeholder="New URL"
+              on-keydown="_handleInputKeydown"
+              bind-value="{{_newUrl}}"
+            >
+              <input
+                class="newUrlInput"
+                is="iron-input"
+                placeholder="New URL"
+                on-keydown="_handleInputKeydown"
+                bind-value="{{_newUrl}}"
+              />
+            </iron-input>
+          </th>
+          <th></th>
+          <th></th>
+          <th>
+            <gr-button
+              link=""
+              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
+              on-click="_handleAddButton"
+              >Add</gr-button
+            >
+          </th>
+        </tr>
+      </tfoot>
+    </table>
+  </div>
+`;
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..9c8db6d 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-menu-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,135 +31,148 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-menu-editor tests', () => {
-    let element;
-    let menu;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-menu-editor.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-menu-editor tests', () => {
+  let element;
+  let menu;
 
-    function assertMenuNamesEqual(element, expected) {
-      const names = element.menuItems.map(i => { return i.name; });
-      assert.equal(names.length, expected.length);
-      for (let i = 0; i < names.length; i++) {
-        assert.equal(names[i], expected[i]);
-      }
+  function assertMenuNamesEqual(element, expected) {
+    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]);
     }
+  }
 
-    // Click the up/down button (according to direction) for the index'th row.
-    // The index of the first row is 0, corresponding to the array.
-    function move(element, index, direction) {
-      const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
-          direction + 'Button';
-      const button =
-          element.$$('tbody').querySelector(selector).$$('paper-button');
-      MockInteractions.tap(button);
-    }
+  // Click the up/down button (according to direction) for the index'th row.
+  // The index of the first row is 0, corresponding to the array.
+  function move(element, index, direction) {
+    const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
+        direction + 'Button';
+    const button =
+        element.shadowRoot
+            .querySelector('tbody').querySelector(selector)
+            .shadowRoot
+            .querySelector('paper-button');
+    MockInteractions.tap(button);
+  }
 
-    setup(done => {
-      element = fixture('basic');
-      menu = [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ];
-      element.set('menuItems', menu);
-      Polymer.dom.flush();
-      flush(done);
-    });
-
-    test('renders', () => {
-      const rows = element.$$('tbody').querySelectorAll('tr');
-      let tds;
-
-      assert.equal(rows.length, menu.length);
-      for (let i = 0; i < menu.length; i++) {
-        tds = rows[i].querySelectorAll('td');
-        assert.equal(tds[0].textContent, menu[i].name);
-        assert.equal(tds[1].textContent, menu[i].url);
-      }
-
-      assert.isTrue(element._computeAddDisabled(element._newName,
-          element._newUrl));
-    });
-
-    test('_computeAddDisabled', () => {
-      assert.isTrue(element._computeAddDisabled('', ''));
-      assert.isTrue(element._computeAddDisabled('name', ''));
-      assert.isTrue(element._computeAddDisabled('', 'url'));
-      assert.isFalse(element._computeAddDisabled('name', 'url'));
-    });
-
-    test('add a new menu item', () => {
-      const newName = 'new name';
-      const newUrl = 'new url';
-
-      element._newName = newName;
-      element._newUrl = newUrl;
-      assert.isFalse(element._computeAddDisabled(element._newName,
-          element._newUrl));
-
-      const originalMenuLength = element.menuItems.length;
-
-      element._handleAddButton();
-
-      assert.equal(element.menuItems.length, originalMenuLength + 1);
-      assert.equal(element.menuItems[element.menuItems.length - 1].name,
-          newName);
-      assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
-    });
-
-    test('move items down', () => {
-      assertMenuNamesEqual(element,
-          ['first name', 'second name', 'third name']);
-
-      // Move the middle item down
-      move(element, 1, 'Down');
-      assertMenuNamesEqual(element,
-          ['first name', 'third name', 'second name']);
-
-      // Moving the bottom item down is a no-op.
-      move(element, 2, 'Down');
-      assertMenuNamesEqual(element,
-          ['first name', 'third name', 'second name']);
-    });
-
-    test('move items up', () => {
-      assertMenuNamesEqual(element,
-          ['first name', 'second name', 'third name']);
-
-      // Move the last item up twice to be the first.
-      move(element, 2, 'Up');
-      move(element, 1, 'Up');
-      assertMenuNamesEqual(element,
-          ['third name', 'first name', 'second name']);
-
-      // Moving the top item up is a no-op.
-      move(element, 0, 'Up');
-      assertMenuNamesEqual(element,
-          ['third name', 'first name', 'second name']);
-    });
-
-    test('remove item', () => {
-      assertMenuNamesEqual(element,
-          ['first name', 'second name', 'third name']);
-
-      // Tap the delete button for the middle item.
-      MockInteractions.tap(element.$$('tbody')
-          .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'));
-      }
-      assertMenuNamesEqual(element, []);
-
-      // Add item to empty menu.
-      element._newName = 'new name';
-      element._newUrl = 'new url';
-      element._handleAddButton();
-      assertMenuNamesEqual(element, ['new name']);
-    });
+  setup(done => {
+    element = fixture('basic');
+    menu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+    ];
+    element.set('menuItems', menu);
+    flush$0();
+    flush(done);
   });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('tbody').querySelectorAll('tr');
+    let tds;
+
+    assert.equal(rows.length, menu.length);
+    for (let i = 0; i < menu.length; i++) {
+      tds = rows[i].querySelectorAll('td');
+      assert.equal(tds[0].textContent, menu[i].name);
+      assert.equal(tds[1].textContent, menu[i].url);
+    }
+
+    assert.isTrue(element._computeAddDisabled(element._newName,
+        element._newUrl));
+  });
+
+  test('_computeAddDisabled', () => {
+    assert.isTrue(element._computeAddDisabled('', ''));
+    assert.isTrue(element._computeAddDisabled('name', ''));
+    assert.isTrue(element._computeAddDisabled('', 'url'));
+    assert.isFalse(element._computeAddDisabled('name', 'url'));
+  });
+
+  test('add a new menu item', () => {
+    const newName = 'new name';
+    const newUrl = 'new url';
+
+    element._newName = newName;
+    element._newUrl = newUrl;
+    assert.isFalse(element._computeAddDisabled(element._newName,
+        element._newUrl));
+
+    const originalMenuLength = element.menuItems.length;
+
+    element._handleAddButton();
+
+    assert.equal(element.menuItems.length, originalMenuLength + 1);
+    assert.equal(element.menuItems[element.menuItems.length - 1].name,
+        newName);
+    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
+  });
+
+  test('move items down', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
+
+    // Move the middle item down
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element,
+        ['first name', 'third name', 'second name']);
+
+    // Moving the bottom item down is a no-op.
+    move(element, 2, 'Down');
+    assertMenuNamesEqual(element,
+        ['first name', 'third name', 'second name']);
+  });
+
+  test('move items up', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
+
+    // Move the last item up twice to be the first.
+    move(element, 2, 'Up');
+    move(element, 1, 'Up');
+    assertMenuNamesEqual(element,
+        ['third name', 'first name', 'second name']);
+
+    // Moving the top item up is a no-op.
+    move(element, 0, 'Up');
+    assertMenuNamesEqual(element,
+        ['third name', 'first name', 'second name']);
+  });
+
+  test('remove item', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
+
+    // Tap the delete button for the middle item.
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('tbody')
+        .querySelector('tr:nth-child(2) .remove-button')
+        .shadowRoot
+        .querySelector('paper-button'));
+
+    assertMenuNamesEqual(element, ['first name', 'third name']);
+
+    // Delete remaining items.
+    for (let i = 0; i < 2; i++) {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('tbody')
+          .querySelector('tr:first-child .remove-button')
+          .shadowRoot
+          .querySelector('paper-button'));
+    }
+    assertMenuNamesEqual(element, []);
+
+    // Add item to empty menu.
+    element._newName = 'new name';
+    element._newUrl = 'new url';
+    element._handleAddButton();
+    assertMenuNamesEqual(element, ['new name']);
+  });
+});
 </script>
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
deleted file mode 100644
index f366d2a..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ /dev/null
@@ -1,142 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-registration-dialog">
-  <template>
-    <style include="gr-form-styles"></style>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      main {
-        max-width: 46em;
-      }
-      :host(.loading) main {
-        display: none;
-      }
-      .loadingMessage {
-        display: none;
-        font-style: italic;
-      }
-      :host(.loading) .loadingMessage {
-        display: block;
-      }
-      hr {
-        margin-top: var(--spacing-l);
-        margin-bottom: var(--spacing-l);
-      }
-      header {
-        border-bottom: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-        margin-bottom: var(--spacing-l);
-      }
-      .container {
-        padding: var(--spacing-m) var(--spacing-xl);
-      }
-      footer {
-        display: flex;
-        justify-content: flex-end;
-      }
-      footer gr-button {
-        margin-left: var(--spacing-l);
-      }
-      input {
-        width: 20em;
-      }
-      section.hide {
-        display: none;
-      }
-    </style>
-    <div class="container gr-form-styles">
-      <header>Please confirm your contact information</header>
-      <div class="loadingMessage">Loading...</div>
-      <main>
-        <p>
-          The following contact information was automatically obtained when you
-          signed in to the site. This information is used to display who you are
-          to others, and to send updates to code reviews you have either started
-          or subscribed to.
-        </p>
-        <hr>
-        <section>
-          <div class="title">Full Name</div>
-          <iron-input
-              bind-value="{{_account.name}}"
-              disabled="[[_saving]]">
-            <input
-                is="iron-input"
-                id="name"
-                bind-value="{{_account.name}}"
-                disabled="[[_saving]]">
-          </iron-input>
-        </section>
-        <section class$="[[_computeUsernameClass(_usernameMutable)]]">
-          <div class="title">Username</div>
-          <iron-input
-              bind-value="{{_account.username}}"
-              disabled="[[_saving]]">
-            <input
-                is="iron-input"
-                id="username"
-                bind-value="{{_account.username}}"
-                disabled="[[_saving]]">
-          </iron-input>
-        </section>
-        <section>
-          <div class="title">Preferred Email</div>
-          <select
-              id="email"
-              disabled="[[_saving]]">
-            <option value="[[_account.email]]">[[_account.email]]</option>
-            <template is="dom-repeat" items="[[_account.secondary_emails]]">
-              <option value="[[item]]">[[item]]</option>
-            </template>
-          </select>
-        </section>
-        <hr>
-        <p>
-          More configuration options for Gerrit may be found in the
-          <a on-click="close" href$="[[settingsUrl]]">settings</a>.
-        </p>
-      </main>
-      <footer>
-        <gr-button
-            id="closeButton"
-            link
-            disabled="[[_saving]]"
-            on-click="_handleClose">Close</gr-button>
-        <gr-button
-            id="saveButton"
-            primary
-            link
-            disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]"
-            on-click="_handleSave">Save</gr-button>
-      </footer>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-registration-dialog.js"></script>
-</dom-module>
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..6635de2 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
@@ -14,32 +14,47 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-registration-dialog',
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-registration-dialog_html.js';
 
-    /**
-     * Fired when account details are changed.
-     *
-     * @event account-detail-update
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrRegistrationDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the close button is pressed.
-     *
-     * @event close
-     */
+  static get is() { return 'gr-registration-dialog'; }
+  /**
+   * Fired when account details are changed.
+   *
+   * @event account-detail-update
+   */
 
-    properties: {
+  /**
+   * Fired when the close button is pressed.
+   *
+   * @event close
+   */
+
+  static get properties() {
+    return {
       settingsUrl: String,
       /** @type {?} */
       _account: {
         type: Object,
         value: () => {
-          // Prepopulate possibly undefined fields with values to trigger
-          // computed bindings.
+        // Prepopulate possibly undefined fields with values to trigger
+        // computed bindings.
           return {email: null, name: null, username: null};
         },
       },
@@ -57,90 +72,94 @@
         value: false,
       },
       _serverConfig: Object,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
 
-    hostAttributes: {
-      role: 'dialog',
-    },
+  loadData() {
+    this._loading = true;
 
-    loadData() {
-      this._loading = true;
+    const loadAccount = this.$.restAPI.getAccount().then(account => {
+      // Using Object.assign here allows preservation of the default values
+      // supplied in the value generating function of this._account, unless
+      // they are overridden by properties in the account from the response.
+      this._account = Object.assign({}, this._account, account);
+    });
 
-      const loadAccount = this.$.restAPI.getAccount().then(account => {
-        // Using Object.assign here allows preservation of the default values
-        // supplied in the value generating function of this._account, unless
-        // they are overridden by properties in the account from the response.
-        this._account = Object.assign({}, this._account, account);
-      });
+    const loadConfig = this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+    });
 
-      const loadConfig = this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-      });
+    return Promise.all([loadAccount, loadConfig]).then(() => {
+      this._loading = false;
+    });
+  }
 
-      return Promise.all([loadAccount, loadConfig]).then(() => {
-        this._loading = false;
-      });
-    },
+  _save() {
+    this._saving = true;
+    const promises = [
+      this.$.restAPI.setAccountName(this.$.name.value),
+      this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
+    ];
 
-    _save() {
-      this._saving = true;
-      const promises = [
-        this.$.restAPI.setAccountName(this.$.name.value),
-        this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
-      ];
+    if (this._usernameMutable) {
+      promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
+    }
 
-      if (this._usernameMutable) {
-        promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
-      }
+    return Promise.all(promises).then(() => {
+      this._saving = false;
+      this.dispatchEvent(new CustomEvent('account-detail-update', {
+        composed: true, bubbles: true,
+      }));
+    });
+  }
 
-      return Promise.all(promises).then(() => {
-        this._saving = false;
-        this.fire('account-detail-update');
-      });
-    },
+  _handleSave(e) {
+    e.preventDefault();
+    this._save().then(this.close.bind(this));
+  }
 
-    _handleSave(e) {
-      e.preventDefault();
-      this._save().then(this.close.bind(this));
-    },
+  _handleClose(e) {
+    e.preventDefault();
+    this.close();
+  }
 
-    _handleClose(e) {
-      e.preventDefault();
-      this.close();
-    },
+  close() {
+    this._saving = true; // disable buttons indefinitely
+    this.dispatchEvent(new CustomEvent('close', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    close() {
-      this._saving = true; // disable buttons indefinitely
-      this.fire('close');
-    },
+  _computeSaveDisabled(name, email, saving) {
+    return !name || !email || saving;
+  }
 
-    _computeSaveDisabled(name, email, saving) {
-      return !name || !email || saving;
-    },
+  _computeUsernameMutable(config, username) {
+    // Polymer 2: check for undefined
+    if ([
+      config,
+      username,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
 
-    _computeUsernameMutable(config, username) {
-      // Polymer 2: check for undefined
-      if ([
-        config,
-        username,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
+    return config.auth.editable_account_fields.includes('USER_NAME') &&
+        !username;
+  }
 
-      return config.auth.editable_account_fields.includes('USER_NAME') &&
-          !username;
-    },
+  _computeUsernameClass(usernameMutable) {
+    return usernameMutable ? '' : 'hide';
+  }
 
-    _computeUsernameClass(usernameMutable) {
-      return usernameMutable ? '' : 'hide';
-    },
+  _loadingChanged() {
+    this.classList.toggle('loading', this._loading);
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
new file mode 100644
index 0000000..3559ba6
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    main {
+      max-width: 46em;
+    }
+    :host(.loading) main {
+      display: none;
+    }
+    .loadingMessage {
+      display: none;
+      font-style: italic;
+    }
+    :host(.loading) .loadingMessage {
+      display: block;
+    }
+    hr {
+      margin-top: var(--spacing-l);
+      margin-bottom: var(--spacing-l);
+    }
+    header {
+      border-bottom: 1px solid var(--border-color);
+      font-weight: var(--font-weight-bold);
+      margin-bottom: var(--spacing-l);
+    }
+    .container {
+      padding: var(--spacing-m) var(--spacing-xl);
+    }
+    footer {
+      display: flex;
+      justify-content: flex-end;
+    }
+    footer gr-button {
+      margin-left: var(--spacing-l);
+    }
+    input {
+      width: 20em;
+    }
+    section.hide {
+      display: none;
+    }
+  </style>
+  <div class="container gr-form-styles">
+    <header>Please confirm your contact information</header>
+    <div class="loadingMessage">Loading...</div>
+    <main>
+      <p>
+        The following contact information was automatically obtained when you
+        signed in to the site. This information is used to display who you are
+        to others, and to send updates to code reviews you have either started
+        or subscribed to.
+      </p>
+      <hr />
+      <section>
+        <div class="title">Full Name</div>
+        <iron-input bind-value="{{_account.name}}">
+          <input
+            is="iron-input"
+            id="name"
+            bind-value="{{_account.name}}"
+            disabled="[[_saving]]"
+          />
+        </iron-input>
+      </section>
+      <section class$="[[_computeUsernameClass(_usernameMutable)]]">
+        <div class="title">Username</div>
+        <iron-input bind-value="{{_account.username}}">
+          <input
+            is="iron-input"
+            id="username"
+            bind-value="{{_account.username}}"
+            disabled="[[_saving]]"
+          />
+        </iron-input>
+      </section>
+      <section>
+        <div class="title">Preferred Email</div>
+        <select id="email" disabled="[[_saving]]">
+          <option value="[[_account.email]]">[[_account.email]]</option>
+          <template is="dom-repeat" items="[[_account.secondary_emails]]">
+            <option value="[[item]]">[[item]]</option>
+          </template>
+        </select>
+      </section>
+      <hr />
+      <p>
+        More configuration options for Gerrit may be found in the
+        <a on-click="close" href$="[[settingsUrl]]">settings</a>.
+      </p>
+    </main>
+    <footer>
+      <gr-button
+        id="closeButton"
+        link=""
+        disabled="[[_saving]]"
+        on-click="_handleClose"
+        >Close</gr-button
+      >
+      <gr-button
+        id="saveButton"
+        primary=""
+        link=""
+        disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]"
+        on-click="_handleSave"
+        >Save</gr-button
+      >
+    </footer>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..a3f8548 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-registration-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -40,146 +37,150 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-registration-dialog tests', () => {
-    let element;
-    let account;
-    let sandbox;
-    let _listeners;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-registration-dialog.js';
+suite('gr-registration-dialog tests', () => {
+  let element;
+  let account;
+  let sandbox;
+  let _listeners;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      _listeners = {};
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    _listeners = {};
 
-      account = {
-        name: 'name',
-        username: null,
-        email: 'email',
-        secondary_emails: [
-          'email2',
-          'email3',
-        ],
-      };
+    account = {
+      name: 'name',
+      username: null,
+      email: 'email',
+      secondary_emails: [
+        'email2',
+        'email3',
+      ],
+    };
 
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve(account);
-        },
-        setAccountName(name) {
-          account.name = name;
-          return Promise.resolve();
-        },
-        setAccountUsername(username) {
-          account.username = username;
-          return Promise.resolve();
-        },
-        setPreferredAccountEmail(email) {
-          account.email = email;
-          return Promise.resolve();
-        },
-        getConfig() {
-          return Promise.resolve(
-              {auth: {editable_account_fields: ['USER_NAME']}});
-        },
-      });
-
-      element = fixture('basic');
-
-      return element.loadData();
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve(account);
+      },
+      setAccountName(name) {
+        account.name = name;
+        return Promise.resolve();
+      },
+      setAccountUsername(username) {
+        account.username = username;
+        return Promise.resolve();
+      },
+      setPreferredAccountEmail(email) {
+        account.email = email;
+        return Promise.resolve();
+      },
+      getConfig() {
+        return Promise.resolve(
+            {auth: {editable_account_fields: ['USER_NAME']}});
+      },
     });
 
-    teardown(() => {
-      sandbox.restore();
-      for (const eventType in _listeners) {
-        if (_listeners.hasOwnProperty(eventType)) {
-          element.removeEventListener(eventType, _listeners[eventType]);
-        }
+    element = fixture('basic');
+
+    return element.loadData();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    for (const eventType in _listeners) {
+      if (_listeners.hasOwnProperty(eventType)) {
+        element.removeEventListener(eventType, _listeners[eventType]);
       }
-    });
-
-    function listen(eventType) {
-      return new Promise(resolve => {
-        _listeners[eventType] = function() { resolve(); };
-        element.addEventListener(eventType, _listeners[eventType]);
-      });
     }
+  });
 
-    function save(opt_action) {
-      const promise = listen('account-detail-update');
-      if (opt_action) {
-        opt_action();
-      } else {
-        MockInteractions.tap(element.$.saveButton);
-      }
-      return promise;
+  function listen(eventType) {
+    return new Promise(resolve => {
+      _listeners[eventType] = function() { resolve(); };
+      element.addEventListener(eventType, _listeners[eventType]);
+    });
+  }
+
+  function save(opt_action) {
+    const promise = listen('account-detail-update');
+    if (opt_action) {
+      opt_action();
+    } else {
+      MockInteractions.tap(element.$.saveButton);
     }
+    return promise;
+  }
 
-    function close(opt_action) {
-      const promise = listen('close');
-      if (opt_action) {
-        opt_action();
-      } else {
-        MockInteractions.tap(element.$.closeButton);
-      }
-      return promise;
+  function close(opt_action) {
+    const promise = listen('close');
+    if (opt_action) {
+      opt_action();
+    } else {
+      MockInteractions.tap(element.$.closeButton);
     }
+    return promise;
+  }
 
-    test('fires the close event on close', done => {
-      close().then(done);
-    });
+  test('fires the close event on close', done => {
+    close().then(done);
+  });
 
-    test('fires the close event on save', done => {
-      close(() => {
-        MockInteractions.tap(element.$.saveButton);
-      }).then(done);
-    });
+  test('fires the close event on save', done => {
+    close(() => {
+      MockInteractions.tap(element.$.saveButton);
+    }).then(done);
+  });
 
-    test('saves account details', done => {
-      flush(() => {
-        element.$.name.value = 'new name';
-        element.$.username.value = 'new username';
-        element.$.email.value = 'email3';
+  test('saves account details', done => {
+    flush(() => {
+      element.$.name.value = 'new name';
+      element.$.username.value = 'new username';
+      element.$.email.value = 'email3';
 
-        // Nothing should be committed yet.
-        assert.equal(account.name, 'name');
-        assert.isNotOk(account.username);
-        assert.equal(account.email, 'email');
+      // Nothing should be committed yet.
+      assert.equal(account.name, 'name');
+      assert.isNotOk(account.username);
+      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);
-      });
-    });
-
-    test('email select properly populated', done => {
-      element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
-      flush(() => {
-        assert.equal(element.$.email.value, 'foo');
-        done();
-      });
-    });
-
-    test('save btn disabled', () => {
-      const compute = element._computeSaveDisabled;
-      assert.isTrue(compute('', '', false));
-      assert.isTrue(compute('', 'test', false));
-      assert.isTrue(compute('test', '', false));
-      assert.isTrue(compute('test', 'test', true));
-      assert.isFalse(compute('test', 'test', false));
-    });
-
-    test('_computeUsernameMutable', () => {
-      assert.isTrue(element._computeUsernameMutable(
-          {auth: {editable_account_fields: ['USER_NAME']}}, null));
-      assert.isFalse(element._computeUsernameMutable(
-          {auth: {editable_account_fields: ['USER_NAME']}}, 'abc'));
-      assert.isFalse(element._computeUsernameMutable(
-          {auth: {editable_account_fields: []}}, null));
-      assert.isFalse(element._computeUsernameMutable(
-          {auth: {editable_account_fields: []}}, 'abc'));
+      // 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);
     });
   });
+
+  test('email select properly populated', done => {
+    element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
+    flush(() => {
+      assert.equal(element.$.email.value, 'foo');
+      done();
+    });
+  });
+
+  test('save btn disabled', () => {
+    const compute = element._computeSaveDisabled;
+    assert.isTrue(compute('', '', false));
+    assert.isTrue(compute('', 'test', false));
+    assert.isTrue(compute('test', '', false));
+    assert.isTrue(compute('test', 'test', true));
+    assert.isFalse(compute('test', 'test', false));
+  });
+
+  test('_computeUsernameMutable', () => {
+    assert.isTrue(element._computeUsernameMutable(
+        {auth: {editable_account_fields: ['USER_NAME']}}, null));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: ['USER_NAME']}}, 'abc'));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: []}}, null));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: []}}, 'abc'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
deleted file mode 100644
index 937ee79..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.html
+++ /dev/null
@@ -1,32 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-settings-item">
-  <template>
-    <style>
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-xxl);
-      }
-    </style>
-    <h2 id="[[anchor]]">[[title]]</h2>
-    <slot></slot>
-  </template>
-  <script src="gr-settings-item.js"></script>
-</dom-module>
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..3884a15 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
@@ -14,15 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-settings-item_html.js';
 
-  Polymer({
-    is: 'gr-settings-item',
+/** @extends Polymer.Element */
+class GrSettingsItem extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-settings-item'; }
+
+  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-item_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
new file mode 100644
index 0000000..e26faab
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style>
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <h2 id="[[anchor]]">[[title]]</h2>
+  <slot></slot>
+`;
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
deleted file mode 100644
index 846f776..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
+++ /dev/null
@@ -1,30 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-page-nav-styles.html">
-
-<dom-module id="gr-settings-menu-item">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-page-nav-styles"></style>
-    <div class="navStyles">
-      <li><a href$="[[href]]">[[title]]</a></li>
-    </div>
-  </template>
-  <script src="gr-settings-menu-item.js"></script>
-</dom-module>
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..5b11516 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
@@ -14,15 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-settings-menu-item',
+import '../../../styles/gr-page-nav-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-settings-menu-item_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrSettingsMenuItem extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-settings-menu-item'; }
+
+  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-menu-item_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
new file mode 100644
index 0000000..95433ac
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
deleted file mode 100644
index 74971cf..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ /dev/null
@@ -1,518 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-
-<link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
-<link rel="import" href="/bower_components/paper-toggle-button/paper-toggle-button.html">
-
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/gr-menu-page-styles.html">
-<link rel="import" href="../../../styles/gr-page-nav-styles.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="../../settings/gr-change-table-editor/gr-change-table-editor.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-diff-preferences/gr-diff-preferences.html">
-<link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../gr-account-info/gr-account-info.html">
-<link rel="import" href="../gr-agreements-list/gr-agreements-list.html">
-<link rel="import" href="../gr-edit-preferences/gr-edit-preferences.html">
-<link rel="import" href="../gr-email-editor/gr-email-editor.html">
-<link rel="import" href="../gr-gpg-editor/gr-gpg-editor.html">
-<link rel="import" href="../gr-group-list/gr-group-list.html">
-<link rel="import" href="../gr-http-password/gr-http-password.html">
-<link rel="import" href="../gr-identities/gr-identities.html">
-<link rel="import" href="../gr-menu-editor/gr-menu-editor.html">
-<link rel="import" href="../gr-ssh-editor/gr-ssh-editor.html">
-<link rel="import" href="../gr-watched-projects-editor/gr-watched-projects-editor.html">
-
-<dom-module id="gr-settings-view">
-  <template>
-    <style include="shared-styles">
-      :host {
-        color: var(--primary-text-color);
-      }
-      .newEmailInput {
-        width: 20em;
-      }
-      #email {
-        margin-bottom: var(--spacing-l);
-      }
-      main section.darkToggle {
-        display: block;
-      }
-      .filters p,
-      .darkToggle p {
-        margin-bottom: var(--spacing-l);
-      }
-      .queryExample em {
-        color: violet;
-      }
-      .toggle {
-        align-items: center;
-        display: flex;
-        margin-bottom: var(--spacing-l);
-        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>
-    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <div hidden$="[[_loading]]" hidden>
-      <gr-page-nav class="navStyles">
-        <ul>
-          <li><a href="#Profile">Profile</a></li>
-          <li><a href="#Preferences">Preferences</a></li>
-          <li><a href="#DiffPreferences">Diff Preferences</a></li>
-          <li><a href="#EditPreferences">Edit Preferences</a></li>
-          <li><a href="#Menu">Menu</a></li>
-          <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
-          <li><a href="#Notifications">Notifications</a></li>
-          <li><a href="#EmailAddresses">Email Addresses</a></li>
-          <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-            <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
-          </template>
-          <li hidden$="[[!_serverConfig.sshd]]"><a href="#SSHKeys">
-            SSH Keys
-          </a></li>
-          <li hidden$="[[!_serverConfig.receive.enable_signed_push]]"><a href="#GPGKeys">
-            GPG Keys
-          </a></li>
-          <li><a href="#Groups">Groups</a></li>
-          <li><a href="#Identities">Identities</a></li>
-          <template is="dom-if" if="[[_serverConfig.auth.use_contributor_agreements]]">
-            <li>
-              <a href="#Agreements">Agreements</a>
-            </li>
-          </template>
-          <li><a href="#MailFilters">Mail Filters</a></li>
-          <gr-endpoint-decorator name="settings-menu-item">
-          </gr-endpoint-decorator>
-        </ul>
-      </gr-page-nav>
-      <main class="gr-form-styles">
-        <h1>User Settings</h1>
-        <section class="darkToggle">
-          <div class="toggle">
-            <paper-toggle-button
-                checked="[[_isDark]]"
-                on-change="_handleToggleDark"></paper-toggle-button>
-            <div>Dark theme (alpha)</div>
-          </div>
-          <p>
-            Gerrit's dark theme is in early alpha, and almost definitely will
-            not play nicely with themes set by specific Gerrit hosts. Filing
-            feedback via the link in the app footer is strongly encouraged!
-          </p>
-        </section>
-        <h2
-            id="Profile"
-            class$="[[_computeHeaderClass(_accountInfoChanged)]]">Profile</h2>
-        <fieldset id="profile">
-          <gr-account-info
-              id="accountInfo"
-              mutable="{{_accountNameMutable}}"
-              has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
-          <gr-button
-              on-click="_handleSaveAccountInfo"
-              disabled="[[!_accountInfoChanged]]">Save changes</gr-button>
-        </fieldset>
-        <h2
-            id="Preferences"
-            class$="[[_computeHeaderClass(_prefsChanged)]]">Preferences</h2>
-        <fieldset id="preferences">
-          <section>
-            <span class="title">Changes per page</span>
-            <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.changes_per_page}}">
-                <select>
-                  <option value="10">10 rows per page</option>
-                  <option value="25">25 rows per page</option>
-                  <option value="50">50 rows per page</option>
-                  <option value="100">100 rows per page</option>
-                </select>
-              </gr-select>
-            </span>
-          </section>
-          <section>
-            <span class="title">Date/time format</span>
-            <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.date_format}}">
-                <select>
-                  <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                  <option value="US">06/03 ; 06/03/16</option>
-                  <option value="ISO">06-03 ; 2016-06-03</option>
-                  <option value="EURO">3. Jun ; 03.06.2016</option>
-                  <option value="UK">03/06 ; 03/06/2016</option>
-                </select>
-              </gr-select>
-              <gr-select
-                  bind-value="{{_localPrefs.time_format}}">
-                <select>
-                  <option value="HHMM_12">4:10 PM</option>
-                  <option value="HHMM_24">16:10</option>
-                </select>
-              </gr-select>
-            </span>
-          </section>
-          <section>
-            <span class="title">Email notifications</span>
-            <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.email_strategy}}">
-                <select>
-                  <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                  <option value="ENABLED">Only comments left by others</option>
-                  <option value="DISABLED">None</option>
-                </select>
-              </gr-select>
-            </span>
-          </section>
-          <section hidden$="[[!_localPrefs.email_format]]">
-            <span class="title">Email format</span>
-            <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.email_format}}">
-                <select>
-                  <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                  <option value="PLAINTEXT">Plaintext only</option>
-                </select>
-              </gr-select>
-            </span>
-          </section>
-          <section hidden$="[[!_localPrefs.default_base_for_merges]]">
-            <span class="title">Default Base For Merges</span>
-            <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.default_base_for_merges}}">
-                <select>
-                  <option value="AUTO_MERGE">Auto Merge</option>
-                  <option value="FIRST_PARENT">First Parent</option>
-                </select>
-              </gr-select>
-            </span>
-          </section>
-          <section>
-            <span class="title">Show Relative Dates In Changes Table</span>
-            <span class="value">
-              <input
-                  id="relativeDateInChangeTable"
-                  type="checkbox"
-                  checked$="[[_localPrefs.relative_date_in_change_table]]"
-                  on-change="_handleRelativeDateInChangeTable">
-            </span>
-          </section>
-          <section>
-            <span class="title">Diff view</span>
-            <span class="value">
-              <gr-select
-                  bind-value="{{_localPrefs.diff_view}}">
-                <select>
-                  <option value="SIDE_BY_SIDE">Side by side</option>
-                  <option value="UNIFIED_DIFF">Unified diff</option>
-                </select>
-              </gr-select>
-            </span>
-          </section>
-          <section>
-            <span class="title">Show size bars in file list</span>
-            <span class="value">
-              <input
-                  id="showSizeBarsInFileList"
-                  type="checkbox"
-                  checked$="[[_localPrefs.size_bar_in_change_table]]"
-                  on-change="_handleShowSizeBarsInFileListChanged">
-            </span>
-          </section>
-          <section>
-            <span class="title">Publish comments on push</span>
-            <span class="value">
-              <input
-                  id="publishCommentsOnPush"
-                  type="checkbox"
-                  checked$="[[_localPrefs.publish_comments_on_push]]"
-                  on-change="_handlePublishCommentsOnPushChanged">
-            </span>
-          </section>
-          <section>
-            <span class="title">Set new changes to "work in progress" by default</span>
-            <span class="value">
-              <input
-                  id="workInProgressByDefault"
-                  type="checkbox"
-                  checked$="[[_localPrefs.work_in_progress_by_default]]"
-                  on-change="_handleWorkInProgressByDefault">
-            </span>
-          </section>
-          <section>
-            <span class="title">
-              Insert Signed-off-by Footer For Inline Edit Changes
-            </span>
-            <span class="value">
-              <input
-                  id="insertSignedOff"
-                  type="checkbox"
-                  checked$="[[_localPrefs.signed_off_by]]"
-                  on-change="_handleInsertSignedOff">
-            </span>
-          </section>
-          <gr-button
-              id="savePrefs"
-              on-click="_handleSavePreferences"
-              disabled="[[!_prefsChanged]]">Save changes</gr-button>
-        </fieldset>
-        <h2
-            id="DiffPreferences"
-            class$="[[_computeHeaderClass(_diffPrefsChanged)]]">
-          Diff Preferences
-        </h2>
-        <fieldset id="diffPreferences">
-          <gr-diff-preferences
-              id="diffPrefs"
-              has-unsaved-changes="{{_diffPrefsChanged}}"></gr-diff-preferences>
-          <gr-button
-              id="saveDiffPrefs"
-              on-click="_handleSaveDiffPreferences"
-              disabled$="[[!_diffPrefsChanged]]">Save changes</gr-button>
-        </fieldset>
-        <h2
-            id="EditPreferences"
-            class$="[[_computeHeaderClass(_editPrefsChanged)]]">
-          Edit Preferences
-        </h2>
-        <fieldset id="editPreferences">
-          <gr-edit-preferences
-              id="editPrefs"
-              has-unsaved-changes="{{_editPrefsChanged}}"></gr-edit-preferences>
-          <gr-button
-              id="saveEditPrefs"
-              on-click="_handleSaveEditPreferences"
-              disabled$="[[!_editPrefsChanged]]">Save changes</gr-button>
-        </fieldset>
-        <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
-        <fieldset id="menu">
-          <gr-menu-editor
-              menu-items="{{_localMenu}}"></gr-menu-editor>
-          <gr-button
-              id="saveMenu"
-              on-click="_handleSaveMenu"
-              disabled="[[!_menuChanged]]">Save changes</gr-button>
-          <gr-button
-              id="resetMenu"
-              link
-              on-click="_handleResetMenuButton">Reset</gr-button>
-        </fieldset>
-        <h2 id="ChangeTableColumns"
-            class$="[[_computeHeaderClass(_changeTableChanged)]]">
-          Change Table Columns
-        </h2>
-        <fieldset id="changeTableColumns">
-          <gr-change-table-editor
-              show-number="{{_showNumber}}"
-              displayed-columns="{{_localChangeTableColumns}}">
-          </gr-change-table-editor>
-          <gr-button
-              id="saveChangeTable"
-              on-click="_handleSaveChangeTable"
-              disabled="[[!_changeTableChanged]]">Save changes</gr-button>
-        </fieldset>
-        <h2
-            id="Notifications"
-            class$="[[_computeHeaderClass(_watchedProjectsChanged)]]">
-          Notifications
-        </h2>
-        <fieldset id="watchedProjects">
-          <gr-watched-projects-editor
-              has-unsaved-changes="{{_watchedProjectsChanged}}"
-              id="watchedProjectsEditor"></gr-watched-projects-editor>
-          <gr-button
-              on-click="_handleSaveWatchedProjects"
-              disabled$="[[!_watchedProjectsChanged]]"
-              id="_handleSaveWatchedProjects">Save changes</gr-button>
-        </fieldset>
-        <h2
-            id="EmailAddresses"
-            class$="[[_computeHeaderClass(_emailsChanged)]]">
-          Email Addresses
-        </h2>
-        <fieldset id="email">
-          <gr-email-editor
-              id="emailEditor"
-              has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
-          <gr-button
-              on-click="_handleSaveEmails"
-              disabled$="[[!_emailsChanged]]">Save changes</gr-button>
-        </fieldset>
-        <fieldset id="newEmail">
-          <section>
-            <span class="title">New email address</span>
-            <span class="value">
-              <iron-input
-                  class="newEmailInput"
-                  bind-value="{{_newEmail}}"
-                  type="text"
-                  disabled="[[_addingEmail]]"
-                  on-keydown="_handleNewEmailKeydown"
-                  placeholder="email@example.com">
-                <input
-                    class="newEmailInput"
-                    bind-value="{{_newEmail}}"
-                    is="iron-input"
-                    type="text"
-                    disabled="[[_addingEmail]]"
-                    on-keydown="_handleNewEmailKeydown"
-                    placeholder="email@example.com">
-              </iron-input>
-            </span>
-          </section>
-          <section
-              id="verificationSentMessage"
-              hidden$="[[!_lastSentVerificationEmail]]">
-            <p>
-              A verification email was sent to
-              <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
-            </p>
-          </section>
-          <gr-button
-              disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-              on-click="_handleAddEmailButton">Send verification</gr-button>
-        </fieldset>
-        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-          <div>
-            <h2 id="HTTPCredentials">HTTP Credentials</h2>
-            <fieldset>
-              <gr-http-password id="httpPass"></gr-http-password>
-            </fieldset>
-          </div>
-        </template>
-        <div hidden$="[[!_serverConfig.sshd]]">
-          <h2
-              id="SSHKeys"
-              class$="[[_computeHeaderClass(_keysChanged)]]">SSH keys</h2>
-          <gr-ssh-editor
-              id="sshEditor"
-              has-unsaved-changes="{{_keysChanged}}"></gr-ssh-editor>
-        </div>
-        <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <h2
-              id="GPGKeys"
-              class$="[[_computeHeaderClass(_gpgKeysChanged)]]">GPG keys</h2>
-          <gr-gpg-editor
-              id="gpgEditor"
-              has-unsaved-changes="{{_gpgKeysChanged}}"></gr-gpg-editor>
-        </div>
-        <h2 id="Groups">Groups</h2>
-        <fieldset>
-          <gr-group-list id="groupList"></gr-group-list>
-        </fieldset>
-        <h2 id="Identities">Identities</h2>
-        <fieldset>
-          <gr-identities id="identities" server-config="[[_serverConfig]]"></gr-identities>
-        </fieldset>
-        <template is="dom-if" if="[[_serverConfig.auth.use_contributor_agreements]]">
-          <h2 id="Agreements">Agreements</h2>
-          <fieldset>
-            <gr-agreements-list id="agreementsList"></gr-agreements-list>
-          </fieldset>
-        </template>
-        <h2 id="MailFilters">Mail Filters</h2>
-        <fieldset class="filters">
-          <p>
-            Gerrit emails include metadata about the change to support
-            writing mail filters.
-          </p>
-          <p>
-            Here are some example Gmail queries that can be used for filters or
-            for searching through archived messages. View the
-            <a href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
-                target="_blank"
-                rel="nofollow">Gerrit documentation</a>
-            for the complete set of footers.
-          </p>
-          <table>
-            <tbody>
-              <tr><th>Name</th><th>Query</th></tr>
-              <tr>
-                <td>Changes requesting my review</td>
-                <td>
-                  <code class="queryExample">
-                    "Gerrit-Reviewer: <em>Your Name</em>
-                    &lt;<em>your.email@example.com</em>&gt;"
-                  </code>
-                </td>
-              </tr>
-              <tr>
-                <td>Changes from a specific owner</td>
-                <td>
-                  <code class="queryExample">
-                    "Gerrit-Owner: <em>Owner name</em>
-                    &lt;<em>owner.email@example.com</em>&gt;"
-                  </code>
-                </td>
-              </tr>
-              <tr>
-                <td>Changes targeting a specific branch</td>
-                <td>
-                  <code class="queryExample">
-                    "Gerrit-Branch: <em>branch-name</em>"
-                  </code>
-                </td>
-              </tr>
-              <tr>
-                <td>Changes in a specific project</td>
-                <td>
-                  <code class="queryExample">
-                    "Gerrit-Project: <em>project-name</em>"
-                  </code>
-                </td>
-              </tr>
-              <tr>
-                <td>Messages related to a specific Change ID</td>
-                <td>
-                  <code class="queryExample">
-                    "Gerrit-Change-Id: <em>Change ID</em>"
-                  </code>
-                </td>
-              </tr>
-              <tr>
-                <td>Messages related to a specific change number</td>
-                <td>
-                  <code class="queryExample">
-                    "Gerrit-Change-Number: <em>change number</em>"
-                  </code>
-                </td>
-              </tr>
-            </tbody>
-          </table>
-        </fieldset>
-        <gr-endpoint-decorator name="settings-screen">
-        </gr-endpoint-decorator>
-      </main>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="../../../scripts/util.js"></script>
-  <script src="gr-settings-view.js"></script>
-</dom-module>
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..158c5eb 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
@@ -14,53 +14,95 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const PREFS_SECTION_FIELDS = [
-    'changes_per_page',
-    'date_format',
-    'time_format',
-    'email_strategy',
-    'diff_view',
-    'publish_comments_on_push',
-    'work_in_progress_by_default',
-    'default_base_for_merges',
-    'signed_off_by',
-    'email_format',
-    'size_bar_in_change_table',
-    'relative_date_in_change_table',
-  ];
+import '@polymer/iron-input/iron-input.js';
+import '@polymer/paper-toggle-button/paper-toggle-button.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/gr-menu-page-styles.js';
+import '../../../styles/gr-page-nav-styles.js';
+import '../../../styles/shared-styles.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../gr-change-table-editor/gr-change-table-editor.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
+import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
+import '../../shared/gr-page-nav/gr-page-nav.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../shared/gr-select/gr-select.js';
+import '../gr-account-info/gr-account-info.js';
+import '../gr-agreements-list/gr-agreements-list.js';
+import '../gr-edit-preferences/gr-edit-preferences.js';
+import '../gr-email-editor/gr-email-editor.js';
+import '../gr-gpg-editor/gr-gpg-editor.js';
+import '../gr-group-list/gr-group-list.js';
+import '../gr-http-password/gr-http-password.js';
+import '../gr-identities/gr-identities.js';
+import '../gr-menu-editor/gr-menu-editor.js';
+import '../gr-ssh-editor/gr-ssh-editor.js';
+import '../gr-watched-projects-editor/gr-watched-projects-editor.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-settings-view_html.js';
+import {DocsUrlBehavior} from '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
+import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
 
-  const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
-      'Documentation';
-  const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-  const ABSOLUTE_URL_PATTERN = /^https?:/;
-  const TRAILING_SLASH_PATTERN = /\/$/;
+const PREFS_SECTION_FIELDS = [
+  'changes_per_page',
+  'date_format',
+  'time_format',
+  'email_strategy',
+  'diff_view',
+  'publish_comments_on_push',
+  'work_in_progress_by_default',
+  'default_base_for_merges',
+  'signed_off_by',
+  'email_format',
+  'size_bar_in_change_table',
+  'relative_date_in_change_table',
+];
 
-  const RELOAD_MESSAGE = 'Reloading...';
+const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
+    'Documentation';
+const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+const ABSOLUTE_URL_PATTERN = /^https?:/;
+const TRAILING_SLASH_PATTERN = /\/$/;
 
-  const HTTP_AUTH = [
-    'HTTP',
-    'HTTP_LDAP',
-  ];
+const RELOAD_MESSAGE = 'Reloading...';
 
-  Polymer({
-    is: 'gr-settings-view',
+const HTTP_AUTH = [
+  'HTTP',
+  'HTTP_LDAP',
+];
 
-    /**
-     * Fired when the title of the page should change.
-     *
-     * @event title-change
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrSettingsView extends mixinBehaviors( [
+  DocsUrlBehavior,
+  ChangeTableBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired with email confirmation text, or when the page reloads.
-     *
-     * @event show-alert
-     */
+  static get is() { return 'gr-settings-view'; }
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
 
-    properties: {
+  /**
+   * Fired with email confirmation text, or when the page reloads.
+   *
+   * @event show-alert
+   */
+
+  static get properties() {
+    return {
       prefs: {
         type: Object,
         value() { return {}; },
@@ -69,7 +111,6 @@
         type: Object,
         value() { return {}; },
       },
-      _accountNameMutable: Boolean,
       _accountInfoChanged: Boolean,
       _changeTableColumnsNotDisplayed: Array,
       /** @type {?} */
@@ -143,313 +184,328 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.DocsUrlBehavior,
-      Gerrit.ChangeTableBehavior,
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
+  static get observers() {
+    return [
       '_handlePrefsChanged(_localPrefs.*)',
       '_handleMenuChanged(_localMenu.splices)',
       '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
-    ],
+    ];
+  }
 
-    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');
-      this.fire('title-change', {title: 'Settings'});
+  /** @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');
+    this.dispatchEvent(new CustomEvent('title-change', {
+      detail: {title: 'Settings'},
+      composed: true, bubbles: true,
+    }));
 
-      this._isDark = !!window.localStorage.getItem('dark-theme');
+    this._isDark = !!window.localStorage.getItem('dark-theme');
 
-      const promises = [
-        this.$.accountInfo.loadData(),
-        this.$.watchedProjectsEditor.loadData(),
-        this.$.groupList.loadData(),
-        this.$.identities.loadData(),
-        this.$.editPrefs.loadData(),
-        this.$.diffPrefs.loadData(),
-      ];
+    const promises = [
+      this.$.accountInfo.loadData(),
+      this.$.watchedProjectsEditor.loadData(),
+      this.$.groupList.loadData(),
+      this.$.identities.loadData(),
+      this.$.editPrefs.loadData(),
+      this.$.diffPrefs.loadData(),
+    ];
 
-      promises.push(this.$.restAPI.getPreferences().then(prefs => {
-        this.prefs = prefs;
-        this._showNumber = !!prefs.legacycid_in_change_table;
-        this._copyPrefs('_localPrefs', 'prefs');
-        this._cloneMenu(prefs.my);
-        this._cloneChangeTableColumns();
-      }));
-
-      promises.push(this.$.restAPI.getConfig().then(config => {
-        this._serverConfig = config;
-        const configPromises = [];
-
-        if (this._serverConfig && this._serverConfig.sshd) {
-          configPromises.push(this.$.sshEditor.loadData());
-        }
-
-        if (this._serverConfig &&
-            this._serverConfig.receive &&
-            this._serverConfig.receive.enable_signed_push) {
-          configPromises.push(this.$.gpgEditor.loadData());
-        }
-
-        configPromises.push(
-            this.getDocsBaseUrl(config, this.$.restAPI)
-                .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
-
-        return Promise.all(configPromises);
-      }));
-
-      if (this.params.emailToken) {
-        promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
-            message => {
-              if (message) {
-                this.fire('show-alert', {message});
-              }
-              this.$.emailEditor.loadData();
-            }));
-      } else {
-        promises.push(this.$.emailEditor.loadData());
-      }
-
-      this._loadingPromise = Promise.all(promises).then(() => {
-        this._loading = false;
-
-        // Handle anchor tag for initial load
-        this._handleLocationChange();
-      });
-    },
-
-    detached() {
-      this.unlisten(window, 'location-change', '_handleLocationChange');
-    },
-
-    _handleLocationChange() {
-      // Handle anchor tag after dom attached
-      const urlHash = window.location.hash;
-      if (urlHash) {
-        // Use shadowRoot for Polymer 2
-        const elem = (this.shadowRoot || document).querySelector(urlHash);
-        if (elem) {
-          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 = [];
-      for (const item of prefs) {
-        menu.push({
-          name: item.name,
-          url: item.url,
-          target: item.target,
-        });
-      }
-      this._localMenu = menu;
-    },
-
-    _cloneChangeTableColumns() {
-      let columns = this.getVisibleColumns(this.prefs.change_table);
-
-      if (columns.length === 0) {
-        columns = this.columnNames;
-        this._changeTableColumnsNotDisplayed = [];
-      } else {
-        this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-            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');
-
-      return this.$.restAPI.savePreferences(this.prefs).then(() => {
-        this._prefsChanged = false;
-      });
-    },
-
-    _handleSaveChangeTable() {
-      this.set('prefs.change_table', this._localChangeTableColumns);
-      this.set('prefs.legacycid_in_change_table', this._showNumber);
+    promises.push(this.$.restAPI.getPreferences().then(prefs => {
+      this.prefs = prefs;
+      this._showNumber = !!prefs.legacycid_in_change_table;
+      this._copyPrefs('_localPrefs', 'prefs');
+      this._cloneMenu(prefs.my);
       this._cloneChangeTableColumns();
-      return this.$.restAPI.savePreferences(this.prefs).then(() => {
-        this._changeTableChanged = false;
-      });
-    },
+    }));
 
-    _handleSaveDiffPreferences() {
-      this.$.diffPrefs.save();
-    },
+    promises.push(this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+      const configPromises = [];
 
-    _handleSaveEditPreferences() {
-      this.$.editPrefs.save();
-    },
-
-    _handleSaveMenu() {
-      this.set('prefs.my', this._localMenu);
-      this._cloneMenu(this.prefs.my);
-      return this.$.restAPI.savePreferences(this.prefs).then(() => {
-        this._menuChanged = false;
-      });
-    },
-
-    _handleResetMenuButton() {
-      return this.$.restAPI.getDefaultPreferences().then(data => {
-        if (data && data.my) {
-          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; }
-
-      this._addingEmail = true;
-      this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
-        this._addingEmail = false;
-
-        // If it was unsuccessful.
-        if (response.status < 200 || response.status >= 300) { return; }
-
-        this._lastSentVerificationEmail = this._newEmail;
-        this._newEmail = '';
-      });
-    },
-
-    _getFilterDocsLink(docsBaseUrl) {
-      let base = docsBaseUrl;
-      if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
-        base = GERRIT_DOCS_BASE_URL;
+      if (this._serverConfig && this._serverConfig.sshd) {
+        configPromises.push(this.$.sshEditor.loadData());
       }
 
-      // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
-      base = base.replace(TRAILING_SLASH_PATTERN, '');
-
-      return base + GERRIT_DOCS_FILTER_PATH;
-    },
-
-    _handleToggleDark() {
-      if (this._isDark) {
-        window.localStorage.removeItem('dark-theme');
-      } else {
-        window.localStorage.setItem('dark-theme', 'true');
-      }
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: RELOAD_MESSAGE},
-        bubbles: true,
-        composed: true,
-      }));
-      this.async(() => {
-        window.location.reload();
-      }, 1);
-    },
-
-    _showHttpAuth(config) {
-      if (config && config.auth &&
-          config.auth.git_basic_auth_policy) {
-        return HTTP_AUTH.includes(
-            config.auth.git_basic_auth_policy.toUpperCase());
+      if (this._serverConfig &&
+          this._serverConfig.receive &&
+          this._serverConfig.receive.enable_signed_push) {
+        configPromises.push(this.$.gpgEditor.loadData());
       }
 
-      return false;
-    },
-  });
-})();
+      configPromises.push(
+          this.getDocsBaseUrl(config, this.$.restAPI)
+              .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
+
+      return Promise.all(configPromises);
+    }));
+
+    if (this.params.emailToken) {
+      promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
+          message => {
+            if (message) {
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message},
+                composed: true, bubbles: true,
+              }));
+            }
+            this.$.emailEditor.loadData();
+          }));
+    } else {
+      promises.push(this.$.emailEditor.loadData());
+    }
+
+    this._loadingPromise = Promise.all(promises).then(() => {
+      this._loading = false;
+
+      // 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
+    const urlHash = window.location.hash;
+    if (urlHash) {
+      // Use shadowRoot for Polymer 2
+      const elem = (this.shadowRoot || document).querySelector(urlHash);
+      if (elem) {
+        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 = [];
+    for (const item of prefs) {
+      menu.push({
+        name: item.name,
+        url: item.url,
+        target: item.target,
+      });
+    }
+    this._localMenu = menu;
+  }
+
+  _cloneChangeTableColumns() {
+    let columns = this.getVisibleColumns(this.prefs.change_table);
+
+    if (columns.length === 0) {
+      columns = this.columnNames;
+      this._changeTableColumnsNotDisplayed = [];
+    } else {
+      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+          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');
+
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._prefsChanged = false;
+    });
+  }
+
+  _handleSaveChangeTable() {
+    this.set('prefs.change_table', this._localChangeTableColumns);
+    this.set('prefs.legacycid_in_change_table', this._showNumber);
+    this._cloneChangeTableColumns();
+    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);
+    this._cloneMenu(this.prefs.my);
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._menuChanged = false;
+    });
+  }
+
+  _handleResetMenuButton() {
+    return this.$.restAPI.getDefaultPreferences().then(data => {
+      if (data && data.my) {
+        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; }
+
+    this._addingEmail = true;
+    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+      this._addingEmail = false;
+
+      // If it was unsuccessful.
+      if (response.status < 200 || response.status >= 300) { return; }
+
+      this._lastSentVerificationEmail = this._newEmail;
+      this._newEmail = '';
+    });
+  }
+
+  _getFilterDocsLink(docsBaseUrl) {
+    let base = docsBaseUrl;
+    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
+      base = GERRIT_DOCS_BASE_URL;
+    }
+
+    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+    base = base.replace(TRAILING_SLASH_PATTERN, '');
+
+    return base + GERRIT_DOCS_FILTER_PATH;
+  }
+
+  _handleToggleDark() {
+    if (this._isDark) {
+      window.localStorage.removeItem('dark-theme');
+    } else {
+      window.localStorage.setItem('dark-theme', 'true');
+    }
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {message: RELOAD_MESSAGE},
+      bubbles: true,
+      composed: true,
+    }));
+    this.async(() => {
+      window.location.reload();
+    }, 1);
+  }
+
+  _showHttpAuth(config) {
+    if (config && config.auth &&
+        config.auth.git_basic_auth_policy) {
+      return HTTP_AUTH.includes(
+          config.auth.git_basic_auth_policy.toUpperCase());
+    }
+
+    return false;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapDarkToggle(e) {
+    e.preventDefault();
+  }
+}
+
+customElements.define(GrSettingsView.is, GrSettingsView);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
new file mode 100644
index 0000000..e92bc68
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
@@ -0,0 +1,537 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      color: var(--primary-text-color);
+    }
+    .newEmailInput {
+      width: 20em;
+    }
+    #email {
+      margin-bottom: var(--spacing-l);
+    }
+    main section.darkToggle {
+      display: block;
+    }
+    .filters p,
+    .darkToggle p {
+      margin-bottom: var(--spacing-l);
+    }
+    .queryExample em {
+      color: violet;
+    }
+    .toggle {
+      align-items: center;
+      display: flex;
+      margin-bottom: var(--spacing-l);
+      margin-right: var(--spacing-l);
+    }
+  </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">
+      <ul>
+        <li><a href="#Profile">Profile</a></li>
+        <li><a href="#Preferences">Preferences</a></li>
+        <li><a href="#DiffPreferences">Diff Preferences</a></li>
+        <li><a href="#EditPreferences">Edit Preferences</a></li>
+        <li><a href="#Menu">Menu</a></li>
+        <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
+        <li><a href="#Notifications">Notifications</a></li>
+        <li><a href="#EmailAddresses">Email Addresses</a></li>
+        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
+          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
+        </template>
+        <li hidden$="[[!_serverConfig.sshd]]">
+          <a href="#SSHKeys">
+            SSH Keys
+          </a>
+        </li>
+        <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
+          <a href="#GPGKeys">
+            GPG Keys
+          </a>
+        </li>
+        <li><a href="#Groups">Groups</a></li>
+        <li><a href="#Identities">Identities</a></li>
+        <template
+          is="dom-if"
+          if="[[_serverConfig.auth.use_contributor_agreements]]"
+        >
+          <li>
+            <a href="#Agreements">Agreements</a>
+          </li>
+        </template>
+        <li><a href="#MailFilters">Mail Filters</a></li>
+        <gr-endpoint-decorator name="settings-menu-item">
+        </gr-endpoint-decorator>
+      </ul>
+    </gr-page-nav>
+    <main class="gr-form-styles">
+      <h1>User Settings</h1>
+      <section class="darkToggle">
+        <div class="toggle">
+          <paper-toggle-button
+            checked="[[_isDark]]"
+            on-change="_handleToggleDark"
+            on-tap="_onTapDarkToggle"
+          ></paper-toggle-button>
+          <div>Dark theme (alpha)</div>
+        </div>
+        <p>
+          Gerrit's dark theme is in early alpha, and almost definitely will not
+          play nicely with themes set by specific Gerrit hosts. Filing feedback
+          via the link in the app footer is strongly encouraged!
+        </p>
+      </section>
+      <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
+        Profile
+      </h2>
+      <fieldset id="profile">
+        <gr-account-info
+          id="accountInfo"
+          has-unsaved-changes="{{_accountInfoChanged}}"
+        ></gr-account-info>
+        <gr-button
+          on-click="_handleSaveAccountInfo"
+          disabled="[[!_accountInfoChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
+        Preferences
+      </h2>
+      <fieldset id="preferences">
+        <section>
+          <span class="title">Changes per page</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.changes_per_page}}">
+              <select>
+                <option value="10">10 rows per page</option>
+                <option value="25">25 rows per page</option>
+                <option value="50">50 rows per page</option>
+                <option value="100">100 rows per page</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Date/time format</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.date_format}}">
+              <select>
+                <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                <option value="US">06/03 ; 06/03/16</option>
+                <option value="ISO">06-03 ; 2016-06-03</option>
+                <option value="EURO">3. Jun ; 03.06.2016</option>
+                <option value="UK">03/06 ; 03/06/2016</option>
+              </select>
+            </gr-select>
+            <gr-select bind-value="{{_localPrefs.time_format}}">
+              <select>
+                <option value="HHMM_12">4:10 PM</option>
+                <option value="HHMM_24">16:10</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Email notifications</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.email_strategy}}">
+              <select>
+                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                <option value="ENABLED">Only comments left by others</option>
+                <option value="DISABLED">None</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section hidden$="[[!_localPrefs.email_format]]">
+          <span class="title">Email format</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.email_format}}">
+              <select>
+                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                <option value="PLAINTEXT">Plaintext only</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section hidden$="[[!_localPrefs.default_base_for_merges]]">
+          <span class="title">Default Base For Merges</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.default_base_for_merges}}">
+              <select>
+                <option value="AUTO_MERGE">Auto Merge</option>
+                <option value="FIRST_PARENT">First Parent</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Show Relative Dates In Changes Table</span>
+          <span class="value">
+            <input
+              id="relativeDateInChangeTable"
+              type="checkbox"
+              checked$="[[_localPrefs.relative_date_in_change_table]]"
+              on-change="_handleRelativeDateInChangeTable"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title">Diff view</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.diff_view}}">
+              <select>
+                <option value="SIDE_BY_SIDE">Side by side</option>
+                <option value="UNIFIED_DIFF">Unified diff</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Show size bars in file list</span>
+          <span class="value">
+            <input
+              id="showSizeBarsInFileList"
+              type="checkbox"
+              checked$="[[_localPrefs.size_bar_in_change_table]]"
+              on-change="_handleShowSizeBarsInFileListChanged"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title">Publish comments on push</span>
+          <span class="value">
+            <input
+              id="publishCommentsOnPush"
+              type="checkbox"
+              checked$="[[_localPrefs.publish_comments_on_push]]"
+              on-change="_handlePublishCommentsOnPushChanged"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title"
+            >Set new changes to "work in progress" by default</span
+          >
+          <span class="value">
+            <input
+              id="workInProgressByDefault"
+              type="checkbox"
+              checked$="[[_localPrefs.work_in_progress_by_default]]"
+              on-change="_handleWorkInProgressByDefault"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title">
+            Insert Signed-off-by Footer For Inline Edit Changes
+          </span>
+          <span class="value">
+            <input
+              id="insertSignedOff"
+              type="checkbox"
+              checked$="[[_localPrefs.signed_off_by]]"
+              on-change="_handleInsertSignedOff"
+            />
+          </span>
+        </section>
+        <gr-button
+          id="savePrefs"
+          on-click="_handleSavePreferences"
+          disabled="[[!_prefsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2
+        id="DiffPreferences"
+        class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
+      >
+        Diff Preferences
+      </h2>
+      <fieldset id="diffPreferences">
+        <gr-diff-preferences
+          id="diffPrefs"
+          has-unsaved-changes="{{_diffPrefsChanged}}"
+        ></gr-diff-preferences>
+        <gr-button
+          id="saveDiffPrefs"
+          on-click="_handleSaveDiffPreferences"
+          disabled$="[[!_diffPrefsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2
+        id="EditPreferences"
+        class$="[[_computeHeaderClass(_editPrefsChanged)]]"
+      >
+        Edit Preferences
+      </h2>
+      <fieldset id="editPreferences">
+        <gr-edit-preferences
+          id="editPrefs"
+          has-unsaved-changes="{{_editPrefsChanged}}"
+        ></gr-edit-preferences>
+        <gr-button
+          id="saveEditPrefs"
+          on-click="_handleSaveEditPreferences"
+          disabled$="[[!_editPrefsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
+      <fieldset id="menu">
+        <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
+        <gr-button
+          id="saveMenu"
+          on-click="_handleSaveMenu"
+          disabled="[[!_menuChanged]]"
+          >Save changes</gr-button
+        >
+        <gr-button id="resetMenu" link="" on-click="_handleResetMenuButton"
+          >Reset</gr-button
+        >
+      </fieldset>
+      <h2
+        id="ChangeTableColumns"
+        class$="[[_computeHeaderClass(_changeTableChanged)]]"
+      >
+        Change Table Columns
+      </h2>
+      <fieldset id="changeTableColumns">
+        <gr-change-table-editor
+          show-number="{{_showNumber}}"
+          displayed-columns="{{_localChangeTableColumns}}"
+        >
+        </gr-change-table-editor>
+        <gr-button
+          id="saveChangeTable"
+          on-click="_handleSaveChangeTable"
+          disabled="[[!_changeTableChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2
+        id="Notifications"
+        class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
+      >
+        Notifications
+      </h2>
+      <fieldset id="watchedProjects">
+        <gr-watched-projects-editor
+          has-unsaved-changes="{{_watchedProjectsChanged}}"
+          id="watchedProjectsEditor"
+        ></gr-watched-projects-editor>
+        <gr-button
+          on-click="_handleSaveWatchedProjects"
+          disabled$="[[!_watchedProjectsChanged]]"
+          id="_handleSaveWatchedProjects"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
+        Email Addresses
+      </h2>
+      <fieldset id="email">
+        <gr-email-editor
+          id="emailEditor"
+          has-unsaved-changes="{{_emailsChanged}}"
+        ></gr-email-editor>
+        <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <fieldset id="newEmail">
+        <section>
+          <span class="title">New email address</span>
+          <span class="value">
+            <iron-input
+              class="newEmailInput"
+              bind-value="{{_newEmail}}"
+              type="text"
+              on-keydown="_handleNewEmailKeydown"
+              placeholder="email@example.com"
+            >
+              <input
+                class="newEmailInput"
+                bind-value="{{_newEmail}}"
+                is="iron-input"
+                type="text"
+                disabled="[[_addingEmail]]"
+                on-keydown="_handleNewEmailKeydown"
+                placeholder="email@example.com"
+              />
+            </iron-input>
+          </span>
+        </section>
+        <section
+          id="verificationSentMessage"
+          hidden$="[[!_lastSentVerificationEmail]]"
+        >
+          <p>
+            A verification email was sent to
+            <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
+          </p>
+        </section>
+        <gr-button
+          disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
+          on-click="_handleAddEmailButton"
+          >Send verification</gr-button
+        >
+      </fieldset>
+      <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
+        <div>
+          <h2 id="HTTPCredentials">HTTP Credentials</h2>
+          <fieldset>
+            <gr-http-password id="httpPass"></gr-http-password>
+          </fieldset>
+        </div>
+      </template>
+      <div hidden$="[[!_serverConfig.sshd]]">
+        <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
+          SSH keys
+        </h2>
+        <gr-ssh-editor
+          id="sshEditor"
+          has-unsaved-changes="{{_keysChanged}}"
+        ></gr-ssh-editor>
+      </div>
+      <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
+        <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
+          GPG keys
+        </h2>
+        <gr-gpg-editor
+          id="gpgEditor"
+          has-unsaved-changes="{{_gpgKeysChanged}}"
+        ></gr-gpg-editor>
+      </div>
+      <h2 id="Groups">Groups</h2>
+      <fieldset>
+        <gr-group-list id="groupList"></gr-group-list>
+      </fieldset>
+      <h2 id="Identities">Identities</h2>
+      <fieldset>
+        <gr-identities
+          id="identities"
+          server-config="[[_serverConfig]]"
+        ></gr-identities>
+      </fieldset>
+      <template
+        is="dom-if"
+        if="[[_serverConfig.auth.use_contributor_agreements]]"
+      >
+        <h2 id="Agreements">Agreements</h2>
+        <fieldset>
+          <gr-agreements-list id="agreementsList"></gr-agreements-list>
+        </fieldset>
+      </template>
+      <h2 id="MailFilters">Mail Filters</h2>
+      <fieldset class="filters">
+        <p>
+          Gerrit emails include metadata about the change to support writing
+          mail filters.
+        </p>
+        <p>
+          Here are some example Gmail queries that can be used for filters or
+          for searching through archived messages. View the
+          <a
+            href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
+            target="_blank"
+            rel="nofollow"
+            >Gerrit documentation</a
+          >
+          for the complete set of footers.
+        </p>
+        <table>
+          <tbody>
+            <tr>
+              <th>Name</th>
+              <th>Query</th>
+            </tr>
+            <tr>
+              <td>Changes requesting my review</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Reviewer: <em>Your Name</em>
+                  &lt;<em>your.email@example.com</em>&gt;"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Changes from a specific owner</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Owner: <em>Owner name</em>
+                  &lt;<em>owner.email@example.com</em>&gt;"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Changes targeting a specific branch</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Branch: <em>branch-name</em>"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Changes in a specific project</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Project: <em>project-name</em>"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Messages related to a specific Change ID</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Change-Id: <em>Change ID</em>"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Messages related to a specific change number</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Change-Number: <em>change number</em>"
+                </code>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </fieldset>
+      <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
+    </main>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..e430ecb 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-settings-view.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -40,486 +37,496 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-settings-view tests', () => {
-    let element;
-    let account;
-    let preferences;
-    let config;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-settings-view.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-settings-view tests', () => {
+  let element;
+  let account;
+  let preferences;
+  let config;
+  let sandbox;
 
-    function valueOf(title, fieldsetid) {
-      const sections = element.$[fieldsetid].querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent.trim() === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    // Because deepEqual isn't behaving in Safari.
-    function assertMenusEqual(actual, expected) {
-      assert.equal(actual.length, expected.length);
-      for (let i = 0; i < actual.length; i++) {
-        assert.equal(actual[i].name, expected[i].name);
-        assert.equal(actual[i].url, expected[i].url);
-      }
+  // Because deepEqual isn't behaving in Safari.
+  function assertMenusEqual(actual, expected) {
+    assert.equal(actual.length, expected.length);
+    for (let i = 0; i < actual.length; i++) {
+      assert.equal(actual[i].name, expected[i].name);
+      assert.equal(actual[i].url, expected[i].url);
     }
+  }
 
-    function stubAddAccountEmail(statusCode) {
-      return sandbox.stub(element.$.restAPI, 'addAccountEmail',
-          () => { return Promise.resolve({status: statusCode}); });
-    }
+  function stubAddAccountEmail(statusCode) {
+    return sandbox.stub(element.$.restAPI, 'addAccountEmail',
+        () => Promise.resolve({status: statusCode}));
+  }
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      account = {
-        _account_id: 123,
-        name: 'user name',
-        email: 'user@email',
-        username: 'user username',
-        registered: '2000-01-01 00:00:00.000000000',
-      };
-      preferences = {
-        changes_per_page: 25,
-        date_format: 'UK',
-        time_format: 'HHMM_12',
-        diff_view: 'UNIFIED_DIFF',
-        email_strategy: 'ENABLED',
-        email_format: 'HTML_PLAINTEXT',
-        default_base_for_merges: 'FIRST_PARENT',
-        relative_date_in_change_table: false,
-        size_bar_in_change_table: true,
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    account = {
+      _account_id: 123,
+      name: 'user name',
+      email: 'user@email',
+      username: 'user username',
+      registered: '2000-01-01 00:00:00.000000000',
+    };
+    preferences = {
+      changes_per_page: 25,
+      date_format: 'UK',
+      time_format: 'HHMM_12',
+      diff_view: 'UNIFIED_DIFF',
+      email_strategy: 'ENABLED',
+      email_format: 'HTML_PLAINTEXT',
+      default_base_for_merges: 'FIRST_PARENT',
+      relative_date_in_change_table: false,
+      size_bar_in_change_table: true,
 
-        my: [
-          {url: '/first/url', name: 'first name', target: '_blank'},
-          {url: '/second/url', name: 'second name', target: '_blank'},
-        ],
-        change_table: [],
-      };
-      config = {auth: {editable_account_fields: []}};
+      my: [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+      ],
+      change_table: [],
+    };
+    config = {auth: {editable_account_fields: []}};
 
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getAccount() { return Promise.resolve(account); },
-        getPreferences() { return Promise.resolve(preferences); },
-        getWatchedProjects() {
-          return Promise.resolve([]);
-        },
-        getAccountEmails() { return Promise.resolve(); },
-        getConfig() { return Promise.resolve(config); },
-        getAccountGroups() { return Promise.resolve([]); },
-      });
-      element = fixture('basic');
-
-      // Allow the element to render.
-      element._loadingPromise.then(done);
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getAccount() { return Promise.resolve(account); },
+      getPreferences() { return Promise.resolve(preferences); },
+      getWatchedProjects() {
+        return Promise.resolve([]);
+      },
+      getAccountEmails() { return Promise.resolve(); },
+      getConfig() { return Promise.resolve(config); },
+      getAccountGroups() { return Promise.resolve([]); },
     });
+    element = fixture('basic');
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    // Allow the element to render.
+    element._loadingPromise.then(done);
+  });
 
-    test('calls the title-change event', () => {
-      const titleChangedStub = sandbox.stub();
+  teardown(() => {
+    sandbox.restore();
+  });
 
-      // Create a new view.
-      const newElement = document.createElement('gr-settings-view');
-      newElement.addEventListener('title-change', titleChangedStub);
+  test('calls the title-change event', () => {
+    const titleChangedStub = sandbox.stub();
 
-      // Attach it to the fixture.
-      const blank = fixture('blank');
-      blank.appendChild(newElement);
+    // Create a new view.
+    const newElement = document.createElement('gr-settings-view');
+    newElement.addEventListener('title-change', titleChangedStub);
 
-      Polymer.dom.flush();
+    // Attach it to the fixture.
+    const blank = fixture('blank');
+    blank.appendChild(newElement);
 
-      assert.isTrue(titleChangedStub.called);
-      assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
-          'Settings');
-    });
+    flush();
 
-    test('user preferences', done => {
-      // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Changes per page', 'preferences')
-          .firstElementChild.bindValue, preferences.changes_per_page);
-      assert.equal(valueOf('Date/time format', 'preferences')
-          .firstElementChild.bindValue, preferences.date_format);
-      assert.equal(valueOf('Date/time format', 'preferences')
-          .lastElementChild.bindValue, preferences.time_format);
-      assert.equal(valueOf('Email notifications', 'preferences')
-          .firstElementChild.bindValue, preferences.email_strategy);
-      assert.equal(valueOf('Email format', 'preferences')
-          .firstElementChild.bindValue, preferences.email_format);
-      assert.equal(valueOf('Default Base For Merges', 'preferences')
-          .firstElementChild.bindValue, preferences.default_base_for_merges);
-      assert.equal(
-          valueOf('Show Relative Dates In Changes Table', 'preferences')
-              .firstElementChild.checked, false);
-      assert.equal(valueOf('Diff view', 'preferences')
-          .firstElementChild.bindValue, preferences.diff_view);
-      assert.equal(valueOf('Show size bars in file list', 'preferences')
-          .firstElementChild.checked, true);
-      assert.equal(valueOf('Publish comments on push', 'preferences')
-          .firstElementChild.checked, false);
-      assert.equal(valueOf(
-          'Set new changes to "work in progress" by default', 'preferences')
-          .firstElementChild.checked, false);
-      assert.equal(valueOf(
-          'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
-          .firstElementChild.checked, false);
+    assert.isTrue(titleChangedStub.called);
+    assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
+        'Settings');
+  });
 
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
+  test('user preferences', done => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Changes per page', 'preferences')
+        .firstElementChild.bindValue, preferences.changes_per_page);
+    assert.equal(valueOf('Date/time format', 'preferences')
+        .firstElementChild.bindValue, preferences.date_format);
+    assert.equal(valueOf('Date/time format', 'preferences')
+        .lastElementChild.bindValue, preferences.time_format);
+    assert.equal(valueOf('Email notifications', 'preferences')
+        .firstElementChild.bindValue, preferences.email_strategy);
+    assert.equal(valueOf('Email format', 'preferences')
+        .firstElementChild.bindValue, preferences.email_format);
+    assert.equal(valueOf('Default Base For Merges', 'preferences')
+        .firstElementChild.bindValue, preferences.default_base_for_merges);
+    assert.equal(
+        valueOf('Show Relative Dates In Changes Table', 'preferences')
+            .firstElementChild.checked, false);
+    assert.equal(valueOf('Diff view', 'preferences')
+        .firstElementChild.bindValue, preferences.diff_view);
+    assert.equal(valueOf('Show size bars in file list', 'preferences')
+        .firstElementChild.checked, true);
+    assert.equal(valueOf('Publish comments on push', 'preferences')
+        .firstElementChild.checked, false);
+    assert.equal(valueOf(
+        'Set new changes to "work in progress" by default', 'preferences')
+        .firstElementChild.checked, false);
+    assert.equal(valueOf(
+        'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
+        .firstElementChild.checked, false);
 
-      // Change the diff view element.
-      const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
-      diffSelect.bindValue = 'SIDE_BY_SIDE';
+    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
 
-      const publishOnPush =
-          valueOf('Publish comments on push', 'preferences').firstElementChild;
-      diffSelect.fire('change');
+    // Change the diff view element.
+    const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
+    diffSelect.bindValue = 'SIDE_BY_SIDE';
 
-      MockInteractions.tap(publishOnPush);
-
-      assert.isTrue(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
-          assertMenusEqual(prefs.my, preferences.my);
-          assert.equal(prefs.publish_comments_on_push, true);
-          return Promise.resolve();
-        },
-      });
-
-      // Save the change.
-      element._handleSavePreferences().then(() => {
-        assert.isFalse(element._prefsChanged);
-        assert.isFalse(element._menuChanged);
-        done();
-      });
-    });
-
-    test('publish comments on push', done => {
-      const publishCommentsOnPush =
+    const publishOnPush =
         valueOf('Publish comments on push', 'preferences').firstElementChild;
-      MockInteractions.tap(publishCommentsOnPush);
+    diffSelect.dispatchEvent(
+        new CustomEvent('change', {
+          composed: true, bubbles: true,
+        }));
 
-      assert.isFalse(element._menuChanged);
-      assert.isTrue(element._prefsChanged);
+    MockInteractions.tap(publishOnPush);
 
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assert.equal(prefs.publish_comments_on_push, true);
-          return Promise.resolve();
-        },
-      });
+    assert.isTrue(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
 
-      // Save the change.
-      element._handleSavePreferences().then(() => {
-        assert.isFalse(element._prefsChanged);
-        assert.isFalse(element._menuChanged);
-        done();
-      });
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
+        assertMenusEqual(prefs.my, preferences.my);
+        assert.equal(prefs.publish_comments_on_push, true);
+        return Promise.resolve();
+      },
     });
 
-    test('set new changes work-in-progress', done => {
-      const newChangesWorkInProgress =
-        valueOf('Set new changes to "work in progress" by default',
-            'preferences').firstElementChild;
-      MockInteractions.tap(newChangesWorkInProgress);
-
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
       assert.isFalse(element._menuChanged);
-      assert.isTrue(element._prefsChanged);
+      done();
+    });
+  });
 
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assert.equal(prefs.work_in_progress_by_default, true);
-          return Promise.resolve();
-        },
-      });
+  test('publish comments on push', done => {
+    const publishCommentsOnPush =
+      valueOf('Publish comments on push', 'preferences').firstElementChild;
+    MockInteractions.tap(publishCommentsOnPush);
 
-      // Save the change.
-      element._handleSavePreferences().then(() => {
-        assert.isFalse(element._prefsChanged);
-        assert.isFalse(element._menuChanged);
-        done();
-      });
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.publish_comments_on_push, true);
+        return Promise.resolve();
+      },
     });
 
-    test('menu', done => {
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+      done();
+    });
+  });
+
+  test('set new changes work-in-progress', done => {
+    const newChangesWorkInProgress =
+      valueOf('Set new changes to "work in progress" by default',
+          'preferences').firstElementChild;
+    MockInteractions.tap(newChangesWorkInProgress);
+
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.work_in_progress_by_default, true);
+        return Promise.resolve();
+      },
+    });
+
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+      done();
+    });
+  });
+
+  test('menu', done => {
+    assert.isFalse(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    assertMenusEqual(element._localMenu, preferences.my);
+
+    const menu = element.$.menu.firstElementChild;
+    let tableRows = dom(menu.root).querySelectorAll('tbody tr');
+    assert.equal(tableRows.length, preferences.my.length);
+
+    // Add a menu item:
+    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
+    flush();
+
+    tableRows = dom(menu.root).querySelectorAll('tbody tr');
+    assert.equal(tableRows.length, preferences.my.length + 1);
+
+    assert.isTrue(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assertMenusEqual(prefs.my, element._localMenu);
+        return Promise.resolve();
+      },
+    });
+
+    element._handleSaveMenu().then(() => {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
-
-      assertMenusEqual(element._localMenu, preferences.my);
-
-      const menu = element.$.menu.firstElementChild;
-      let tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
-      assert.equal(tableRows.length, preferences.my.length);
-
-      // Add a menu item:
-      element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-      Polymer.dom.flush();
-
-      tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
-      assert.equal(tableRows.length, preferences.my.length + 1);
-
-      assert.isTrue(element._menuChanged);
-      assert.isFalse(element._prefsChanged);
-
-      stub('gr-rest-api-interface', {
-        savePreferences(prefs) {
-          assertMenusEqual(prefs.my, element._localMenu);
-          return Promise.resolve();
-        },
-      });
-
-      element._handleSaveMenu().then(() => {
-        assert.isFalse(element._menuChanged);
-        assert.isFalse(element._prefsChanged);
-        assertMenusEqual(element.prefs.my, element._localMenu);
-        done();
-      });
+      assertMenusEqual(element.prefs.my, element._localMenu);
+      done();
     });
+  });
 
-    test('add email validation', () => {
-      assert.isFalse(element._isNewEmailValid('invalid email'));
-      assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+  test('add email validation', () => {
+    assert.isFalse(element._isNewEmailValid('invalid email'));
+    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
 
-      assert.isFalse(
-          element._computeAddEmailButtonEnabled('invalid email'), true);
-      assert.isFalse(
-          element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
-      assert.isTrue(
-          element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
+    assert.isFalse(
+        element._computeAddEmailButtonEnabled('invalid email'), true);
+    assert.isFalse(
+        element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
+    assert.isTrue(
+        element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
+  });
+
+  test('add email does not save invalid', () => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'invalid email';
+
+    element._handleAddEmailButton();
+
+    assert.isFalse(element._addingEmail);
+    assert.isFalse(addEmailStub.called);
+    assert.isNotOk(element._lastSentVerificationEmail);
+
+    assert.isFalse(addEmailStub.called);
+  });
+
+  test('add email does save valid', done => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(element._addingEmail);
+    assert.isTrue(addEmailStub.called);
+
+    assert.isTrue(addEmailStub.called);
+    addEmailStub.lastCall.returnValue.then(() => {
+      assert.isOk(element._lastSentVerificationEmail);
+      done();
     });
+  });
 
-    test('add email does not save invalid', () => {
-      const addEmailStub = stubAddAccountEmail(201);
+  test('add email does not set last-email if error', done => {
+    const addEmailStub = stubAddAccountEmail(500);
 
-      assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(addEmailStub.called);
+    addEmailStub.lastCall.returnValue.then(() => {
       assert.isNotOk(element._lastSentVerificationEmail);
-      element._newEmail = 'invalid email';
-
-      element._handleAddEmailButton();
-
-      assert.isFalse(element._addingEmail);
-      assert.isFalse(addEmailStub.called);
-      assert.isNotOk(element._lastSentVerificationEmail);
-
-      assert.isFalse(addEmailStub.called);
+      done();
     });
+  });
 
-    test('add email does save valid', done => {
-      const addEmailStub = stubAddAccountEmail(201);
+  test('emails are loaded without emailToken', () => {
+    sandbox.stub(element.$.emailEditor, 'loadData');
+    element.params = {};
+    element.attached();
+    assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+  });
 
-      assert.isFalse(element._addingEmail);
-      assert.isNotOk(element._lastSentVerificationEmail);
-      element._newEmail = 'valid@email.com';
+  test('_handleSaveChangeTable', () => {
+    let newColumns = ['Owner', 'Project', 'Branch'];
+    element._localChangeTableColumns = newColumns.slice(0);
+    element._showNumber = false;
+    const cloneStub = sandbox.stub(element, '_cloneChangeTableColumns');
+    element._handleSaveChangeTable();
+    assert.isTrue(cloneStub.calledOnce);
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isNotOk(element.prefs.legacycid_in_change_table);
 
-      element._handleAddEmailButton();
+    newColumns = ['Size'];
+    element._localChangeTableColumns = newColumns;
+    element._showNumber = true;
+    element._handleSaveChangeTable();
+    assert.isTrue(cloneStub.calledTwice);
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isTrue(element.prefs.legacycid_in_change_table);
+  });
 
-      assert.isTrue(element._addingEmail);
-      assert.isTrue(addEmailStub.called);
-
-      assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(() => {
-        assert.isOk(element._lastSentVerificationEmail);
-        done();
-      });
-    });
-
-    test('add email does not set last-email if error', done => {
-      const addEmailStub = stubAddAccountEmail(500);
-
-      assert.isNotOk(element._lastSentVerificationEmail);
-      element._newEmail = 'valid@email.com';
-
-      element._handleAddEmailButton();
-
-      assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(() => {
-        assert.isNotOk(element._lastSentVerificationEmail);
-        done();
-      });
-    });
-
-    test('emails are loaded without emailToken', () => {
-      sandbox.stub(element.$.emailEditor, 'loadData');
-      element.params = {};
-      element.attached();
-      assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-    });
-
-    test('_handleSaveChangeTable', () => {
-      let newColumns = ['Owner', 'Project', 'Branch'];
-      element._localChangeTableColumns = newColumns.slice(0);
-      element._showNumber = false;
-      const cloneStub = sandbox.stub(element, '_cloneChangeTableColumns');
-      element._handleSaveChangeTable();
-      assert.isTrue(cloneStub.calledOnce);
-      assert.deepEqual(element.prefs.change_table, newColumns);
-      assert.isNotOk(element.prefs.legacycid_in_change_table);
-
-      newColumns = ['Size'];
-      element._localChangeTableColumns = newColumns;
-      element._showNumber = true;
-      element._handleSaveChangeTable();
-      assert.isTrue(cloneStub.calledTwice);
-      assert.deepEqual(element.prefs.change_table, newColumns);
-      assert.isTrue(element.prefs.legacycid_in_change_table);
-    });
-
-    test('reset menu item back to default', done => {
-      const originalMenu = {
-        my: [
-          {url: '/first/url', name: 'first name', target: '_blank'},
-          {url: '/second/url', name: 'second name', target: '_blank'},
-          {url: '/third/url', name: 'third name', target: '_blank'},
-        ],
-      };
-
-      stub('gr-rest-api-interface', {
-        getDefaultPreferences() { return Promise.resolve(originalMenu); },
-      });
-
-      const updatedMenu = [
+  test('reset menu item back to default', done => {
+    const originalMenu = {
+      my: [
         {url: '/first/url', name: 'first name', target: '_blank'},
         {url: '/second/url', name: 'second name', target: '_blank'},
         {url: '/third/url', name: 'third name', target: '_blank'},
-        {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-      ];
+      ],
+    };
 
-      element.set('_localMenu', updatedMenu);
-
-      element._handleResetMenuButton().then(() => {
-        assertMenusEqual(element._localMenu, originalMenu.my);
-        done();
-      });
+    stub('gr-rest-api-interface', {
+      getDefaultPreferences() { return Promise.resolve(originalMenu); },
     });
 
-    test('test that reset button is called', () => {
-      const overlayOpen = sandbox.stub(element, '_handleResetMenuButton');
+    const updatedMenu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
+    ];
 
-      MockInteractions.tap(element.$.resetMenu);
+    element.set('_localMenu', updatedMenu);
 
-      assert.isTrue(overlayOpen.called);
-    });
-
-    test('_showHttpAuth', () => {
-      let serverConfig;
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP',
-        },
-      };
-
-      assert.isTrue(element._showHttpAuth(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'HTTP_LDAP',
-        },
-      };
-
-      assert.isTrue(element._showHttpAuth(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'LDAP',
-        },
-      };
-
-      assert.isFalse(element._showHttpAuth(serverConfig));
-
-      serverConfig = {
-        auth: {
-          git_basic_auth_policy: 'OAUTH',
-        },
-      };
-
-      assert.isFalse(element._showHttpAuth(serverConfig));
-
-      serverConfig = {};
-
-      assert.isFalse(element._showHttpAuth(serverConfig));
-    });
-
-    suite('_getFilterDocsLink', () => {
-      test('with http: docs base URL', () => {
-        const base = 'http://example.com/';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'http://example.com/user-notify.html');
-      });
-
-      test('with http: docs base URL without slash', () => {
-        const base = 'http://example.com';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'http://example.com/user-notify.html');
-      });
-
-      test('with https: docs base URL', () => {
-        const base = 'https://example.com/';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'https://example.com/user-notify.html');
-      });
-
-      test('without docs base URL', () => {
-        const result = element._getFilterDocsLink(null);
-        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-            'Documentation/user-notify.html');
-      });
-
-      test('ignores non HTTP links', () => {
-        const base = 'javascript://alert("evil");';
-        const result = element._getFilterDocsLink(base);
-        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-            'Documentation/user-notify.html');
-      });
-    });
-
-    suite('when email verification token is provided', () => {
-      let resolveConfirm;
-
-      setup(() => {
-        sandbox.stub(element.$.emailEditor, 'loadData');
-        sandbox.stub(element.$.restAPI, 'confirmEmail', () => {
-          return new Promise(resolve => { resolveConfirm = resolve; });
-        });
-        element.params = {emailToken: 'foo'};
-        element.attached();
-      });
-
-      test('it is used to confirm email via rest API', () => {
-        assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
-        assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
-      });
-
-      test('emails are not loaded initially', () => {
-        assert.isFalse(element.$.emailEditor.loadData.called);
-      });
-
-      test('user emails are loaded after email confirmed', done => {
-        element._loadingPromise.then(() => {
-          assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-          done();
-        });
-        resolveConfirm();
-      });
-
-      test('show-alert is fired when email is confirmed', done => {
-        sandbox.spy(element, 'fire');
-        element._loadingPromise.then(() => {
-          assert.isTrue(
-              element.fire.calledWith('show-alert', {message: 'bar'}));
-          done();
-        });
-        resolveConfirm('bar');
-      });
+    element._handleResetMenuButton().then(() => {
+      assertMenusEqual(element._localMenu, originalMenu.my);
+      done();
     });
   });
+
+  test('test that reset button is called', () => {
+    const overlayOpen = sandbox.stub(element, '_handleResetMenuButton');
+
+    MockInteractions.tap(element.$.resetMenu);
+
+    assert.isTrue(overlayOpen.called);
+  });
+
+  test('_showHttpAuth', () => {
+    let serverConfig;
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP',
+      },
+    };
+
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP_LDAP',
+      },
+    };
+
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    serverConfig = {};
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+  });
+
+  suite('_getFilterDocsLink', () => {
+    test('with http: docs base URL', () => {
+      const base = 'http://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with http: docs base URL without slash', () => {
+      const base = 'http://example.com';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with https: docs base URL', () => {
+      const base = 'https://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'https://example.com/user-notify.html');
+    });
+
+    test('without docs base URL', () => {
+      const result = element._getFilterDocsLink(null);
+      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html');
+    });
+
+    test('ignores non HTTP links', () => {
+      const base = 'javascript://alert("evil");';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html');
+    });
+  });
+
+  suite('when email verification token is provided', () => {
+    let resolveConfirm;
+
+    setup(() => {
+      sandbox.stub(element.$.emailEditor, 'loadData');
+      sandbox.stub(
+          element.$.restAPI,
+          'confirmEmail',
+          () => new Promise(resolve => { resolveConfirm = resolve; }));
+      element.params = {emailToken: 'foo'};
+      element.attached();
+    });
+
+    test('it is used to confirm email via rest API', () => {
+      assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
+      assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
+    });
+
+    test('emails are not loaded initially', () => {
+      assert.isFalse(element.$.emailEditor.loadData.called);
+    });
+
+    test('user emails are loaded after email confirmed', done => {
+      element._loadingPromise.then(() => {
+        assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+        done();
+      });
+      resolveConfirm();
+    });
+
+    test('show-alert is fired when email is confirmed', done => {
+      sandbox.spy(element, 'dispatchEvent');
+      element._loadingPromise.then(() => {
+        assert.equal(
+            element.dispatchEvent.lastCall.args[0].type, 'show-alert');
+        assert.deepEqual(
+            element.dispatchEvent.lastCall.args[0].detail, {message: 'bar'}
+        );
+        done();
+      });
+      resolveConfirm('bar');
+    });
+  });
+});
 </script>
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
deleted file mode 100644
index 2a27194..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ /dev/null
@@ -1,148 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.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="../../../styles/shared-styles.html">
-
-<dom-module id="gr-ssh-editor">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      .statusHeader {
-        width: 4em;
-      }
-      .keyHeader {
-        width: 7.5em;
-      }
-      #viewKeyOverlay {
-        padding: var(--spacing-xxl);
-        width: 50em;
-      }
-      .publicKey {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        overflow-x: scroll;
-        overflow-wrap: break-word;
-        width: 30em;
-      }
-      .closeButton {
-        bottom: 2em;
-        position: absolute;
-        right: 2em;
-      }
-      #existing {
-        margin-bottom: var(--spacing-l);
-      }
-      #existing .commentColumn {
-        min-width: 27em;
-        width: auto;
-      }
-    </style>
-    <div class="gr-form-styles">
-      <fieldset id="existing">
-        <table>
-          <thead>
-            <tr>
-              <th class="commentColumn">Comment</th>
-              <th class="statusHeader">Status</th>
-              <th class="keyHeader">Public key</th>
-              <th></th>
-              <th></th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[_keys]]" as="key">
-              <tr>
-                <td class="commentColumn">[[key.comment]]</td>
-                <td>[[_getStatusLabel(key.valid)]]</td>
-                <td>
-                  <gr-button
-                      link
-                      on-click="_showKey"
-                      data-index$="[[index]]"
-                      link>Click to View</gr-button>
-                </td>
-                <td>
-                  <gr-copy-clipboard
-                      has-tooltip
-                      button-title="Copy SSH public key to clipboard"
-                      hide-input
-                      text="[[key.ssh_public_key]]">
-                  </gr-copy-clipboard>
-                </td>
-                <td>
-                  <gr-button
-                      link
-                      data-index$="[[index]]"
-                      on-click="_handleDeleteKey">Delete</gr-button>
-                </td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-        <gr-overlay id="viewKeyOverlay" with-backdrop>
-          <fieldset>
-            <section>
-              <span class="title">Algorithm</span>
-              <span class="value">[[_keyToView.algorithm]]</span>
-            </section>
-            <section>
-              <span class="title">Public key</span>
-              <span class="value publicKey">[[_keyToView.encoded_key]]</span>
-            </section>
-            <section>
-              <span class="title">Comment</span>
-              <span class="value">[[_keyToView.comment]]</span>
-            </section>
-          </fieldset>
-          <gr-button
-              class="closeButton"
-              on-click="_closeOverlay">Close</gr-button>
-        </gr-overlay>
-        <gr-button
-            on-click="save"
-            disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
-      </fieldset>
-      <fieldset>
-        <section>
-          <span class="title">New SSH key</span>
-          <span class="value">
-            <iron-autogrow-textarea
-                id="newKey"
-                autocomplete="on"
-                bind-value="{{_newKey}}"
-                placeholder="New SSH Key"></iron-autogrow-textarea>
-          </span>
-        </section>
-        <gr-button
-            id="addButton"
-            link
-            disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-            on-click="_handleAddKey">Add new SSH key</gr-button>
-      </fieldset>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-ssh-editor.js"></script>
-</dom-module>
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..814eb7a 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
@@ -14,13 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-ssh-editor',
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/gr-form-styles.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
+import '../../shared/gr-overlay/gr-overlay.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-ssh-editor_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrSshEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-ssh-editor'; }
+
+  static get properties() {
+    return {
       hasUnsavedChanges: {
         type: Boolean,
         value: false,
@@ -37,64 +55,67 @@
         type: Array,
         value() { return []; },
       },
-    },
+    };
+  }
 
-    loadData() {
-      return this.$.restAPI.getAccountSSHKeys().then(keys => {
-        this._keys = keys;
-      });
-    },
+  loadData() {
+    return this.$.restAPI.getAccountSSHKeys().then(keys => {
+      this._keys = keys;
+    });
+  }
 
-    save() {
-      const promises = this._keysToRemove.map(key => {
-        this.$.restAPI.deleteAccountSSHKey(key.seq);
-      });
+  save() {
+    const promises = this._keysToRemove.map(key => {
+      this.$.restAPI.deleteAccountSSHKey(key.seq);
+    });
 
-      return Promise.all(promises).then(() => {
-        this._keysToRemove = [];
-        this.hasUnsavedChanges = false;
-      });
-    },
+    return Promise.all(promises).then(() => {
+      this._keysToRemove = [];
+      this.hasUnsavedChanges = false;
+    });
+  }
 
-    _getStatusLabel(isValid) {
-      return isValid ? 'Valid' : 'Invalid';
-    },
+  _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();
-    },
+  _showKey(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this._keyToView = this._keys[index];
+    this.$.viewKeyOverlay.open();
+  }
 
-    _closeOverlay() {
-      this.$.viewKeyOverlay.close();
-    },
+  _closeOverlay() {
+    this.$.viewKeyOverlay.close();
+  }
 
-    _handleDeleteKey(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      this.push('_keysToRemove', this._keys[index]);
-      this.splice('_keys', index, 1);
-      this.hasUnsavedChanges = true;
-    },
+  _handleDeleteKey(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    this.push('_keysToRemove', this._keys[index]);
+    this.splice('_keys', index, 1);
+    this.hasUnsavedChanges = true;
+  }
 
-    _handleAddKey() {
-      this.$.addButton.disabled = true;
-      this.$.newKey.disabled = true;
-      return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
-          .then(key => {
-            this.$.newKey.disabled = false;
-            this._newKey = '';
-            this.push('_keys', key);
-          }).catch(() => {
-            this.$.addButton.disabled = false;
-            this.$.newKey.disabled = false;
-          });
-    },
+  _handleAddKey() {
+    this.$.addButton.disabled = true;
+    this.$.newKey.disabled = true;
+    return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
+        .then(key => {
+          this.$.newKey.disabled = false;
+          this._newKey = '';
+          this.push('_keys', key);
+        })
+        .catch(() => {
+          this.$.addButton.disabled = false;
+          this.$.newKey.disabled = false;
+        });
+  }
 
-    _computeAddButtonDisabled(newKey) {
-      return !newKey.length;
-    },
-  });
-})();
+  _computeAddButtonDisabled(newKey) {
+    return !newKey.length;
+  }
+}
+
+customElements.define(GrSshEditor.is, GrSshEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
new file mode 100644
index 0000000..1f3c793
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+    }
+    .keyHeader {
+      width: 7.5em;
+    }
+    #viewKeyOverlay {
+      padding: var(--spacing-xxl);
+      width: 50em;
+    }
+    .publicKey {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      overflow-x: scroll;
+      overflow-wrap: break-word;
+      width: 30em;
+    }
+    .closeButton {
+      bottom: 2em;
+      position: absolute;
+      right: 2em;
+    }
+    #existing {
+      margin-bottom: var(--spacing-l);
+    }
+    #existing .commentColumn {
+      min-width: 27em;
+      width: auto;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset id="existing">
+      <table>
+        <thead>
+          <tr>
+            <th class="commentColumn">Comment</th>
+            <th class="statusHeader">Status</th>
+            <th class="keyHeader">Public key</th>
+            <th></th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_keys]]" as="key">
+            <tr>
+              <td class="commentColumn">[[key.comment]]</td>
+              <td>[[_getStatusLabel(key.valid)]]</td>
+              <td>
+                <gr-button link="" on-click="_showKey" data-index$="[[index]]"
+                  >Click to View</gr-button
+                >
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  has-tooltip=""
+                  button-title="Copy SSH public key to clipboard"
+                  hide-input=""
+                  text="[[key.ssh_public_key]]"
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button
+                  link=""
+                  data-index$="[[index]]"
+                  on-click="_handleDeleteKey"
+                  >Delete</gr-button
+                >
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+      <gr-overlay id="viewKeyOverlay" with-backdrop="">
+        <fieldset>
+          <section>
+            <span class="title">Algorithm</span>
+            <span class="value">[[_keyToView.algorithm]]</span>
+          </section>
+          <section>
+            <span class="title">Public key</span>
+            <span class="value publicKey">[[_keyToView.encoded_key]]</span>
+          </section>
+          <section>
+            <span class="title">Comment</span>
+            <span class="value">[[_keyToView.comment]]</span>
+          </section>
+        </fieldset>
+        <gr-button class="closeButton" on-click="_closeOverlay"
+          >Close</gr-button
+        >
+      </gr-overlay>
+      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
+        >Save changes</gr-button
+      >
+    </fieldset>
+    <fieldset>
+      <section>
+        <span class="title">New SSH key</span>
+        <span class="value">
+          <iron-autogrow-textarea
+            id="newKey"
+            autocomplete="on"
+            bind-value="{{_newKey}}"
+            placeholder="New SSH Key"
+          ></iron-autogrow-textarea>
+        </span>
+      </section>
+      <gr-button
+        id="addButton"
+        link=""
+        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
+        on-click="_handleAddKey"
+        >Add new SSH key</gr-button
+      >
+    </fieldset>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..56625ae 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-ssh-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,148 +31,151 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-ssh-editor tests', () => {
-    let element;
-    let keys;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-ssh-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-ssh-editor tests', () => {
+  let element;
+  let keys;
 
-    setup(done => {
-      keys = [{
-        seq: 1,
-        ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
-        encoded_key: '<key 1>',
-        algorithm: 'ssh-rsa',
-        comment: 'comment-one@machine-one',
-        valid: true,
-      }, {
-        seq: 2,
-        ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
-        encoded_key: '<key 2>',
-        algorithm: 'ssh-rsa',
-        comment: 'comment-two@machine-two',
-        valid: true,
-      }];
+  setup(done => {
+    keys = [{
+      seq: 1,
+      ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
+      encoded_key: '<key 1>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-one@machine-one',
+      valid: true,
+    }, {
+      seq: 2,
+      ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
+      encoded_key: '<key 2>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-two@machine-two',
+      valid: true,
+    }];
 
-      stub('gr-rest-api-interface', {
-        getAccountSSHKeys() { return Promise.resolve(keys); },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getAccountSSHKeys() { return Promise.resolve(keys); },
     });
 
-    test('renders', () => {
-      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    element = fixture('basic');
 
-      assert.equal(rows.length, 2);
+    element.loadData().then(() => { flush(done); });
+  });
 
-      let cells = rows[0].querySelectorAll('td');
-      assert.equal(cells[0].textContent, keys[0].comment);
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
 
-      cells = rows[1].querySelectorAll('td');
-      assert.equal(cells[0].textContent, keys[1].comment);
-    });
+    assert.equal(rows.length, 2);
 
-    test('remove key', done => {
-      const lastKey = keys[1];
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, keys[0].comment);
 
-      const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
-          () => { return Promise.resolve(); });
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, keys[1].comment);
+  });
 
+  test('remove key', done => {
+    const lastKey = keys[1];
+
+    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
+        () => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(5) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keys.length, 1);
+    assert.equal(element._keysToRemove.length, 1);
+    assert.equal(element._keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    element.save().then(() => {
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.lastCall.args[0], lastKey.seq);
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
-
-      // Get the delete button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(5) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keys.length, 1);
-      assert.equal(element._keysToRemove.length, 1);
-      assert.equal(element._keysToRemove[0], lastKey);
-      assert.isTrue(element.hasUnsavedChanges);
-      assert.isFalse(saveStub.called);
-
-      element.save().then(() => {
-        assert.isTrue(saveStub.called);
-        assert.equal(saveStub.lastCall.args[0], lastKey.seq);
-        assert.equal(element._keysToRemove.length, 0);
-        assert.isFalse(element.hasUnsavedChanges);
-        done();
-      });
-    });
-
-    test('show key', () => {
-      const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-      // Get the show button for the last row.
-      const button = Polymer.dom(element.root).querySelector(
-          'tbody tr:last-of-type td:nth-child(3) gr-button');
-
-      MockInteractions.tap(button);
-
-      assert.equal(element._keyToView, keys[1]);
-      assert.isTrue(openSpy.called);
-    });
-
-    test('add key', done => {
-      const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
-      const newKeyObject = {
-        seq: 3,
-        ssh_public_key: newKeyString,
-        encoded_key: '<key 3>',
-        algorithm: 'ssh-rsa',
-        comment: 'comment-three@machine-three',
-        valid: true,
-      };
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          () => { return Promise.resolve(newKeyObject); });
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isTrue(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 3);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.equal(addStub.lastCall.args[0], newKeyString);
-    });
-
-    test('add invalid key', done => {
-      const newKeyString = 'not even close to valid';
-
-      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          () => { return Promise.reject(new Error('error')); });
-
-      element._newKey = newKeyString;
-
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-
-      element._handleAddKey().then(() => {
-        assert.isFalse(element.$.addButton.disabled);
-        assert.isFalse(element.$.newKey.disabled);
-        assert.equal(element._keys.length, 2);
-        done();
-      });
-
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isTrue(element.$.newKey.disabled);
-
-      assert.isTrue(addStub.called);
-      assert.equal(addStub.lastCall.args[0], newKeyString);
+      done();
     });
   });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+    // Get the show button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(3) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keyToView, keys[1]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', done => {
+    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+    const newKeyObject = {
+      seq: 3,
+      ssh_public_key: newKeyString,
+      encoded_key: '<key 3>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-three@machine-three',
+      valid: true,
+    };
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+        () => Promise.resolve(newKeyObject));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 3);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+  });
+
+  test('add invalid key', done => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+        () => Promise.reject(new Error('error')));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+  });
+});
 </script>
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
deleted file mode 100644
index 360ea2d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
+++ /dev/null
@@ -1,127 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/gr-form-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-watched-projects-editor">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles">
-      #watchedProjects .notifType {
-        text-align: center;
-        padding: 0 var(--spacing-s);
-      }
-      .notifControl {
-        cursor: pointer;
-        text-align: center;
-      }
-      .notifControl:hover {
-        outline: 1px solid var(--border-color);
-      }
-      .projectFilter {
-        color: var(--deemphasized-text-color);
-        font-style: italic;
-        margin-left: var(--spacing-l);
-      }
-      .newFilterInput {
-        width: 100%;
-      }
-    </style>
-    <div class="gr-form-styles">
-      <table id="watchedProjects">
-        <thead>
-          <tr>
-            <th>Repo</th>
-            <template is="dom-repeat" items="[[_getTypes()]]">
-              <th class="notifType">[[item.name]]</th>
-            </template>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template
-              is="dom-repeat"
-              items="[[_projects]]"
-              as="project"
-              index-as="projectIndex">
-            <tr>
-              <td>
-                [[project.project]]
-                <template is="dom-if" if="[[project.filter]]">
-                  <div class="projectFilter">[[project.filter]]</div>
-                </template>
-              </td>
-              <template
-                  is="dom-repeat"
-                  items="[[_getTypes()]]"
-                  as="type">
-                <td class="notifControl" on-click="_handleNotifCellClick">
-                  <input
-                      type="checkbox"
-                      data-index$="[[projectIndex]]"
-                      data-key$="[[type.key]]"
-                      on-change="_handleCheckboxChange"
-                      checked$="[[_computeCheckboxChecked(project, type.key)]]">
-                </td>
-              </template>
-              <td>
-                <gr-button
-                    link
-                    data-index$="[[projectIndex]]"
-                    on-click="_handleRemoveProject">Delete</gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-        <tfoot>
-          <tr>
-            <th>
-              <gr-autocomplete
-                  id="newProject"
-                  query="[[_query]]"
-                  threshold="1"
-                  allow-non-suggested-values
-                  tab-complete
-                  placeholder="Repo"></gr-autocomplete>
-            </th>
-            <th colspan$="[[_getTypeCount()]]">
-              <iron-input
-                  class="newFilterInput"
-                  placeholder="branch:name, or other search expression">
-                <input
-                    id="newFilter"
-                    class="newFilterInput"
-                    is="iron-input"
-                    placeholder="branch:name, or other search expression">
-              </iron-input>
-            </th>
-            <th>
-              <gr-button link on-click="_handleAddProject">Add</gr-button>
-            </th>
-          </tr>
-        </tfoot>
-      </table>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-watched-projects-editor.js"></script>
-</dom-module>
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..b8960e8 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
@@ -14,21 +14,38 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const NOTIFICATION_TYPES = [
-    {name: 'Changes', key: 'notify_new_changes'},
-    {name: 'Patches', key: 'notify_new_patch_sets'},
-    {name: 'Comments', key: 'notify_all_comments'},
-    {name: 'Submits', key: 'notify_submitted_changes'},
-    {name: 'Abandons', key: 'notify_abandoned_changes'},
-  ];
+import '@polymer/iron-input/iron-input.js';
+import '../../shared/gr-autocomplete/gr-autocomplete.js';
+import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/gr-form-styles.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-watched-projects-editor_html.js';
 
-  Polymer({
-    is: 'gr-watched-projects-editor',
+const NOTIFICATION_TYPES = [
+  {name: 'Changes', key: 'notify_new_changes'},
+  {name: 'Patches', key: 'notify_new_patch_sets'},
+  {name: 'Comments', key: 'notify_all_comments'},
+  {name: 'Submits', key: 'notify_submitted_changes'},
+  {name: 'Abandons', key: 'notify_abandoned_changes'},
+];
 
-    properties: {
+/** @extends Polymer.Element */
+class GrWatchedProjectsEditor extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-watched-projects-editor'; }
+
+  static get properties() {
+    return {
       hasUnsavedChanges: {
         type: Boolean,
         value: false,
@@ -46,132 +63,132 @@
           return this._getProjectSuggestions.bind(this);
         },
       },
-    },
+    };
+  }
 
-    loadData() {
-      return this.$.restAPI.getWatchedProjects().then(projs => {
-        this._projects = projs;
-      });
-    },
+  loadData() {
+    return this.$.restAPI.getWatchedProjects().then(projs => {
+      this._projects = projs;
+    });
+  }
 
-    save() {
-      let deletePromise;
-      if (this._projectsToRemove.length) {
-        deletePromise = this.$.restAPI.deleteWatchedProjects(
-            this._projectsToRemove);
-      } else {
-        deletePromise = Promise.resolve();
+  save() {
+    let deletePromise;
+    if (this._projectsToRemove.length) {
+      deletePromise = this.$.restAPI.deleteWatchedProjects(
+          this._projectsToRemove);
+    } else {
+      deletePromise = Promise.resolve();
+    }
+
+    return deletePromise
+        .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)
+        .then(response => {
+          const projects = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            projects.push({
+              name: key,
+              value: response[key],
+            });
+          }
+          return projects;
+        });
+  }
+
+  _handleRemoveProject(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    const project = this._projects[index];
+    this.splice('_projects', index, 1);
+    this.push('_projectsToRemove', project);
+    this.hasUnsavedChanges = true;
+  }
+
+  _canAddProject(project, text, filter) {
+    if ((!project || !project.id) && !text) { return false; }
+
+    // This will only be used if not using the auto complete
+    if (!project && text) { return true; }
+
+    // Check if the project with filter is already in the list. Compare
+    // filters using == to coalesce null and undefined.
+    for (let i = 0; i < this._projects.length; i++) {
+      if (this._projects[i].project === project.id &&
+          this._projects[i].filter == filter) {
+        return false;
       }
+    }
 
-      return deletePromise
-          .then(() => {
-            return this.$.restAPI.saveWatchedProjects(this._projects);
-          })
-          .then(projects => {
-            this._projects = projects;
-            this._projectsToRemove = [];
-            this.hasUnsavedChanges = false;
-          });
-    },
+    return true;
+  }
 
-    _getTypes() {
-      return NOTIFICATION_TYPES;
-    },
-
-    _getTypeCount() {
-      return this._getTypes().length;
-    },
-
-    _computeCheckboxChecked(project, key) {
-      return project.hasOwnProperty(key);
-    },
-
-    _getProjectSuggestions(input) {
-      return this.$.restAPI.getSuggestedProjects(input)
-          .then(response => {
-            const projects = [];
-            for (const key in response) {
-              if (!response.hasOwnProperty(key)) { continue; }
-              projects.push({
-                name: key,
-                value: response[key],
-              });
-            }
-            return projects;
-          });
-    },
-
-    _handleRemoveProject(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      const project = this._projects[index];
-      this.splice('_projects', index, 1);
-      this.push('_projectsToRemove', project);
-      this.hasUnsavedChanges = true;
-    },
-
-    _canAddProject(project, text, filter) {
-      if ((!project || !project.id) && !text) { return false; }
-
-      // This will only be used if not using the auto complete
-      if (!project && text) { return true; }
-
-      // Check if the project with filter is already in the list. Compare
-      // filters using == to coalesce null and undefined.
-      for (let i = 0; i < this._projects.length; i++) {
-        if (this._projects[i].project === project.id &&
-            this._projects[i].filter == filter) {
-          return false;
-        }
+  _getNewProjectIndex(name, filter) {
+    let i;
+    for (i = 0; i < this._projects.length; i++) {
+      if (this._projects[i].project > name ||
+          (this._projects[i].project === name &&
+              this._projects[i].filter > filter)) {
+        break;
       }
+    }
+    return i;
+  }
 
-      return true;
-    },
+  _handleAddProject() {
+    const newProject = this.$.newProject.value;
+    const newProjectName = this.$.newProject.text;
+    const filter = this.$.newFilter.value || null;
 
-    _getNewProjectIndex(name, filter) {
-      let i;
-      for (i = 0; i < this._projects.length; i++) {
-        if (this._projects[i].project > name ||
-            (this._projects[i].project === name &&
-                this._projects[i].filter > filter)) {
-          break;
-        }
-      }
-      return i;
-    },
+    if (!this._canAddProject(newProject, newProjectName, filter)) { return; }
 
-    _handleAddProject() {
-      const newProject = this.$.newProject.value;
-      const newProjectName = this.$.newProject.text;
-      const filter = this.$.newFilter.value || null;
+    const insertIndex = this._getNewProjectIndex(newProjectName, filter);
 
-      if (!this._canAddProject(newProject, newProjectName, filter)) { return; }
+    this.splice('_projects', insertIndex, 0, {
+      project: newProjectName,
+      filter,
+      _is_local: true,
+    });
 
-      const insertIndex = this._getNewProjectIndex(newProjectName, filter);
+    this.$.newProject.clear();
+    this.$.newFilter.bindValue = '';
+    this.hasUnsavedChanges = true;
+  }
 
-      this.splice('_projects', insertIndex, 0, {
-        project: newProjectName,
-        filter,
-        _is_local: true,
-      });
+  _handleCheckboxChange(e) {
+    const el = dom(e).localTarget;
+    const index = parseInt(el.getAttribute('data-index'), 10);
+    const key = el.getAttribute('data-key');
+    const checked = el.checked;
+    this.set(['_projects', index, key], !!checked);
+    this.hasUnsavedChanges = true;
+  }
 
-      this.$.newProject.clear();
-      this.$.newFilter.bindValue = '';
-      this.hasUnsavedChanges = true;
-    },
+  _handleNotifCellClick(e) {
+    const checkbox = dom(e.target).querySelector('input');
+    if (checkbox) { checkbox.click(); }
+  }
+}
 
-    _handleCheckboxChange(e) {
-      const el = Polymer.dom(e).localTarget;
-      const index = parseInt(el.getAttribute('data-index'), 10);
-      const key = el.getAttribute('data-key');
-      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_html.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
new file mode 100644
index 0000000..b1ca653
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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;
+      padding: 0 var(--spacing-s);
+    }
+    .notifControl {
+      cursor: pointer;
+      text-align: center;
+    }
+    .notifControl:hover {
+      outline: 1px solid var(--border-color);
+    }
+    .projectFilter {
+      color: var(--deemphasized-text-color);
+      font-style: italic;
+      margin-left: var(--spacing-l);
+    }
+    .newFilterInput {
+      width: 100%;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="watchedProjects">
+      <thead>
+        <tr>
+          <th>Repo</th>
+          <template is="dom-repeat" items="[[_getTypes()]]">
+            <th class="notifType">[[item.name]]</th>
+          </template>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        <template
+          is="dom-repeat"
+          items="[[_projects]]"
+          as="project"
+          index-as="projectIndex"
+        >
+          <tr>
+            <td>
+              [[project.project]]
+              <template is="dom-if" if="[[project.filter]]">
+                <div class="projectFilter">[[project.filter]]</div>
+              </template>
+            </td>
+            <template is="dom-repeat" items="[[_getTypes()]]" as="type">
+              <td class="notifControl" on-click="_handleNotifCellClick">
+                <input
+                  type="checkbox"
+                  data-index$="[[projectIndex]]"
+                  data-key$="[[type.key]]"
+                  on-change="_handleCheckboxChange"
+                  checked$="[[_computeCheckboxChecked(project, type.key)]]"
+                />
+              </td>
+            </template>
+            <td>
+              <gr-button
+                link=""
+                data-index$="[[projectIndex]]"
+                on-click="_handleRemoveProject"
+                >Delete</gr-button
+              >
+            </td>
+          </tr>
+        </template>
+      </tbody>
+      <tfoot>
+        <tr>
+          <th>
+            <gr-autocomplete
+              id="newProject"
+              query="[[_query]]"
+              threshold="1"
+              allow-non-suggested-values=""
+              tab-complete=""
+              placeholder="Repo"
+            ></gr-autocomplete>
+          </th>
+          <th colspan$="[[_getTypeCount()]]">
+            <iron-input
+              class="newFilterInput"
+              placeholder="branch:name, or other search expression"
+            >
+              <input
+                id="newFilter"
+                class="newFilterInput"
+                is="iron-input"
+                placeholder="branch:name, or other search expression"
+              />
+            </iron-input>
+          </th>
+          <th>
+            <gr-button link="" on-click="_handleAddProject">Add</gr-button>
+          </th>
+        </tr>
+      </tfoot>
+    </table>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..2a08e4f 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-watched-projects-editor.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,180 +31,185 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-watched-projects-editor tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-watched-projects-editor.js';
+suite('gr-watched-projects-editor tests', () => {
+  let element;
 
-    setup(done => {
-      const projects = [
-        {
-          project: 'project a',
-          notify_submitted_changes: true,
-          notify_abandoned_changes: true,
-        }, {
-          project: 'project b',
-          filter: 'filter 1',
-          notify_new_changes: true,
-        }, {
-          project: 'project b',
-          filter: 'filter 2',
-        }, {
-          project: 'project c',
-          notify_new_changes: true,
-          notify_new_patch_sets: true,
-          notify_all_comments: true,
-        },
-      ];
+  setup(done => {
+    const projects = [
+      {
+        project: 'project a',
+        notify_submitted_changes: true,
+        notify_abandoned_changes: true,
+      }, {
+        project: 'project b',
+        filter: 'filter 1',
+        notify_new_changes: true,
+      }, {
+        project: 'project b',
+        filter: 'filter 2',
+      }, {
+        project: 'project c',
+        notify_new_changes: true,
+        notify_new_patch_sets: true,
+        notify_all_comments: true,
+      },
+    ];
 
-      stub('gr-rest-api-interface', {
-        getSuggestedProjects(input) {
-          if (input.startsWith('th')) {
-            return Promise.resolve({'the project': {
-              id: 'the project',
-              state: 'ACTIVE',
-              web_links: [],
-            }});
-          } else {
-            return Promise.resolve({});
-          }
-        },
-        getWatchedProjects() {
-          return Promise.resolve(projects);
-        },
-      });
-
-      element = fixture('basic');
-
-      element.loadData().then(() => { flush(done); });
+    stub('gr-rest-api-interface', {
+      getSuggestedProjects(input) {
+        if (input.startsWith('th')) {
+          return Promise.resolve({'the project': {
+            id: 'the project',
+            state: 'ACTIVE',
+            web_links: [],
+          }});
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getWatchedProjects() {
+        return Promise.resolve(projects);
+      },
     });
 
-    test('renders', () => {
-      const rows = element.$$('table').querySelectorAll('tbody tr');
-      assert.equal(rows.length, 4);
+    element = fixture('basic');
 
-      function getKeysOfRow(row) {
-        const boxes = rows[row].querySelectorAll('input[checked]');
-        return Array.prototype.map.call(boxes,
-            e => { return e.getAttribute('data-key'); });
-      }
+    element.loadData().then(() => { flush(done); });
+  });
 
-      let checkedKeys = getKeysOfRow(0);
-      assert.equal(checkedKeys.length, 2);
-      assert.equal(checkedKeys[0], 'notify_submitted_changes');
-      assert.equal(checkedKeys[1], 'notify_abandoned_changes');
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
+    assert.equal(rows.length, 4);
 
-      checkedKeys = getKeysOfRow(1);
-      assert.equal(checkedKeys.length, 1);
-      assert.equal(checkedKeys[0], 'notify_new_changes');
+    function getKeysOfRow(row) {
+      const boxes = rows[row].querySelectorAll('input[checked]');
+      return Array.prototype.map.call(boxes,
+          e => e.getAttribute('data-key'));
+    }
 
-      checkedKeys = getKeysOfRow(2);
-      assert.equal(checkedKeys.length, 0);
+    let checkedKeys = getKeysOfRow(0);
+    assert.equal(checkedKeys.length, 2);
+    assert.equal(checkedKeys[0], 'notify_submitted_changes');
+    assert.equal(checkedKeys[1], 'notify_abandoned_changes');
 
-      checkedKeys = getKeysOfRow(3);
-      assert.equal(checkedKeys.length, 3);
-      assert.equal(checkedKeys[0], 'notify_new_changes');
-      assert.equal(checkedKeys[1], 'notify_new_patch_sets');
-      assert.equal(checkedKeys[2], 'notify_all_comments');
-    });
+    checkedKeys = getKeysOfRow(1);
+    assert.equal(checkedKeys.length, 1);
+    assert.equal(checkedKeys[0], 'notify_new_changes');
 
-    test('_getProjectSuggestions empty', done => {
-      element._getProjectSuggestions('nonexistent').then(projects => {
-        assert.equal(projects.length, 0);
-        done();
-      });
-    });
+    checkedKeys = getKeysOfRow(2);
+    assert.equal(checkedKeys.length, 0);
 
-    test('_getProjectSuggestions non-empty', done => {
-      element._getProjectSuggestions('the project').then(projects => {
-        assert.equal(projects.length, 1);
-        assert.equal(projects[0].name, 'the project');
-        done();
-      });
-    });
+    checkedKeys = getKeysOfRow(3);
+    assert.equal(checkedKeys.length, 3);
+    assert.equal(checkedKeys[0], 'notify_new_changes');
+    assert.equal(checkedKeys[1], 'notify_new_patch_sets');
+    assert.equal(checkedKeys[2], 'notify_all_comments');
+  });
 
-    test('_getProjectSuggestions non-empty with two letter project', done => {
-      element._getProjectSuggestions('th').then(projects => {
-        assert.equal(projects.length, 1);
-        assert.equal(projects[0].name, 'the project');
-        done();
-      });
-    });
-
-    test('_canAddProject', () => {
-      assert.isFalse(element._canAddProject(null, null, null));
-      assert.isFalse(element._canAddProject({}, null, null));
-
-      // Can add a project that is not in the list.
-      assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
-      assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
-
-      // Cannot add a project that is in the list with no filter.
-      assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
-
-      // Can add a project that is in the list if the filter differs.
-      assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
-
-      // Cannot add a project that is in the list with the same filter.
-      assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
-      assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
-
-      // Can add a project that is in the list using a new filter.
-      assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
-
-      // Can add a project that is not added by the auto complete
-      assert.isTrue(element._canAddProject(null, 'test', null));
-    });
-
-    test('_getNewProjectIndex', () => {
-      // Projects are sorted in ASCII order.
-      assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
-      assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
-
-      // Projects are sorted by filter when the names are equal
-      assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
-      assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
-      assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
-
-      // Projects with filters follow those without
-      assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
-    });
-
-    test('_handleAddProject', () => {
-      element.$.newProject.value = {id: 'project d'};
-      element.$.newProject.setText('project d');
-      element.$.newFilter.bindValue = '';
-
-      element._handleAddProject();
-
-      assert.equal(element._projects.length, 5);
-      assert.equal(element._projects[4].project, 'project d');
-      assert.isNotOk(element._projects[4].filter);
-      assert.isTrue(element._projects[4]._is_local);
-    });
-
-    test('_handleAddProject with invalid inputs', () => {
-      element.$.newProject.value = {id: 'project b'};
-      element.$.newProject.setText('project b');
-      element.$.newFilter.bindValue = 'filter 1';
-      element.$.newFilter.value = 'filter 1';
-
-      element._handleAddProject();
-
-      assert.equal(element._projects.length, 4);
-    });
-
-    test('_handleRemoveProject', () => {
-      assert.equal(element._projectsToRemove, 0);
-      const button = element.$$('table tbody tr:nth-child(2) gr-button');
-      MockInteractions.tap(button);
-
-      flushAsynchronousOperations();
-
-      const rows = element.$$('table tbody').querySelectorAll('tr');
-      assert.equal(rows.length, 3);
-
-      assert.equal(element._projectsToRemove.length, 1);
-      assert.equal(element._projectsToRemove[0].project, 'project b');
+  test('_getProjectSuggestions empty', done => {
+    element._getProjectSuggestions('nonexistent').then(projects => {
+      assert.equal(projects.length, 0);
+      done();
     });
   });
+
+  test('_getProjectSuggestions non-empty', done => {
+    element._getProjectSuggestions('the project').then(projects => {
+      assert.equal(projects.length, 1);
+      assert.equal(projects[0].name, 'the project');
+      done();
+    });
+  });
+
+  test('_getProjectSuggestions non-empty with two letter project', done => {
+    element._getProjectSuggestions('th').then(projects => {
+      assert.equal(projects.length, 1);
+      assert.equal(projects[0].name, 'the project');
+      done();
+    });
+  });
+
+  test('_canAddProject', () => {
+    assert.isFalse(element._canAddProject(null, null, null));
+    assert.isFalse(element._canAddProject({}, null, null));
+
+    // Can add a project that is not in the list.
+    assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
+    assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
+
+    // Cannot add a project that is in the list with no filter.
+    assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
+
+    // Can add a project that is in the list if the filter differs.
+    assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
+
+    // Cannot add a project that is in the list with the same filter.
+    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
+    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
+
+    // Can add a project that is in the list using a new filter.
+    assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
+
+    // Can add a project that is not added by the auto complete
+    assert.isTrue(element._canAddProject(null, 'test', null));
+  });
+
+  test('_getNewProjectIndex', () => {
+    // Projects are sorted in ASCII order.
+    assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
+    assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+
+    // Projects are sorted by filter when the names are equal
+    assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
+    assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
+    assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+
+    // Projects with filters follow those without
+    assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+  });
+
+  test('_handleAddProject', () => {
+    element.$.newProject.value = {id: 'project d'};
+    element.$.newProject.setText('project d');
+    element.$.newFilter.bindValue = '';
+
+    element._handleAddProject();
+
+    assert.equal(element._projects.length, 5);
+    assert.equal(element._projects[4].project, 'project d');
+    assert.isNotOk(element._projects[4].filter);
+    assert.isTrue(element._projects[4]._is_local);
+  });
+
+  test('_handleAddProject with invalid inputs', () => {
+    element.$.newProject.value = {id: 'project b'};
+    element.$.newProject.setText('project b');
+    element.$.newFilter.bindValue = 'filter 1';
+    element.$.newFilter.value = 'filter 1';
+
+    element._handleAddProject();
+
+    assert.equal(element._projects.length, 4);
+  });
+
+  test('_handleRemoveProject', () => {
+    assert.equal(element._projectsToRemove, 0);
+    const button = element.shadowRoot
+        .querySelector('table tbody tr:nth-child(2) gr-button');
+    MockInteractions.tap(button);
+
+    flushAsynchronousOperations();
+
+    const rows = element.shadowRoot
+        .querySelector('table tbody').querySelectorAll('tr');
+    assert.equal(rows.length, 3);
+
+    assert.equal(element._projectsToRemove.length, 1);
+    assert.equal(element._projectsToRemove[0].project, 'project b');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
deleted file mode 100644
index 5a095a4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ /dev/null
@@ -1,110 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-account-link/gr-account-link.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-icons/gr-icons.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-account-chip">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        overflow: hidden;
-      }
-      .container {
-        align-items: center;
-        background: var(--chip-background-color);
-        border-radius: .75em;
-        display: inline-flex;
-        padding: 0 var(--spacing-m);
-      }
-      :host([show-avatar]) .container {
-        padding-left: 0;
-      }
-      gr-button.remove {
-        --gr-remove-button-style: {
-          border: 0;
-          color: var(--deemphasized-text-color);
-          font-weight: normal;
-          height: .6em;
-          line-height: 10px;
-          margin-left: var(--spacing-xs);
-          padding: 0;
-          text-decoration: none;
-        }
-      }
-
-      gr-button.remove:hover,
-      gr-button.remove:focus {
-        --gr-button: {
-          @apply --gr-remove-button-style;
-          color: #333;
-        }
-      }
-      gr-button.remove {
-        --gr-button: {
-          @apply --gr-remove-button-style;
-        }
-      }
-      :host:focus {
-        border-color: transparent;
-        box-shadow: none;
-        outline: none;
-      }
-      :host:focus .container,
-      :host:focus gr-button {
-        background: #ccc;
-      }
-      .transparentBackground,
-      gr-button.transparentBackground {
-        background-color: transparent;
-        padding: 0;
-      }
-      :host([disabled]) {
-        opacity: .6;
-        pointer-events: none;
-      }
-      iron-icon {
-        height: 1.2rem;
-        width: 1.2rem;
-      }
-    </style>
-    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-      <gr-account-link account="[[account]]"
-          additional-text="[[additionalText]]">
-      </gr-account-link>
-      <gr-button
-          id="remove"
-          link
-          hidden$="[[!removable]]"
-          hidden
-          tabindex="-1"
-          aria-label="Remove"
-          class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-click="_handleRemoveTap">
-        <iron-icon icon="gr-icons:close"></iron-icon>
-      </gr-button>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-account-chip.js"></script>
-</dom-module>
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..6ceee26 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,29 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../scripts/bundled-polymer.js';
 
-(function() {
-  'use strict';
+import '../gr-account-link/gr-account-link.js';
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-chip_html.js';
 
-  Polymer({
-    is: 'gr-account-chip',
+/**
+ * @extends Polymer.Element
+ */
+class GrAccountChip extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired to indicate a key was pressed while this chip was focused.
-     *
-     * @event account-chip-keydown
-     */
+  static get is() { return 'gr-account-chip'; }
+  /**
+   * Fired to indicate a key was pressed while this chip was focused.
+   *
+   * @event account-chip-keydown
+   */
 
-    /**
-     * Fired to indicate this chip should be removed, i.e. when the x button is
-     * clicked or when the remove function is called.
-     *
-     * @event remove
-     */
+  /**
+   * Fired to indicate this chip should be removed, i.e. when the x button is
+   * clicked or when the remove function is called.
+   *
+   * @event remove
+   */
 
-    properties: {
+  static get properties() {
+    return {
       account: Object,
-      additionalText: String,
+      voteableText: String,
       disabled: {
         type: Boolean,
         value: false,
@@ -46,6 +60,10 @@
         type: Boolean,
         value: false,
       },
+      showAttention: {
+        type: Boolean,
+        value: false,
+      },
       showAvatar: {
         type: Boolean,
         reflectToAttribute: true,
@@ -54,31 +72,35 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  /** @override */
+  ready() {
+    super.ready();
+    this._getHasAvatars().then(hasAvatars => {
+      this.showAvatar = hasAvatars;
+    });
+  }
 
-    ready() {
-      this._getHasAvatars().then(hasAvatars => {
-        this.showAvatar = hasAvatars;
-      });
-    },
+  _getBackgroundClass(transparent) {
+    return transparent ? 'transparentBackground' : '';
+  }
 
-    _getBackgroundClass(transparent) {
-      return transparent ? 'transparentBackground' : '';
-    },
+  _handleRemoveTap(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('remove', {
+      detail: {account: this.account},
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _handleRemoveTap(e) {
-      e.preventDefault();
-      this.fire('remove', {account: this.account});
-    },
+  _getHasAvatars() {
+    return this.$.restAPI.getConfig()
+        .then(cfg => Promise.resolve(!!(
+          cfg && cfg.plugin && cfg.plugin.has_avatars
+        )));
+  }
+}
 
-    _getHasAvatars() {
-      return this.$.restAPI.getConfig().then(cfg => {
-        return Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars));
-      });
-    },
-  });
-})();
+customElements.define(GrAccountChip.is, GrAccountChip);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
new file mode 100644
index 0000000..96e0160
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      overflow: hidden;
+    }
+    .container {
+      align-items: center;
+      background: var(--chip-background-color);
+      border-radius: 0.75em;
+      display: inline-flex;
+      padding: 0 var(--spacing-m);
+    }
+    :host([show-avatar]) .container {
+      padding-left: 0;
+    }
+    gr-button.remove {
+      --gr-remove-button-style: {
+        border: 0;
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-normal);
+        height: 0.6em;
+        line-height: 10px;
+        margin-left: var(--spacing-xs);
+        padding: 0;
+        text-decoration: none;
+      }
+    }
+
+    gr-button.remove:hover,
+    gr-button.remove:focus {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+        color: #333;
+      }
+    }
+    gr-button.remove {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+      }
+    }
+    :host:focus {
+      border-color: transparent;
+      box-shadow: none;
+      outline: none;
+    }
+    :host:focus .container,
+    :host:focus gr-button {
+      background: #ccc;
+    }
+    .transparentBackground,
+    gr-button.transparentBackground {
+      background-color: transparent;
+      padding: 0;
+    }
+    :host([disabled]) {
+      opacity: 0.6;
+      pointer-events: none;
+    }
+    iron-icon {
+      height: 1.2rem;
+      width: 1.2rem;
+    }
+  </style>
+  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
+    <gr-account-link
+      account="[[account]]"
+      show-attention="[[showAttention]]"
+      voteable-text="[[voteableText]]"
+    >
+    </gr-account-link>
+    <gr-button
+      id="remove"
+      link=""
+      hidden$="[[!removable]]"
+      hidden=""
+      tabindex="-1"
+      aria-label="Remove"
+      class$="remove [[_getBackgroundClass(transparentBackground)]]"
+      on-click="_handleRemoveTap"
+    >
+      <iron-icon icon="gr-icons:close"></iron-icon>
+    </gr-button>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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
deleted file mode 100644
index ae656fd..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
+++ /dev/null
@@ -1,47 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-account-entry">
-  <template>
-    <style include="shared-styles">
-      gr-autocomplete {
-        display: inline-block;
-        flex: 1;
-        overflow: hidden;
-      }
-    </style>
-    <gr-autocomplete
-        id="input"
-        borderless="[[borderless]]"
-        placeholder="[[placeholder]]"
-        threshold="[[suggestFrom]]"
-        query="[[querySuggestions]]"
-        allow-non-suggested-values="[[allowAnyInput]]"
-        on-commit="_handleInputCommit"
-        clear-on-commit
-        warn-uncommitted
-        text="{{_inputText}}">
-    </gr-autocomplete>
-  </template>
-  <script src="gr-account-entry.js"></script>
-</dom-module>
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..c991a37 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
@@ -14,30 +14,44 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../styles/shared-styles.js';
+import '../gr-autocomplete/gr-autocomplete.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-entry_html.js';
+
+/**
+ * gr-account-entry is an element for entering account
+ * and/or group with autocomplete support.
+ *
+ * @extends Polymer.Element
+ */
+class GrAccountEntry extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-account-entry'; }
+  /**
+   * Fired when an account is entered.
+   *
+   * @event add
+   */
 
   /**
-   * gr-account-entry is an element for entering account
-   * and/or group with autocomplete support.
+   * When allowAnyInput is true, account-text-changed is fired when input text
+   * changed. This is needed so that the reply dialog's save button can be
+   * enabled for arbitrary cc's, which don't need a 'commit'.
+   *
+   * @event account-text-changed
    */
-  Polymer({
-    is: 'gr-account-entry',
 
-    /**
-     * Fired when an account is entered.
-     *
-     * @event add
-     */
-
-    /**
-     * When allowAnyInput is true, account-text-changed is fired when input text
-     * changed. This is needed so that the reply dialog's save button can be
-     * enabled for arbitrary cc's, which don't need a 'commit'.
-     *
-     * @event account-text-changed
-     */
-    properties: {
+  static get properties() {
+    return {
       allowAnyInput: Boolean,
       borderless: Boolean,
       placeholder: String,
@@ -64,38 +78,43 @@
         observer: '_inputTextChanged',
       },
 
-    },
+    };
+  }
 
-    get focusStart() {
-      return this.$.input.focusStart;
-    },
+  get focusStart() {
+    return this.$.input.focusStart;
+  }
 
-    focus() {
-      this.$.input.focus();
-    },
+  focus() {
+    this.$.input.focus();
+  }
 
-    clear() {
-      this.$.input.clear();
-    },
+  clear() {
+    this.$.input.clear();
+  }
 
-    setText(text) {
-      this.$.input.setText(text);
-    },
+  setText(text) {
+    this.$.input.setText(text);
+  }
 
-    getText() {
-      return this.$.input.text;
-    },
+  getText() {
+    return this.$.input.text;
+  }
 
-    _handleInputCommit(e) {
-      this.fire('add', {value: e.detail.value});
-      this.$.input.focus();
-    },
+  _handleInputCommit(e) {
+    this.dispatchEvent(new CustomEvent('add', {
+      detail: {value: e.detail.value},
+      composed: true, bubbles: true,
+    }));
+    this.$.input.focus();
+  }
 
-    _inputTextChanged(text) {
-      if (text.length && this.allowAnyInput) {
-        this.dispatchEvent(new CustomEvent(
-            'account-text-changed', {bubbles: true, composed: true}));
-      }
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
new file mode 100644
index 0000000..afd427a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-autocomplete {
+      display: inline-block;
+      flex: 1;
+      overflow: hidden;
+    }
+  </style>
+  <gr-autocomplete
+    id="input"
+    borderless="[[borderless]]"
+    placeholder="[[placeholder]]"
+    threshold="[[suggestFrom]]"
+    query="[[querySuggestions]]"
+    allow-non-suggested-values="[[allowAnyInput]]"
+    on-commit="_handleInputCommit"
+    clear-on-commit=""
+    warn-uncommitted=""
+    text="{{_inputText}}"
+    vertical-offset="24"
+  >
+  </gr-autocomplete>
+`;
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..5899ad4 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-account-entry.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,78 +31,79 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-entry tests', () => {
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-account-entry.js';
+suite('gr-account-entry tests', () => {
+  let sandbox;
+  let element;
 
-    const suggestion1 = {
-      email: 'email1@example.com',
-      _account_id: 1,
-      some_property: 'value',
-    };
-    const suggestion2 = {
-      email: 'email2@example.com',
-      _account_id: 2,
-    };
-    const suggestion3 = {
-      email: 'email25@example.com',
-      _account_id: 25,
-      some_other_property: 'other value',
-    };
+  const suggestion1 = {
+    email: 'email1@example.com',
+    _account_id: 1,
+    some_property: 'value',
+  };
+  const suggestion2 = {
+    email: 'email2@example.com',
+    _account_id: 2,
+  };
+  const suggestion3 = {
+    email: 'email25@example.com',
+    _account_id: 25,
+    some_other_property: 'other value',
+  };
 
-    setup(done => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      return flush(done);
-    });
+  setup(done => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    return flush(done);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    suite('stubbed values for querySuggestions', () => {
-      setup(() => {
-        element.querySuggestions = input => {
-          return Promise.resolve([
-            suggestion1,
-            suggestion2,
-            suggestion3,
-          ]);
-        };
-      });
-    });
-
-    test('account-text-changed fired when input text changed and allowAnyInput',
-        () => {
-          // Spy on query, as that is called when _updateSuggestions proceeds.
-          const changeStub = sandbox.stub();
-          element.allowAnyInput = true;
-          element.querySuggestions = input => Promise.resolve([]);
-          element.addEventListener('account-text-changed', changeStub);
-          element.$.input.text = 'a';
-          assert.isTrue(changeStub.calledOnce);
-          element.$.input.text = 'ab';
-          assert.isTrue(changeStub.calledTwice);
-        });
-
-    test('account-text-changed not fired when input text changed without ' +
-        'allowAnyInput', () => {
-      // Spy on query, as that is called when _updateSuggestions proceeds.
-      const changeStub = sandbox.stub();
-      element.querySuggestions = input => Promise.resolve([]);
-      element.addEventListener('account-text-changed', changeStub);
-      element.$.input.text = 'a';
-      assert.isFalse(changeStub.called);
-    });
-
-    test('setText', () => {
-      // Spy on query, as that is called when _updateSuggestions proceeds.
-      const suggestSpy = sandbox.spy(element.$.input, 'query');
-      element.setText('test text');
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.input.$.input.value, 'test text');
-      assert.isFalse(suggestSpy.called);
+  suite('stubbed values for querySuggestions', () => {
+    setup(() => {
+      element.querySuggestions = input => Promise.resolve([
+        suggestion1,
+        suggestion2,
+        suggestion3,
+      ]);
     });
   });
+
+  test('account-text-changed fired when input text changed and allowAnyInput',
+      () => {
+        // Spy on query, as that is called when _updateSuggestions proceeds.
+        const changeStub = sandbox.stub();
+        element.allowAnyInput = true;
+        element.querySuggestions = input => Promise.resolve([]);
+        element.addEventListener('account-text-changed', changeStub);
+        element.$.input.text = 'a';
+        assert.isTrue(changeStub.calledOnce);
+        element.$.input.text = 'ab';
+        assert.isTrue(changeStub.calledTwice);
+      });
+
+  test('account-text-changed not fired when input text changed without ' +
+      'allowAnyInput', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const changeStub = sandbox.stub();
+    element.querySuggestions = input => Promise.resolve([]);
+    element.addEventListener('account-text-changed', changeStub);
+    element.$.input.text = 'a';
+    assert.isFalse(changeStub.called);
+  });
+
+  test('setText', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const suggestSpy = sandbox.spy(element.$.input, 'query');
+    element.setText('test text');
+    flushAsynchronousOperations();
+
+    assert.equal(element.$.input.$.input.value, 'test text');
+    assert.isFalse(suggestSpy.called);
+  });
+});
 </script>
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
deleted file mode 100644
index 7ed7962..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ /dev/null
@@ -1,79 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-avatar/gr-avatar.html">
-<link rel="import" href="../gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-account-label">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: inline;
-      }
-      :host::after {
-        content: var(--account-label-suffix);
-      }
-      gr-avatar {
-        height: 1.3em;
-        width: 1.3em;
-        margin-right: var(--spacing-xs);
-        vertical-align: -.25em;
-      }
-      .text {
-        @apply --gr-account-label-text-style;
-      }
-      .text:hover {
-        @apply --gr-account-label-text-hover-style;
-      }
-      .email,
-      .showEmail .name {
-        display: none;
-      }
-      .showEmail .email {
-        display: inline-block;
-      }
-    </style>
-    <span>
-      <template is="dom-if" if="[[!hideAvatar]]">
-        <gr-avatar account="[[account]]"
-            image-size="[[avatarImageSize]]"></gr-avatar>
-      </template>
-      <span class$="text [[_computeShowEmailClass(account)]]">
-        <span class="name">
-          [[_computeName(account, _serverConfig)]]</span>
-        <span class="email">
-          [[_computeEmailStr(account)]]
-        </span>
-        <template is="dom-if" if="[[account.status]]">
-          (<gr-limited-text
-            disable-tooltip="true"
-            limit="[[_computeStatusTextLength(account, _serverConfig)]]"
-            text="[[account.status]]">
-          </gr-limited-text>)
-        </template>
-      </span>
-    </span>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="../../../scripts/util.js"></script>
-  <script src="gr-account-label.js"></script>
-</dom-module>
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..110d884 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
@@ -14,110 +14,68 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-account-label',
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../gr-avatar/gr-avatar.js';
+import '../gr-hovercard-account/gr-hovercard-account.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-label_html.js';
+import {DisplayNameBehavior} from '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrAccountLabel extends mixinBehaviors( [
+  DisplayNameBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-account-label'; }
+
+  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: {
+      voteableText: String,
+      showAttention: {
         type: Boolean,
-        reflectToAttribute: true,
-        computed: '_computeHasTooltip(account)',
+        value: false,
       },
       hideAvatar: {
         type: Boolean,
         value: false,
       },
+      hideStatus: {
+        type: Boolean,
+        value: false,
+      },
       _serverConfig: {
         type: Object,
         value: null,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.DisplayNameBehavior,
-      Gerrit.TooltipBehavior,
-    ],
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.restAPI.getConfig()
+        .then(config => { this._serverConfig = config; });
+  }
 
-    ready() {
-      if (!this.additionalText) { this.additionalText = ''; }
-      this.$.restAPI.getConfig()
-          .then(config => { this._serverConfig = config; });
-    },
+  _computeName(account, config) {
+    return this.getDisplayName(config, account);
+  }
+}
 
-    _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
-      if ([
-        account,
-        tooltip,
-      ].some(arg => arg === undefined)) {
-        return undefined;
-      }
-
-      if (!account) { return; }
-      let result = '';
-      if (this._computeName(account, this._serverConfig)) {
-        result += this._computeName(account, this._serverConfig);
-      }
-      if (account.email) {
-        result += ` <${account.email}>`;
-      }
-      if (this.additionalText) {
-        result += ` ${this.additionalText}`;
-      }
-
-      // Show status in the label tooltip instead of
-      // in a separate tooltip on status
-      if (account.status) {
-        result += ` (${account.status})`;
-      }
-
-      return result;
-    },
-
-    _computeShowEmailClass(account) {
-      if (!account || account.name || !account.email) { return ''; }
-      return 'showEmail';
-    },
-
-    _computeEmailStr(account) {
-      if (!account || !account.email) {
-        return '';
-      }
-      if (account.name) {
-        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_html.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
new file mode 100644
index 0000000..ba2d9cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline;
+      position: relative;
+    }
+    :host::after {
+      content: var(--account-label-suffix);
+    }
+    :host(:not([blurred])) .overlay {
+      display: none;
+    }
+    .overlay {
+      position: absolute;
+      pointer-events: none;
+      height: var(--line-height-normal);
+      right: 0;
+      left: 0;
+      background-color: var(--background-color-primary);
+      opacity: 0.5;
+    }
+    gr-avatar {
+      height: var(--line-height-normal);
+      width: var(--line-height-normal);
+      vertical-align: top;
+    }
+    .text {
+      @apply --gr-account-label-text-style;
+    }
+    .text:hover {
+      @apply --gr-account-label-text-hover-style;
+    }
+    iron-icon.attention {
+      vertical-align: top;
+    }
+    iron-icon.status {
+      width: 14px;
+      height: 14px;
+      vertical-align: top;
+      position: relative;
+      top: 2px;
+    }
+  </style>
+  <div class="overlay"></div>
+  <span>
+    <gr-hovercard-account
+      attention="[[showAttention]]"
+      account="[[account]]"
+      voteable-text="[[voteableText]]"
+    >
+    </gr-hovercard-account>
+    <template is="dom-if" if="[[showAttention]]">
+      <iron-icon class="attention" icon="gr-icons:attention"></iron-icon
+      ><!--
+   --></template
+    ><!--
+   --><template is="dom-if" if="[[!hideAvatar]]"
+      ><!--
+     --><gr-avatar account="[[account]]" image-size="32"></gr-avatar>
+    </template>
+    <span class="text">
+      <span class="name"> [[_computeName(account, _serverConfig)]]</span>
+      <template is="dom-if" if="[[!hideStatus]]">
+        <template is="dom-if" if="[[account.status]]">
+          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
+        </template>
+      </template>
+    </span>
+  </span>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..4cc66c4 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-account-label.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,147 +31,64 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-label tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-account-label.js';
+suite('gr-account-label tests', () => {
+  let element;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-      element._config = {
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = fixture('basic');
+    element._config = {
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+  });
+
+  test('null guard', () => {
+    assert.doesNotThrow(() => {
+      element.account = null;
+    });
+  });
+
+  suite('_computeName', () => {
+    test('not showing anonymous', () => {
+      const account = {name: 'Wyatt'};
+      assert.deepEqual(element._computeName(account, null), 'Wyatt');
+    });
+
+    test('showing anonymous but no config', () => {
+      const account = {};
+      assert.deepEqual(element._computeName(account, null),
+          'Anonymous');
+    });
+
+    test('test for Anonymous Coward user and replace with Anonymous', () => {
+      const config = {
         user: {
           anonymous_coward_name: 'Anonymous Coward',
         },
       };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config),
+          'Anonymous');
     });
 
-    test('null guard', () => {
-      assert.doesNotThrow(() => {
-        element.account = null;
-      });
-    });
-
-    test('missing email', () => {
-      assert.equal('', element._computeEmailStr({name: 'foo'}));
-    });
-
-    test('computed fields', () => {
-      assert.equal(element._computeAccountTitle(
-          {
-            name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''),
-      'Andrew Bonventre <andybons+gerrit@gmail.com>');
-
-      assert.equal(element._computeAccountTitle(
-          {name: 'Andrew Bonventre'}, /* additionalText= */ ''),
-      'Andrew Bonventre');
-
-      assert.equal(element._computeAccountTitle(
-          {
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''),
-      'Anonymous <andybons+gerrit@gmail.com>');
-
-      assert.equal(element._computeShowEmailClass(
-          {
-            name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''), '');
-
-      assert.equal(element._computeShowEmailClass(
-          {
-            email: 'andybons+gerrit@gmail.com',
-          }, /* additionalText= */ ''), 'showEmail');
-
-      assert.equal(element._computeShowEmailClass(
-          {name: 'Andrew Bonventre'},
-          /* additionalText= */ ''
-      ),
-      '');
-
-      assert.equal(element._computeShowEmailClass(undefined), '');
-
-      assert.equal(
-          element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
-      assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
-    });
-
-    suite('_computeName', () => {
-      test('not showing anonymous', () => {
-        const account = {name: 'Wyatt'};
-        assert.deepEqual(element._computeName(account, null), 'Wyatt');
-      });
-
-      test('showing anonymous but no config', () => {
-        const account = {};
-        assert.deepEqual(element._computeName(account, null),
-            'Anonymous');
-      });
-
-      test('test for Anonymous Coward user and replace with Anonymous', () => {
-        const config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward',
-          },
-        };
-        const account = {};
-        assert.deepEqual(element._computeName(account, config),
-            'Anonymous');
-      });
-
-      test('test for anonymous_coward_name', () => {
-        const config = {
-          user: {
-            anonymous_coward_name: 'TestAnon',
-          },
-        };
-        const account = {};
-        assert.deepEqual(element._computeName(account, config),
-            'TestAnon');
-      });
-    });
-
-    suite('status in tooltip', () => {
-      setup(() => {
-        element = fixture('basic');
-        element.account = {
-          name: 'test',
-          email: 'test@google.com',
-          status: 'OOO until Aug 10th',
-        };
-        element._config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward',
-          },
-        };
-      });
-
-      test('tooltip should contain status text', () => {
-        assert.deepEqual(element.title,
-            'test <test@google.com> (OOO until Aug 10th)');
-      });
-
-      test('status text should not have tooltip', () => {
-        flushAsynchronousOperations();
-        assert.deepEqual(element.$$('gr-limited-text').title, '');
-      });
-
-      test('status text should honor the name length and total length', () => {
-        assert.deepEqual(
-            element._computeStatusTextLength(element.account, element._config),
-            31
-        );
-        assert.deepEqual(
-            element._computeStatusTextLength({
-              name: 'a very long long long long name',
-            }, element._config),
-            10
-        );
-      });
+    test('test for anonymous_coward_name', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config),
+          'TestAnon');
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
deleted file mode 100644
index d3575b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../gr-account-label/gr-account-label.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-account-link">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: inline-block;
-      }
-      a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      gr-account-label {
-        --gr-account-label-text-hover-style: {
-          text-decoration: underline;
-        };
-      }
-    </style>
-    <span>
-      <a href$="[[_computeOwnerLink(account)]]" tabindex="-1">
-        <gr-account-label account="[[account]]"
-            additional-text="[[additionalText]]"
-            avatar-image-size="[[avatarImageSize]]"></gr-account-label>
-      </a>
-    </span>
-  </template>
-  <script src="gr-account-link.js"></script>
-</dom-module>
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..27de4b3 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,30 +14,55 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  Polymer({
-    is: 'gr-account-link',
+import '../../../scripts/bundled-polymer.js';
+import '../gr-account-label/gr-account-label.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-link_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
-      additionalText: String,
+/**
+ * @extends Polymer.Element
+ */
+class GrAccountLink extends mixinBehaviors( [
+  BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-account-link'; }
+
+  static get properties() {
+    return {
+      voteableText: String,
       account: Object,
-      avatarImageSize: {
-        type: Number,
-        value: 32,
+      showAttention: {
+        type: Boolean,
+        value: false,
       },
-    },
+      hideAvatar: {
+        type: Boolean,
+        value: false,
+      },
+      hideStatus: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
+  _computeOwnerLink(account) {
+    if (!account) { return; }
+    return GerritNav.getUrlForOwner(
+        account.email || account.username || account.name ||
+        account._account_id);
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
new file mode 100644
index 0000000..17e7f49
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+      /* Setting this really high, so all the following rules don't change
+           anything, only if --account-max-length is actually set to something
+           smaller like 20ch. */
+      max-width: var(--account-max-length, 500px);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      vertical-align: top;
+      white-space: nowrap;
+    }
+    a {
+      color: var(--primary-text-color);
+      text-decoration: none;
+    }
+    gr-account-label {
+      --gr-account-label-text-hover-style: {
+        text-decoration: underline;
+      }
+    }
+  </style>
+  <span>
+    <a href$="[[_computeOwnerLink(account)]]" tabindex="-1">
+      <gr-account-label
+        show-attention="[[showAttention]]"
+        hide-avatar="[[hideAvatar]]"
+        hide-status="[[hideStatus]]"
+        account="[[account]]"
+        voteable-text="[[voteableText]]"
+      >
+      </gr-account-label>
+    </a>
+  </span>
+`;
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..f3bff6e 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-link.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,47 +31,51 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-link tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-account-link.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+suite('gr-account-link tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('computed fields', () => {
-      const url = 'test/url';
-      const urlStub = sandbox.stub(Gerrit.Nav, 'getUrlForOwner').returns(url);
-      const account = {
-        email: 'email',
-        username: 'username',
-        name: 'name',
-        _account_id: '_account_id',
-      };
-      assert.isNotOk(element._computeOwnerLink());
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
-
-      delete account.email;
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
-
-      delete account.username;
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
-
-      delete account.name;
-      assert.equal(element._computeOwnerLink(account), url);
-      assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
-    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('computed fields', () => {
+    const url = 'test/url';
+    const urlStub = sandbox.stub(GerritNav, 'getUrlForOwner').returns(url);
+    const account = {
+      email: 'email',
+      username: 'username',
+      name: 'name',
+      _account_id: '_account_id',
+    };
+    assert.isNotOk(element._computeOwnerLink());
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
+
+    delete account.email;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
+
+    delete account.username;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
+
+    delete account.name;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
+  });
+});
 </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
deleted file mode 100644
index 2ce608be..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html
+++ /dev/null
@@ -1,79 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../gr-account-entry/gr-account-entry.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-account-list">
-  <template>
-    <style include="shared-styles">
-      gr-account-chip {
-        display: inline-block;
-        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-      }
-      gr-account-entry {
-        display: flex;
-        flex: 1;
-        min-width: 10em;
-      }
-      .group {
-        --account-label-suffix: ' (group)';
-      }
-      .pending-add {
-        font-style: italic;
-      }
-      .list {
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-        @apply --account-list-style;
-      }
-    </style>
-    <!--
-      NOTE(Issue 6419): Nest the inner dom-repeat template in a div rather than
-      as a direct child of the dom-module's template.
-    -->
-    <div class="list">
-      <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
-        <gr-account-chip
-            account="[[account]]"
-            class$="[[_computeChipClass(account)]]"
-            data-account-id$="[[account._account_id]]"
-            removable="[[_computeRemovable(account, readonly)]]"
-            on-keydown="_handleChipKeydown"
-            tabindex="-1">
-        </gr-account-chip>
-      </template>
-    </div>
-    <gr-account-entry
-        borderless
-        hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
-        id="entry"
-        change="[[change]]"
-        placeholder="[[placeholder]]"
-        on-add="_handleAdd"
-        on-input-keydown="_handleInputKeydown"
-        allow-any-input="[[allowAnyInput]]"
-        query-suggestions="[[_querySuggestions]]">
-    </gr-account-entry>
-    <slot></slot>
-  </template>
-  <script src="gr-account-list.js"></script>
-</dom-module>
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..73ccf7d 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
@@ -14,21 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const VALID_EMAIL_ALERT = 'Please input a valid email.';
+import '../gr-account-chip/gr-account-chip.js';
+import '../gr-account-entry/gr-account-entry.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-account-list_html.js';
 
-  Polymer({
-    is: 'gr-account-list',
+const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
-    /**
-     * Fired when user inputs an invalid email address.
-     *
-     * @event show-alert
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrAccountList extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-account-list'; }
+  /**
+   * Fired when user inputs an invalid email address.
+   *
+   * @event show-alert
+   */
+
+  static get properties() {
+    return {
       accounts: {
         type: Array,
         value() { return []; },
@@ -54,7 +68,8 @@
       /**
        * Needed for template checking since value is initially set to null.
        *
-       * @type {?Object} */
+       * @type {?Object}
+       */
       pendingConfirmation: {
         type: Object,
         value: null,
@@ -82,7 +97,8 @@
         value: 0,
       },
 
-      /** Returns suggestion items
+      /**
+       * Returns suggestion items
        *
        * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
        */
@@ -92,241 +108,252 @@
           return this._getSuggestions.bind(this);
         },
       },
-    },
 
-    behaviors: [
-      // Used in the tests for gr-account-list and other elements tests.
-      Gerrit.FireBehavior,
-    ],
+      /**
+       * Set to true to disable suggestions on empty input.
+       */
+      skipSuggestOnEmpty: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
 
-    listeners: {
-      remove: '_handleRemove',
-    },
+  /** @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 accountChips() {
+    return Array.from(
+        dom(this.root).querySelectorAll('gr-account-chip'));
+  }
 
-    get focusStart() {
-      return this.$.entry.focusStart;
-    },
+  get focusStart() {
+    return this.$.entry.focusStart;
+  }
 
-    _getSuggestions(input) {
-      const provider = this.suggestionsProvider;
-      if (!provider) {
-        return Promise.resolve([]);
+  _getSuggestions(input) {
+    if (this.skipSuggestOnEmpty && !input) {
+      return Promise.resolve([]);
+    }
+    const provider = this.suggestionsProvider;
+    if (!provider) {
+      return Promise.resolve([]);
+    }
+    return provider.getSuggestions(input).then(suggestions => {
+      if (!suggestions) { return []; }
+      if (this.filter) {
+        suggestions = suggestions.filter(this.filter);
       }
-      return provider.getSuggestions(input).then(suggestions => {
-        if (!suggestions) { return []; }
-        if (this.filter) {
-          suggestions = suggestions.filter(this.filter);
-        }
-        return suggestions.map(suggestion =>
-          provider.makeSuggestionItem(suggestion));
-      });
-    },
+      return suggestions.map(suggestion =>
+        provider.makeSuggestionItem(suggestion));
+    });
+  }
 
-    _handleAdd(e) {
-      this._addAccountItem(e.detail.value);
-    },
+  _handleAdd(e) {
+    this._addAccountItem(e.detail.value);
+  }
 
-    _addAccountItem(item) {
-      // Append new account or group to the accounts property. We add our own
-      // internal properties to the account/group here, so we clone the object
-      // to avoid cluttering up the shared change object.
-      if (item.account) {
-        const account =
-            Object.assign({}, item.account, {_pendingAdd: true});
-        this.push('accounts', account);
-      } else if (item.group) {
-        if (item.confirm) {
-          this.pendingConfirmation = item;
-          return;
-        }
-        const group = Object.assign({}, item.group,
-            {_pendingAdd: true, _group: true});
-        this.push('accounts', group);
-      } else if (this.allowAnyInput) {
-        if (!item.includes('@')) {
-          // Repopulate the input with what the user tried to enter and have
-          // a toast tell them why they can't enter it.
-          this.$.entry.setText(item);
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: VALID_EMAIL_ALERT},
-            bubbles: true,
-            composed: true,
-          }));
-          return false;
-        } else {
-          const account = {email: item, _pendingAdd: true};
-          this.push('accounts', account);
-        }
+  _addAccountItem(item) {
+    // Append new account or group to the accounts property. We add our own
+    // internal properties to the account/group here, so we clone the object
+    // to avoid cluttering up the shared change object.
+    if (item.account) {
+      const account =
+          Object.assign({}, item.account, {_pendingAdd: true});
+      this.push('accounts', account);
+    } else if (item.group) {
+      if (item.confirm) {
+        this.pendingConfirmation = item;
+        return;
       }
-      this.pendingConfirmation = null;
-      return true;
-    },
-
-    confirmGroup(group) {
-      group = Object.assign(
-          {}, group, {confirmed: true, _pendingAdd: true, _group: true});
+      const group = Object.assign({}, item.group,
+          {_pendingAdd: true, _group: true});
       this.push('accounts', group);
-      this.pendingConfirmation = null;
-    },
-
-    _computeChipClass(account) {
-      const classes = [];
-      if (account._group) {
-        classes.push('group');
+    } else if (this.allowAnyInput) {
+      if (!item.includes('@')) {
+        // Repopulate the input with what the user tried to enter and have
+        // a toast tell them why they can't enter it.
+        this.$.entry.setText(item);
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: VALID_EMAIL_ALERT},
+          bubbles: true,
+          composed: true,
+        }));
+        return false;
+      } else {
+        const account = {email: item, _pendingAdd: true};
+        this.push('accounts', account);
       }
-      if (account._pendingAdd) {
-        classes.push('pendingAdd');
-      }
-      return classes.join(' ');
-    },
+    }
+    this.pendingConfirmation = null;
+    return true;
+  }
 
-    _accountMatches(a, b) {
-      if (a && b) {
-        if (a._account_id) {
-          return a._account_id === b._account_id;
-        }
-        if (a.email) {
-          return a.email === b.email;
+  confirmGroup(group) {
+    group = Object.assign(
+        {}, group, {confirmed: true, _pendingAdd: true, _group: true});
+    this.push('accounts', group);
+    this.pendingConfirmation = null;
+  }
+
+  _computeChipClass(account) {
+    const classes = [];
+    if (account._group) {
+      classes.push('group');
+    }
+    if (account._pendingAdd) {
+      classes.push('pendingAdd');
+    }
+    return classes.join(' ');
+  }
+
+  _accountMatches(a, b) {
+    if (a && b) {
+      if (a._account_id) {
+        return a._account_id === b._account_id;
+      }
+      if (a.email) {
+        return a.email === b.email;
+      }
+    }
+    return a === b;
+  }
+
+  _computeRemovable(account, readonly) {
+    if (readonly) { return false; }
+    if (this.removableValues) {
+      for (let i = 0; i < this.removableValues.length; i++) {
+        if (this._accountMatches(this.removableValues[i], account)) {
+          return true;
         }
       }
-      return a === b;
-    },
+      return !!account._pendingAdd;
+    }
+    return true;
+  }
 
-    _computeRemovable(account, readonly) {
-      if (readonly) { return false; }
-      if (this.removableValues) {
-        for (let i = 0; i < this.removableValues.length; i++) {
-          if (this._accountMatches(this.removableValues[i], account)) {
-            return true;
-          }
-        }
-        return !!account._pendingAdd;
+  _handleRemove(e) {
+    const toRemove = e.detail.account;
+    this._removeAccount(toRemove);
+    this.$.entry.focus();
+  }
+
+  _removeAccount(toRemove) {
+    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+      return;
+    }
+    for (let i = 0; i < this.accounts.length; i++) {
+      let matches;
+      const account = this.accounts[i];
+      if (toRemove._group) {
+        matches = toRemove.id === account.id;
+      } else {
+        matches = this._accountMatches(toRemove, account);
       }
-      return true;
-    },
-
-    _handleRemove(e) {
-      const toRemove = e.detail.account;
-      this._removeAccount(toRemove);
-      this.$.entry.focus();
-    },
-
-    _removeAccount(toRemove) {
-      if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+      if (matches) {
+        this.splice('accounts', i, 1);
         return;
       }
-      for (let i = 0; i < this.accounts.length; i++) {
-        let matches;
-        const account = this.accounts[i];
-        if (toRemove._group) {
-          matches = toRemove.id === account.id;
+    }
+    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);
+    if (input.selectionStart !== input.selectionEnd ||
+        input.selectionStart !== 0) {
+      return;
+    }
+    switch (e.detail.keyCode) {
+      case 8: // Backspace
+        this._removeAccount(this.accounts[this.accounts.length - 1]);
+        break;
+      case 37: // Left arrow
+        if (this.accountChips[this.accountChips.length - 1]) {
+          this.accountChips[this.accountChips.length - 1].focus();
+        }
+        break;
+    }
+  }
+
+  _handleChipKeydown(e) {
+    const chip = e.target;
+    const chips = this.accountChips;
+    const index = chips.indexOf(chip);
+    switch (e.keyCode) {
+      case 8: // Backspace
+      case 13: // Enter
+      case 32: // Spacebar
+      case 46: // Delete
+        this._removeAccount(chip.account);
+        // Splice from this array to avoid inconsistent ordering of
+        // event handling.
+        chips.splice(index, 1);
+        if (index < chips.length) {
+          chips[index].focus();
+        } else if (index > 0) {
+          chips[index - 1].focus();
         } else {
-          matches = this._accountMatches(toRemove, account);
+          this.$.entry.focus();
         }
-        if (matches) {
-          this.splice('accounts', i, 1);
-          return;
-        }
-      }
-      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);
-      if (input.selectionStart !== input.selectionEnd ||
-          input.selectionStart !== 0) {
-        return;
-      }
-      switch (e.detail.keyCode) {
-        case 8: // Backspace
-          this._removeAccount(this.accounts[this.accounts.length - 1]);
-          break;
-        case 37: // Left arrow
-          if (this.accountChips[this.accountChips.length - 1]) {
-            this.accountChips[this.accountChips.length - 1].focus();
-          }
-          break;
-      }
-    },
-
-    _handleChipKeydown(e) {
-      const chip = e.target;
-      const chips = this.accountChips;
-      const index = chips.indexOf(chip);
-      switch (e.keyCode) {
-        case 8: // Backspace
-        case 13: // Enter
-        case 32: // Spacebar
-        case 46: // Delete
-          this._removeAccount(chip.account);
-          // Splice from this array to avoid inconsistent ordering of
-          // event handling.
-          chips.splice(index, 1);
-          if (index < chips.length) {
-            chips[index].focus();
-          } else if (index > 0) {
-            chips[index - 1].focus();
-          } else {
-            this.$.entry.focus();
-          }
-          break;
-        case 37: // Left arrow
-          if (index > 0) {
-            chip.blur();
-            chips[index - 1].focus();
-          }
-          break;
-        case 39: // Right arrow
+        break;
+      case 37: // Left arrow
+        if (index > 0) {
           chip.blur();
-          if (index < chips.length - 1) {
-            chips[index + 1].focus();
-          } else {
-            this.$.entry.focus();
-          }
-          break;
-      }
-    },
-
-    /**
-     * Submit the text of the entry as a reviewer value, if it exists. If it is
-     * a successful submit of the text, clear the entry value.
-     *
-     * @return {boolean} If there is text in the entry, return true if the
-     *     submission was successful and false if not. If there is no text,
-     *     return true.
-     */
-    submitEntryText() {
-      const text = this.$.entry.getText();
-      if (!text.length) { return true; }
-      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};
+          chips[index - 1].focus();
         }
-      });
-    },
+        break;
+      case 39: // Right arrow
+        chip.blur();
+        if (index < chips.length - 1) {
+          chips[index + 1].focus();
+        } else {
+          this.$.entry.focus();
+        }
+        break;
+    }
+  }
 
-    _computeEntryHidden(maxCount, accountsRecord, readonly) {
-      return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
-    },
-  });
-})();
+  /**
+   * Submit the text of the entry as a reviewer value, if it exists. If it is
+   * a successful submit of the text, clear the entry value.
+   *
+   * @return {boolean} If there is text in the entry, return true if the
+   *     submission was successful and false if not. If there is no text,
+   *     return true.
+   */
+  submitEntryText() {
+    const text = this.$.entry.getText();
+    if (!text.length) { return true; }
+    const wasSubmitted = this._addAccountItem(text);
+    if (wasSubmitted) { this.$.entry.clear(); }
+    return wasSubmitted;
+  }
+
+  additions() {
+    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_html.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
new file mode 100644
index 0000000..6fee9f3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-account-chip {
+      display: inline-block;
+      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+    }
+    gr-account-entry {
+      display: flex;
+      flex: 1;
+      min-width: 10em;
+      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+    }
+    .group {
+      --account-label-suffix: ' (group)';
+    }
+    .pending-add {
+      font-style: italic;
+    }
+    .list {
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+      @apply --account-list-style;
+    }
+  </style>
+  <!--
+      NOTE(Issue 6419): Nest the inner dom-repeat template in a div rather than
+      as a direct child of the dom-module's template.
+    -->
+  <div class="list">
+    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
+      <gr-account-chip
+        account="[[account]]"
+        class$="[[_computeChipClass(account)]]"
+        data-account-id$="[[account._account_id]]"
+        removable="[[_computeRemovable(account, readonly)]]"
+        on-keydown="_handleChipKeydown"
+        tabindex="-1"
+      >
+      </gr-account-chip>
+    </template>
+  </div>
+  <gr-account-entry
+    borderless=""
+    hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
+    id="entry"
+    placeholder="[[placeholder]]"
+    on-add="_handleAdd"
+    on-input-keydown="_handleInputKeydown"
+    allow-any-input="[[allowAnyInput]]"
+    query-suggestions="[[_querySuggestions]]"
+  >
+  </gr-account-entry>
+  <slot></slot>
+`;
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..b3b32606 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-account-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,453 +31,524 @@
   </template>
 </test-fixture>
 
-<script>
-  class MockSuggestionsProvider {
-    getSuggestions(input) {
-      return Promise.resolve([]);
-    }
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-account-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 
-    makeSuggestionItem(item) {
-      return item;
-    }
+class MockSuggestionsProvider {
+  getSuggestions(input) {
+    return Promise.resolve([]);
   }
 
-  suite('gr-account-list tests', () => {
-    let _nextAccountId = 0;
-    const makeAccount = function() {
-      const accountId = ++_nextAccountId;
-      return {
-        _account_id: accountId,
-      };
+  makeSuggestionItem(item) {
+    return item;
+  }
+}
+
+suite('gr-account-list tests', () => {
+  let _nextAccountId = 0;
+  const makeAccount = function() {
+    const accountId = ++_nextAccountId;
+    return {
+      _account_id: accountId,
     };
-    const makeGroup = function() {
-      const groupId = 'group' + (++_nextAccountId);
-      return {
-        id: groupId,
-        _group: true,
-      };
+  };
+  const makeGroup = function() {
+    const groupId = 'group' + (++_nextAccountId);
+    return {
+      id: groupId,
+      _group: true,
     };
+  };
 
-    let existingAccount1;
-    let existingAccount2;
-    let sandbox;
-    let element;
-    let suggestionsProvider;
+  let existingAccount1;
+  let existingAccount2;
+  let sandbox;
+  let element;
+  let suggestionsProvider;
 
-    function getChips() {
-      return Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-    }
+  function getChips() {
+    return dom(element.root).querySelectorAll('gr-account-chip');
+  }
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    existingAccount1 = makeAccount();
+    existingAccount2 = makeAccount();
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    element.accounts = [existingAccount1, existingAccount2];
+    suggestionsProvider = new MockSuggestionsProvider();
+    element.suggestionsProvider = suggestionsProvider;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('account entry only appears when editable', () => {
+    element.readonly = false;
+    assert.isFalse(element.$.entry.hasAttribute('hidden'));
+    element.readonly = true;
+    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+  });
+
+  test('addition and removal of account/group chips', () => {
+    flushAsynchronousOperations();
+    sandbox.stub(element, '_computeRemovable').returns(true);
+    // Existing accounts are listed.
+    let chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+
+    // New accounts are added to end with pendingAdd class.
+    const newAccount = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: newAccount,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 3);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+    assert.isTrue(chips[2].classList.contains('pendingAdd'));
+
+    // Removed accounts are taken out of the list.
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: existingAccount1},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+    // Invalid remove is ignored.
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: existingAccount1},
+          composed: true, bubbles: true,
+        }));
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: newAccount},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 1);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+
+    // New groups are added to end with pendingAdd and group classes.
+    const newGroup = makeGroup();
+    element._handleAdd({
+      detail: {
+        value: {
+          group: newGroup,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isTrue(chips[1].classList.contains('group'));
+    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+    // Removed groups are taken out of the list.
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: newGroup},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 1);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+  });
+
+  test('_getSuggestions uses filter correctly', done => {
+    const originalSuggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+        _account_id: 3,
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+        _account_id: 1,
+      },
+      {
+        email: 'xyz@example.com',
+        text: 'aaaaa',
+        _account_id: 25,
+      },
+    ];
+    sandbox.stub(suggestionsProvider, 'getSuggestions')
+        .returns(Promise.resolve(originalSuggestions));
+    sandbox.stub(suggestionsProvider, 'makeSuggestionItem', suggestion => {
+      return {
+        name: suggestion.email,
+        value: suggestion._account_id,
+      };
+    });
+
+    element._getSuggestions().then(suggestions => {
+      // Default is no filtering.
+      assert.equal(suggestions.length, 3);
+
+      // Set up filter that only accepts suggestion1.
+      const accountId = originalSuggestions[0]._account_id;
+      element.filter = function(suggestion) {
+        return suggestion._account_id === accountId;
+      };
+
+      element._getSuggestions()
+          .then(suggestions => {
+            assert.deepEqual(suggestions,
+                [{name: originalSuggestions[0].email,
+                  value: originalSuggestions[0]._account_id}]);
+          })
+          .then(done);
+    });
+  });
+
+  test('_computeChipClass', () => {
+    const account = makeAccount();
+    assert.equal(element._computeChipClass(account), '');
+    account._pendingAdd = true;
+    assert.equal(element._computeChipClass(account), 'pendingAdd');
+    account._group = true;
+    assert.equal(element._computeChipClass(account), 'group pendingAdd');
+    account._pendingAdd = false;
+    assert.equal(element._computeChipClass(account), 'group');
+  });
+
+  test('_computeRemovable', () => {
+    const newAccount = makeAccount();
+    newAccount._pendingAdd = true;
+    element.readonly = false;
+    element.removableValues = [];
+    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));
+    assert.isFalse(element._computeRemovable(existingAccount2, false));
+
+    element.readonly = true;
+    assert.isFalse(element._computeRemovable(existingAccount1, true));
+    assert.isFalse(element._computeRemovable(newAccount, true));
+  });
+
+  test('submitEntryText', () => {
+    element.allowAnyInput = true;
+    flushAsynchronousOperations();
+
+    const getTextStub = sandbox.stub(element.$.entry, 'getText');
+    getTextStub.onFirstCall().returns('');
+    getTextStub.onSecondCall().returns('test');
+    getTextStub.onThirdCall().returns('test@test');
+
+    // When entry is empty, return true.
+    const clearStub = sandbox.stub(element.$.entry, 'clear');
+    assert.isTrue(element.submitEntryText());
+    assert.isFalse(clearStub.called);
+
+    // When entry is invalid, return false.
+    assert.isFalse(element.submitEntryText());
+    assert.isFalse(clearStub.called);
+
+    // When entry is valid, return true and clear text.
+    assert.isTrue(element.submitEntryText());
+    assert.isTrue(clearStub.called);
+    assert.equal(element.additions()[0].account.email, 'test@test');
+  });
+
+  test('additions returns sanitized new accounts and groups', () => {
+    assert.equal(element.additions().length, 0);
+
+    const newAccount = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: newAccount,
+        },
+      },
+    });
+    const newGroup = makeGroup();
+    element._handleAdd({
+      detail: {
+        value: {
+          group: newGroup,
+        },
+      },
+    });
+
+    assert.deepEqual(element.additions(), [
+      {
+        account: {
+          _account_id: newAccount._account_id,
+          _pendingAdd: true,
+        },
+      },
+      {
+        group: {
+          id: newGroup.id,
+          _group: true,
+          _pendingAdd: true,
+        },
+      },
+    ]);
+  });
+
+  test('large group confirmations', () => {
+    assert.isNull(element.pendingConfirmation);
+    assert.deepEqual(element.additions(), []);
+
+    const group = makeGroup();
+    const reviewer = {
+      group,
+      count: 10,
+      confirm: true,
+    };
+    element._handleAdd({
+      detail: {
+        value: reviewer,
+      },
+    });
+
+    assert.deepEqual(element.pendingConfirmation, reviewer);
+    assert.deepEqual(element.additions(), []);
+
+    element.confirmGroup(group);
+    assert.isNull(element.pendingConfirmation);
+    assert.deepEqual(element.additions(), [
+      {
+        group: {
+          id: group.id,
+          _group: true,
+          _pendingAdd: true,
+          confirmed: true,
+        },
+      },
+    ]);
+  });
+
+  test('removeAccount fails if account is not removable', () => {
+    element.readonly = true;
+    const acct = makeAccount();
+    element.accounts = [acct];
+    element._removeAccount(acct);
+    assert.equal(element.accounts.length, 1);
+  });
+
+  test('max-count', () => {
+    element.maxCount = 1;
+    const acct = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: acct,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+  });
+
+  test('enter text calls suggestions provider', done => {
+    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 = 'newTest';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    flushAsynchronousOperations();
+    flush(() => {
+      assert.isTrue(getSuggestionsStub.calledOnce);
+      assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
+      assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+      done();
+    });
+  });
+
+  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(() => {
-      sandbox = sinon.sandbox.create();
-      existingAccount1 = makeAccount();
-      existingAccount2 = makeAccount();
-
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      element.accounts = [existingAccount1, existingAccount2];
-      suggestionsProvider = new MockSuggestionsProvider();
-      element.suggestionsProvider = suggestionsProvider;
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('account entry only appears when editable', () => {
-      element.readonly = false;
-      assert.isFalse(element.$.entry.hasAttribute('hidden'));
-      element.readonly = true;
-      assert.isTrue(element.$.entry.hasAttribute('hidden'));
-    });
-
-    test('addition and removal of account/group chips', () => {
-      flushAsynchronousOperations();
-      sandbox.stub(element, '_computeRemovable').returns(true);
-      // Existing accounts are listed.
-      let chips = getChips();
-      assert.equal(chips.length, 2);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-      assert.isFalse(chips[1].classList.contains('pendingAdd'));
-
-      // New accounts are added to end with pendingAdd class.
-      const newAccount = makeAccount();
-      element._handleAdd({
-        detail: {
-          value: {
-            account: newAccount,
-          },
-        },
-      });
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 3);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-      assert.isFalse(chips[1].classList.contains('pendingAdd'));
-      assert.isTrue(chips[2].classList.contains('pendingAdd'));
-
-      // Removed accounts are taken out of the list.
-      element.fire('remove', {account: existingAccount1});
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 2);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-      assert.isTrue(chips[1].classList.contains('pendingAdd'));
-
-      // Invalid remove is ignored.
-      element.fire('remove', {account: existingAccount1});
-      element.fire('remove', {account: newAccount});
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 1);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-
-      // New groups are added to end with pendingAdd and group classes.
-      const newGroup = makeGroup();
-      element._handleAdd({
-        detail: {
-          value: {
-            group: newGroup,
-          },
-        },
-      });
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 2);
-      assert.isTrue(chips[1].classList.contains('group'));
-      assert.isTrue(chips[1].classList.contains('pendingAdd'));
-
-      // Removed groups are taken out of the list.
-      element.fire('remove', {account: newGroup});
-      flushAsynchronousOperations();
-      chips = getChips();
-      assert.equal(chips.length, 1);
-      assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    });
-
-    test('_getSuggestions uses filter correctly', done => {
-      const originalSuggestions = [
-        {
-          email: 'abc@example.com',
-          text: 'abcd',
-          _account_id: 3,
-        },
-        {
-          email: 'qwe@example.com',
-          text: 'qwer',
-          _account_id: 1,
-        },
-        {
-          email: 'xyz@example.com',
-          text: 'aaaaa',
-          _account_id: 25,
-        },
-      ];
-      sandbox.stub(suggestionsProvider, 'getSuggestions')
-          .returns(Promise.resolve(originalSuggestions));
-      sandbox.stub(suggestionsProvider, 'makeSuggestionItem', suggestion => {
-        return {
-          name: suggestion.email,
-          value: suggestion._account_id,
-        };
-      });
-
-
-      element._getSuggestions().then(suggestions => {
-        // Default is no filtering.
-        assert.equal(suggestions.length, 3);
-
-        // Set up filter that only accepts suggestion1.
-        const accountId = originalSuggestions[0]._account_id;
-        element.filter = function(suggestion) {
-          return suggestion._account_id === accountId;
-        };
-
-        element._getSuggestions().then(suggestions => {
-          assert.deepEqual(suggestions,
-              [{name: originalSuggestions[0].email,
-                value: originalSuggestions[0]._account_id}]);
-        }).then(done);
-      });
-    });
-
-    test('_computeChipClass', () => {
-      const account = makeAccount();
-      assert.equal(element._computeChipClass(account), '');
-      account._pendingAdd = true;
-      assert.equal(element._computeChipClass(account), 'pendingAdd');
-      account._group = true;
-      assert.equal(element._computeChipClass(account), 'group pendingAdd');
-      account._pendingAdd = false;
-      assert.equal(element._computeChipClass(account), 'group');
-    });
-
-    test('_computeRemovable', () => {
-      const newAccount = makeAccount();
-      newAccount._pendingAdd = true;
-      element.readonly = false;
-      element.removableValues = [];
-      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));
-      assert.isFalse(element._computeRemovable(existingAccount2, false));
-
-      element.readonly = true;
-      assert.isFalse(element._computeRemovable(existingAccount1, true));
-      assert.isFalse(element._computeRemovable(newAccount, true));
-    });
-
-    test('submitEntryText', () => {
       element.allowAnyInput = true;
-      flushAsynchronousOperations();
-
-      const getTextStub = sandbox.stub(element.$.entry, 'getText');
-      getTextStub.onFirstCall().returns('');
-      getTextStub.onSecondCall().returns('test');
-      getTextStub.onThirdCall().returns('test@test');
-
-      // When entry is empty, return true.
-      const clearStub = sandbox.stub(element.$.entry, 'clear');
-      assert.isTrue(element.submitEntryText());
-      assert.isFalse(clearStub.called);
-
-      // When entry is invalid, return false.
-      assert.isFalse(element.submitEntryText());
-      assert.isFalse(clearStub.called);
-
-      // When entry is valid, return true and clear text.
-      assert.isTrue(element.submitEntryText());
-      assert.isTrue(clearStub.called);
-      assert.equal(element.additions()[0].account.email, 'test@test');
     });
 
-    test('additions returns sanitized new accounts and groups', () => {
-      assert.equal(element.additions().length, 0);
-
-      const newAccount = makeAccount();
-      element._handleAdd({
-        detail: {
-          value: {
-            account: newAccount,
-          },
-        },
-      });
-      const newGroup = makeGroup();
-      element._handleAdd({
-        detail: {
-          value: {
-            group: newGroup,
-          },
-        },
-      });
-
-      assert.deepEqual(element.additions(), [
-        {
-          account: {
-            _account_id: newAccount._account_id,
-            _pendingAdd: true,
-          },
-        },
-        {
-          group: {
-            id: newGroup.id,
-            _group: true,
-            _pendingAdd: true,
-          },
-        },
-      ]);
+    test('adds emails', () => {
+      const accountLen = element.accounts.length;
+      element._handleAdd({detail: {value: 'test@test'}});
+      assert.equal(element.accounts.length, accountLen + 1);
+      assert.equal(element.accounts[accountLen].email, 'test@test');
     });
 
-    test('large group confirmations', () => {
-      assert.isNull(element.pendingConfirmation);
-      assert.deepEqual(element.additions(), []);
-
-      const group = makeGroup();
-      const reviewer = {
-        group,
-        count: 10,
-        confirm: true,
-      };
-      element._handleAdd({
-        detail: {
-          value: reviewer,
-        },
-      });
-
-      assert.deepEqual(element.pendingConfirmation, reviewer);
-      assert.deepEqual(element.additions(), []);
-
-      element.confirmGroup(group);
-      assert.isNull(element.pendingConfirmation);
-      assert.deepEqual(element.additions(), [
-        {
-          group: {
-            id: group.id,
-            _group: true,
-            _pendingAdd: true,
-            confirmed: true,
-          },
-        },
-      ]);
+    test('toasts on invalid email', () => {
+      const toastHandler = sandbox.stub();
+      element.addEventListener('show-alert', toastHandler);
+      element._handleAdd({detail: {value: 'test'}});
+      assert.isTrue(toastHandler.called);
     });
+  });
 
-    test('removeAccount fails if account is not removable', () => {
-      element.readonly = true;
-      const acct = makeAccount();
-      element.accounts = [acct];
-      element._removeAccount(acct);
-      assert.equal(element.accounts.length, 1);
-    });
+  test('_accountMatches', () => {
+    const acct = makeAccount();
 
-    test('max-count', () => {
-      element.maxCount = 1;
-      const acct = makeAccount();
-      element._handleAdd({
-        detail: {
-          value: {
-            account: acct,
-          },
-        },
-      });
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.entry.hasAttribute('hidden'));
-    });
+    assert.isTrue(element._accountMatches(acct, acct));
+    acct.email = 'test';
+    assert.isTrue(element._accountMatches(acct, acct));
+    assert.isTrue(element._accountMatches({email: 'test'}, acct));
 
-    test('enter text calls suggestions provider', done => {
-      const suggestions = [
-        {
-          email: 'abc@example.com',
-          text: 'abcd',
-        },
-        {
-          email: 'qwe@example.com',
-          text: 'qwer',
-        },
-      ];
-      const getSuggestionsStub =
-          sandbox.stub(suggestionsProvider, 'getSuggestions')
-              .returns(Promise.resolve(suggestions));
+    assert.isFalse(element._accountMatches({}, acct));
+    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
+    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
+  });
 
-      const makeSuggestionItemStub =
-          sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
-
+  suite('keyboard interactions', () => {
+    test('backspace at text input start removes last account', done => {
       const input = element.$.entry.$.input;
-
-      input.text = 'newTest';
-      MockInteractions.focus(input.$.input);
-      input.noDebounce = true;
-      flushAsynchronousOperations();
+      sandbox.stub(input, '_updateSuggestions');
+      sandbox.stub(element, '_computeRemovable').returns(true);
       flush(() => {
-        assert.isTrue(getSuggestionsStub.calledOnce);
-        assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-        assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+        // Next line is a workaround for Firefix not moving cursor
+        // on input field update
+        assert.equal(
+            element._getNativeInput(input.$.input).selectionStart, 0);
+        input.text = 'test';
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 2);
+        MockInteractions.pressAndReleaseKeyOn(
+            element._getNativeInput(input.$.input), 8); // Backspace
+        assert.equal(element.accounts.length, 2);
+        input.text = '';
+        MockInteractions.pressAndReleaseKeyOn(
+            element._getNativeInput(input.$.input), 8); // Backspace
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 1);
         done();
       });
     });
 
-    suite('allowAnyInput', () => {
-      setup(() => {
-        element.allowAnyInput = true;
-      });
-
-      test('adds emails', () => {
-        const accountLen = element.accounts.length;
-        element._handleAdd({detail: {value: 'test@test'}});
-        assert.equal(element.accounts.length, accountLen + 1);
-        assert.equal(element.accounts[accountLen].email, 'test@test');
-      });
-
-      test('toasts on invalid email', () => {
-        const toastHandler = sandbox.stub();
-        element.addEventListener('show-alert', toastHandler);
-        element._handleAdd({detail: {value: 'test'}});
-        assert.isTrue(toastHandler.called);
+    test('arrow key navigation', done => {
+      const input = element.$.entry.$.input;
+      input.text = '';
+      element.accounts = [makeAccount(), makeAccount()];
+      flush(() => {
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        const chips = element.accountChips;
+        const chipsOneSpy = sandbox.spy(chips[1], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+        assert.isTrue(chipsOneSpy.called);
+        const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+        assert.isTrue(chipsZeroSpy.called);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+        assert.isTrue(chipsZeroSpy.calledOnce);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+        assert.isTrue(chipsOneSpy.calledTwice);
+        done();
       });
     });
 
-    test('_accountMatches', () => {
-      const acct = makeAccount();
+    test('delete', done => {
+      element.accounts = [makeAccount(), makeAccount()];
+      flush(() => {
+        const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
+        const removeSpy = sandbox.spy(element, '_removeAccount');
+        MockInteractions.pressAndReleaseKeyOn(
+            element.accountChips[0], 8); // Backspace
+        assert.isTrue(focusSpy.called);
+        assert.isTrue(removeSpy.calledOnce);
 
-      assert.isTrue(element._accountMatches(acct, acct));
-      acct.email = 'test';
-      assert.isTrue(element._accountMatches(acct, acct));
-      assert.isTrue(element._accountMatches({email: 'test'}, acct));
-
-      assert.isFalse(element._accountMatches({}, acct));
-      assert.isFalse(element._accountMatches({email: 'test2'}, acct));
-      assert.isFalse(element._accountMatches({_account_id: -1}, acct));
-    });
-
-    suite('keyboard interactions', () => {
-      test('backspace at text input start removes last account', done => {
-        const input = element.$.entry.$.input;
-        sandbox.stub(input, '_updateSuggestions');
-        sandbox.stub(element, '_computeRemovable').returns(true);
-        flush(() => {
-          // Next line is a workaround for Firefix not moving cursor
-          // on input field update
-          assert.equal(
-              element._getNativeInput(input.$.input).selectionStart, 0);
-          input.text = 'test';
-          MockInteractions.focus(input.$.input);
-          flushAsynchronousOperations();
-          assert.equal(element.accounts.length, 2);
-          MockInteractions.pressAndReleaseKeyOn(
-              element._getNativeInput(input.$.input), 8); // Backspace
-          assert.equal(element.accounts.length, 2);
-          input.text = '';
-          MockInteractions.pressAndReleaseKeyOn(
-              element._getNativeInput(input.$.input), 8); // Backspace
-          flushAsynchronousOperations();
-          assert.equal(element.accounts.length, 1);
-          done();
-        });
-      });
-
-      test('arrow key navigation', done => {
-        const input = element.$.entry.$.input;
-        input.text = '';
-        element.accounts = [makeAccount(), makeAccount()];
-        flush(() => {
-          MockInteractions.focus(input.$.input);
-          flushAsynchronousOperations();
-          const chips = element.accountChips;
-          const chipsOneSpy = sandbox.spy(chips[1], 'focus');
-          MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
-          assert.isTrue(chipsOneSpy.called);
-          const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
-          MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
-          assert.isTrue(chipsZeroSpy.called);
-          MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
-          assert.isTrue(chipsZeroSpy.calledOnce);
-          MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
-          assert.isTrue(chipsOneSpy.calledTwice);
-          done();
-        });
-      });
-
-      test('delete', done => {
-        element.accounts = [makeAccount(), makeAccount()];
-        flush(() => {
-          const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
-          const removeSpy = sandbox.spy(element, '_removeAccount');
-          MockInteractions.pressAndReleaseKeyOn(
-              element.accountChips[0], 8); // Backspace
-          assert.isTrue(focusSpy.called);
-          assert.isTrue(removeSpy.calledOnce);
-
-          MockInteractions.pressAndReleaseKeyOn(
-              element.accountChips[1], 46); // Delete
-          assert.isTrue(removeSpy.calledTwice);
-          done();
-        });
+        MockInteractions.pressAndReleaseKeyOn(
+            element.accountChips[1], 46); // Delete
+        assert.isTrue(removeSpy.calledTwice);
+        done();
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
deleted file mode 100644
index b0018df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ /dev/null
@@ -1,86 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<script src="../../../scripts/rootElement.js"></script>
-
-<dom-module id="gr-alert">
-  <template>
-    <style include="shared-styles">
-      /**
-       * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
-       * HOW THEY ARE USED IN THE CODE.
-       */
-      :host([toast]) {
-        background-color: var(--tooltip-background-color);
-        bottom: 1.25rem;
-        border-radius: var(--border-radius);
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        color: var(--view-background-color);
-        left: 1.25rem;
-        position: fixed;
-        transform: translateY(5rem);
-        transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
-        z-index: 1000;
-      }
-      :host([shown]) {
-        transform: translateY(0);
-      }
-      /**
-       * NOTE: To avoid style being overwritten by outside of the shadow DOM
-       * (as outside styles always win), .content-wrapper is introduced as a
-       * wrapper around main content to have better encapsulation, styles that
-       * may be affected by outside should be defined on it.
-       * In this case, `padding:0px` is defined in main.css for all elements
-       * with the universal selector: *.
-       */
-      .content-wrapper {
-        padding: var(--spacing-l) var(--spacing-xl);
-      }
-      .text {
-        color: var(--tooltip-text-color);
-        display: inline-block;
-        max-height: 10rem;
-        max-width: 80vw;
-        vertical-align: bottom;
-        word-break: break-all;
-      }
-      .action {
-        color: var(--link-color);
-        font-weight: var(--font-weight-bold);
-        margin-left: var(--spacing-l);
-        text-decoration: none;
-        --gr-button: {
-          padding: 0;
-        }
-      }
-    </style>
-    <div class="content-wrapper">
-      <span class="text">[[text]]</span>
-      <gr-button
-          link
-          class="action"
-          hidden$="[[_hideActionButton]]"
-          on-click="_handleActionTap">[[actionText]]</gr-button>
-    </div>
-  </template>
-  <script src="gr-alert.js"></script>
-</dom-module>
-
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..1ec453e 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -14,21 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-alert',
+import '../gr-button/gr-button.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-alert_html.js';
+import {getRootElement} from '../../../scripts/rootElement.js';
 
-    /**
-     * Fired when the action button is pressed.
-     *
-     * @event action
-     */
+/** @extends Polymer.Element */
+class GrAlert extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-alert'; }
+  /**
+   * Fired when the action button is pressed.
+   *
+   * @event action
+   */
+
+  static get properties() {
+    return {
       text: String,
       actionText: String,
+      /** @type {?string} */
+      type: String,
       shown: {
         type: Boolean,
         value: true,
@@ -47,49 +61,55 @@
         value() { return this._handleTransitionEnd.bind(this); },
       },
       _actionCallback: Function,
-    },
+    };
+  }
 
-    attached() {
-      this.addEventListener('transitionend', this._boundTransitionEndHandler);
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this.addEventListener('transitionend', this._boundTransitionEndHandler);
+  }
 
-    detached() {
-      this.removeEventListener('transitionend',
-          this._boundTransitionEndHandler);
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this.removeEventListener('transitionend',
+        this._boundTransitionEndHandler);
+  }
 
-    show(text, opt_actionText, opt_actionCallback) {
-      this.text = text;
-      this.actionText = opt_actionText;
-      this._hideActionButton = !opt_actionText;
-      this._actionCallback = opt_actionCallback;
-      Gerrit.getRootElement().appendChild(this);
-      this._setShown(true);
-    },
+  show(text, opt_actionText, opt_actionCallback) {
+    this.text = text;
+    this.actionText = opt_actionText;
+    this._hideActionButton = !opt_actionText;
+    this._actionCallback = opt_actionCallback;
+    getRootElement().appendChild(this);
+    this._setShown(true);
+  }
 
-    hide() {
-      this._setShown(false);
-      if (this._hasZeroTransitionDuration()) {
-        Gerrit.getRootElement().removeChild(this);
-      }
-    },
+  hide() {
+    this._setShown(false);
+    if (this._hasZeroTransitionDuration()) {
+      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;
-    },
+  _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; }
+  _handleTransitionEnd(e) {
+    if (this.shown) { return; }
 
-      Gerrit.getRootElement().removeChild(this);
-    },
+    getRootElement().removeChild(this);
+  }
 
-    _handleActionTap(e) {
-      e.preventDefault();
-      if (this._actionCallback) { this._actionCallback(); }
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
new file mode 100644
index 0000000..e9f386d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /**
+       * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
+       * HOW THEY ARE USED IN THE CODE.
+       */
+    :host([toast]) {
+      background-color: var(--tooltip-background-color);
+      bottom: 1.25rem;
+      border-radius: var(--border-radius);
+      box-shadow: var(--elevation-level-2);
+      color: var(--view-background-color);
+      left: 1.25rem;
+      position: fixed;
+      transform: translateY(5rem);
+      transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
+      z-index: 1000;
+    }
+    :host([shown]) {
+      transform: translateY(0);
+    }
+    /**
+       * NOTE: To avoid style being overwritten by outside of the shadow DOM
+       * (as outside styles always win), .content-wrapper is introduced as a
+       * wrapper around main content to have better encapsulation, styles that
+       * may be affected by outside should be defined on it.
+       * In this case, \`padding:0px\` is defined in main.css for all elements
+       * with the universal selector: *.
+       */
+    .content-wrapper {
+      padding: var(--spacing-l) var(--spacing-xl);
+    }
+    .text {
+      color: var(--tooltip-text-color);
+      display: inline-block;
+      max-height: 10rem;
+      max-width: 80vw;
+      vertical-align: bottom;
+      word-break: break-all;
+    }
+    .action {
+      color: var(--link-color);
+      font-weight: var(--font-weight-bold);
+      margin-left: var(--spacing-l);
+      text-decoration: none;
+      --gr-button: {
+        padding: 0;
+      }
+    }
+  </style>
+  <div class="content-wrapper">
+    <span class="text">[[text]]</span>
+    <gr-button
+      link=""
+      class="action"
+      hidden$="[[_hideActionButton]]"
+      on-click="_handleActionTap"
+      >[[actionText]]</gr-button
+    >
+  </div>
+`;
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..557ec28 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
@@ -17,42 +17,43 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-alert.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-alert tests', () => {
-    let element;
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-alert.js';
+suite('gr-alert tests', () => {
+  let element;
 
-    setup(() => {
-      element = document.createElement('gr-alert');
-    });
-
-    teardown(() => {
-      if (element.parentNode) {
-        element.parentNode.removeChild(element);
-      }
-    });
-
-    test('show/hide', () => {
-      assert.isNull(element.parentNode);
-      element.show();
-      assert.equal(element.parentNode, document.body);
-      element.updateStyles({'--gr-alert-transition-duration': '0ms'});
-      element.hide();
-      assert.isNull(element.parentNode);
-    });
-
-    test('action event', done => {
-      element.show();
-      element._actionCallback = done;
-      MockInteractions.tap(element.$$('.action'));
-    });
+  setup(() => {
+    element = document.createElement('gr-alert');
   });
+
+  teardown(() => {
+    if (element.parentNode) {
+      element.parentNode.removeChild(element);
+    }
+  });
+
+  test('show/hide', () => {
+    assert.isNull(element.parentNode);
+    element.show();
+    assert.equal(element.parentNode, document.body);
+    element.updateStyles({'--gr-alert-transition-duration': '0ms'});
+    element.hide();
+    assert.isNull(element.parentNode);
+  });
+
+  test('action event', done => {
+    element.show();
+    element._actionCallback = done;
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.action'));
+  });
+});
 </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
deleted file mode 100644
index 9208068..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+++ /dev/null
@@ -1,107 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<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="../../../scripts/rootElement.js"></script>
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-autocomplete-dropdown">
-  <template>
-    <style include="shared-styles">
-      :host {
-        z-index: 100;
-      }
-      :host([is-hidden]) {
-        display: none;
-      }
-      ul {
-        list-style: none;
-      }
-      li {
-        border-bottom: 1px solid var(--border-color);
-        cursor: pointer;
-        display: flex;
-        justify-content: space-between;
-        padding: var(--spacing-m) var(--spacing-l);
-      }
-      li:last-of-type {
-        border: none;
-      }
-      li:focus {
-        outline: none;
-      }
-      li:hover {
-        background-color: var(--hover-background-color);
-      }
-      li.selected {
-        background-color: var(--selection-background-color);
-      }
-      .dropdown-content {
-        background: var(--dropdown-background-color);
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
-        max-height: 50vh;
-        overflow: auto;
-      }
-      @media only screen and (max-height: 35em) {
-        .dropdown-content {
-          max-height: 80vh;
-        }
-      }
-      .label {
-        color: var(--deemphasized-text-color);
-        padding-left: var(--spacing-l);
-      }
-      .hide {
-        display: none;
-      }
-    </style>
-    <div
-        class="dropdown-content"
-        slot="dropdown-content"
-        id="suggestions"
-        role="listbox">
-      <ul>
-        <template is="dom-repeat" items="[[suggestions]]">
-          <li data-index$="[[index]]"
-              data-value$="[[item.dataValue]]"
-              tabindex="-1"
-              aria-label$="[[item.name]]"
-              class="autocompleteOption"
-              role="option"
-              on-click="_handleClickItem">
-            <span>[[item.text]]</span>
-            <span class$="label [[_computeLabelClass(item)]]">[[item.label]]</span>
-          </li>
-        </template>
-      </ul>
-    </div>
-    <gr-cursor-manager
-        id="cursor"
-        index="{{index}}"
-        cursor-target-class="selected"
-        scroll-behavior="never"
-        focus-on-move
-        stops="[[_suggestionEls]]"></gr-cursor-manager>
-  </template>
-  <script src="gr-autocomplete-dropdown.js"></script>
-</dom-module>
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..46e8829 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
@@ -14,25 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../../../styles/shared-styles.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-autocomplete-dropdown_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  Polymer({
-    is: 'gr-autocomplete-dropdown',
+/**
+ * @extends Polymer.Element
+ */
+class GrAutocompleteDropdown extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+  IronFitBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the dropdown is closed.
-     *
-     * @event dropdown-closed
-     */
+  static get is() { return 'gr-autocomplete-dropdown'; }
+  /**
+   * Fired when the dropdown is closed.
+   *
+   * @event dropdown-closed
+   */
 
-    /**
-     * Fired when item is selected.
-     *
-     * @event item-selected
-     */
+  /**
+   * Fired when item is selected.
+   *
+   * @event item-selected
+   */
 
-    properties: {
+  static get properties() {
+    return {
       index: Number,
       isHidden: {
         type: Boolean,
@@ -53,129 +73,138 @@
         observer: '_resetCursorStops',
       },
       _suggestionEls: Array,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Polymer.IronFitBehavior,
-    ],
-
-    keyBindings: {
+  get keyBindings() {
+    return {
       up: '_handleUp',
       down: '_handleDown',
       enter: '_handleEnter',
       esc: '_handleEscape',
       tab: '_handleTab',
-    },
+    };
+  }
 
-    close() {
-      this.isHidden = true;
-    },
+  close() {
+    this.isHidden = true;
+  }
 
-    open() {
-      this.isHidden = false;
-      this._resetCursorStops();
-      // Refit should run after we call Polymer.flush inside _resetCursorStops
-      this.refit();
-    },
+  open() {
+    this.isHidden = false;
+    this._resetCursorStops();
+    // Refit should run after we call Polymer.flush inside _resetCursorStops
+    this.refit();
+  }
 
-    getCurrentText() {
-      return this.getCursorTarget().dataset.value;
-    },
+  getCurrentText() {
+    return this.getCursorTarget().dataset.value;
+  }
 
-    _handleUp(e) {
-      if (!this.isHidden) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.cursorUp();
-      }
-    },
-
-    _handleDown(e) {
-      if (!this.isHidden) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.cursorDown();
-      }
-    },
-
-    cursorDown() {
-      if (!this.isHidden) {
-        this.$.cursor.next();
-      }
-    },
-
-    cursorUp() {
-      if (!this.isHidden) {
-        this.$.cursor.previous();
-      }
-    },
-
-    _handleTab(e) {
+  _handleUp(e) {
+    if (!this.isHidden) {
       e.preventDefault();
       e.stopPropagation();
-      this.fire('item-selected', {
+      this.cursorUp();
+    }
+  }
+
+  _handleDown(e) {
+    if (!this.isHidden) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.cursorDown();
+    }
+  }
+
+  cursorDown() {
+    if (!this.isHidden) {
+      this.$.cursor.next();
+    }
+  }
+
+  cursorUp() {
+    if (!this.isHidden) {
+      this.$.cursor.previous();
+    }
+  }
+
+  _handleTab(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('item-selected', {
+      detail: {
         trigger: 'tab',
         selected: this.$.cursor.target,
-      });
-    },
+      },
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _handleEnter(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('item-selected', {
+  _handleEnter(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('item-selected', {
+      detail: {
         trigger: 'enter',
         selected: this.$.cursor.target,
-      });
-    },
+      },
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _handleEscape() {
-      this._fireClose();
-      this.close();
-    },
+  _handleEscape() {
+    this._fireClose();
+    this.close();
+  }
 
-    _handleClickItem(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      let selected = e.target;
-      while (!selected.classList.contains('autocompleteOption')) {
-        if (!selected || selected === this) { return; }
-        selected = selected.parentElement;
-      }
-      this.fire('item-selected', {
+  _handleClickItem(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    let selected = e.target;
+    while (!selected.classList.contains('autocompleteOption')) {
+      if (!selected || selected === this) { return; }
+      selected = selected.parentElement;
+    }
+    this.dispatchEvent(new CustomEvent('item-selected', {
+      detail: {
         trigger: 'click',
         selected,
-      });
-    },
+      },
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _fireClose() {
-      this.fire('dropdown-closed');
-    },
+  _fireClose() {
+    this.dispatchEvent(new CustomEvent('dropdown-closed', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    getCursorTarget() {
-      return this.$.cursor.target;
-    },
+  getCursorTarget() {
+    return this.$.cursor.target;
+  }
 
-    _resetCursorStops() {
-      if (this.suggestions.length > 0) {
-        if (!this.isHidden) {
-          Polymer.dom.flush();
-          this._suggestionEls = Array.from(
-              this.$.suggestions.querySelectorAll('li'));
-          this._resetCursorIndex();
-        }
-      } else {
-        this._suggestionEls = [];
+  _resetCursorStops() {
+    if (this.suggestions.length > 0) {
+      if (!this.isHidden) {
+        flush();
+        this._suggestionEls = Array.from(
+            this.$.suggestions.querySelectorAll('li'));
+        this._resetCursorIndex();
       }
-    },
+    } else {
+      this._suggestionEls = [];
+    }
+  }
 
-    _resetCursorIndex() {
-      this.$.cursor.setCursorAtIndex(0);
-    },
+  _resetCursorIndex() {
+    this.$.cursor.setCursorAtIndex(0);
+  }
 
-    _computeLabelClass(item) {
-      return item.label ? '' : 'hide';
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
new file mode 100644
index 0000000..b31af73
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      z-index: 100;
+    }
+    :host([is-hidden]) {
+      display: none;
+    }
+    ul {
+      list-style: none;
+    }
+    li {
+      border-bottom: 1px solid var(--border-color);
+      cursor: pointer;
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    li:last-of-type {
+      border: none;
+    }
+    li:focus {
+      outline: none;
+    }
+    li:hover {
+      background-color: var(--hover-background-color);
+    }
+    li.selected {
+      background-color: var(--selection-background-color);
+    }
+    .dropdown-content {
+      background: var(--dropdown-background-color);
+      box-shadow: var(--elevation-level-2);
+      border-radius: var(--border-radius);
+      max-height: 50vh;
+      overflow: auto;
+    }
+    @media only screen and (max-height: 35em) {
+      .dropdown-content {
+        max-height: 80vh;
+      }
+    }
+    .label {
+      color: var(--deemphasized-text-color);
+      padding-left: var(--spacing-l);
+    }
+    .hide {
+      display: none;
+    }
+  </style>
+  <div
+    class="dropdown-content"
+    slot="dropdown-content"
+    id="suggestions"
+    role="listbox"
+  >
+    <ul>
+      <template is="dom-repeat" items="[[suggestions]]">
+        <li
+          data-index$="[[index]]"
+          data-value$="[[item.dataValue]]"
+          tabindex="-1"
+          aria-label$="[[item.name]]"
+          class="autocompleteOption"
+          role="option"
+          on-click="_handleClickItem"
+        >
+          <span>[[item.text]]</span>
+          <span class$="label [[_computeLabelClass(item)]]"
+            >[[item.label]]</span
+          >
+        </li>
+      </template>
+    </ul>
+  </div>
+  <gr-cursor-manager
+    id="cursor"
+    index="{{index}}"
+    cursor-target-class="selected"
+    scroll-behavior="never"
+    focus-on-move=""
+    stops="[[_suggestionEls]]"
+  ></gr-cursor-manager>
+`;
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..d836155 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-autocomplete-dropdown.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,123 +31,124 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-autocomplete-dropdown', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-autocomplete-dropdown.js';
+suite('gr-autocomplete-dropdown', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.open();
-      element.suggestions = [
-        {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
-        {dataValue: 'test value 2', name: 'test name 2', text: 2}];
-      flushAsynchronousOperations();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.open();
+    element.suggestions = [
+      {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
+      {dataValue: 'test value 2', name: 'test name 2', text: 2}];
+    flushAsynchronousOperations();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-      if (element.isOpen) element.close();
-    });
+  teardown(() => {
+    sandbox.restore();
+    if (element.isOpen) element.close();
+  });
 
-    test('shows labels', () => {
-      const els = element.$.suggestions.querySelectorAll('li');
-      assert.equal(els[0].innerText.trim(), '1\nhi');
-      assert.equal(els[1].innerText.trim(), '2');
-    });
+  test('shows labels', () => {
+    const els = element.$.suggestions.querySelectorAll('li');
+    assert.equal(els[0].innerText.trim(), '1\nhi');
+    assert.equal(els[1].innerText.trim(), '2');
+  });
 
-    test('escape key', done => {
-      const closeSpy = sandbox.spy(element, 'close');
-      MockInteractions.pressAndReleaseKeyOn(element, 27);
-      flushAsynchronousOperations();
-      assert.isTrue(closeSpy.called);
-      done();
-    });
+  test('escape key', done => {
+    const closeSpy = sandbox.spy(element, 'close');
+    MockInteractions.pressAndReleaseKeyOn(element, 27);
+    flushAsynchronousOperations();
+    assert.isTrue(closeSpy.called);
+    done();
+  });
 
-    test('tab key', () => {
-      const handleTabSpy = sandbox.spy(element, '_handleTab');
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-      MockInteractions.pressAndReleaseKeyOn(element, 9);
-      assert.isTrue(handleTabSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      assert.isTrue(itemSelectedStub.called);
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'tab',
-        selected: element.getCursorTarget(),
-      });
-    });
-
-    test('enter key', () => {
-      const handleEnterSpy = sandbox.spy(element, '_handleEnter');
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-      MockInteractions.pressAndReleaseKeyOn(element, 13);
-      assert.isTrue(handleEnterSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'enter',
-        selected: element.getCursorTarget(),
-      });
-    });
-
-    test('down key', () => {
-      element.isHidden = true;
-      const nextSpy = sandbox.spy(element.$.cursor, 'next');
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
-      assert.isFalse(nextSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      element.isHidden = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
-      assert.isTrue(nextSpy.called);
-      assert.equal(element.$.cursor.index, 1);
-    });
-
-    test('up key', () => {
-      element.isHidden = true;
-      const prevSpy = sandbox.spy(element.$.cursor, 'previous');
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
-      assert.isFalse(prevSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-      element.isHidden = false;
-      element.$.cursor.setCursorAtIndex(1);
-      assert.equal(element.$.cursor.index, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
-      assert.isTrue(prevSpy.called);
-      assert.equal(element.$.cursor.index, 0);
-    });
-
-    test('tapping selects item', () => {
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-
-      MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
-      flushAsynchronousOperations();
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'click',
-        selected: element.$.suggestions.querySelectorAll('li')[1],
-      });
-    });
-
-    test('tapping child still selects item', () => {
-      const itemSelectedStub = sandbox.stub();
-      element.addEventListener('item-selected', itemSelectedStub);
-
-      MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
-          .lastElementChild);
-      flushAsynchronousOperations();
-      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-        trigger: 'click',
-        selected: element.$.suggestions.querySelectorAll('li')[0],
-      });
-    });
-
-    test('updated suggestions resets cursor stops', () => {
-      resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
-      element.suggestions = [];
-      assert.isTrue(resetStopsSpy.called);
+  test('tab key', () => {
+    const handleTabSpy = sandbox.spy(element, '_handleTab');
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+    MockInteractions.pressAndReleaseKeyOn(element, 9);
+    assert.isTrue(handleTabSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    assert.isTrue(itemSelectedStub.called);
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'tab',
+      selected: element.getCursorTarget(),
     });
   });
 
+  test('enter key', () => {
+    const handleEnterSpy = sandbox.spy(element, '_handleEnter');
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+    MockInteractions.pressAndReleaseKeyOn(element, 13);
+    assert.isTrue(handleEnterSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'enter',
+      selected: element.getCursorTarget(),
+    });
+  });
+
+  test('down key', () => {
+    element.isHidden = true;
+    const nextSpy = sandbox.spy(element.$.cursor, 'next');
+    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    assert.isFalse(nextSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    element.isHidden = false;
+    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    assert.isTrue(nextSpy.called);
+    assert.equal(element.$.cursor.index, 1);
+  });
+
+  test('up key', () => {
+    element.isHidden = true;
+    const prevSpy = sandbox.spy(element.$.cursor, 'previous');
+    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    assert.isFalse(prevSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    element.isHidden = false;
+    element.$.cursor.setCursorAtIndex(1);
+    assert.equal(element.$.cursor.index, 1);
+    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    assert.isTrue(prevSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+  });
+
+  test('tapping selects item', () => {
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+    flushAsynchronousOperations();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[1],
+    });
+  });
+
+  test('tapping child still selects item', () => {
+    const itemSelectedStub = sandbox.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
+        .lastElementChild);
+    flushAsynchronousOperations();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[0],
+    });
+  });
+
+  test('updated suggestions resets cursor stops', () => {
+    const resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
+    element.suggestions = [];
+    assert.isTrue(resetStopsSpy.called);
+  });
+});
 </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
deleted file mode 100644
index c9d12ce..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ /dev/null
@@ -1,104 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/paper-input/paper-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-autocomplete">
-  <template>
-    <style include="shared-styles">
-      .searchIcon {
-        display: none;
-      }
-      .searchIcon.showSearchIcon {
-        display: inline-block;
-      }
-      iron-icon {
-        margin: 0 var(--spacing-xs);
-        vertical-align: top;
-      }
-      paper-input:not(.borderless) {
-        border: 1px solid var(--border-color);
-      }
-      paper-input {
-        height: var(--line-height-normal);
-        width: 100%;
-        @apply --gr-autocomplete;
-        --paper-input-container: {
-          padding: 0;
-        };
-        --paper-input-container-input: {
-          font-size: var(--font-size-normal);
-          line-height: var(--line-height-normal);
-        };
-        --paper-input-container-underline: {
-          display: none;
-        };
-        --paper-input-container-underline-focus: {
-          display: none;
-        };
-        --paper-input-container-underline-disabled: {
-          display: none;
-        };
-      }
-      paper-input.warnUncommitted {
-        --paper-input-container-input: {
-          color: var(--error-text-color);
-          font-size: inherit;
-        }
-      }
-    </style>
-    <paper-input
-        no-label-float
-        id="input"
-        class$="[[_computeClass(borderless)]]"
-        disabled$="[[disabled]]"
-        value="{{text}}"
-        placeholder="[[placeholder]]"
-        on-keydown="_handleKeydown"
-        on-focus="_onInputFocus"
-        on-blur="_onInputBlur"
-        autocomplete="off">
-
-      <!-- prefix as attribute is required to for polymer 1 -->
-      <div slot="prefix" prefix>
-        <iron-icon
-          icon="gr-icons:search"
-          class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]">
-        </iron-icon>
-      </div>
-    </paper-input>
-    <gr-autocomplete-dropdown
-        vertical-align="top"
-        vertical-offset="[[verticalOffset]]"
-        horizontal-align="left"
-        id="suggestions"
-        on-item-selected="_handleItemSelect"
-        on-keydown="_handleKeydown"
-        suggestions="[[_suggestions]]"
-        role="listbox"
-        index="[[_index]]"
-        position-target="[[_inputElement]]">
-    </gr-autocomplete-dropdown>
-  </template>
-  <script src="gr-autocomplete.js"></script>
-</dom-module>
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..7f9ed72 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -14,35 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
-  const DEBOUNCE_WAIT_MS = 200;
+import '@polymer/paper-input/paper-input.js';
+import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../gr-icons/gr-icons.js';
+import '../../../styles/shared-styles.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-autocomplete_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  Polymer({
-    is: 'gr-autocomplete',
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+const DEBOUNCE_WAIT_MS = 200;
 
-    /**
-     * Fired when a value is chosen.
-     *
-     * @event commit
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrAutocomplete extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the user cancels.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-autocomplete'; }
+  /**
+   * Fired when a value is chosen.
+   *
+   * @event commit
+   */
 
-    /**
-     * Fired on keydown to allow for custom hooks into autocomplete textbox
-     * behavior.
-     *
-     * @event input-keydown
-     */
+  /**
+   * Fired when the user cancels.
+   *
+   * @event cancel
+   */
 
-    properties: {
+  /**
+   * Fired on keydown to allow for custom hooks into autocomplete textbox
+   * behavior.
+   *
+   * @event input-keydown
+   */
+
+  static get properties() {
+    return {
 
       /**
        * Query for requesting autocomplete suggestions. The function should
@@ -80,11 +101,15 @@
         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.
+      /**
+       * 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: 20,
+        value: 31,
       },
 
       text: {
@@ -164,271 +189,292 @@
 
       /** The DOM element of the selected suggestion. */
       _selected: Object,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    observers: [
+  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;
-    },
+  get _nativeInput() {
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return this.$.input.$.nativeInput || this.$.input.inputElement;
+  }
 
-    attached() {
-      this.listen(document.body, 'click', '_handleBodyClick');
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(document.body, 'click', '_handleBodyClick');
+  }
 
-    detached() {
-      this.unlisten(document.body, 'click', '_handleBodyClick');
-      this.cancelDebouncer('update-suggestions');
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(document.body, 'click', '_handleBodyClick');
+    this.cancelDebouncer('update-suggestions');
+  }
 
-    get focusStart() {
-      return this.$.input;
-    },
+  get focusStart() {
+    return this.$.input;
+  }
 
-    focus() {
-      this._nativeInput.focus();
-    },
+  focus() {
+    this._nativeInput.focus();
+  }
 
-    selectAll() {
-      const nativeInputElement = this._nativeInput;
-      if (!this.$.input.value) { return; }
-      nativeInputElement.setSelectionRange(0, this.$.input.value.length);
-    },
+  selectAll() {
+    const nativeInputElement = this._nativeInput;
+    if (!this.$.input.value) { return; }
+    nativeInputElement.setSelectionRange(0, this.$.input.value.length);
+  }
 
-    clear() {
-      this.text = '';
-    },
+  clear() {
+    this.text = '';
+  }
 
-    _handleItemSelect(e) {
-      // Let _handleKeydown deal with keyboard interaction.
-      if (e.detail.trigger !== 'click') { return; }
-      this._selected = e.detail.selected;
-      this._commit();
-    },
+  _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;
-    },
+  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.
-     *
-     * @param {string} text The new text for the input.
-     */
-    setText(text) {
-      this._disableSuggestions = true;
-      this.text = text;
-      this._disableSuggestions = false;
-    },
+  /**
+   * Set the text of the input without triggering the suggestion dropdown.
+   *
+   * @param {string} text The new text for the input.
+   */
+  setText(text) {
+    this._disableSuggestions = true;
+    this.text = text;
+    this._disableSuggestions = false;
+  }
 
-    _onInputFocus() {
-      this._focused = true;
-      this._updateSuggestions(this.text, this.threshold, this.noDebounce);
-      this.$.input.classList.remove('warnUncommitted');
-      // Needed so that --paper-input-container-input updated style is applied.
-      this.updateStyles();
-    },
+  _onInputFocus() {
+    this._focused = true;
+    this._updateSuggestions(this.text, this.threshold, this.noDebounce);
+    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();
-    },
+  _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
-      if ([text, threshold, noDebounce].some(arg => arg === undefined)) {
-        return;
-      }
+  _updateSuggestions(text, threshold, noDebounce) {
+    // Polymer 2: check for undefined
+    if ([text, threshold, noDebounce].some(arg => arg === undefined)) {
+      return;
+    }
 
-      if (this._disableSuggestions) { return; }
-      if (text.length < threshold) {
-        this._suggestions = [];
-        this.value = '';
-        return;
-      }
+    // Reset _suggestions for every update
+    // This will also prevent from carrying over suggestions:
+    // @see Issue 12039
+    this._suggestions = [];
 
-      const update = () => {
-        this.query(text).then(suggestions => {
-          if (text !== this.text) {
-            // Late response.
-            return;
-          }
-          for (const suggestion of suggestions) {
-            suggestion.text = suggestion.name;
-          }
-          this._suggestions = suggestions;
-          Polymer.dom.flush();
-          if (this._index === -1) {
-            this.value = '';
-          }
-        });
-      };
+    // TODO(taoalpha): Also skip if text has not changed
 
-      if (noDebounce) {
-        update();
-      } else {
-        this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
-      }
-    },
+    if (this._disableSuggestions) { return; }
+    if (text.length < threshold) {
+      this.value = '';
+      return;
+    }
 
-    _maybeOpenDropdown(suggestions, focused) {
-      if (suggestions.length > 0 && focused) {
-        return this.$.suggestions.open();
-      }
-      return this.$.suggestions.close();
-    },
+    if (!this._focused) {
+      return;
+    }
 
-    _computeClass(borderless) {
-      return borderless ? 'borderless' : '';
-    },
-
-    /**
-     * _handleKeydown used for key handling in the this.$.input AND all child
-     * autocomplete options.
-     */
-    _handleKeydown(e) {
-      this._focused = true;
-      switch (e.keyCode) {
-        case 38: // Up
-          e.preventDefault();
-          this.$.suggestions.cursorUp();
-          break;
-        case 40: // Down
-          e.preventDefault();
-          this.$.suggestions.cursorDown();
-          break;
-        case 27: // Escape
-          e.preventDefault();
-          this._cancel();
-          break;
-        case 9: // Tab
-          if (this._suggestions.length > 0 && this.tabComplete) {
-            e.preventDefault();
-            this._handleInputCommit(true);
-            this.focus();
-          } else {
-            this._focused = false;
-          }
-          break;
-        case 13: // Enter
-          if (this.modifierPressed(e)) { break; }
-          e.preventDefault();
-          this._handleInputCommit();
-          break;
-        default:
-          // For any normal keypress, return focus to the input to allow for
-          // unbroken user input.
-          this.focus();
-
-          // Since this has been a normal keypress, the suggestions will have
-          // been based on a previous input. Clear them. This prevents an
-          // outdated suggestion from being used if the input keystroke is
-          // immediately followed by a commit keystroke. @see Issue 8655
-          this._suggestions = [];
-      }
-      this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
-    },
-
-    _cancel() {
-      if (this._suggestions.length) {
-        this.set('_suggestions', []);
-      } 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; }
-
-      this._selected = this.$.suggestions.getCursorTarget();
-      this._commit(opt_tabComplete);
-    },
-
-    _updateValue(suggestion, suggestions) {
-      if (!suggestion) { return; }
-      const completed = suggestions[suggestion.dataset.index].value;
-      if (this.multi) {
-        // Append the completed text to the end of the string.
-        // Allow spaces within quoted terms.
-        const tokens = this.text.match(TOKENIZE_REGEX);
-        tokens[tokens.length - 1] = completed;
-        this.value = tokens.join(' ');
-      } else {
-        this.value = completed;
-      }
-    },
-
-    _handleBodyClick(e) {
-      const eventPath = Polymer.dom(e).path;
-      for (let i = 0; i < eventPath.length; i++) {
-        if (eventPath[i] === this) {
+    const update = () => {
+      this.query(text).then(suggestions => {
+        if (text !== this.text) {
+          // Late response.
           return;
         }
-      }
-      this._focused = false;
-    },
-
-    _handleSuggestionTap(e) {
-      e.stopPropagation();
-      this.$.cursor.setCursor(e.target);
-      this._commit();
-    },
-
-    /**
-     * Commits the suggestion, optionally firing the commit event.
-     *
-     * @param {boolean=} opt_silent Allows for silent committing of an
-     *     autocomplete suggestion in order to handle cases like tab-to-complete
-     *     without firing the commit event.
-     */
-    _commit(opt_silent) {
-      // Allow values that are not in suggestion list iff suggestions are empty.
-      if (this._suggestions.length > 0) {
-        this._updateValue(this._selected, this._suggestions);
-      } else {
-        this.value = this.text || '';
-      }
-
-      const value = this.value;
-
-      // Value and text are mirrors of each other in multi mode.
-      if (this.multi) {
-        this.setText(this.value);
-      } else {
-        if (!this.clearOnCommit && this._selected) {
-          this.setText(this._suggestions[this._selected.dataset.index].name);
-        } else {
-          this.clear();
+        for (const suggestion of suggestions) {
+          suggestion.text = suggestion.name;
         }
+        this._suggestions = suggestions;
+        flush();
+        if (this._index === -1) {
+          this.value = '';
+        }
+      });
+    };
+
+    if (noDebounce) {
+      update();
+    } 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
+   * autocomplete options.
+   */
+  _handleKeydown(e) {
+    this._focused = true;
+    switch (e.keyCode) {
+      case 38: // Up
+        e.preventDefault();
+        this.$.suggestions.cursorUp();
+        break;
+      case 40: // Down
+        e.preventDefault();
+        this.$.suggestions.cursorDown();
+        break;
+      case 27: // Escape
+        e.preventDefault();
+        this._cancel();
+        break;
+      case 9: // Tab
+        if (this._suggestions.length > 0 && this.tabComplete) {
+          e.preventDefault();
+          this._handleInputCommit(true);
+          this.focus();
+        } else {
+          this._focused = false;
+        }
+        break;
+      case 13: // Enter
+        if (this.modifierPressed(e)) { break; }
+        e.preventDefault();
+        this._handleInputCommit();
+        break;
+      default:
+        // For any normal keypress, return focus to the input to allow for
+        // unbroken user input.
+        this.focus();
+
+        // Since this has been a normal keypress, the suggestions will have
+        // been based on a previous input. Clear them. This prevents an
+        // outdated suggestion from being used if the input keystroke is
+        // immediately followed by a commit keystroke. @see Issue 8655
+        this._suggestions = [];
+    }
+    this.dispatchEvent(new CustomEvent('input-keydown', {
+      detail: {keyCode: e.keyCode, input: this.$.input},
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _cancel() {
+    if (this._suggestions.length) {
+      this.set('_suggestions', []);
+    } else {
+      this.dispatchEvent(new CustomEvent('cancel', {
+        composed: true, bubbles: true,
+      }));
+    }
+  }
+
+  /**
+   * @param {boolean=} opt_tabComplete
+   */
+  _handleInputCommit(opt_tabComplete) {
+    // Nothing to do if the dropdown is not open.
+    if (!this.allowNonSuggestedValues &&
+        this.$.suggestions.isHidden) { return; }
+
+    this._selected = this.$.suggestions.getCursorTarget();
+    this._commit(opt_tabComplete);
+  }
+
+  _updateValue(suggestion, suggestions) {
+    if (!suggestion) { return; }
+    const completed = suggestions[suggestion.dataset.index].value;
+    if (this.multi) {
+      // Append the completed text to the end of the string.
+      // Allow spaces within quoted terms.
+      const tokens = this.text.match(TOKENIZE_REGEX);
+      tokens[tokens.length - 1] = completed;
+      this.value = tokens.join(' ');
+    } else {
+      this.value = completed;
+    }
+  }
+
+  _handleBodyClick(e) {
+    const eventPath = dom(e).path;
+    for (let i = 0; i < eventPath.length; i++) {
+      if (eventPath[i] === this) {
+        return;
       }
+    }
+    this._focused = false;
+  }
 
-      this._suggestions = [];
-      if (!opt_silent) {
-        this.fire('commit', {value});
+  _handleSuggestionTap(e) {
+    e.stopPropagation();
+    this.$.cursor.setCursor(e.target);
+    this._commit();
+  }
+
+  /**
+   * Commits the suggestion, optionally firing the commit event.
+   *
+   * @param {boolean=} opt_silent Allows for silent committing of an
+   *     autocomplete suggestion in order to handle cases like tab-to-complete
+   *     without firing the commit event.
+   */
+  _commit(opt_silent) {
+    // Allow values that are not in suggestion list iff suggestions are empty.
+    if (this._suggestions.length > 0) {
+      this._updateValue(this._selected, this._suggestions);
+    } else {
+      this.value = this.text || '';
+    }
+
+    const value = this.value;
+
+    // Value and text are mirrors of each other in multi mode.
+    if (this.multi) {
+      this.setText(this.value);
+    } else {
+      if (!this.clearOnCommit && this._selected) {
+        this.setText(this._suggestions[this._selected.dataset.index].name);
+      } else {
+        this.clear();
       }
+    }
 
-      this._textChangedSinceCommit = false;
-    },
+    this._suggestions = [];
+    if (!opt_silent) {
+      this.dispatchEvent(new CustomEvent('commit', {
+        detail: {value},
+        composed: true, bubbles: true,
+      }));
+    }
 
-    _computeShowSearchIconClass(showSearchIcon) {
-      return showSearchIcon ? 'showSearchIcon' : '';
-    },
-  });
-})();
+    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_html.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
new file mode 100644
index 0000000..eae8741
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
@@ -0,0 +1,111 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .searchIcon {
+      display: none;
+    }
+    .searchIcon.showSearchIcon {
+      display: inline-block;
+    }
+    iron-icon {
+      margin: 0 var(--spacing-xs);
+      vertical-align: top;
+    }
+    paper-input.borderless {
+      border: none;
+      padding: 0;
+    }
+    paper-input {
+      background-color: var(--view-background-color);
+      color: var(--primary-text-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      padding: var(--spacing-s);
+      --paper-input-container: {
+        padding: 0;
+      }
+      --paper-input-container-input: {
+        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;
+      }
+    }
+    paper-input.warnUncommitted {
+      --paper-input-container-input: {
+        color: var(--error-text-color);
+        font-size: inherit;
+      }
+    }
+  </style>
+  <paper-input
+    no-label-float=""
+    id="input"
+    class$="[[_computeClass(borderless)]]"
+    disabled$="[[disabled]]"
+    value="{{text}}"
+    placeholder="[[placeholder]]"
+    on-keydown="_handleKeydown"
+    on-focus="_onInputFocus"
+    on-blur="_onInputBlur"
+    autocomplete="off"
+  >
+    <!-- prefix as attribute is required to for polymer 1 -->
+    <div slot="prefix" prefix="">
+      <iron-icon
+        icon="gr-icons:search"
+        class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]"
+      >
+      </iron-icon>
+    </div>
+  </paper-input>
+  <gr-autocomplete-dropdown
+    vertical-align="top"
+    vertical-offset="[[verticalOffset]]"
+    horizontal-align="left"
+    id="suggestions"
+    on-item-selected="_handleItemSelect"
+    on-keydown="_handleKeydown"
+    suggestions="[[_suggestions]]"
+    role="listbox"
+    index="[[_index]]"
+    position-target="[[_inputElement]]"
+  >
+  </gr-autocomplete-dropdown>
+`;
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..6dd5a97 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-autocomplete.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,515 +31,582 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-autocomplete tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-autocomplete.js';
+import {dom, flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-autocomplete tests', () => {
+  let element;
+  let sandbox;
+  const focusOnInput = element => {
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+        'enter');
+  };
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('renders', () => {
+    let promise;
+    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);
+    element._focused = true;
+
+    return promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+      const suggestions =
+          dom(element.$.suggestions.root).querySelectorAll('li');
+      assert.equal(suggestions.length, 5);
+
+      for (let i = 0; i < 5; i++) {
+        assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
+      }
+
+      assert.notEqual(element.$.suggestions.$.cursor.index, -1);
+    });
+  });
+
+  test('selectAll', done => {
+    flush(() => {
+      const nativeInput = element._nativeInput;
+      const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
+
+      element.selectAll();
+      assert.isFalse(selectionStub.called);
+
+      element.$.input.value = 'test';
+      element.selectAll();
+      assert.isTrue(selectionStub.called);
+      done();
+    });
+  });
+
+  test('esc key behavior', done => {
+    let promise;
+    const queryStub = sandbox.spy(() => promise = Promise.resolve([
+      {name: 'blah', value: 123},
+    ]));
+    element.query = queryStub;
+
+    assert.isTrue(element.$.suggestions.isHidden);
+
+    element._focused = true;
+    element.text = 'blah';
+
+    promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const cancelHandler = sandbox.spy();
+      element.addEventListener('cancel', cancelHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+      assert.isFalse(cancelHandler.called);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.equal(element._suggestions.length, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+      assert.isTrue(cancelHandler.called);
+      done();
+    });
+  });
+
+  test('emits commit and handles cursor movement', done => {
+    let promise;
+    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);
+    element._focused = true;
+    element.text = 'blah';
+
+    promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      assert.equal(element.$.suggestions.$.cursor.index, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+          'down');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+          'down');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.equal(element.value, 1);
+      assert.isTrue(commitHandler.called);
+      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.isTrue(element._focused);
+      done();
+    });
+  });
+
+  test('clear-on-commit behavior (off)', done => {
+    let promise;
+    const queryStub = sandbox.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+
+    promise.then(() => {
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'suggestion');
+      done();
+    });
+  });
+
+  test('clear-on-commit behavior (on)', done => {
+    let promise;
+    const queryStub = sandbox.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+    element.clearOnCommit = true;
+
+    promise.then(() => {
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, '');
+      done();
+    });
+  });
+
+  test('threshold guards the query', () => {
+    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(() => 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);
+    assert.equal(debounceStub.lastCall.args[2], 200);
+    assert.isFunction(callback);
+    callback();
+    assert.isTrue(queryStub.called);
+  });
+
+  test('_computeClass respects border property', () => {
+    assert.equal(element._computeClass(), '');
+    assert.equal(element._computeClass(false), '');
+    assert.equal(element._computeClass(true), 'borderless');
+  });
+
+  test('undefined or empty text results in no suggestions', () => {
+    element._updateSuggestions(undefined, 0, null);
+    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},
+        ]));
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah blah';
+    element.multi = true;
+
+    promise.then(() => {
+      const commitHandler = sandbox.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'blah 0');
+      done();
+    });
+  });
+
+  test('tabComplete flag functions', () => {
+    // commitHandler checks for the commit event, whereas commitSpy checks for
+    // the _commit function of the element.
+    const commitHandler = sandbox.spy();
+    element.addEventListener('commit', commitHandler);
+    const commitSpy = sandbox.spy(element, '_commit');
+    element._focused = true;
+
+    element._suggestions = ['tunnel snakes rule!'];
+    element.tabComplete = false;
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isFalse(commitSpy.called);
+    assert.isFalse(element._focused);
+
+    element.tabComplete = true;
+    element._focused = true;
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isTrue(commitSpy.called);
+    assert.isTrue(element._focused);
+  });
+
+  test('_focused flag properly triggered', done => {
+    flush(() => {
+      assert.isFalse(element._focused);
+      const input = element.shadowRoot
+          .querySelector('paper-input').inputElement;
+      MockInteractions.focus(input);
+      assert.isTrue(element._focused);
+      done();
+    });
+  });
+
+  test('search icon shows with showSearchIcon property', done => {
+    flush(() => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('iron-icon')).display,
+      'none');
+      element.showSearchIcon = true;
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('iron-icon')).display,
+      'none');
+      done();
+    });
+  });
+
+  test('vertical offset overridden by param if it exists', () => {
+    assert.equal(element.$.suggestions.verticalOffset, 31);
+    element.verticalOffset = 30;
+    assert.equal(element.$.suggestions.verticalOffset, 30);
+  });
+
+  test('_focused flag shows/hides the suggestions', () => {
+    const openStub = sandbox.stub(element.$.suggestions, 'open');
+    const closedStub = sandbox.stub(element.$.suggestions, 'close');
+    element._suggestions = ['hello', 'its me'];
+    assert.isFalse(openStub.called);
+    assert.isTrue(closedStub.calledOnce);
+    element._focused = true;
+    assert.isTrue(openStub.calledOnce);
+    element._suggestions = [];
+    assert.isTrue(closedStub.calledTwice);
+    assert.isTrue(openStub.calledOnce);
+  });
+
+  test('_handleInputCommit with autocomplete hidden does nothing without' +
+        'without allowNonSuggestedValues', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.$.suggestions.isHidden = true;
+    element._handleInputCommit();
+    assert.isFalse(commitStub.called);
+  });
+
+  test('_handleInputCommit with autocomplete hidden with' +
+        'allowNonSuggestedValues', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.allowNonSuggestedValues = true;
+    element.$.suggestions.isHidden = true;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.called);
+  });
+
+  test('_handleInputCommit with autocomplete open calls commit', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.$.suggestions.isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test('_handleInputCommit with autocomplete open calls commit' +
+        'with allowNonSuggestedValues', () => {
+    const commitStub = sandbox.stub(element, '_commit');
+    element.allowNonSuggestedValues = true;
+    element.$.suggestions.isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test('issue 8655', () => {
+    function makeSuggestion(s) { return {name: s, text: s, value: s}; }
+    const keydownSpy = sandbox.spy(element, '_handleKeydown');
+    element.setText('file:');
+    element._suggestions =
+        [makeSuggestion('file:'), makeSuggestion('-file:')];
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
+    // 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'
+    );
+    assert.isTrue(keydownSpy.calledTwice);
+    assert.equal(element.text, 'file:x');
+  });
+
+  suite('focus', () => {
+    let commitSpy;
+    let focusSpy;
 
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+      commitSpy = sandbox.spy(element, '_commit');
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    test('enter does not call focus', () => {
+      element._suggestions = ['sugar bombs'];
+      focusSpy = sandbox.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+      flushAsynchronousOperations();
 
-    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},
-        ]);
-      });
-      element.query = queryStub;
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element.$.suggestions.$.cursor.index, -1);
-
-      element.text = 'blah';
-
-      assert.isTrue(queryStub.called);
-      element._focused = true;
-
-      return promise.then(() => {
-        assert.isFalse(element.$.suggestions.isHidden);
-        const suggestions =
-            Polymer.dom(element.$.suggestions.root).querySelectorAll('li');
-        assert.equal(suggestions.length, 5);
-
-        for (let i = 0; i < 5; i++) {
-          assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
-        }
-
-        assert.notEqual(element.$.suggestions.$.cursor.index, -1);
-      });
-    });
-
-    test('selectAll', done => {
-      flush(() => {
-        const nativeInput = element._nativeInput;
-        const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
-
-        element.selectAll();
-        assert.isFalse(selectionStub.called);
-
-        element.$.input.value = 'test';
-        element.selectAll();
-        assert.isTrue(selectionStub.called);
-        done();
-      });
-    });
-
-    test('esc key behavior', done => {
-      let promise;
-      const queryStub = sandbox.spy(() => {
-        return promise = Promise.resolve([
-          {name: 'blah', value: 123},
-        ]);
-      });
-      element.query = queryStub;
-
-      assert.isTrue(element.$.suggestions.isHidden);
-
-      element._focused = true;
-      element.text = 'blah';
-
-      promise.then(() => {
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        const cancelHandler = sandbox.spy();
-        element.addEventListener('cancel', cancelHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-        assert.isFalse(cancelHandler.called);
-        assert.isTrue(element.$.suggestions.isHidden);
-        assert.equal(element._suggestions.length, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-        assert.isTrue(cancelHandler.called);
-        done();
-      });
-    });
-
-    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},
-        ]);
-      });
-      element.query = queryStub;
-
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element.$.suggestions.$.cursor.index, -1);
-      element._focused = true;
-      element.text = 'blah';
-
-      promise.then(() => {
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        assert.equal(element.$.suggestions.$.cursor.index, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-            'down');
-
-        assert.equal(element.$.suggestions.$.cursor.index, 1);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-            'down');
-
-        assert.equal(element.$.suggestions.$.cursor.index, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
-
-        assert.equal(element.$.suggestions.$.cursor.index, 1);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.equal(element.value, 1);
-        assert.isTrue(commitHandler.called);
-        assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-        assert.isTrue(element.$.suggestions.isHidden);
-        assert.isTrue(element._focused);
-        done();
-      });
-    });
-
-    test('clear-on-commit behavior (off)', done => {
-      let promise;
-      const queryStub = sandbox.spy(() => {
-        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      });
-      element.query = queryStub;
-      element.text = 'blah';
-
-      promise.then(() => {
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.isTrue(commitHandler.called);
-        assert.equal(element.text, 'suggestion');
-        done();
-      });
-    });
-
-    test('clear-on-commit behavior (on)', done => {
-      let promise;
-      const queryStub = sandbox.spy(() => {
-        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      });
-      element.query = queryStub;
-      element.text = 'blah';
-      element.clearOnCommit = true;
-
-      promise.then(() => {
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.isTrue(commitHandler.called);
-        assert.equal(element.text, '');
-        done();
-      });
-    });
-
-    test('threshold guards the query', () => {
-      const queryStub = sandbox.spy(() => {
-        return Promise.resolve([]);
-      });
-      element.query = queryStub;
-
-      element.threshold = 2;
-
-      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([]);
-      });
-      let callback;
-      const debounceStub = sandbox.stub(element, 'debounce',
-          (name, cb) => { callback = cb; });
-      element.query = queryStub;
-      element.noDebounce = false;
-      element.text = 'a';
-      assert.isFalse(queryStub.called);
-      assert.isTrue(debounceStub.called);
-      assert.equal(debounceStub.lastCall.args[2], 200);
-      assert.isFunction(callback);
-      callback();
-      assert.isTrue(queryStub.called);
-    });
-
-    test('_computeClass respects border property', () => {
-      assert.equal(element._computeClass(), '');
-      assert.equal(element._computeClass(false), '');
-      assert.equal(element._computeClass(true), 'borderless');
-    });
-
-    test('undefined or empty text results in no suggestions', () => {
-      element._updateSuggestions(undefined, 0, null);
+      assert.isTrue(commitSpy.called);
+      assert.isFalse(focusSpy.called);
       assert.equal(element._suggestions.length, 0);
     });
 
-    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}]));
-      element.query = queryStub;
-      element.text = 'blah blah';
-      element.multi = true;
+    test('tab in input, tabComplete = true', () => {
+      focusSpy = sandbox.spy(element, 'focus');
+      const commitHandler = sandbox.stub();
+      element.addEventListener('commit', commitHandler);
+      element.tabComplete = true;
+      element._suggestions = ['tunnel snakes drool'];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flushAsynchronousOperations();
 
-      promise.then(() => {
-        const commitHandler = sandbox.spy();
-        element.addEventListener('commit', commitHandler);
-
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-
-        assert.isTrue(commitHandler.called);
-        assert.equal(element.text, 'blah 0');
-        done();
-      });
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(focusSpy.called);
+      assert.isFalse(commitHandler.called);
+      assert.equal(element._suggestions.length, 0);
     });
 
-    test('tabComplete flag functions', () => {
-      // commitHandler checks for the commit event, whereas commitSpy checks for
-      // the _commit function of the element.
-      const commitHandler = sandbox.spy();
-      element.addEventListener('commit', commitHandler);
-      const commitSpy = sandbox.spy(element, '_commit');
-      element._focused = true;
-
-      element._suggestions = ['tunnel snakes rule!'];
-      element.tabComplete = false;
+    test('tab in input, tabComplete = false', () => {
+      element._suggestions = ['sugar bombs'];
+      focusSpy = sandbox.spy(element, 'focus');
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      assert.isFalse(commitHandler.called);
+      flushAsynchronousOperations();
+
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 1);
+    });
+
+    test('tab on suggestion, tabComplete = false', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is false, do not focus.
+      element.tabComplete = false;
+      focusSpy = sandbox.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flushAsynchronousOperations();
       assert.isFalse(commitSpy.called);
       assert.isFalse(element._focused);
+    });
 
-      element.tabComplete = true;
+    test('tab on suggestion, tabComplete = true', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
       element._focused = true;
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      assert.isFalse(commitHandler.called);
+      // When tabComplete is true, focus.
+      element.tabComplete = true;
+      focusSpy = sandbox.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flushAsynchronousOperations();
+
       assert.isTrue(commitSpy.called);
       assert.isTrue(element._focused);
     });
 
-    test('_focused flag properly triggered', done => {
-      flush(() => {
-        assert.isFalse(element._focused);
-        const input = element.$$('paper-input').inputElement;
-        MockInteractions.focus(input);
-        assert.isTrue(element._focused);
-        done();
-      });
-    });
-
-    test('search icon shows with showSearchIcon property', done => {
-      flush(() => {
-        assert.equal(getComputedStyle(element.$$('iron-icon')).display,
-            'none');
-        element.showSearchIcon = true;
-        assert.notEqual(getComputedStyle(element.$$('iron-icon')).display,
-            'none');
-        done();
-      });
-    });
-
-    test('vertical offset overridden by param if it exists', () => {
-      assert.equal(element.$.suggestions.verticalOffset, 20);
-      element.verticalOffset = 30;
-      assert.equal(element.$.suggestions.verticalOffset, 30);
-    });
-
-    test('_focused flag shows/hides the suggestions', () => {
-      const openStub = sandbox.stub(element.$.suggestions, 'open');
-      const closedStub = sandbox.stub(element.$.suggestions, 'close');
-      element._suggestions = ['hello', 'its me'];
-      assert.isFalse(openStub.called);
-      assert.isTrue(closedStub.calledOnce);
+    test('tap on suggestion commits, does not call focus', () => {
+      focusSpy = sandbox.spy(element, 'focus');
       element._focused = true;
-      assert.isTrue(openStub.calledOnce);
-      element._suggestions = [];
-      assert.isTrue(closedStub.calledTwice);
-      assert.isTrue(openStub.calledOnce);
-    });
-
-    test('_handleInputCommit with autocomplete hidden does nothing without' +
-          'without allowNonSuggestedValues', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.$.suggestions.isHidden = true;
-      element._handleInputCommit();
-      assert.isFalse(commitStub.called);
-    });
-
-    test('_handleInputCommit with autocomplete hidden with' +
-          'allowNonSuggestedValues', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.allowNonSuggestedValues = true;
-      element.$.suggestions.isHidden = true;
-      element._handleInputCommit();
-      assert.isTrue(commitStub.called);
-    });
-
-    test('_handleInputCommit with autocomplete open calls commit', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.$.suggestions.isHidden = false;
-      element._handleInputCommit();
-      assert.isTrue(commitStub.calledOnce);
-    });
-
-    test('_handleInputCommit with autocomplete open calls commit' +
-          'with allowNonSuggestedValues', () => {
-      const commitStub = sandbox.stub(element, '_commit');
-      element.allowNonSuggestedValues = true;
-      element.$.suggestions.isHidden = false;
-      element._handleInputCommit();
-      assert.isTrue(commitStub.calledOnce);
-    });
-
-    test('issue 8655', () => {
-      function makeSuggestion(s) { return {name: s, text: s, value: s}; }
-      const keydownSpy = sandbox.spy(element, '_handleKeydown');
-      element.setText('file:');
-      element._suggestions =
-          [makeSuggestion('file:'), makeSuggestion('-file:')];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
-      // 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');
-      assert.isTrue(keydownSpy.calledTwice);
-      assert.equal(element.text, 'file:x');
-    });
-
-    suite('focus', () => {
-      let commitSpy;
-      let focusSpy;
-
-      setup(() => {
-        commitSpy = sandbox.spy(element, '_commit');
-      });
-
-      test('enter does not call focus', () => {
-        element._suggestions = ['sugar bombs'];
-        focusSpy = sandbox.spy(element, 'focus');
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-            'enter');
-        flushAsynchronousOperations();
-
-        assert.isTrue(commitSpy.called);
-        assert.isFalse(focusSpy.called);
-        assert.equal(element._suggestions.length, 0);
-      });
-
-      test('tab in input, tabComplete = true', () => {
-        focusSpy = sandbox.spy(element, 'focus');
-        const commitHandler = sandbox.stub();
-        element.addEventListener('commit', commitHandler);
-        element.tabComplete = true;
-        element._suggestions = ['tunnel snakes drool'];
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-        flushAsynchronousOperations();
-
-        assert.isTrue(commitSpy.called);
-        assert.isTrue(focusSpy.called);
-        assert.isFalse(commitHandler.called);
-        assert.equal(element._suggestions.length, 0);
-      });
-
-      test('tab in input, tabComplete = false', () => {
-        element._suggestions = ['sugar bombs'];
-        focusSpy = sandbox.spy(element, 'focus');
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-        flushAsynchronousOperations();
-
-        assert.isFalse(commitSpy.called);
-        assert.isFalse(focusSpy.called);
-        assert.equal(element._suggestions.length, 1);
-      });
-
-      test('tab on suggestion, tabComplete = false', () => {
-        element._suggestions = [{name: 'sugar bombs'}];
-        element._focused = true;
-        // When tabComplete is false, do not focus.
-        element.tabComplete = false;
-        focusSpy = sandbox.spy(element, 'focus');
-        Polymer.dom.flush();
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element.$.suggestions.$$('li:first-child'), 9, null, 'tab');
-        flushAsynchronousOperations();
-        assert.isFalse(commitSpy.called);
-        assert.isFalse(element._focused);
-      });
-
-      test('tab on suggestion, tabComplete = true', () => {
-        element._suggestions = [{name: 'sugar bombs'}];
-        element._focused = true;
-        // When tabComplete is true, focus.
-        element.tabComplete = true;
-        focusSpy = sandbox.spy(element, 'focus');
-        Polymer.dom.flush();
-        assert.isFalse(element.$.suggestions.isHidden);
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element.$.suggestions.$$('li:first-child'), 9, null, 'tab');
-        flushAsynchronousOperations();
-
-        assert.isTrue(commitSpy.called);
-        assert.isTrue(element._focused);
-      });
-
-      test('tap on suggestion commits, does not call focus', () => {
-        focusSpy = sandbox.spy(element, 'focus');
-        element._focused = true;
-        element._suggestions = [{name: 'first suggestion'}];
-        Polymer.dom.flush();
-        assert.isFalse(element.$.suggestions.isHidden);
-        MockInteractions.tap(element.$.suggestions.$$('li:first-child'));
-        flushAsynchronousOperations();
-
-        assert.isFalse(focusSpy.called);
-        assert.isTrue(commitSpy.called);
-        assert.isTrue(element.$.suggestions.isHidden);
-      });
-    });
-
-    test('input-keydown event fired', () => {
-      const listener = sandbox.spy();
-      element.addEventListener('input-keydown', listener);
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      element._suggestions = [{name: 'first suggestion'}];
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+      MockInteractions.tap(element.$.suggestions.shadowRoot
+          .querySelector('li:first-child'));
       flushAsynchronousOperations();
-      assert.isTrue(listener.called);
-    });
 
-    test('enter with modifier does not complete', () => {
-      const handleSpy = sandbox.spy(element, '_handleKeydown');
-      const commitStub = sandbox.stub(element, '_handleInputCommit');
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.input, 13, 'ctrl', 'enter');
-      assert.isTrue(handleSpy.called);
-      assert.isFalse(commitStub.called);
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.input, 13, null, 'enter');
-      assert.isTrue(commitStub.called);
-    });
-
-    suite('warnUncommitted', () => {
-      let inputClassList;
-      setup(() => {
-        inputClassList = element.$.input.classList;
-      });
-
-      test('enabled', () => {
-        element.warnUncommitted = true;
-        element.text = 'blah blah blah';
-        MockInteractions.blur(element.$.input);
-        assert.isTrue(inputClassList.contains('warnUncommitted'));
-        MockInteractions.focus(element.$.input);
-        assert.isFalse(inputClassList.contains('warnUncommitted'));
-      });
-
-      test('disabled', () => {
-        element.warnUncommitted = false;
-        element.text = 'blah blah blah';
-        MockInteractions.blur(element.$.input);
-        assert.isFalse(inputClassList.contains('warnUncommitted'));
-      });
-
-      test('no text', () => {
-        element.warnUncommitted = true;
-        element.text = '';
-        MockInteractions.blur(element.$.input);
-        assert.isFalse(inputClassList.contains('warnUncommitted'));
-      });
+      assert.isFalse(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element.$.suggestions.isHidden);
     });
   });
+
+  test('input-keydown event fired', () => {
+    const listener = sandbox.spy();
+    element.addEventListener('input-keydown', listener);
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    flushAsynchronousOperations();
+    assert.isTrue(listener.called);
+  });
+
+  test('enter with modifier does not complete', () => {
+    const handleSpy = sandbox.spy(element, '_handleKeydown');
+    const commitStub = sandbox.stub(element, '_handleInputCommit');
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input, 13, 'ctrl', 'enter');
+    assert.isTrue(handleSpy.called);
+    assert.isFalse(commitStub.called);
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input, 13, null, 'enter');
+    assert.isTrue(commitStub.called);
+  });
+
+  suite('warnUncommitted', () => {
+    let inputClassList;
+    setup(() => {
+      inputClassList = element.$.input.classList;
+    });
+
+    test('enabled', () => {
+      element.warnUncommitted = true;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(element.$.input);
+      assert.isTrue(inputClassList.contains('warnUncommitted'));
+      MockInteractions.focus(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('disabled', () => {
+      element.warnUncommitted = false;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('no text', () => {
+      element.warnUncommitted = true;
+      element.text = '';
+      MockInteractions.blur(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
deleted file mode 100644
index 1daffa2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
+++ /dev/null
@@ -1,37 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-avatar">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: inline-block;
-        border-radius: 50%;
-        background-size: cover;
-        background-color: var(--avatar-background-color, #f1f2f3);
-      }
-    </style>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-avatar.js"></script>
-</dom-module>
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..75181b1 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,13 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-avatar',
+import '../../../styles/shared-styles.js';
+import '../gr-js-api-interface/gr-js-api-interface.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-avatar_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrAvatar extends mixinBehaviors( [
+  BaseUrlBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-avatar'; }
+
+  static get properties() {
+    return {
       account: {
         type: Object,
         observer: '_accountChanged',
@@ -33,60 +53,60 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    Promise.all([
+      this._getConfig(),
+      pluginLoader.awaitPluginsLoaded(),
+    ]).then(([cfg]) => {
+      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
 
-    attached() {
-      Promise.all([
-        this._getConfig(),
-        Gerrit.awaitPluginsLoaded(),
-      ]).then(([cfg]) => {
-        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-
-        this._updateAvatarURL();
-      });
-    },
-
-    _getConfig() {
-      return this.$.restAPI.getConfig();
-    },
-
-    _accountChanged(account) {
       this._updateAvatarURL();
-    },
+    });
+  }
 
-    _updateAvatarURL() {
-      if (!this._hasAvatars || !this.account) {
-        this.hidden = true;
-        return;
+  _getConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _accountChanged(account) {
+    this._updateAvatarURL();
+  }
+
+  _updateAvatarURL() {
+    if (!this._hasAvatars || !this.account) {
+      this.hidden = true;
+      return;
+    }
+    this.hidden = false;
+
+    const url = this._buildAvatarURL(this.account);
+    if (url) {
+      this.style.backgroundImage = 'url("' + url + '")';
+    }
+  }
+
+  _getAccounts(account) {
+    return account._account_id || account.email || account.username ||
+        account.name;
+  }
+
+  _buildAvatarURL(account) {
+    if (!account) { return ''; }
+    const avatars = account.avatars || [];
+    for (let i = 0; i < avatars.length; i++) {
+      if (avatars[i].height === this.imageSize) {
+        return avatars[i].url;
       }
-      this.hidden = false;
+    }
+    return this.getBaseUrl() + '/accounts/' +
+      encodeURIComponent(this._getAccounts(account)) +
+      '/avatar?s=' + this.imageSize;
+  }
+}
 
-      const url = this._buildAvatarURL(this.account);
-      if (url) {
-        this.style.backgroundImage = 'url("' + url + '")';
-      }
-    },
-
-    _getAccounts(account) {
-      return account._account_id || account.email || account.username ||
-          account.name;
-    },
-
-    _buildAvatarURL(account) {
-      if (!account) { return ''; }
-      const avatars = account.avatars || [];
-      for (let i = 0; i < avatars.length; i++) {
-        if (avatars[i].height === this.imageSize) {
-          return avatars[i].url;
-        }
-      }
-      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_html.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
new file mode 100644
index 0000000..cc8a42f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+      border-radius: 50%;
+      background-size: cover;
+      background-color: var(--avatar-background-color, #f1f2f3);
+    }
+  </style>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..dddc3d8 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-avatar.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,100 +31,104 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-avatar tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-avatar.js';
+import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+suite('gr-avatar tests', () => {
+  let element;
+  let sandbox;
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    test('methods', () => {
-      assert.equal(element._buildAvatarURL(
-          {
-            _account_id: 123,
-          }),
-      '/accounts/123/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
-            email: 'test@example.com',
-          }),
-      '/accounts/test%40example.com/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
-            name: 'John Doe',
-          }),
-      '/accounts/John%20Doe/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
-            username: 'John_Doe',
-          }),
-      '/accounts/John_Doe/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
-            _account_id: 123,
-            avatars: [
-              {
-                url: 'https://cdn.example.com/s12-p/photo.jpg',
-                height: 12,
-              },
-              {
-                url: 'https://cdn.example.com/s16-p/photo.jpg',
-                height: 16,
-              },
-              {
-                url: 'https://cdn.example.com/s100-p/photo.jpg',
-                height: 100,
-              },
-            ],
-          }),
-      'https://cdn.example.com/s16-p/photo.jpg');
-      assert.equal(element._buildAvatarURL(
-          {
-            _account_id: 123,
-            avatars: [
-              {
-                url: 'https://cdn.example.com/s95-p/photo.jpg',
-                height: 95,
-              },
-            ],
-          }),
-      '/accounts/123/avatar?s=16');
-      assert.equal(element._buildAvatarURL(undefined), '');
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('dom for existing account', () => {
+  test('methods', () => {
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+        }),
+        '/accounts/123/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          email: 'test@example.com',
+        }),
+        '/accounts/test%40example.com/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          name: 'John Doe',
+        }),
+        '/accounts/John%20Doe/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          username: 'John_Doe',
+        }),
+        '/accounts/John_Doe/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+          avatars: [
+            {
+              url: 'https://cdn.example.com/s12-p/photo.jpg',
+              height: 12,
+            },
+            {
+              url: 'https://cdn.example.com/s16-p/photo.jpg',
+              height: 16,
+            },
+            {
+              url: 'https://cdn.example.com/s100-p/photo.jpg',
+              height: 100,
+            },
+          ],
+        }),
+        'https://cdn.example.com/s16-p/photo.jpg');
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+          avatars: [
+            {
+              url: 'https://cdn.example.com/s95-p/photo.jpg',
+              height: 95,
+            },
+          ],
+        }),
+        '/accounts/123/avatar?s=16');
+    assert.equal(element._buildAvatarURL(undefined), '');
+  });
+
+  test('dom for existing account', () => {
+    assert.isFalse(element.hasAttribute('hidden'));
+
+    sandbox.stub(
+        element,
+        '_getConfig',
+        () => Promise.resolve({plugin: {has_avatars: true}}));
+
+    element.imageSize = 64;
+    element.account = {
+      _account_id: 123,
+    };
+
+    assert.strictEqual(element.style.backgroundImage, '');
+
+    // Emulate plugins loaded.
+    pluginLoader.loadPlugins([]);
+
+    Promise.all([
+      element.$.restAPI.getConfig(),
+      pluginLoader.awaitPluginsLoaded(),
+    ]).then(() => {
       assert.isFalse(element.hasAttribute('hidden'));
 
-      sandbox.stub(element, '_getConfig', () => {
-        return Promise.resolve({plugin: {has_avatars: true}});
-      });
-
-      element.imageSize = 64;
-      element.account = {
-        _account_id: 123,
-      };
-
-      assert.strictEqual(element.style.backgroundImage, '');
-
-      // Emulate plugins loaded.
-      Gerrit._loadPlugins([]);
-
-      Promise.all([
-        element.$.restAPI.getConfig(),
-        Gerrit.awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isFalse(element.hasAttribute('hidden'));
-
-        assert.isTrue(
-            element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
-      });
+      assert.isTrue(
+          element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
     });
   });
 
@@ -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');
@@ -155,11 +154,11 @@
       assert.isFalse(element.hasAttribute('hidden'));
 
       // Emulate plugins loaded.
-      Gerrit._loadPlugins([]);
+      pluginLoader.loadPlugins([]);
 
       return Promise.all([
         element.$.restAPI.getConfig(),
-        Gerrit.awaitPluginsLoaded(),
+        pluginLoader.awaitPluginsLoaded(),
       ]).then(() => {
         assert.isTrue(element.hasAttribute('hidden'));
 
@@ -176,9 +175,7 @@
       sandbox = sinon.sandbox.create();
 
       stub('gr-avatar', {
-        _getConfig: () => {
-          return Promise.resolve({});
-        },
+        _getConfig: () => Promise.resolve({}),
       });
 
       element = fixture('basic');
@@ -197,15 +194,16 @@
           _account_id: 123,
         };
         // Emulate plugins loaded.
-        Gerrit._loadPlugins([]);
+        pluginLoader.loadPlugins([]);
 
         return Promise.all([
           element.$.restAPI.getConfig(),
-          Gerrit.awaitPluginsLoaded(),
+          pluginLoader.awaitPluginsLoaded(),
         ]).then(() => {
           assert.isTrue(element.hasAttribute('hidden'));
         });
       });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
deleted file mode 100644
index 87caf64..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ /dev/null
@@ -1,172 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<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">
-
-<dom-module id="gr-button">
-  <template strip-whitespace>
-    <style include="shared-styles">
-      /* general styles for all buttons */
-      :host {
-        --background-color: var(--button-background-color, var(--default-button-background-color));
-        --text-color: var(--default-button-text-color);
-        display: inline-block;
-        position: relative;
-      }
-      :host([hidden]) {
-        display: none;
-      }
-      :host([no-uppercase]) paper-button {
-        text-transform: none;
-      }
-      paper-button {
-        /* The next lines contains a copy of paper-button style.
-          Without a copy, the @apply works incorrectly with Polymer 2.
-          @apply is deprecated and is not recommended to use. It is expected
-          that @apply will be replaced with the ::part CSS pseudo-element.
-          After replacecment copied lines can be removed.
-        */
-        @apply --layout-inline;
-        @apply --layout-center-center;
-        position: relative;
-        box-sizing: border-box;
-        min-width: 5.14em;
-        margin: 0 0.29em;
-        background: transparent;
-        -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-        -webkit-tap-highlight-color: transparent;
-        font: inherit;
-        text-transform: uppercase;
-        outline-width: 0;
-        border-radius: var(--border-radius);
-        -moz-user-select: none;
-        -ms-user-select: none;
-        -webkit-user-select: none;
-        user-select: none;
-        cursor: pointer;
-        z-index: 0;
-        padding: var(--spacing-m);
-
-        @apply --paper-font-common-base;
-        @apply --paper-button;
-        /* End of copy*/
-
-        /* paper-button sets this to anti-aliased, which appears different than
-          bold font elsewhere on macOS. */
-        -webkit-font-smoothing: initial;
-        align-items: center;
-        background-color: var(--background-color);
-        color: var(--text-color);
-        display: flex;
-        font-family: inherit;
-        justify-content: center;
-        margin: var(--margin, 0);
-        min-width: var(--border, 0);
-        padding: var(--padding, 4px 8px);
-        @apply --gr-button;
-      }
-      /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
-      /* BEGIN: Copy from paper-button */
-      paper-button[elevation="1"] {
-        @apply --paper-material-elevation-1;
-      }
-      paper-button[elevation="2"] {
-        @apply --paper-material-elevation-2;
-      }
-      paper-button[elevation="3"] {
-        @apply --paper-material-elevation-3;
-      }
-      paper-button[elevation="4"] {
-        @apply --paper-material-elevation-4;
-      }
-      paper-button[elevation="5"] {
-        @apply --paper-material-elevation-5;
-      }
-      /* END: Copy from paper-button */
-      paper-button:hover {
-        background: linear-gradient(
-          rgba(0, 0, 0, .12),
-          rgba(0, 0, 0, .12)
-        ), var(--background-color);
-      }
-
-      :host([primary]) {
-        --background-color: var(--primary-button-background-color);
-        --text-color: var(--primary-button-text-color);
-      }
-      :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. */
-      :host([disabled]) {
-        --background-color: var(--table-subheader-background-color);
-        --text-color: var(--deemphasized-text-color);
-        cursor: default;
-      }
-
-      /* Styles for link buttons specifically */
-      :host([link]) {
-        --background-color: transparent;
-        --margin: 0;
-        --padding: 5px 4px;
-      }
-      :host([disabled][link]) {
-        --background-color: transparent;
-        --text-color: var(--deemphasized-text-color);
-        cursor: default;
-      }
-
-      /* Styles for the optional down arrow */
-      :host(:not([down-arrow])) .downArrow {
-        display: none;
-      }
-      :host([down-arrow]) .downArrow {
-        border-top: .36em solid #ccc;
-        border-left: .36em solid transparent;
-        border-right: .36em solid transparent;
-        margin-bottom: var(--spacing-xxs);
-        margin-left: var(--spacing-m);
-        transition: border-top-color 200ms;
-      }
-      :host([down-arrow]) paper-button:hover .downArrow {
-        border-top-color: var(--deemphasized-text-color);
-      }
-    </style>
-    <paper-button
-        raised="[[!link]]"
-        disabled="[[_computeDisabled(disabled, loading)]]"
-        tabindex="-1">
-      <slot></slot>
-      <i class="downArrow"></i>
-    </paper-button>
-  </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..3169c56 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -14,13 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-button',
+import '@polymer/paper-button/paper-button.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-button_html.js';
+import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {util} from '../../../scripts/util.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrButton extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+  TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-button'; }
+
+  static get properties() {
+    return {
       tooltip: String,
       downArrow: {
         type: Boolean,
@@ -31,11 +53,6 @@
         value: false,
         reflectToAttribute: true,
       },
-      loading: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
       disabled: {
         type: Boolean,
         observer: '_disabledChanged',
@@ -45,59 +62,71 @@
         type: Boolean,
         value: false,
       },
-      _enabledTabindex: {
+      loading: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+
+      _disabled: {
+        type: Boolean,
+        computed: '_computeDisabled(disabled, loading)',
+      },
+
+      _initialTabindex: {
         type: String,
         value: '0',
       },
-    },
+    };
+  }
 
-    listeners: {
-      click: '_handleAction',
-      keydown: '_handleKeydown',
-    },
+  /** @override */
+  created() {
+    super.created();
+    this._initialTabindex = this.getAttribute('tabindex') || '0';
+    this.addEventListener('click', e => this._handleAction(e));
+    this.addEventListener('keydown',
+        e => this._handleKeydown(e));
+  }
 
-    observers: [
-      '_computeDisabled(disabled, loading)',
-    ],
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'button');
+    this._ensureAttribute('tabindex', '0');
+  }
 
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.TooltipBehavior,
-    ],
+  _handleAction(e) {
+    if (this._disabled) {
+      e.preventDefault();
+      e.stopPropagation();
+      e.stopImmediatePropagation();
+      return;
+    }
 
-    hostAttributes: {
-      role: 'button',
-      tabindex: '0',
-    },
+    this.$.reporting.reportInteraction('button-click',
+        {path: util.getEventPath(e)});
+  }
 
-    _handleAction(e) {
-      if (this.disabled) {
-        e.preventDefault();
-        e.stopImmediatePropagation();
-      }
-    },
+  _disabledChanged(disabled) {
+    this.setAttribute('tabindex', disabled ? '-1' : this._initialTabindex);
+    this.updateStyles();
+  }
 
-    _disabledChanged(disabled) {
-      if (disabled) {
-        this._enabledTabindex = this.getAttribute('tabindex') || '0';
-      }
-      this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
-      this.updateStyles();
-    },
+  _computeDisabled(disabled, loading) {
+    return disabled || loading;
+  }
 
-    _computeDisabled(disabled, loading) {
-      return disabled || loading;
-    },
+  _handleKeydown(e) {
+    if (this.modifierPressed(e)) { return; }
+    e = this.getKeyboardEvent(e);
+    // Handle `enter`, `space`.
+    if (e.keyCode === 13 || e.keyCode === 32) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.click();
+    }
+  }
+}
 
-    _handleKeydown(e) {
-      if (this.modifierPressed(e)) { return; }
-      e = this.getKeyboardEvent(e);
-      // Handle `enter`, `space`.
-      if (e.keyCode === 13 || e.keyCode === 32) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.click();
-      }
-    },
-  });
-})();
+customElements.define(GrButton.is, GrButton);
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
new file mode 100644
index 0000000..db3b880
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
@@ -0,0 +1,177 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* general styles for all buttons */
+    :host {
+      --background-color: var(
+        --button-background-color,
+        var(--default-button-background-color)
+      );
+      --text-color: var(--default-button-text-color);
+      display: inline-block;
+      position: relative;
+    }
+    :host([hidden]) {
+      display: none;
+    }
+    :host([no-uppercase]) paper-button {
+      text-transform: none;
+    }
+    paper-button {
+      /* The next lines contains a copy of paper-button style.
+          Without a copy, the @apply works incorrectly with Polymer 2.
+          @apply is deprecated and is not recommended to use. It is expected
+          that @apply will be replaced with the ::part CSS pseudo-element.
+          After replacecment copied lines can be removed.
+        */
+      @apply --layout-inline;
+      @apply --layout-center-center;
+      position: relative;
+      box-sizing: border-box;
+      min-width: 5.14em;
+      margin: 0 0.29em;
+      background: transparent;
+      -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+      -webkit-tap-highlight-color: transparent;
+      font: inherit;
+      text-transform: uppercase;
+      outline-width: 0;
+      border-radius: var(--border-radius);
+      -moz-user-select: none;
+      -ms-user-select: none;
+      -webkit-user-select: none;
+      user-select: none;
+      cursor: pointer;
+      z-index: 0;
+      padding: var(--spacing-m);
+
+      @apply --paper-font-common-base;
+      @apply --paper-button;
+      /* End of copy*/
+
+      /* paper-button sets this to anti-aliased, which appears different than
+          bold font elsewhere on macOS. */
+      -webkit-font-smoothing: initial;
+      align-items: center;
+      background-color: var(--background-color);
+      color: var(--text-color);
+      display: flex;
+      font-family: inherit;
+      justify-content: center;
+      margin: var(--margin, 0);
+      min-width: var(--border, 0);
+      padding: var(--padding, 4px 8px);
+      @apply --gr-button;
+    }
+    /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
+    /* BEGIN: Copy from paper-button */
+    paper-button[elevation='1'] {
+      @apply --paper-material-elevation-1;
+    }
+    paper-button[elevation='2'] {
+      @apply --paper-material-elevation-2;
+    }
+    paper-button[elevation='3'] {
+      @apply --paper-material-elevation-3;
+    }
+    paper-button[elevation='4'] {
+      @apply --paper-material-elevation-4;
+    }
+    paper-button[elevation='5'] {
+      @apply --paper-material-elevation-5;
+    }
+    /* END: Copy from paper-button */
+    paper-button:hover {
+      background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+        var(--background-color);
+    }
+
+    /* Some mobile browsers treat focused element as hovered element.
+      As a result, element remains hovered after click (has grey background in default theme).
+      Use @media (hover:none) to remove background if
+      user's primary input mechanism can't hover over elements.
+      See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
+
+      Note 1: not all browsers support this media query
+      (see https://caniuse.com/#feat=css-media-interaction).
+      If browser doesn't support it, then the whole content of @media .. is ignored.
+      This is why the default behavior is placed outside of @media.
+      */
+    @media (hover: none) {
+      paper-button:hover {
+        background: transparent;
+      }
+    }
+
+    :host([primary]) {
+      --background-color: var(--primary-button-background-color);
+      --text-color: var(--primary-button-text-color);
+    }
+    :host([link][primary]) {
+      --text-color: var(--primary-button-background-color);
+    }
+
+    /* Keep below color definition for primary so that this takes precedence
+        when disabled. */
+    :host([disabled]),
+    :host([loading]) {
+      --background-color: var(--disabled-button-background-color);
+      --text-color: var(--deemphasized-text-color);
+      cursor: default;
+    }
+
+    /* Styles for link buttons specifically */
+    :host([link]) {
+      --background-color: transparent;
+      --margin: 0;
+      --padding: 5px 4px;
+    }
+    :host([disabled][link]),
+    :host([loading][link]) {
+      --background-color: transparent;
+      --text-color: var(--deemphasized-text-color);
+      cursor: default;
+    }
+
+    /* Styles for the optional down arrow */
+    :host(:not([down-arrow])) .downArrow {
+      display: none;
+    }
+    :host([down-arrow]) .downArrow {
+      border-top: 0.36em solid #ccc;
+      border-left: 0.36em solid transparent;
+      border-right: 0.36em solid transparent;
+      margin-bottom: var(--spacing-xxs);
+      margin-left: var(--spacing-m);
+      transition: border-top-color 200ms;
+    }
+    :host([down-arrow]) paper-button:hover .downArrow {
+      border-top-color: var(--deemphasized-text-color);
+    }
+  </style>
+  <paper-button raised="[[!link]]" disabled="[[_disabled]]" tabindex="-1">
+    <template is="dom-if" if="[[loading]]">
+      <span class="loadingSpin"></span>
+    </template>
+    <slot></slot>
+    <i class="downArrow"></i>
+  </paper-button>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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..ae627d1 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-button.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,107 +31,193 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-select tests', () => {
-    let element;
-    let sandbox;
+<test-fixture id="nested">
+  <template>
+    <div id="test">
+      <gr-button class="testBtn"></gr-button>
+    </div>
+  </template>
+</test-fixture>
 
-    const addSpyOn = function(eventName) {
-      const spy = sandbox.spy();
-      if (eventName == 'tap') {
-        Polymer.Gestures.addListener(element, eventName, spy);
-      } else {
-        element.addEventListener(eventName, spy);
-      }
-      return spy;
-    };
+<test-fixture id="tabindex">
+  <template>
+    <gr-button tabindex="3"></gr-button>
+  </template>
+</test-fixture>
 
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-button.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+suite('gr-button tests', () => {
+  let element;
+  let sandbox;
+
+  const addSpyOn = function(eventName) {
+    const spy = sandbox.spy();
+    if (eventName == 'tap') {
+      addListener(element, eventName, spy);
+    } else {
+      element.addEventListener(eventName, spy);
+    }
+    return spy;
+  };
+
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('disabled is set by disabled', () => {
+    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    assert.isFalse(paperBtn.disabled);
+    element.disabled = true;
+    assert.isTrue(paperBtn.disabled);
+    element.disabled = false;
+    assert.isFalse(paperBtn.disabled);
+  });
+
+  test('loading set from listener', done => {
+    let resolve;
+    element.addEventListener('click', e => {
+      e.target.loading = true;
+      resolve = () => e.target.loading = false;
+    });
+    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    assert.isFalse(paperBtn.disabled);
+    MockInteractions.tap(element);
+    assert.isTrue(paperBtn.disabled);
+    assert.isTrue(element.hasAttribute('loading'));
+    resolve();
+    flush(() => {
+      assert.isFalse(paperBtn.disabled);
+      assert.isFalse(element.hasAttribute('loading'));
+      done();
+    });
+  });
+
+  test('tabindex should be -1 if disabled', () => {
+    element.disabled = true;
+    assert.isTrue(element.getAttribute('tabindex') === '-1');
+  });
+
+  // Regression tests for Issue: 11969
+  test('tabindex should be reset to 0 if enabled', () => {
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '0');
+    element.disabled = true;
+    assert.equal(element.getAttribute('tabindex'), '-1');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '0');
+  });
+
+  test('tabindex should be preserved', () => {
+    element = fixture('tabindex');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '3');
+    element.disabled = true;
+    assert.equal(element.getAttribute('tabindex'), '-1');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '3');
+  });
+
+  // 'tap' event is tested so we don't loose backward compatibility with older
+  // plugins who didn't move to on-click which is faster and well supported.
+  test('dispatches click event', () => {
+    const spy = addSpyOn('click');
+    MockInteractions.click(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  test('dispatches tap event', () => {
+    const spy = addSpyOn('tap');
+    MockInteractions.tap(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  test('dispatches click from tap event', () => {
+    const spy = addSpyOn('click');
+    MockInteractions.tap(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  // Keycodes: 32 for Space, 13 for Enter.
+  for (const key of [32, 13]) {
+    test('dispatches click event on keycode ' + key, () => {
+      const tapSpy = sandbox.spy();
+      element.addEventListener('click', tapSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, key);
+      assert.isTrue(tapSpy.calledOnce);
+    });
+
+    test('dispatches no click event with modifier on keycode ' + key, () => {
+      const tapSpy = sandbox.spy();
+      element.addEventListener('click', tapSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
+      assert.isFalse(tapSpy.calledOnce);
+    });
+  }
+
+  suite('disabled', () => {
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('disabled is set by disabled or loading', () => {
-      assert.isFalse(element.$$('paper-button').disabled);
       element.disabled = true;
-      assert.isTrue(element.$$('paper-button').disabled);
-      element.disabled = false;
-      assert.isFalse(element.$$('paper-button').disabled);
-      element.loading = true;
-      assert.isTrue(element.$$('paper-button').disabled);
     });
 
-    test('tabindex should be -1 if disabled', () => {
-      element.disabled = true;
-      assert.isTrue(element.getAttribute('tabindex') === '-1');
-    });
-
-    // Regression tests for Issue: 11969
-    test('tabindex should be reset to 0 if enabled', () => {
-      element.disabled = false;
-      assert.isTrue(element.getAttribute('tabindex') === '0');
-      element.disabled = true;
-      assert.isTrue(element.getAttribute('tabindex') === '-1');
-      element.disabled = false;
-      assert.isTrue(element.getAttribute('tabindex') === '0');
-    });
-
-    // 'tap' event is tested so we don't loose backward compatibility with older
-    // plugins who didn't move to on-click which is faster and well supported.
     for (const eventName of ['tap', 'click']) {
-      test('dispatches ' + eventName + ' event', () => {
+      test('stops ' + eventName + ' event', () => {
         const spy = addSpyOn(eventName);
         MockInteractions.tap(element);
-        assert.isTrue(spy.calledOnce);
+        assert.isFalse(spy.called);
       });
     }
 
     // Keycodes: 32 for Space, 13 for Enter.
     for (const key of [32, 13]) {
-      test('dispatches click event on keycode ' + key, () => {
+      test('stops click event on keycode ' + key, () => {
         const tapSpy = sandbox.spy();
         element.addEventListener('click', tapSpy);
         MockInteractions.pressAndReleaseKeyOn(element, key);
-        assert.isTrue(tapSpy.calledOnce);
-      });
-
-      test('dispatches no click event with modifier on keycode ' + key, () => {
-        const tapSpy = sandbox.spy();
-        element.addEventListener('click', tapSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
-        MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
-        assert.isFalse(tapSpy.calledOnce);
+        assert.isFalse(tapSpy.called);
       });
     }
+  });
 
-    suite('disabled', () => {
-      setup(() => {
-        element.disabled = true;
+  suite('reporting', () => {
+    const reportStub = sinon.stub();
+    setup(() => {
+      stub('gr-reporting', {
+        reportInteraction: (...args) => {
+          reportStub(...args);
+        },
       });
+      reportStub.reset();
+    });
 
-      for (const eventName of ['tap', 'click']) {
-        test('stops ' + eventName + ' event', () => {
-          const spy = addSpyOn(eventName);
-          MockInteractions.tap(element);
-          assert.isFalse(spy.called);
-        });
-      }
+    test('report event after click', () => {
+      MockInteractions.click(element);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'button-click');
+      assert.deepEqual(reportStub.lastCall.args[1], {
+        path: 'html>body>test-fixture#basic>gr-button',
+      });
+    });
 
-      // Keycodes: 32 for Space, 13 for Enter.
-      for (const key of [32, 13]) {
-        test('stops click event on keycode ' + key, () => {
-          const tapSpy = sandbox.spy();
-          element.addEventListener('click', tapSpy);
-          MockInteractions.pressAndReleaseKeyOn(element, key);
-          assert.isFalse(tapSpy.called);
-        });
-      }
+    test('report event after click on nested', () => {
+      element = fixture('nested');
+      MockInteractions.click(element.querySelector('gr-button'));
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'button-click');
+      assert.deepEqual(reportStub.lastCall.args[1], {
+        path: 'html>body>test-fixture#nested>div#test>gr-button.testBtn',
+      });
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
deleted file mode 100644
index 3774529..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
+++ /dev/null
@@ -1,40 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-star">
-  <template>
-    <style include="shared-styles">
-      button {
-        background-color: transparent;
-        cursor: pointer;
-      }
-      iron-icon.active {
-        fill: var(--link-color);
-      }
-    </style>
-    <button aria-label="Change star" on-click="toggleStar">
-      <iron-icon
-          class$="[[_computeStarClass(change.starred)]]"
-          icon$="[[_computeStarIcon(change.starred)]]"></iron-icon>
-    </button>
-  </template>
-  <script src="gr-change-star.js"></script>
-</dom-module>
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..10e06dd 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,43 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-change-star',
+import '../gr-icons/gr-icons.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-star_html.js';
 
-    /**
-     * Fired when star state is toggled.
-     *
-     * @event toggle-star
-     */
+/** @extends Polymer.Element */
+class GrChangeStar extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /** @type {?} */
+  static get is() { return 'gr-change-star'; }
+  /**
+   * Fired when star state is toggled.
+   *
+   * @event toggle-star
+   */
+
+  static get properties() {
+    return {
+    /** @type {?} */
       change: {
         type: Object,
         notify: true,
       },
-    },
+    };
+  }
 
-    _computeStarClass(starred) {
-      return starred ? 'active' : '';
-    },
+  _computeStarClass(starred) {
+    return starred ? 'active' : '';
+  }
 
-    _computeStarIcon(starred) {
-      // Hollow star is used to indicate inactive state.
-      return `gr-icons:star${starred ? '' : '-border'}`;
-    },
+  _computeStarIcon(starred) {
+    // Hollow star is used to indicate inactive state.
+    return `gr-icons:star${starred ? '' : '-border'}`;
+  }
 
-    toggleStar() {
-      const newVal = !this.change.starred;
-      this.set('change.starred', newVal);
-      this.dispatchEvent(new CustomEvent('toggle-star', {
-        bubbles: true,
-        composed: true,
-        detail: {change: this.change, starred: newVal},
-      }));
-    },
-  });
-})();
+  toggleStar() {
+    const newVal = !this.change.starred;
+    this.set('change.starred', newVal);
+    this.dispatchEvent(new CustomEvent('toggle-star', {
+      bubbles: true,
+      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_html.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
new file mode 100644
index 0000000..f723717a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    button {
+      background-color: transparent;
+      cursor: pointer;
+    }
+    iron-icon.active {
+      fill: var(--link-color);
+    }
+    iron-icon {
+      vertical-align: top;
+      --iron-icon-height: var(
+        --gr-change-star-size,
+        var(--line-height-normal, 20px)
+      );
+      --iron-icon-width: var(
+        --gr-change-star-size,
+        var(--line-height-normal, 20px)
+      );
+    }
+  </style>
+  <button aria-label="Change star" on-click="toggleStar">
+    <iron-icon
+      class$="[[_computeStarClass(change.starred)]]"
+      icon$="[[_computeStarIcon(change.starred)]]"
+    ></iron-icon>
+  </button>
+`;
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..1ea9071 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-star.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,46 +31,52 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-star tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-star.js';
+suite('gr-change-star tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-      element.change = {
-        _number: 2,
-        starred: true,
-      };
-    });
-
-    test('star visibility states', () => {
-      element.set('change.starred', true);
-      let icon = element.$$('iron-icon');
-      assert.isTrue(icon.classList.contains('active'));
-      assert.equal(icon.icon, 'gr-icons:star');
-
-      element.set('change.starred', false);
-      icon = element.$$('iron-icon');
-      assert.isFalse(icon.classList.contains('active'));
-      assert.equal(icon.icon, 'gr-icons:star-border');
-    });
-
-    test('starring', done => {
-      element.addEventListener('toggle-star', () => {
-        assert.equal(element.change.starred, true);
-        done();
-      });
-      element.set('change.starred', false);
-      MockInteractions.tap(element.$$('button'));
-    });
-
-    test('unstarring', done => {
-      element.addEventListener('toggle-star', () => {
-        assert.equal(element.change.starred, false);
-        done();
-      });
-      element.set('change.starred', true);
-      MockInteractions.tap(element.$$('button'));
-    });
+  setup(() => {
+    element = fixture('basic');
+    element.change = {
+      _number: 2,
+      starred: true,
+    };
   });
+
+  test('star visibility states', () => {
+    element.set('change.starred', true);
+    let icon = element.shadowRoot
+        .querySelector('iron-icon');
+    assert.isTrue(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star');
+
+    element.set('change.starred', false);
+    icon = element.shadowRoot
+        .querySelector('iron-icon');
+    assert.isFalse(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star-border');
+  });
+
+  test('starring', done => {
+    element.addEventListener('toggle-star', () => {
+      assert.equal(element.change.starred, true);
+      done();
+    });
+    element.set('change.starred', false);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+  });
+
+  test('unstarring', done => {
+    element.addEventListener('toggle-star', () => {
+      assert.equal(element.change.starred, false);
+      done();
+    });
+    element.set('change.starred', true);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
deleted file mode 100644
index 55623b3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.html
+++ /dev/null
@@ -1,86 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-change-status">
-  <template>
-    <style include="shared-styles">
-      .chip {
-        border-radius: var(--border-radius);
-        background-color: var(--chip-background-color);
-        padding: var(--spacing-xxs) var(--spacing-m);
-        white-space: nowrap;
-      }
-      :host(.merged) .chip {
-        background-color: #5b9d52;
-        color: #5b9d52;
-      }
-      :host(.abandoned) .chip {
-        background-color: #afafaf;
-        color: #afafaf;
-      }
-      :host(.wip) .chip {
-        background-color: #8f756c;
-        color: #8f756c;
-      }
-      :host(.private) .chip {
-        background-color: #c17ccf;
-        color: #c17ccf;
-      }
-      :host(.merge-conflict) .chip {
-        background-color: #dc5c60;
-        color: #dc5c60;
-      }
-      :host(.active) .chip {
-        background-color: #29b6f6;
-        color: #29b6f6;
-      }
-      :host(.ready-to-submit) .chip {
-        background-color: #e10ca3;
-        color: #e10ca3;
-      }
-      :host(.custom) .chip {
-        background-color: #825cc2;
-        color: #825cc2;
-      }
-      :host([flat]) .chip {
-        background-color: transparent;
-        padding: var(--spacing-xxs);
-      }
-      :host(:not([flat])) .chip {
-        color: white;
-      }
-    </style>
-    <gr-tooltip-content
-        has-tooltip
-        position-below
-        title="[[tooltipText]]"
-        max-width="40em">
-      <div
-          class="chip"
-          aria-label$="Label: [[status]]">
-        [[_computeStatusString(status)]]
-      </div>
-    </gr-tooltip-content>
-  </template>
-  <script src="gr-change-status.js"></script>
-</dom-module>
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..b99612e 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
@@ -14,32 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const ChangeStates = {
-    MERGED: 'Merged',
-    ABANDONED: 'Abandoned',
-    MERGE_CONFLICT: 'Merge Conflict',
-    WIP: 'WIP',
-    PRIVATE: 'Private',
-  };
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-tooltip-content/gr-tooltip-content.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-change-status_html.js';
 
-  const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-      'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
-      'and email notifications will be silenced until the review is started.';
+const ChangeStates = {
+  MERGED: 'Merged',
+  ABANDONED: 'Abandoned',
+  MERGE_CONFLICT: 'Merge Conflict',
+  WIP: 'WIP',
+  PRIVATE: 'Private',
+};
 
-  const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-      'Download the patch and run "git rebase master". ' +
-      'Upload a new patchset after resolving all merge conflicts.';
+const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
+    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
+    'and email notifications will be silenced until the review is started.';
 
-  const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-      'current reviewers (or anyone with "View Private Changes" permission).';
+const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
+    'Download the patch and run "git rebase master". ' +
+    'Upload a new patchset after resolving all merge conflicts.';
 
-  Polymer({
-    is: 'gr-change-status',
+const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
+    'current reviewers (or anyone with "View Private Changes" permission).';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrChangeStatus extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-change-status'; }
+
+  static get properties() {
+    return {
       flat: {
         type: Boolean,
         value: false,
@@ -53,39 +66,41 @@
         type: String,
         value: '',
       },
-    },
+    };
+  }
 
-    _computeStatusString(status) {
-      if (status === ChangeStates.WIP && !this.flat) {
-        return 'Work in Progress';
-      }
-      return status;
-    },
+  _computeStatusString(status) {
+    if (status === ChangeStates.WIP && !this.flat) {
+      return 'Work in Progress';
+    }
+    return status;
+  }
 
-    _toClassName(str) {
-      return str.toLowerCase().replace(/\s/g, '-');
-    },
+  _toClassName(str) {
+    return str.toLowerCase().replace(/\s/g, '-');
+  }
 
-    _updateChipDetails(status, previousStatus) {
-      if (previousStatus) {
-        this.classList.remove(this._toClassName(previousStatus));
-      }
-      this.classList.add(this._toClassName(status));
+  _updateChipDetails(status, previousStatus) {
+    if (previousStatus) {
+      this.classList.remove(this._toClassName(previousStatus));
+    }
+    this.classList.add(this._toClassName(status));
 
-      switch (status) {
-        case ChangeStates.WIP:
-          this.tooltipText = WIP_TOOLTIP;
-          break;
-        case ChangeStates.PRIVATE:
-          this.tooltipText = PRIVATE_TOOLTIP;
-          break;
-        case ChangeStates.MERGE_CONFLICT:
-          this.tooltipText = MERGE_CONFLICT_TOOLTIP;
-          break;
-        default:
-          this.tooltipText = '';
-          break;
-      }
-    },
-  });
-})();
+    switch (status) {
+      case ChangeStates.WIP:
+        this.tooltipText = WIP_TOOLTIP;
+        break;
+      case ChangeStates.PRIVATE:
+        this.tooltipText = PRIVATE_TOOLTIP;
+        break;
+      case ChangeStates.MERGE_CONFLICT:
+        this.tooltipText = MERGE_CONFLICT_TOOLTIP;
+        break;
+      default:
+        this.tooltipText = '';
+        break;
+    }
+  }
+}
+
+customElements.define(GrChangeStatus.is, GrChangeStatus);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
new file mode 100644
index 0000000..904ef1d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .chip {
+      border-radius: var(--border-radius);
+      background-color: var(--chip-background-color);
+      padding: 0 var(--spacing-m);
+      white-space: nowrap;
+    }
+    :host(.merged) .chip {
+      background-color: #5b9d52;
+      color: #5b9d52;
+    }
+    :host(.abandoned) .chip {
+      background-color: #afafaf;
+      color: #afafaf;
+    }
+    :host(.wip) .chip {
+      background-color: #8f756c;
+      color: #8f756c;
+    }
+    :host(.private) .chip {
+      background-color: #c17ccf;
+      color: #c17ccf;
+    }
+    :host(.merge-conflict) .chip {
+      background-color: #dc5c60;
+      color: #dc5c60;
+    }
+    :host(.active) .chip {
+      background-color: #29b6f6;
+      color: #29b6f6;
+    }
+    :host(.ready-to-submit) .chip {
+      background-color: #e10ca3;
+      color: #e10ca3;
+    }
+    :host(.custom) .chip {
+      background-color: #825cc2;
+      color: #825cc2;
+    }
+    :host([flat]) .chip {
+      background-color: transparent;
+      padding: 0;
+    }
+    :host(:not([flat])) .chip {
+      color: white;
+    }
+  </style>
+  <gr-tooltip-content
+    has-tooltip=""
+    position-below=""
+    title="[[tooltipText]]"
+    max-width="40em"
+  >
+    <div class="chip" aria-label$="Label: [[status]]">
+      [[_computeStatusString(status)]]
+    </div>
+  </gr-tooltip-content>
+`;
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..806203b 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-change-status.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,97 +31,107 @@
   </template>
 </test-fixture>
 
-<script>
-  const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-      'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
-      'and email notifications will be silenced until the review is started.';
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-change-status.js';
+const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
+    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
+    'and email notifications will be silenced until the review is started.';
 
-  const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-    'Download the patch and run "git rebase master". ' +
-    'Upload a new patchset after resolving all merge conflicts.';
+const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
+  'Download the patch and run "git rebase master". ' +
+  'Upload a new patchset after resolving all merge conflicts.';
 
-  const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-      'current reviewers (or anyone with "View Private Changes" permission).';
+const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
+    'current reviewers (or anyone with "View Private Changes" permission).';
 
-  suite('gr-change-status tests', () => {
-    let element;
-    let sandbox;
+suite('gr-change-status tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('WIP', () => {
-      element.status = 'WIP';
-      assert.equal(element.$$('.chip').innerText, 'Work in Progress');
-      assert.equal(element.tooltipText, WIP_TOOLTIP);
-      assert.isTrue(element.classList.contains('wip'));
-    });
-
-    test('WIP flat', () => {
-      element.flat = true;
-      element.status = 'WIP';
-      assert.equal(element.$$('.chip').innerText, 'WIP');
-      assert.isDefined(element.tooltipText);
-      assert.isTrue(element.classList.contains('wip'));
-      assert.isTrue(element.hasAttribute('flat'));
-    });
-
-    test('merged', () => {
-      element.status = 'Merged';
-      assert.equal(element.$$('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('merged'));
-    });
-
-    test('abandoned', () => {
-      element.status = 'Abandoned';
-      assert.equal(element.$$('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('abandoned'));
-    });
-
-    test('merge conflict', () => {
-      element.status = 'Merge Conflict';
-      assert.equal(element.$$('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
-      assert.isTrue(element.classList.contains('merge-conflict'));
-    });
-
-    test('private', () => {
-      element.status = 'Private';
-      assert.equal(element.$$('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
-      assert.isTrue(element.classList.contains('private'));
-    });
-
-    test('active', () => {
-      element.status = 'Active';
-      assert.equal(element.$$('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('active'));
-    });
-
-    test('ready to submit', () => {
-      element.status = 'Ready to submit';
-      assert.equal(element.$$('.chip').innerText, element.status);
-      assert.equal(element.tooltipText, '');
-      assert.isTrue(element.classList.contains('ready-to-submit'));
-    });
-
-    test('updating status removes the previous class', () => {
-      element.status = 'Private';
-      assert.isTrue(element.classList.contains('private'));
-      assert.isFalse(element.classList.contains('wip'));
-
-      element.status = 'WIP';
-      assert.isFalse(element.classList.contains('private'));
-      assert.isTrue(element.classList.contains('wip'));
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('WIP', () => {
+    element.status = 'WIP';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, 'Work in Progress');
+    assert.equal(element.tooltipText, WIP_TOOLTIP);
+    assert.isTrue(element.classList.contains('wip'));
+  });
+
+  test('WIP flat', () => {
+    element.flat = true;
+    element.status = 'WIP';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, 'WIP');
+    assert.isDefined(element.tooltipText);
+    assert.isTrue(element.classList.contains('wip'));
+    assert.isTrue(element.hasAttribute('flat'));
+  });
+
+  test('merged', () => {
+    element.status = 'Merged';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('merged'));
+  });
+
+  test('abandoned', () => {
+    element.status = 'Abandoned';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('abandoned'));
+  });
+
+  test('merge conflict', () => {
+    element.status = 'Merge Conflict';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
+    assert.isTrue(element.classList.contains('merge-conflict'));
+  });
+
+  test('private', () => {
+    element.status = 'Private';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
+    assert.isTrue(element.classList.contains('private'));
+  });
+
+  test('active', () => {
+    element.status = 'Active';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('active'));
+  });
+
+  test('ready to submit', () => {
+    element.status = 'Ready to submit';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('ready-to-submit'));
+  });
+
+  test('updating status removes the previous class', () => {
+    element.status = 'Private';
+    assert.isTrue(element.classList.contains('private'));
+    assert.isFalse(element.classList.contains('wip'));
+
+    element.status = 'WIP';
+    assert.isFalse(element.classList.contains('private'));
+    assert.isTrue(element.classList.contains('wip'));
+  });
+});
 </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
deleted file mode 100644
index bbd7ddf..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
+++ /dev/null
@@ -1,139 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.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">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../gr-comment/gr-comment.html">
-
-<dom-module id="gr-comment-thread">
-  <template>
-    <style include="shared-styles">
-      :host {
-        font-family: var(--font-family);
-        font-size: var(--font-size-normal);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-normal);
-      }
-      gr-button {
-        margin-left: var(--spacing-m);
-      }
-      #actions {
-        margin-left: auto;
-        padding: var(--spacing-m);
-      }
-      #container {
-        background-color: var(--comment-background-color);
-        color: var(--comment-text-color);
-        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);
-        border-radius: var(--border-radius);
-        /** This is required for firefox to continue the inheritance */
-        -webkit-user-select: inherit;
-        -moz-user-select: inherit;
-        -ms-user-select: inherit;
-        user-select: inherit;
-      }
-      #container.unresolved {
-        background-color: var(--unresolved-comment-background-color);
-      }
-      #commentInfoContainer {
-        border-top: 1px dotted var(--border-color);
-        display: flex;
-      }
-      #unresolvedLabel {
-        font-family: var(--font-family);
-        margin: auto 0;
-        padding: var(--spacing-m);
-      }
-      .pathInfo {
-        display: flex;
-        align-items: baseline;
-      }
-      .descriptionText {
-        margin-left: var(--spacing-m);
-        font-style: italic;
-      }
-    </style>
-    <template is="dom-if" if="[[showFilePath]]">
-      <div class="pathInfo">
-        <a href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]">[[_computeDisplayPath(path)]]</a>
-        <span class="descriptionText">Patchset [[patchNum]]</span>
-      </div>
-    </template>
-    <div id="container" class$="[[_computeHostClass(unresolved)]]">
-      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]"
-          as="comment">
-        <gr-comment
-            comment="{{comment}}"
-            robot-button-disabled="[[_hideActions(_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"
-            on-comment-save="_handleCommentSavedOrDiscarded"></gr-comment>
-      </template>
-      <div id="commentInfoContainer"
-          hidden$="[[_hideActions(_showActions, _lastComment)]]">
-        <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
-        <div id="actions">
-          <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>
-      </div>
-    </div>
-    <gr-reporting id="reporting"></gr-reporting>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-comment-thread.js"></script>
-</dom-module>
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..ccfc44c 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,48 +14,76 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const UNRESOLVED_EXPAND_COUNT = 5;
-  const NEWLINE_PATTERN = /\n/g;
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-storage/gr-storage.js';
+import '../gr-comment/gr-comment.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment-thread_html.js';
+import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {util} from '../../../scripts/util.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-  Polymer({
-    is: 'gr-comment-thread',
+const UNRESOLVED_EXPAND_COUNT = 5;
+const NEWLINE_PATTERN = /\n/g;
 
-    /**
-     * Fired when the thread should be discarded.
-     *
-     * @event thread-discard
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrCommentThread extends mixinBehaviors( [
+  /**
+   * Not used in this element rather other elements tests
+   */
+  KeyboardShortcutBehavior,
+  PathListBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when a comment in the thread is permanently modified.
-     *
-     * @event thread-changed
-     */
+  static get is() { return 'gr-comment-thread'; }
+  /**
+   * Fired when the thread should be discarded.
+   *
+   * @event thread-discard
+   */
 
-    /**
-     * gr-comment-thread exposes the following attributes that allow a
-     * diff widget like gr-diff to show the thread in the right location:
-     *
-     * line-num:
-     *     1-based line number or undefined if it refers to the entire file.
-     *
-     * comment-side:
-     *     "left" or "right". These indicate which of the two diffed versions
-     *     the comment relates to. In the case of unified diff, the left
-     *     version is the one whose line number column is further to the left.
-     *
-     * range:
-     *     The range of text that the comment refers to (start_line,
-     *     start_character, end_line, end_character), serialized as JSON. If
-     *     set, range's end_line will have the same value as line-num. Line
-     *     numbers are 1-based, char numbers are 0-based. The start position
-     *     (start_line, start_character) is inclusive, and the end position
-     *     (end_line, end_character) is exclusive.
-     */
-    properties: {
+  /**
+   * Fired when a comment in the thread is permanently modified.
+   *
+   * @event thread-changed
+   */
+
+  /**
+   * gr-comment-thread exposes the following attributes that allow a
+   * diff widget like gr-diff to show the thread in the right location:
+   *
+   * line-num:
+   *     1-based line number or undefined if it refers to the entire file.
+   *
+   * comment-side:
+   *     "left" or "right". These indicate which of the two diffed versions
+   *     the comment relates to. In the case of unified diff, the left
+   *     version is the one whose line number column is further to the left.
+   *
+   * range:
+   *     The range of text that the comment refers to (start_line,
+   *     start_character, end_line, end_character), serialized as JSON. If
+   *     set, range's end_line will have the same value as line-num. Line
+   *     numbers are 1-based, char numbers are 0-based. The start position
+   *     (start_line, start_character) is inclusive, and the end position
+   *     (end_line, end_character) is exclusive.
+   */
+  static get properties() {
+    return {
       changeNum: String,
       comments: {
         type: Array,
@@ -123,377 +151,394 @@
       _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,
-    ],
-
-    listeners: {
-      'comment-update': '_handleCommentUpdate',
-    },
-
-    observers: [
+  static get observers() {
+    return [
       '_commentsChanged(comments.*)',
-    ],
+    ];
+  }
 
-    keyBindings: {
+  get keyBindings() {
+    return {
       'e shift+e': '_handleEKey',
-    },
+    };
+  }
 
-    attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._showActions = loggedIn;
-      });
-      this._setInitialExpandedState();
-    },
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('comment-update',
+        e => this._handleCommentUpdate(e));
+  }
 
-    addOrEditDraft(opt_lineNum, opt_range) {
-      const lastComment = this.comments[this.comments.length - 1] || {};
-      if (lastComment.__draft) {
-        const commentEl = this._commentElWithDraftID(
-            lastComment.id || lastComment.__draftID);
-        commentEl.editing = true;
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._showActions = loggedIn;
+    });
+    this._setInitialExpandedState();
+  }
 
-        // If the comment was collapsed, re-open it to make it clear which
-        // actions are available.
-        commentEl.collapsed = false;
-      } else {
-        const range = opt_range ? opt_range :
-          lastComment ? lastComment.range : undefined;
-        const unresolved = lastComment ? lastComment.unresolved : undefined;
-        this.addDraft(opt_lineNum, range, unresolved);
-      }
-    },
+  addOrEditDraft(opt_lineNum, opt_range) {
+    const lastComment = this.comments[this.comments.length - 1] || {};
+    if (lastComment.__draft) {
+      const commentEl = this._commentElWithDraftID(
+          lastComment.id || lastComment.__draftID);
+      commentEl.editing = true;
 
-    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);
-    },
+      // If the comment was collapsed, re-open it to make it clear which
+      // actions are available.
+      commentEl.collapsed = false;
+    } else {
+      const range = opt_range ? opt_range :
+        lastComment ? lastComment.range : undefined;
+      const unresolved = lastComment ? lastComment.unresolved : undefined;
+      this.addDraft(opt_lineNum, range, unresolved);
+    }
+  }
 
-    fireRemoveSelf() {
-      this.dispatchEvent(new CustomEvent('thread-discard',
-          {detail: {rootId: this.rootId}, bubbles: false}));
-    },
+  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);
+  }
 
-    _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
-      return Gerrit.Nav.getUrlForDiffById(changeNum,
-          projectName, path, patchNum,
-          null, this.lineNum);
-    },
+  fireRemoveSelf() {
+    this.dispatchEvent(new CustomEvent('thread-discard',
+        {detail: {rootId: this.rootId}, bubbles: false}));
+  }
 
-    _computeDisplayPath(path) {
-      const lineString = this.lineNum ? `#${this.lineNum}` : '';
-      return this.computeDisplayPath(path) + lineString;
-    },
+  _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
+    return GerritNav.getUrlForDiffById(changeNum,
+        projectName, path, patchNum,
+        null, this.lineNum);
+  }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
+  _computeDisplayPath(path) {
+    const lineString = this.lineNum ? `#${this.lineNum}` : '';
+    return this.computeDisplayPath(path) + lineString;
+  }
 
-    _commentsChanged() {
-      this._orderedComments = this._sortedComments(this.comments);
-      this.updateThreadProperties();
-    },
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
 
-    updateThreadProperties() {
-      if (this._orderedComments.length) {
-        this._lastComment = this._getLastComment();
-        this.unresolved = this._lastComment.unresolved;
-        this.hasDraft = this._lastComment.__draft;
-      }
-    },
+  _commentsChanged() {
+    this._orderedComments = this._sortedComments(this.comments);
+    this.updateThreadProperties();
+  }
 
-    _hideActions(_showActions, _lastComment) {
-      return !_showActions || !_lastComment || !!_lastComment.__draft ||
-        !!_lastComment.robot_id;
-    },
+  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);
+    }
+  }
 
-    _getLastComment() {
-      return this._orderedComments[this._orderedComments.length - 1] || {};
-    },
+  _shouldDisableAction(_showActions, _lastComment) {
+    return !_showActions || !_lastComment || !!_lastComment.__draft;
+  }
 
-    _handleEKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+  _hideActions(_showActions, _lastComment) {
+    return this._shouldDisableAction(_showActions, _lastComment) ||
+      !!_lastComment.robot_id;
+  }
 
-      // Don’t preventDefault in this case because it will render the event
-      // useless for other handlers (other gr-comment-thread elements).
-      if (e.detail.keyboardEvent.shiftKey) {
-        this._expandCollapseComments(true);
-      } else {
-        if (this.modifierPressed(e)) { return; }
-        this._expandCollapseComments(false);
-      }
-    },
+  _getLastComment() {
+    return this._orderedComments[this._orderedComments.length - 1] || {};
+  }
 
-    _expandCollapseComments(actionIsCollapse) {
-      const comments =
-          Polymer.dom(this.root).querySelectorAll('gr-comment');
-      for (const comment of comments) {
-        comment.collapsed = actionIsCollapse;
-      }
-    },
+  _handleEKey(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-    /**
-     * Sets the initial state of the comment thread.
-     * Expands the thread if one of the following is true:
-     * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-     * thread is unresolved,
-     * - it's a robot comment.
-     */
-    _setInitialExpandedState() {
-      if (this._orderedComments) {
-        for (let i = 0; i < this._orderedComments.length; i++) {
-          const comment = this._orderedComments[i];
-          const isRobotComment = !!comment.robot_id;
-          // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
-          const resolvedThread = !this.unresolved ||
-                this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
+    // Don’t preventDefault in this case because it will render the event
+    // useless for other handlers (other gr-comment-thread elements).
+    if (e.detail.keyboardEvent.shiftKey) {
+      this._expandCollapseComments(true);
+    } else {
+      if (this.modifierPressed(e)) { return; }
+      this._expandCollapseComments(false);
+    }
+  }
+
+  _expandCollapseComments(actionIsCollapse) {
+    const comments =
+        dom(this.root).querySelectorAll('gr-comment');
+    for (const comment of comments) {
+      comment.collapsed = actionIsCollapse;
+    }
+  }
+
+  /**
+   * Sets the initial state of the comment thread.
+   * Expands the thread if one of the following is true:
+   * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
+   * thread is unresolved,
+   * - it's a robot comment.
+   */
+  _setInitialExpandedState() {
+    if (this._orderedComments) {
+      for (let i = 0; i < this._orderedComments.length; i++) {
+        const comment = this._orderedComments[i];
+        const isRobotComment = !!comment.robot_id;
+        // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
+        const resolvedThread = !this.unresolved ||
+              this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
+        if (comment.collapsed === undefined) {
           comment.collapsed = !isRobotComment && resolvedThread;
         }
       }
-    },
+    }
+  }
 
-    _sortedComments(comments) {
-      return comments.slice().sort((c1, c2) => {
-        const c1Date = c1.__date || util.parseDate(c1.updated);
-        const c2Date = c2.__date || util.parseDate(c2.updated);
-        const dateCompare = c1Date - c2Date;
-        // Ensure drafts are at the end. There should only be one but in edge
-        // cases could be more. In the unlikely event two drafts are being
-        // compared, use the typical date compare.
-        if (c2.__draft && !c1.__draft ) { return -1; }
-        if (c1.__draft && !c2.__draft ) { return 1; }
-        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
-        // If same date, fall back to sorting by id.
-        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-      });
-    },
+  _sortedComments(comments) {
+    return comments.slice().sort((c1, c2) => {
+      const c1Date = c1.__date || util.parseDate(c1.updated);
+      const c2Date = c2.__date || util.parseDate(c2.updated);
+      const dateCompare = c1Date - c2Date;
+      // Ensure drafts are at the end. There should only be one but in edge
+      // cases could be more. In the unlikely event two drafts are being
+      // compared, use the typical date compare.
+      if (c2.__draft && !c1.__draft ) { return -1; }
+      if (c1.__draft && !c2.__draft ) { return 1; }
+      if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
+      // If same date, fall back to sorting by id.
+      return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+    });
+  }
 
-    _createReplyComment(parent, content, opt_isEditing,
-        opt_unresolved) {
-      this.$.reporting.recordDraftInteraction();
-      const reply = this._newReply(
-          this._orderedComments[this._orderedComments.length - 1].id,
-          parent.line,
-          content,
-          opt_unresolved,
-          parent.range);
+  _createReplyComment(parent, content, opt_isEditing,
+      opt_unresolved) {
+    this.$.reporting.recordDraftInteraction();
+    const reply = this._newReply(
+        this._orderedComments[this._orderedComments.length - 1].id,
+        parent.line,
+        content,
+        opt_unresolved,
+        parent.range);
 
-      // If there is currently a comment in an editing state, add an attribute
-      // so that the gr-comment knows not to populate the draft text.
-      for (let i = 0; i < this.comments.length; i++) {
-        if (this.comments[i].__editing) {
-          reply.__otherEditing = true;
-          break;
-        }
+    // If there is currently a comment in an editing state, add an attribute
+    // so that the gr-comment knows not to populate the draft text.
+    for (let i = 0; i < this.comments.length; i++) {
+      if (this.comments[i].__editing) {
+        reply.__otherEditing = true;
+        break;
       }
+    }
 
-      if (opt_isEditing) {
-        reply.__editing = true;
-      }
+    if (opt_isEditing) {
+      reply.__editing = true;
+    }
 
-      this.push('comments', reply);
+    this.push('comments', reply);
 
-      if (!opt_isEditing) {
-        // Allow the reply to render in the dom-repeat.
-        this.async(() => {
-          const commentEl = this._commentElWithDraftID(reply.__draftID);
-          commentEl.save();
-        }, 1);
-      }
-    },
+    if (!opt_isEditing) {
+      // Allow the reply to render in the dom-repeat.
+      this.async(() => {
+        const commentEl = this._commentElWithDraftID(reply.__draftID);
+        commentEl.save();
+      }, 1);
+    }
+  }
 
-    _isDraft(comment) {
-      return !!comment.__draft;
-    },
+  _isDraft(comment) {
+    return !!comment.__draft;
+  }
 
-    /**
-     * @param {boolean=} opt_quote
-     */
-    _processCommentReply(opt_quote) {
-      const comment = this._lastComment;
-      let quoteStr;
-      if (opt_quote) {
-        const msg = comment.message;
-        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;
+  /**
+   * @param {boolean=} opt_quote
+   */
+  _processCommentReply(opt_quote) {
+    const comment = this._lastComment;
+    let quoteStr;
+    if (opt_quote) {
       const msg = comment.message;
-      const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      const response = quoteStr + 'Please Fix';
-      this._createReplyComment(comment, response, false, true);
-    },
+      quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+    }
+    this._createReplyComment(comment, quoteStr, true, comment.unresolved);
+  }
 
-    _commentElWithDraftID(id) {
-      const els = Polymer.dom(this.root).querySelectorAll('gr-comment');
-      for (const el of els) {
-        if (el.comment.id === id || el.comment.__draftID === id) {
-          return el;
-        }
+  _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.';
+    this._createReplyComment(comment, response, false, true);
+  }
+
+  _commentElWithDraftID(id) {
+    const els = dom(this.root).querySelectorAll('gr-comment');
+    for (const el of els) {
+      if (el.comment.id === id || el.comment.__draftID === id) {
+        return el;
       }
-      return null;
-    },
+    }
+    return null;
+  }
 
-    _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
-        opt_range) {
-      const d = this._newDraft(opt_lineNum);
-      d.in_reply_to = inReplyTo;
+  _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
+      opt_range) {
+    const d = this._newDraft(opt_lineNum);
+    d.in_reply_to = inReplyTo;
+    d.range = opt_range;
+    if (opt_message != null) {
+      d.message = opt_message;
+    }
+    if (opt_unresolved !== undefined) {
+      d.unresolved = opt_unresolved;
+    }
+    return d;
+  }
+
+  /**
+   * @param {number=} opt_lineNum
+   * @param {!Object=} opt_range
+   */
+  _newDraft(opt_lineNum, opt_range) {
+    const d = {
+      __draft: true,
+      __draftID: Math.random().toString(36),
+      __date: new Date(),
+      path: this.path,
+      patchNum: this.patchNum,
+      side: this._getSide(this.isOnParent),
+      __commentSide: this.commentSide,
+    };
+    if (opt_lineNum) {
+      d.line = opt_lineNum;
+    }
+    if (opt_range) {
       d.range = opt_range;
-      if (opt_message != null) {
-        d.message = opt_message;
-      }
-      if (opt_unresolved !== undefined) {
-        d.unresolved = opt_unresolved;
-      }
-      return d;
-    },
+    }
+    if (this.parentIndex) {
+      d.parent = this.parentIndex;
+    }
+    return d;
+  }
 
-    /**
-     * @param {number=} opt_lineNum
-     * @param {!Object=} opt_range
-     */
-    _newDraft(opt_lineNum, opt_range) {
-      const d = {
-        __draft: true,
-        __draftID: Math.random().toString(36),
-        __date: new Date(),
-        path: this.path,
-        patchNum: this.patchNum,
-        side: this._getSide(this.isOnParent),
-        __commentSide: this.commentSide,
-      };
-      if (opt_lineNum) {
-        d.line = opt_lineNum;
+  _getSide(isOnParent) {
+    if (isOnParent) { return 'PARENT'; }
+    return 'REVISION';
+  }
+
+  _computeRootId(comments) {
+    // Keep the root ID even if the comment was removed, so that notification
+    // to sync will know which thread to remove.
+    if (!comments.base.length) { return this.rootId; }
+    const rootComment = comments.base[0];
+    return rootComment.id || rootComment.__draftID;
+  }
+
+  _handleCommentDiscard(e) {
+    const diffCommentEl = dom(e).rootTarget;
+    const comment = diffCommentEl.comment;
+    const idx = this._indexOf(comment, this.comments);
+    if (idx == -1) {
+      throw Error('Cannot find comment ' +
+          JSON.stringify(diffCommentEl.comment));
+    }
+    this.splice('comments', idx, 1);
+    if (this.comments.length === 0) {
+      this.fireRemoveSelf();
+    }
+    this._handleCommentSavedOrDiscarded(e);
+
+    // Check to see if there are any other open comments getting edited and
+    // set the local storage value to its message value.
+    for (const changeComment of this.comments) {
+      if (changeComment.__editing) {
+        const commentLocation = {
+          changeNum: this.changeNum,
+          patchNum: this.patchNum,
+          path: changeComment.path,
+          line: changeComment.line,
+        };
+        return this.$.storage.setDraftComment(commentLocation,
+            changeComment.message);
       }
-      if (opt_range) {
-        d.range = opt_range;
+    }
+  }
+
+  _handleCommentSavedOrDiscarded(e) {
+    this.dispatchEvent(new CustomEvent('thread-changed',
+        {detail: {rootId: this.rootId, path: this.path},
+          bubbles: false}));
+  }
+
+  _handleCommentUpdate(e) {
+    const comment = e.detail.comment;
+    const index = this._indexOf(comment, this.comments);
+    if (index === -1) {
+      // This should never happen: comment belongs to another thread.
+      console.warn('Comment update for another comment thread.');
+      return;
+    }
+    this.set(['comments', index], comment);
+    // Because of the way we pass these comment objects around by-ref, in
+    // combination with the fact that Polymer does dirty checking in
+    // 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++) {
+      const c = arr[i];
+      if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
+          (c.id != null && c.id == comment.id)) {
+        return i;
       }
-      if (this.parentIndex) {
-        d.parent = this.parentIndex;
-      }
-      return d;
-    },
+    }
+    return -1;
+  }
 
-    _getSide(isOnParent) {
-      if (isOnParent) { return 'PARENT'; }
-      return 'REVISION';
-    },
+  _computeHostClass(unresolved) {
+    if (this.isRobotComment) {
+      return 'robotComment';
+    }
+    return unresolved ? 'unresolved' : '';
+  }
 
-    _computeRootId(comments) {
-      // Keep the root ID even if the comment was removed, so that notification
-      // to sync will know which thread to remove.
-      if (!comments.base.length) { return this.rootId; }
-      const rootComment = comments.base[0];
-      return rootComment.id || rootComment.__draftID;
-    },
+  /**
+   * Load the project config when a project name has been provided.
+   *
+   * @param {string} name The project name.
+   */
+  _projectNameChanged(name) {
+    if (!name) { return; }
+    this.$.restAPI.getProjectConfig(name).then(config => {
+      this._projectConfig = config;
+    });
+  }
+}
 
-    _handleCommentDiscard(e) {
-      const diffCommentEl = Polymer.dom(e).rootTarget;
-      const comment = diffCommentEl.comment;
-      const idx = this._indexOf(comment, this.comments);
-      if (idx == -1) {
-        throw Error('Cannot find comment ' +
-            JSON.stringify(diffCommentEl.comment));
-      }
-      this.splice('comments', idx, 1);
-      if (this.comments.length === 0) {
-        this.fireRemoveSelf();
-      }
-      this._handleCommentSavedOrDiscarded(e);
-
-      // Check to see if there are any other open comments getting edited and
-      // set the local storage value to its message value.
-      for (const changeComment of this.comments) {
-        if (changeComment.__editing) {
-          const commentLocation = {
-            changeNum: this.changeNum,
-            patchNum: this.patchNum,
-            path: changeComment.path,
-            line: changeComment.line,
-          };
-          return this.$.storage.setDraftComment(commentLocation,
-              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;
-      const index = this._indexOf(comment, this.comments);
-      if (index === -1) {
-        // This should never happen: comment belongs to another thread.
-        console.warn('Comment update for another comment thread.');
-        return;
-      }
-      this.set(['comments', index], comment);
-      // Because of the way we pass these comment objects around by-ref, in
-      // combination with the fact that Polymer does dirty checking in
-      // 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++) {
-        const c = arr[i];
-        if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
-            (c.id != null && c.id == comment.id)) {
-          return i;
-        }
-      }
-      return -1;
-    },
-
-    _computeHostClass(unresolved) {
-      return unresolved ? 'unresolved' : '';
-    },
-
-    /**
-     * Load the project config when a project name has been provided.
-     *
-     * @param {string} name The project name.
-     */
-    _projectNameChanged(name) {
-      if (!name) { return; }
-      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_html.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
new file mode 100644
index 0000000..fbc18b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
@@ -0,0 +1,154 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      font-family: var(--font-family);
+      font-size: var(--font-size-normal);
+      font-weight: var(--font-weight-normal);
+      line-height: var(--line-height-normal);
+    }
+    gr-button {
+      margin-left: var(--spacing-m);
+    }
+    gr-comment:not(:last-of-type) {
+      border-bottom: 1px solid var(--comment-separator-color);
+    }
+    #actions {
+      margin-left: auto;
+      padding: var(--spacing-m);
+    }
+    #container {
+      background-color: var(--comment-background-color);
+      color: var(--comment-text-color);
+      display: block;
+      margin: 0 var(--spacing-s) var(--spacing-s);
+      white-space: normal;
+      box-shadow: var(--elevation-level-2);
+      border-radius: var(--border-radius);
+      /** This is required for firefox to continue the inheritance */
+      -webkit-user-select: inherit;
+      -moz-user-select: inherit;
+      -ms-user-select: inherit;
+      user-select: inherit;
+    }
+    #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;
+    }
+    #unresolvedLabel {
+      font-family: var(--font-family);
+      margin: auto 0;
+      padding: var(--spacing-m);
+    }
+    .pathInfo {
+      display: flex;
+      align-items: baseline;
+      justify-content: space-between;
+      padding: 0 var(--spacing-s) var(--spacing-s);
+    }
+    .descriptionText {
+      margin-left: var(--spacing-m);
+      font-style: italic;
+    }
+  </style>
+  <template is="dom-if" if="[[showFilePath]]">
+    <div class="pathInfo">
+      <a
+        href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
+        >[[_computeDisplayPath(path)]]</a
+      >
+      <span class="descriptionText">Patchset [[patchNum]]</span>
+    </div>
+  </template>
+  <div
+    id="container"
+    class$="[[_computeHostClass(unresolved, isRobotComment)]]"
+  >
+    <template
+      id="commentList"
+      is="dom-repeat"
+      items="[[_orderedComments]]"
+      as="comment"
+    >
+      <gr-comment
+        comment="{{comment}}"
+        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]]"
+        project-config="[[_projectConfig]]"
+        on-create-fix-comment="_handleCommentFix"
+        on-comment-discard="_handleCommentDiscard"
+        on-comment-save="_handleCommentSavedOrDiscarded"
+      ></gr-comment>
+    </template>
+    <div
+      id="commentInfoContainer"
+      hidden$="[[_hideActions(_showActions, _lastComment)]]"
+    >
+      <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
+      <div id="actions">
+        <gr-button
+          id="replyBtn"
+          link=""
+          class="action reply"
+          on-click="_handleCommentReply"
+          >Reply</gr-button
+        >
+        <gr-button
+          id="quoteBtn"
+          link=""
+          class="action quote"
+          on-click="_handleCommentQuote"
+          >Quote</gr-button
+        >
+        <template is="dom-if" if="[[unresolved]]">
+          <gr-button
+            id="ackBtn"
+            link=""
+            class="action ack"
+            on-click="_handleCommentAck"
+            >Ack</gr-button
+          >
+          <gr-button
+            id="doneBtn"
+            link=""
+            class="action done"
+            on-click="_handleCommentDone"
+            >Done</gr-button
+          >
+        </template>
+      </div>
+    </div>
+  </div>
+  <gr-reporting id="reporting"></gr-reporting>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+`;
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..244a9ec 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-comment-thread.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -42,8 +37,14 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-comment-thread tests', () => {
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-comment-thread.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+suite('gr-comment-thread tests', () => {
+  suite('basic test', () => {
     let element;
     let sandbox;
 
@@ -152,6 +153,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 = {};
@@ -180,9 +199,10 @@
     test('optionally show file path', () => {
       // Path info doesn't exist when showFilePath is false. Because it's in a
       // dom-if it is not yet in the dom.
-      assert.isNotOk(element.$$('.pathInfo'));
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.pathInfo'));
 
-      sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      sandbox.stub(GerritNav, 'getUrlForDiffById');
       element.changeNum = 123;
       element.projectName = 'test project';
       element.path = 'path/to/file';
@@ -190,10 +210,12 @@
       element.lineNum = 5;
       element.showFilePath = true;
       flushAsynchronousOperations();
-      assert.isOk(element.$$('.pathInfo'));
-      assert.notEqual(getComputedStyle(element.$$('.pathInfo')).display,
-          'none');
-      assert.isTrue(Gerrit.Nav.getUrlForDiffById.lastCall.calledWithExactly(
+      assert.isOk(element.shadowRoot
+          .querySelector('.pathInfo'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.pathInfo')).display,
+      'none');
+      assert.isTrue(GerritNav.getUrlForDiffById.lastCall.calledWithExactly(
           element.changeNum, element.projectName, element.path,
           element.patchNum, null, element.lineNum));
     });
@@ -206,551 +228,650 @@
       assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
     });
   });
+});
 
-  suite('comment action tests', () => {
-    let element;
-    let sandbox;
+suite('comment action tests with unresolved thread', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(')]}\'\n' +
-                  JSON.stringify({
-                    id: '7afa4931_de3d65bd',
-                    path: '/path/to/file.txt',
-                    line: 5,
-                    in_reply_to: 'baf0414d_60047215',
-                    updated: '2015-12-21 02:01:10.850000000',
-                    message: 'Done',
-                  }));
-            },
-          });
-        },
-        deleteDiffDraft() { return Promise.resolve({ok: true}); },
-      });
-      element = fixture('withComment');
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-        path: '/path/to/file.txt',
-      }];
-      flushAsynchronousOperations();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      saveDiffDraft() {
+        return Promise.resolve({
+          ok: true,
+          text() {
+            return Promise.resolve(')]}\'\n' +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215',
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done',
+                }));
+          },
+        });
+      },
+      deleteDiffDraft() { return Promise.resolve({ok: true}); },
     });
+    element = fixture('withComment');
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      path: '/path/to/file.txt',
+      unresolved: true,
+    }];
+    flushAsynchronousOperations();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('reply', () => {
-      const commentEl = element.$$('gr-comment');
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      assert.ok(commentEl);
+  test('reply', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
 
-      const replyBtn = element.$.replyBtn;
-      MockInteractions.tap(replyBtn);
-      flushAsynchronousOperations();
+    const replyBtn = element.$.replyBtn;
+    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');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flushAsynchronousOperations();
+
+    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');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply multiline', () => {
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?\nIt might be!',
+      updated: '2015-12-08 19:48:33.843000000',
+    }];
+    flushAsynchronousOperations();
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flushAsynchronousOperations();
+
+    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');
+    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('ack', done => {
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    element.changeNum = '42';
+    element.patchNum = '1';
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
+    MockInteractions.tap(ackBtn);
+    flush(() => {
+      const drafts = element.comments.filter(c => c.__draft == true);
       assert.equal(drafts.length, 1);
-      assert.notOk(drafts[0].message, 'message should be empty');
+      assert.equal(drafts[0].message, 'Ack');
       assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.equal(drafts[0].unresolved, false);
       assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('quote reply', () => {
-      const commentEl = element.$$('gr-comment');
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      assert.ok(commentEl);
-
-      const quoteBtn = element.$.quoteBtn;
-      MockInteractions.tap(quoteBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => {
-        return 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');
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('quote reply multiline', () => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?\nIt might be!',
-        updated: '2015-12-08 19:48:33.843000000',
-      }];
-      flushAsynchronousOperations();
-
-      const commentEl = element.$$('gr-comment');
-      assert.ok(commentEl);
-
-      const quoteBtn = element.$.quoteBtn;
-      MockInteractions.tap(quoteBtn);
-      flushAsynchronousOperations();
-
-      const drafts = element._orderedComments.filter(c => {
-        return c.__draft == true;
-      });
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message,
-          '> is this a crossover episode!?\n> It might be!\n\n');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('ack', done => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.changeNum = '42';
-      element.patchNum = '1';
-
-      const commentEl = element.$$('gr-comment');
-      assert.ok(commentEl);
-
-      const ackBtn = element.$.ackBtn;
-      MockInteractions.tap(ackBtn);
-      flush(() => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, 'Ack');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.equal(drafts[0].unresolved, false);
-        assert.isTrue(reportStub.calledOnce);
-        done();
-      });
-    });
-
-    test('done', done => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.changeNum = '42';
-      element.patchNum = '1';
-      const commentEl = element.$$('gr-comment');
-      assert.ok(commentEl);
-
-      const doneBtn = element.$.doneBtn;
-      MockInteractions.tap(doneBtn);
-      flush(() => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, 'Done');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        assert.isFalse(drafts[0].unresolved);
-        assert.isTrue(reportStub.calledOnce);
-        done();
-      });
-    });
-
-    test('save', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.path = '/path/to/file.txt';
-      const commentEl = element.$$('gr-comment');
-      assert.ok(commentEl);
-
-      const saveOrDiscardStub = sandbox.stub();
-      element.addEventListener('thread-changed', saveOrDiscardStub);
-      element.$$('gr-comment')._fireSave();
-
-      flush(() => {
-        assert.isTrue(saveOrDiscardStub.called);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-            'baf0414d_60047215');
-        assert.equal(element.rootId, 'baf0414d_60047215');
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-            '/path/to/file.txt');
-        done();
-      });
-    });
-
-    test('please fix', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      const commentEl = element.$$('gr-comment');
-      assert.ok(commentEl);
-      commentEl.addEventListener('create-fix-comment', () => {
-        const drafts = element._orderedComments.filter(c => {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 1);
-        assert.equal(
-            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();
-      });
-      commentEl.fire('create-fix-comment', {comment: commentEl.comment},
-          {bubbles: false});
-    });
-
-    test('discard', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.path = '/path/to/file.txt';
-      element.push('comments', element._newReply(
-          element.comments[0].id,
-          element.comments[0].line,
-          element.comments[0].path,
-          'it’s pronouced jiff, not giff'));
-      flushAsynchronousOperations();
-
-      const saveOrDiscardStub = sandbox.stub();
-      element.addEventListener('thread-changed', saveOrDiscardStub);
-      const draftEl =
-          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;
-        });
-        assert.equal(drafts.length, 0);
-        assert.isTrue(saveOrDiscardStub.called);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-            element.rootId);
-        assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-            element.path);
-        done();
-      });
-      draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
-    });
-
-    test('discard with a single comment still fires event with previous rootId',
-        done => {
-          element.changeNum = '42';
-          element.patchNum = '1';
-          element.path = '/path/to/file.txt';
-          element.comments = [];
-          element.addOrEditDraft('1');
-          flushAsynchronousOperations();
-          const rootId = element.rootId;
-          assert.isOk(rootId);
-
-          const saveOrDiscardStub = sandbox.stub();
-          element.addEventListener('thread-changed', saveOrDiscardStub);
-          const draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-comment')[0];
-          assert.ok(draftEl);
-          draftEl.addEventListener('comment-discard', () => {
-            assert.equal(element.comments.length, 0);
-            assert.isTrue(saveOrDiscardStub.called);
-            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-                rootId);
-            assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-                element.path);
-            done();
-          });
-          draftEl.fire('comment-discard', {comment: draftEl.comment},
-              {bubbles: false});
-        });
-
-    test('first editing comment does not add __otherEditing attribute', () => {
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      }];
-
-      const replyBtn = element.$.replyBtn;
-      MockInteractions.tap(replyBtn);
-      flushAsynchronousOperations();
-
-      const editing = element._orderedComments.filter(c => {
-        return c.__editing == true;
-      });
-      assert.equal(editing.length, 1);
-      assert.equal(!!editing[0].__otherEditing, false);
-    });
-
-    test('When not editing other comments, local storage not set' +
-        ' after discard', done => {
-      element.changeNum = '42';
-      element.patchNum = '1';
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:31.843000000',
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        __draftID: '1',
-        in_reply_to: 'baf0414d_60047215',
-        line: 5,
-        message: 'yes',
-        updated: '2015-12-08 19:48:32.843000000',
-        __draft: true,
-        __editing: true,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        __draftID: '2',
-        in_reply_to: 'baf0414d_60047215',
-        line: 5,
-        message: 'no',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      }];
-      const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
-      flushAsynchronousOperations();
-
-      const draftEl =
-      Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
-      assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', () => {
-        assert.isFalse(storageStub.called);
-        storageStub.restore();
-        done();
-      });
-      draftEl.fire('comment-discard', {comment: draftEl.comment},
-          {bubbles: false});
-    });
-
-    test('comment-update', () => {
-      const commentEl = element.$$('gr-comment');
-      const updatedComment = {
-        id: element.comments[0].id,
-        foo: 'bar',
-      };
-      commentEl.fire('comment-update', {comment: updatedComment});
-      assert.strictEqual(element.comments[0], updatedComment);
-    });
-
-    suite('jack and sally comment data test consolidation', () => {
-      setup(() => {
-        element.comments = [
-          {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            in_reply_to: 'sallys_confession',
-            updated: '2015-12-25 15:00:20.396000000',
-            unresolved: false,
-          }, {
-            id: 'sallys_confession',
-            in_reply_to: 'nonexistent_comment',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:20.396000000',
-          }, {
-            id: 'sally_to_dr_finklestein',
-            in_reply_to: 'nonexistent_comment',
-            message: 'i’m running away',
-            updated: '2015-10-31 09:00:20.396000000',
-          }, {
-            id: 'sallys_defiance',
-            message: 'i will poison you so i can get away',
-            updated: '2015-10-31 15:00:20.396000000',
-          }];
-      });
-
-      test('orphan replies', () => {
-        assert.equal(4, element._orderedComments.length);
-      });
-
-      test('keyboard shortcuts', () => {
-        const expandCollapseStub =
-            sinon.stub(element, '_expandCollapseComments');
-        MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
-        assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-        MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
-        assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-      });
-
-      test('comment in_reply_to is either null or most recent comment', () => {
-        element._createReplyComment(element.comments[3], 'dummy', true);
-        flushAsynchronousOperations();
-        assert.equal(element._orderedComments.length, 5);
-        assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
-      });
-
-      test('resolvable comments', () => {
-        assert.isFalse(element.unresolved);
-        element._createReplyComment(element.comments[3], 'dummy', true, true);
-        flushAsynchronousOperations();
-        assert.isTrue(element.unresolved);
-      });
-
-      test('_setInitialExpandedState', () => {
-        element.unresolved = true;
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isFalse(element.comments[i].collapsed);
-        }
-        element.unresolved = false;
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isTrue(element.comments[i].collapsed);
-        }
-        for (let i = 0; i < element.comments.length; i++) {
-          element.comments[i].robot_id = 123;
-        }
-        element._setInitialExpandedState();
-        for (let i = 0; i < element.comments.length; i++) {
-          assert.isFalse(element.comments[i].collapsed);
-        }
-      });
-    });
-
-    test('_computeHostClass', () => {
-      assert.equal(element._computeHostClass(true), 'unresolved');
-      assert.equal(element._computeHostClass(false), '');
-    });
-
-    test('addDraft sets unresolved state correctly', () => {
-      let unresolved = true;
-      element.comments = [];
-      element.addDraft(null, null, unresolved);
-      assert.equal(element.comments[0].unresolved, true);
-
-      unresolved = false; // comment should get added as actually resolved.
-      element.comments = [];
-      element.addDraft(null, null, unresolved);
-      assert.equal(element.comments[0].unresolved, false);
-
-      element.comments = [];
-      element.addDraft();
-      assert.equal(element.comments[0].unresolved, true);
-    });
-
-    test('_newDraft', () => {
-      element.commentSide = 'left';
-      element.patchNum = 3;
-      const draft = element._newDraft();
-      assert.equal(draft.__commentSide, 'left');
-      assert.equal(draft.patchNum, 3);
-    });
-
-    test('new comment gets created', () => {
-      element.comments = [];
-      element.addOrEditDraft(1);
-      assert.equal(element.comments.length, 1);
-      // Mock a submitted comment.
-      element.comments[0].id = element.comments[0].__draftID;
-      element.comments[0].__draft = false;
-      element.addOrEditDraft(1);
-      assert.equal(element.comments.length, 2);
-    });
-
-    test('unresolved label', () => {
-      element.unresolved = false;
-      assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
-      element.unresolved = true;
-      assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
-    });
-
-    test('draft comments are at the end of orderedComments', () => {
-      element.comments = [{
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 2,
-        line: 5,
-        message: 'Earlier draft',
-        updated: '2015-12-08 19:48:33.843000000',
-        __draft: true,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 1,
-        line: 5,
-        message: 'This comment was left last but is not a draft',
-        updated: '2015-12-10 19:48:33.843000000',
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 3,
-        line: 5,
-        message: 'Later draft',
-        updated: '2015-12-09 19:48:33.843000000',
-        __draft: true,
-      }];
-      assert.equal(element._orderedComments[0].id, '1');
-      assert.equal(element._orderedComments[1].id, '2');
-      assert.equal(element._orderedComments[2].id, '3');
-    });
-
-    test('reflects lineNum and commentSide to attributes', () => {
-      element.lineNum = 7;
-      element.commentSide = 'left';
-
-      assert.equal(element.getAttribute('line-num'), '7');
-      assert.equal(element.getAttribute('comment-side'), 'left');
-    });
-
-    test('reflects range to JSON serialized attribute if set', () => {
-      element.range = {
-        start_line: 4,
-        end_line: 5,
-        start_character: 6,
-        end_character: 7,
-      };
-
-      assert.deepEqual(
-          JSON.parse(element.getAttribute('range')),
-          {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
-    });
-
-    test('removes range attribute if range is unset', () => {
-      element.range = {
-        start_line: 4,
-        end_line: 5,
-        start_character: 6,
-        end_character: 7,
-      };
-      element.range = undefined;
-
-      assert.notOk(element.hasAttribute('range'));
+      done();
     });
   });
+
+  test('done', done => {
+    const reportStub = sandbox.stub(element.$.reporting,
+        'recordDraftInteraction');
+    element.changeNum = '42';
+    element.patchNum = '1';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
+    MockInteractions.tap(doneBtn);
+    flush(() => {
+      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');
+      assert.isFalse(drafts[0].unresolved);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('save', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.path = '/path/to/file.txt';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const saveOrDiscardStub = sandbox.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    element.shadowRoot
+        .querySelector('gr-comment')._fireSave();
+
+    flush(() => {
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+          'baf0414d_60047215');
+      assert.equal(element.rootId, 'baf0414d_60047215');
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+          '/path/to/file.txt');
+      done();
+    });
+  });
+
+  test('please fix', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+    commentEl.addEventListener('create-fix-comment', () => {
+      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.');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isTrue(drafts[0].unresolved);
+      done();
+    });
+    commentEl.dispatchEvent(
+        new CustomEvent('create-fix-comment', {
+          detail: {comment: commentEl.comment},
+          composed: true, bubbles: false,
+        }));
+  });
+
+  test('discard', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.path = '/path/to/file.txt';
+    element.push('comments', element._newReply(
+        element.comments[0].id,
+        element.comments[0].line,
+        element.comments[0].path,
+        'it’s pronouced jiff, not giff'));
+    flushAsynchronousOperations();
+
+    const saveOrDiscardStub = sandbox.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl =
+        dom(element.root).querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl.addEventListener('comment-discard', () => {
+      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,
+          element.rootId);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+          element.path);
+      done();
+    });
+    draftEl.dispatchEvent(
+        new CustomEvent('comment-discard', {
+          detail: {comment: draftEl.comment},
+          composed: true, bubbles: false,
+        }));
+  });
+
+  test('discard with a single comment still fires event with previous rootId',
+      done => {
+        element.changeNum = '42';
+        element.patchNum = '1';
+        element.path = '/path/to/file.txt';
+        element.comments = [];
+        element.addOrEditDraft('1');
+        flushAsynchronousOperations();
+        const rootId = element.rootId;
+        assert.isOk(rootId);
+
+        const saveOrDiscardStub = sandbox.stub();
+        element.addEventListener('thread-changed', saveOrDiscardStub);
+        const draftEl =
+        dom(element.root).querySelectorAll('gr-comment')[0];
+        assert.ok(draftEl);
+        draftEl.addEventListener('comment-discard', () => {
+          assert.equal(element.comments.length, 0);
+          assert.isTrue(saveOrDiscardStub.called);
+          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+              rootId);
+          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+              element.path);
+          done();
+        });
+        draftEl.dispatchEvent(
+            new CustomEvent('comment-discard', {
+              detail: {comment: draftEl.comment},
+              composed: true, bubbles: false,
+            }));
+      });
+
+  test('first editing comment does not add __otherEditing attribute', () => {
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+
+    const replyBtn = element.$.replyBtn;
+    MockInteractions.tap(replyBtn);
+    flushAsynchronousOperations();
+
+    const editing = element._orderedComments.filter(c => c.__editing == true);
+    assert.equal(editing.length, 1);
+    assert.equal(!!editing[0].__otherEditing, false);
+  });
+
+  test('When not editing other comments, local storage not set' +
+      ' after discard', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:31.843000000',
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      __draftID: '1',
+      in_reply_to: 'baf0414d_60047215',
+      line: 5,
+      message: 'yes',
+      updated: '2015-12-08 19:48:32.843000000',
+      __draft: true,
+      __editing: true,
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      __draftID: '2',
+      in_reply_to: 'baf0414d_60047215',
+      line: 5,
+      message: 'no',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+    flushAsynchronousOperations();
+
+    const draftEl =
+    dom(element.root).querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl.addEventListener('comment-discard', () => {
+      assert.isFalse(storageStub.called);
+      storageStub.restore();
+      done();
+    });
+    draftEl.dispatchEvent(
+        new CustomEvent('comment-discard', {
+          detail: {comment: draftEl.comment},
+          composed: true, bubbles: false,
+        }));
+  });
+
+  test('comment-update', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const updatedComment = {
+      id: element.comments[0].id,
+      foo: 'bar',
+    };
+    commentEl.dispatchEvent(
+        new CustomEvent('comment-update', {
+          detail: {comment: updatedComment},
+          composed: true, bubbles: true,
+        }));
+    assert.strictEqual(element.comments[0], updatedComment);
+  });
+
+  suite('jack and sally comment data test consolidation', () => {
+    setup(() => {
+      element.comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+          unresolved: false,
+        }, {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+    });
+
+    test('orphan replies', () => {
+      assert.equal(4, element._orderedComments.length);
+    });
+
+    test('keyboard shortcuts', () => {
+      const expandCollapseStub =
+          sinon.stub(element, '_expandCollapseComments');
+      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+    });
+
+    test('comment in_reply_to is either null or most recent comment', () => {
+      element._createReplyComment(element.comments[3], 'dummy', true);
+      flushAsynchronousOperations();
+      assert.equal(element._orderedComments.length, 5);
+      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
+    });
+
+    test('resolvable comments', () => {
+      assert.isFalse(element.unresolved);
+      element._createReplyComment(element.comments[3], 'dummy', true, true);
+      flushAsynchronousOperations();
+      assert.isTrue(element.unresolved);
+    });
+
+    test('_setInitialExpandedState with unresolved', () => {
+      element.unresolved = true;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState without unresolved', () => {
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with robot_ids', () => {
+      for (let i = 0; i < element.comments.length; i++) {
+        element.comments[i].robot_id = 123;
+      }
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with collapsed state', () => {
+      element.comments[0].collapsed = false;
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      assert.isFalse(element.comments[0].collapsed);
+      for (let i = 1; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+  });
+
+  test('_computeHostClass', () => {
+    assert.equal(element._computeHostClass(true), 'unresolved');
+    assert.equal(element._computeHostClass(false), '');
+  });
+
+  test('addDraft sets unresolved state correctly', () => {
+    let unresolved = true;
+    element.comments = [];
+    element.addDraft(null, null, unresolved);
+    assert.equal(element.comments[0].unresolved, true);
+
+    unresolved = false; // comment should get added as actually resolved.
+    element.comments = [];
+    element.addDraft(null, null, unresolved);
+    assert.equal(element.comments[0].unresolved, false);
+
+    element.comments = [];
+    element.addDraft();
+    assert.equal(element.comments[0].unresolved, true);
+  });
+
+  test('_newDraft', () => {
+    element.commentSide = 'left';
+    element.patchNum = 3;
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, 'left');
+    assert.equal(draft.patchNum, 3);
+  });
+
+  test('new comment gets created', () => {
+    element.comments = [];
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 1);
+    // Mock a submitted comment.
+    element.comments[0].id = element.comments[0].__draftID;
+    element.comments[0].__draft = false;
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 2);
+  });
+
+  test('unresolved label', () => {
+    element.unresolved = false;
+    assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
+    element.unresolved = true;
+    assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
+  });
+
+  test('draft comments are at the end of orderedComments', () => {
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 2,
+      line: 5,
+      message: 'Earlier draft',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter2',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 1,
+      line: 5,
+      message: 'This comment was left last but is not a draft',
+      updated: '2015-12-10 19:48:33.843000000',
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter2',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 3,
+      line: 5,
+      message: 'Later draft',
+      updated: '2015-12-09 19:48:33.843000000',
+      __draft: true,
+    }];
+    assert.equal(element._orderedComments[0].id, '1');
+    assert.equal(element._orderedComments[1].id, '2');
+    assert.equal(element._orderedComments[2].id, '3');
+  });
+
+  test('reflects lineNum and commentSide to attributes', () => {
+    element.lineNum = 7;
+    element.commentSide = 'left';
+
+    assert.equal(element.getAttribute('line-num'), '7');
+    assert.equal(element.getAttribute('comment-side'), 'left');
+  });
+
+  test('reflects range to JSON serialized attribute if set', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+
+    assert.deepEqual(
+        JSON.parse(element.getAttribute('range')),
+        {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
+  });
+
+  test('removes range attribute if range is unset', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+    element.range = undefined;
+
+    assert.notOk(element.hasAttribute('range'));
+  });
+});
+
+suite('comment action tests on resolved comments', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      saveDiffDraft() {
+        return Promise.resolve({
+          ok: true,
+          text() {
+            return Promise.resolve(')]}\'\n' +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215',
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done',
+                }));
+          },
+        });
+      },
+      deleteDiffDraft() { return Promise.resolve({ok: true}); },
+    });
+    element = fixture('withComment');
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      path: '/path/to/file.txt',
+      unresolved: false,
+    }];
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('ack and done should be hidden', () => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
+    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
+    assert.equal(ackBtn, null);
+    assert.equal(doneBtn, null);
+  });
+
+  test('reply and quote button should be visible', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const replyBtn = element.shadowRoot.querySelector('#replyBtn');
+    const quoteBtn = element.shadowRoot.querySelector('#quoteBtn');
+    assert.ok(replyBtn);
+    assert.ok(quoteBtn);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
deleted file mode 100644
index 8ad261c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
+++ /dev/null
@@ -1,403 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
-<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.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="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
-<script src="../../../scripts/rootElement.js"></script>
-
-<dom-module id="gr-comment">
-  <template>
-    <style include="shared-styles">
-      :host {
-        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;
-      }
-      :host([disabled]) .actions,
-      :host([disabled]) .robotActions,
-      :host([disabled]) .date {
-        opacity: .5;
-      }
-      :host([discarding]) {
-        display: none;
-      }
-      .header {
-        align-items: baseline;
-        cursor: pointer;
-        display: flex;
-        margin: calc(0px - var(--spacing-m)) calc(0px - var(--spacing-m)) 0 calc(0px - var(--spacing-m));
-        padding: var(--spacing-m);
-      }
-      .container.collapsed .header {
-        margin-bottom: calc(0 - var(--spacing-m));
-      }
-      .headerMiddle {
-        color: var(--deemphasized-text-color);
-        flex: 1;
-        overflow: hidden;
-      }
-      .draftLabel,
-      .draftTooltip {
-        color: var(--deemphasized-text-color);
-        display: none;
-      }
-      .date {
-        justify-content: flex-end;
-        margin-left: 5px;
-        min-width: 4.5em;
-        text-align: right;
-        white-space: nowrap;
-      }
-      span.date {
-        color: var(--deemphasized-text-color);
-      }
-      span.date:hover {
-        text-decoration: underline;
-      }
-      .actions {
-        display: flex;
-        justify-content: flex-end;
-        padding-top: 0;
-      }
-      .action {
-        margin-left: var(--spacing-l);
-      }
-      .robotActions {
-        display: flex;
-        justify-content: flex-start;
-        padding-top: var(--spacing-m);
-        border-top: 1px solid var(--border-color);
-      }
-      .robotActions .action {
-        /* Keep button text lined up with output text */
-        margin-left: -4px;
-        margin-right: var(--spacing-l);
-      }
-      .rightActions {
-        display: flex;
-        justify-content: flex-end;
-      }
-      .rightActions gr-button {
-        --gr-button: {
-          height: 20px;
-          padding: 0 var(--spacing-s);
-          color: var(--default-button-text-color);
-        }
-      }
-      .editMessage {
-        display: none;
-        margin: var(--spacing-m) 0;
-        width: 100%;
-      }
-      .container:not(.draft) .actions .hideOnPublished {
-        display: none;
-      }
-      .draft .reply,
-      .draft .quote,
-      .draft .ack,
-      .draft .done {
-        display: none;
-      }
-      .draft .draftLabel,
-      .draft .draftTooltip {
-        display: inline;
-      }
-      .draft:not(.editing) .save,
-      .draft:not(.editing) .cancel {
-        display: none;
-      }
-      .editing .message,
-      .editing .reply,
-      .editing .quote,
-      .editing .ack,
-      .editing .done,
-      .editing .edit,
-      .editing .discard,
-      .editing .unresolved {
-        display: none;
-      }
-      .editing .editMessage {
-        display: block;
-      }
-      .show-hide {
-        margin-left: var(--spacing-s);
-      }
-      .robotId {
-        color: var(--deemphasized-text-color);
-        margin-bottom: var(--spacing-m);
-        margin-top: -.4em;
-      }
-      .robotIcon {
-        margin-right: var(--spacing-xs);
-        /* because of the antenna of the robot, it looks off center even when it
-         is centered. artificially adjust margin to account for this. */
-        margin-top: -4px;
-      }
-      .runIdInformation {
-        margin: var(--spacing-m) 0;
-      }
-      .robotRun {
-        margin-left: var(--spacing-m);
-      }
-      .robotRunLink {
-        margin-left: var(--spacing-m);
-      }
-      input.show-hide {
-        display: none;
-      }
-      label.show-hide {
-        cursor: pointer;
-        display: block;
-      }
-      label.show-hide iron-icon {
-        vertical-align: top;
-      }
-      #container .collapsedContent {
-        display: none;
-      }
-      #container.collapsed {
-        padding-bottom: 3px;
-      }
-      #container.collapsed .collapsedContent {
-        display: block;
-        overflow: hidden;
-        padding-left: 5px;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      #container.collapsed .actions,
-      #container.collapsed gr-formatted-text,
-      #container.collapsed gr-textarea {
-        display: none;
-      }
-      .resolve,
-      .unresolved {
-        align-items: center;
-        display: flex;
-        flex: 1;
-        margin: 0;
-      }
-      .resolve label {
-        color: var(--comment-text-color);
-      }
-      gr-dialog .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      #deleteBtn {
-        display: none;
-        --gr-button: {
-          color: var(--deemphasized-text-color);
-          padding: 0;
-        }
-      }
-      #deleteBtn.showDeleteButtons {
-        display: block;
-      }
-
-      /** Disable select for the caret and actions */
-      .actions,
-      .show-hide {
-        -webkit-user-select: none;
-        -moz-user-select: none;
-        -ms-user-select: none;
-        user-select: none;
-      }
-
-    </style>
-    <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="draftLabel">DRAFT</span>
-          <gr-tooltip-content class="draftTooltip"
-              has-tooltip
-              title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key."
-              max-width="20em"
-              show-icon></gr-tooltip-content>
-        </div>
-        <div class="headerMiddle">
-          <span class="collapsedContent">[[comment.message]]</span>
-        </div>
-        <gr-button
-            id="deleteBtn"
-            link
-            secondary
-            class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-            on-click="_handleCommentDelete">
-          (Delete)
-        </gr-button>
-        <span class="date" on-click="_handleAnchorClick">
-          <gr-date-formatter
-              has-tooltip
-              date-str="[[comment.updated]]"></gr-date-formatter>
-        </span>
-        <div class="show-hide">
-          <label class="show-hide">
-            <input type="checkbox" class="show-hide"
-               checked$="[[collapsed]]"
-               on-change="_handleToggleCollapsed">
-            <iron-icon
-                id="icon"
-                icon="[[_computeShowHideIcon(collapsed)]]">
-            </iron-icon>
-          </label>
-        </div>
-      </div>
-      <div class="body">
-        <template is="dom-if" if="[[comment.robot_id]]">
-          <div class="robotId" hidden$="[[collapsed]]">
-            <iron-icon class="robotIcon" icon="gr-icons:robot"></iron-icon>
-            [[comment.robot_id]]
-          </div>
-        </template>
-        <template is="dom-if" if="[[editing]]">
-          <gr-textarea
-              id="editTextarea"
-              class="editMessage"
-              autocomplete="on"
-              code
-              disabled="{{disabled}}"
-              rows="4"
-              text="{{_messageText}}"></gr-textarea>
-        </template>
-        <!--The message class is needed to ensure selectability from
-        gr-diff-selection.-->
-        <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>
-              <input type="checkbox"
-                  id="resolvedCheckbox"
-                  checked="[[resolved]]"
-                  on-change="_handleToggleResolved">
-              Resolved
-            </label>
-          </div>
-          <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>
-          </div>
-        </div>
-        <div class="robotActions" hidden$="[[!_showRobotActions]]">
-          <template is="dom-if" if="[[isRobotComment]]">
-            <gr-button
-                link
-                secondary
-                class="action fix"
-                on-click="_handleFix"
-                disabled="[[robotButtonDisabled]]">
-              Please Fix
-            </gr-button>
-            <gr-endpoint-decorator name="robot-comment-controls">
-              <gr-endpoint-param name="comment" value="[[comment]]">
-              </gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </template>
-        </div>
-      </div>
-    </div>
-    <template is="dom-if" if="[[_enableOverlay]]">
-      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
-        <gr-confirm-delete-comment-dialog id="confirmDeleteComment"
-            on-confirm="_handleConfirmDeleteComment"
-            on-cancel="_handleCancelDeleteComment">
-        </gr-confirm-delete-comment-dialog>
-      </gr-overlay>
-      <gr-overlay id="confirmDiscardOverlay" with-backdrop>
-        <gr-dialog
-            id="confirmDiscardDialog"
-            confirm-label="Discard"
-            confirm-on-enter
-            on-confirm="_handleConfirmDiscard"
-            on-cancel="_closeConfirmDiscardOverlay">
-          <div class="header" slot="header">
-            Discard comment
-          </div>
-          <div class="main" slot="main">
-            Are you sure you want to discard this draft comment?
-          </div>
-        </gr-dialog>
-      </gr-overlay>
-    </template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-    <gr-storage id="storage"></gr-storage>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-comment.js"></script>
-</dom-module>
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..ee9df11 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,64 +14,125 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const STORAGE_DEBOUNCE_INTERVAL = 400;
-  const TOAST_DEBOUNCE_INTERVAL = 200;
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
+import '../gr-button/gr-button.js';
+import '../gr-dialog/gr-dialog.js';
+import '../gr-date-formatter/gr-date-formatter.js';
+import '../gr-formatted-text/gr-formatted-text.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-overlay/gr-overlay.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-storage/gr-storage.js';
+import '../gr-textarea/gr-textarea.js';
+import '../gr-tooltip-content/gr-tooltip-content.js';
+import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-comment_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {getRootElement} from '../../../scripts/rootElement.js';
+import {GrDisplayNameUtils} from '../../../scripts/gr-display-name-utils/gr-display-name-utils.js';
 
-  const SAVING_MESSAGE = 'Saving';
-  const DRAFT_SINGULAR = 'draft...';
-  const DRAFT_PLURAL = 'drafts...';
-  const SAVED_MESSAGE = 'All changes saved';
+const STORAGE_DEBOUNCE_INTERVAL = 400;
+const TOAST_DEBOUNCE_INTERVAL = 200;
 
-  const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-  const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-  const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
+const SAVING_MESSAGE = 'Saving';
+const DRAFT_SINGULAR = 'draft...';
+const DRAFT_PLURAL = 'drafts...';
+const SAVED_MESSAGE = 'All changes saved';
 
-  const FILE = 'FILE';
+const REPORT_CREATE_DRAFT = 'CreateDraftComment';
+const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
+const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
 
-  Polymer({
-    is: 'gr-comment',
+const FILE = 'FILE';
 
-    /**
-     * Fired when the create fix comment action is triggered.
-     *
-     * @event create-fix-comment
-     */
+/**
+ * All candidates tips to show, will pick randomly.
+ */
+const RESPECTFUL_REVIEW_TIPS= [
+  'Assume competence.',
+  'Provide rationale or context.',
+  'Consider how comments may be interpreted.',
+  'Avoid harsh language.',
+  'Make your comments specific and actionable.',
+  'When disagreeing, explain the advantage of your approach.',
+];
 
-    /**
-     * Fired when this comment is discarded.
-     *
-     * @event comment-discard
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrComment extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when this comment is saved.
-     *
-     * @event comment-save
-     */
+  static get is() { return 'gr-comment'; }
+  /**
+   * Fired when the create fix comment action is triggered.
+   *
+   * @event create-fix-comment
+   */
 
-    /**
-     * Fired when this comment is updated.
-     *
-     * @event comment-update
-     */
+  /**
+   * Fired when the show fix preview action is triggered.
+   *
+   * @event open-fix-preview
+   */
 
-    /**
-     * Fired when the comment's timestamp is tapped.
-     *
-     * @event comment-anchor-tap
-     */
+  /**
+   * Fired when this comment is discarded.
+   *
+   * @event comment-discard
+   */
 
-    properties: {
+  /**
+   * Fired when this comment is saved.
+   *
+   * @event comment-save
+   */
+
+  /**
+   * Fired when this comment is updated.
+   *
+   * @event comment-update
+   */
+
+  /**
+   * Fired when editing status changed.
+   *
+   * @event comment-editing-changed
+   */
+
+  /**
+   * Fired when the comment's timestamp is tapped.
+   *
+   * @event comment-anchor-tap
+   */
+
+  static get properties() {
+    return {
       changeNum: String,
-      /** @type {?} */
+      /** @type {!Gerrit.Comment} */
       comment: {
         type: Object,
         notify: true,
         observer: '_commentChanged',
       },
+      comments: {
+        type: Array,
+      },
       isRobotComment: {
         type: Boolean,
         value: false,
@@ -110,6 +171,7 @@
       /** @type {?} */
       projectConfig: Object,
       robotButtonDisabled: Boolean,
+      _hasHumanReply: Boolean,
       _isAdmin: {
         type: Boolean,
         value: false,
@@ -122,13 +184,14 @@
         observer: '_messageTextChanged',
       },
       commentSide: String,
+      side: String,
 
       resolved: Boolean,
 
       _numPendingDraftRequests: {
         type: Object,
         value:
-            {number: 0}, // Intentional to share the object across instances.
+          {number: 0}, // Intentional to share the object across instances.
       },
 
       _enableOverlay: {
@@ -138,542 +201,664 @@
 
       /**
        * Property for storing references to overlay elements. When the overlays
-       * are moved to Gerrit.getRootElement() to be shown they are no-longer
+       * are moved to 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: () => ({}),
+        value: () => { return {}; },
       },
-    },
 
-    observers: [
+      _showRespectfulTip: {
+        type: Boolean,
+        value: false,
+      },
+      _respectfulReviewTip: String,
+      _respectfulTipDismissed: {
+        type: Boolean,
+        value: false,
+      },
+      _serverConfig: Object,
+    };
+  }
+
+  static get observers() {
+    return [
       '_commentMessageChanged(comment.message)',
       '_loadLocalDraft(changeNum, patchNum, comment)',
       '_isRobotComment(comment)',
       '_calculateActionstoShow(showActions, isRobotComment)',
-    ],
+      '_computeHasHumanReply(comment, comments.*)',
+      '_onEditingChange(editing)',
+    ];
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
+  get keyBindings() {
+    return {
       'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
       'esc': '_handleEsc',
-    },
+    };
+  }
 
-    attached() {
-      if (this.editing) {
-        this.collapsed = false;
-      } else if (this.comment) {
-        this.collapsed = this.comment.collapsed;
-      }
-      this._getIsAdmin().then(isAdmin => {
-        this._isAdmin = isAdmin;
+  /** @override */
+  attached() {
+    super.attached();
+    if (this.editing) {
+      this.collapsed = false;
+    } else if (this.comment) {
+      this.collapsed = this.comment.collapsed;
+    }
+    this._getIsAdmin().then(isAdmin => {
+      this._isAdmin = isAdmin;
+    });
+    this.$.restAPI.getConfig().then(cfg => {
+      this._serverConfig = cfg;
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('fire-update');
+    if (this.textarea) {
+      this.textarea.closeDropdown();
+    }
+  }
+
+  _onEditingChange(editing) {
+    this.dispatchEvent(new CustomEvent('comment-editing-changed', {
+      detail: !!editing,
+      bubbles: true,
+      composed: true,
+    }));
+    if (!editing) return;
+    // visibility based on cache this will make sure we only and always show
+    // a tip once every Math.max(a day, period between creating comments)
+    const cachedVisibilityOfRespectfulTip =
+      this.$.storage.getRespectfulTipVisibility();
+    if (!cachedVisibilityOfRespectfulTip) {
+      // we still want to show the tip with a probability of 30%
+      if (this.getRandomNum(0, 3) >= 1) return;
+      this._showRespectfulTip = true;
+      const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
+      this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
+      this.$.reporting.reportInteraction(
+          'respectful-tip-appeared',
+          {tip: this._respectfulReviewTip}
+      );
+      // update cache
+      this.$.storage.setRespectfulTipVisibility();
+    }
+  }
+
+  /** Set as a separate method so easy to stub. */
+  getRandomNum(min, max) {
+    return Math.floor(Math.random() * (max - min) + min);
+  }
+
+  _computeVisibilityOfTip(showTip, tipDismissed) {
+    return showTip && !tipDismissed;
+  }
+
+  _dismissRespectfulTip() {
+    this._respectfulTipDismissed = true;
+    this.$.reporting.reportInteraction(
+        'respectful-tip-dismissed',
+        {tip: this._respectfulReviewTip}
+    );
+    // add a 14-day delay to the tip cache
+    this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+  }
+
+  _onRespectfulReadMoreClick() {
+    this.$.reporting.reportInteraction('respectful-read-more-clicked');
+  }
+
+  get textarea() {
+    return this.shadowRoot.querySelector('#editTextarea');
+  }
+
+  get confirmDeleteOverlay() {
+    if (!this._overlays.confirmDelete) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDelete = this.shadowRoot
+          .querySelector('#confirmDeleteOverlay');
+    }
+    return this._overlays.confirmDelete;
+  }
+
+  get confirmDiscardOverlay() {
+    if (!this._overlays.confirmDiscard) {
+      this._enableOverlay = true;
+      flush();
+      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
+    if ([showActions, isRobotComment].some(arg => arg === undefined)) {
+      return;
+    }
+
+    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
+   */
+  save(opt_comment) {
+    let comment = opt_comment;
+    if (!comment) {
+      comment = this.comment;
+    }
+
+    this.set('comment.message', this._messageText);
+    this.editing = false;
+    this.disabled = true;
+
+    if (!this._messageText) {
+      return this._discardDraft();
+    }
+
+    this._xhrPromise = this._saveDraft(comment).then(response => {
+      this.disabled = false;
+      if (!response.ok) { return response; }
+
+      this._eraseDraftComment();
+      return this.$.restAPI.getResponseObject(response).then(obj => {
+        const resComment = obj;
+        resComment.__draft = true;
+        // Maintain the ephemeral draft ID for identification by other
+        // elements.
+        if (this.comment.__draftID) {
+          resComment.__draftID = this.comment.__draftID;
+        }
+        resComment.__commentSide = this.commentSide;
+        this.comment = resComment;
+        this._fireSave();
+        return obj;
       });
-    },
-
-    detached() {
-      this.cancelDebouncer('fire-update');
-      if (this.textarea) {
-        this.textarea.closeDropdown();
-      }
-    },
-
-    get textarea() {
-      return this.$$('#editTextarea');
-    },
-
-    get confirmDeleteOverlay() {
-      if (!this._overlays.confirmDelete) {
-        this._enableOverlay = true;
-        Polymer.dom.flush();
-        this._overlays.confirmDelete = this.$$('#confirmDeleteOverlay');
-      }
-      return this._overlays.confirmDelete;
-    },
-
-    get confirmDiscardOverlay() {
-      if (!this._overlays.confirmDiscard) {
-        this._enableOverlay = true;
-        Polymer.dom.flush();
-        this._overlays.confirmDiscard = this.$$('#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
-      if ([showActions, isRobotComment].some(arg => arg === undefined)) {
-        return;
-      }
-
-      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
-     */
-    save(opt_comment) {
-      let comment = opt_comment;
-      if (!comment) {
-        comment = this.comment;
-      }
-
-      this.set('comment.message', this._messageText);
-      this.editing = false;
-      this.disabled = true;
-
-      if (!this._messageText) {
-        return this._discardDraft();
-      }
-
-      this._xhrPromise = this._saveDraft(comment).then(response => {
-        this.disabled = false;
-        if (!response.ok) { return response; }
-
-        this._eraseDraftComment();
-        return this.$.restAPI.getResponseObject(response).then(obj => {
-          const resComment = obj;
-          resComment.__draft = true;
-          // Maintain the ephemeral draft ID for identification by other
-          // elements.
-          if (this.comment.__draftID) {
-            resComment.__draftID = this.comment.__draftID;
-          }
-          resComment.__commentSide = this.commentSide;
-          this.comment = resComment;
-          this._fireSave();
-          return obj;
+    })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
         });
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
 
-      return this._xhrPromise;
-    },
+    return this._xhrPromise;
+  }
 
-    _eraseDraftComment() {
-      // Prevents a race condition in which removing the draft comment occurs
-      // prior to it being saved.
-      this.cancelDebouncer('store');
+  _eraseDraftComment() {
+    // Prevents a race condition in which removing the draft comment occurs
+    // prior to it being saved.
+    this.cancelDebouncer('store');
 
-      this.$.storage.eraseDraftComment({
+    this.$.storage.eraseDraftComment({
+      changeNum: this.changeNum,
+      patchNum: this._getPatchNum(),
+      path: this.comment.path,
+      line: this.comment.line,
+      range: this.comment.range,
+    });
+  }
+
+  _commentChanged(comment) {
+    this.editing = !!comment.__editing;
+    this.resolved = !comment.unresolved;
+    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
+   *
+   * @return {!Object}
+   */
+  _getEventPayload(opt_mixin) {
+    return Object.assign({}, opt_mixin, {
+      comment: this.comment,
+      patchNum: this.patchNum,
+    });
+  }
+
+  _fireSave() {
+    this.dispatchEvent(new CustomEvent('comment-save', {
+      detail: this._getEventPayload(),
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _fireUpdate() {
+    this.debounce('fire-update', () => {
+      this.dispatchEvent(new CustomEvent('comment-update', {
+        detail: this._getEventPayload(),
+        composed: true, bubbles: true,
+      }));
+    });
+  }
+
+  _draftChanged(draft) {
+    this.$.container.classList.toggle('draft', draft);
+  }
+
+  _editingChanged(editing, previousValue) {
+    // Polymer 2: observer fires when at least one property is defined.
+    // Do nothing to prevent comment.__editing being overwritten
+    // if previousValue is undefined
+    if (previousValue === undefined) return;
+
+    this.$.container.classList.toggle('editing', editing);
+    if (this.comment && this.comment.id) {
+      this.shadowRoot.querySelector('.cancel').hidden = !editing;
+    }
+    if (this.comment) {
+      this.comment.__editing = this.editing;
+    }
+    if (editing != !!previousValue) {
+      // To prevent event firing on comment creation.
+      this._fireUpdate();
+    }
+    if (editing) {
+      this.async(() => {
+        flush();
+        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.
+    if (!comment || comment.unresolved === resolved && draft) {
+      return false;
+    }
+    return !draft || draft.trim() === '';
+  }
+
+  _handleSaveKey(e) {
+    if (!this._computeSaveDisabled(this._messageText, this.comment,
+        this.resolved)) {
+      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) {
+      this.$.container.classList.add('collapsed');
+    } else {
+      this.$.container.classList.remove('collapsed');
+    }
+  }
+
+  _commentMessageChanged(message) {
+    this._messageText = message || '';
+  }
+
+  _messageTextChanged(newValue, oldValue) {
+    if (!this.comment || (this.comment && this.comment.id)) {
+      return;
+    }
+
+    this.debounce('store', () => {
+      const message = this._messageText;
+      const commentLocation = {
         changeNum: this.changeNum,
         patchNum: this._getPatchNum(),
         path: this.comment.path,
         line: this.comment.line,
         range: this.comment.range,
-      });
-    },
+      };
 
-    _commentChanged(comment) {
-      this.editing = !!comment.__editing;
-      this.resolved = !comment.unresolved;
-      if (this.editing) { // It's a new draft/reply, notify.
-        this._fireUpdate();
-      }
-    },
-
-    /**
-     * @param {!Object=} opt_mixin
-     *
-     * @return {!Object}
-     */
-    _getEventPayload(opt_mixin) {
-      return Object.assign({}, opt_mixin, {
-        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.
-      // Do nothing to prevent comment.__editing being overwritten
-      // if previousValue is undefined
-      if (previousValue === undefined) return;
-
-      this.$.container.classList.toggle('editing', editing);
-      if (this.comment && this.comment.id) {
-        this.$$('.cancel').hidden = !editing;
-      }
-      if (this.comment) {
-        this.comment.__editing = this.editing;
-      }
-      if (editing != !!previousValue) {
-        // To prevent event firing on comment creation.
-        this._fireUpdate();
-      }
-      if (editing) {
-        this.async(() => {
-          Polymer.dom.flush();
-          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.
-      if (!comment || comment.unresolved === resolved && draft) {
-        return false;
-      }
-      return !draft || draft.trim() === '';
-    },
-
-    _handleSaveKey(e) {
-      if (!this._computeSaveDisabled(this._messageText, this.comment,
-          this.resolved)) {
-        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) {
-        this.$.container.classList.add('collapsed');
+      if ((!this._messageText || !this._messageText.length) && oldValue) {
+        // If the draft has been modified to be empty, then erase the storage
+        // entry.
+        this.$.storage.eraseDraftComment(commentLocation);
       } else {
-        this.$.container.classList.remove('collapsed');
+        this.$.storage.setDraftComment(commentLocation, message);
       }
-    },
+    }, STORAGE_DEBOUNCE_INTERVAL);
+  }
 
-    _commentMessageChanged(message) {
-      this._messageText = message || '';
-    },
+  _handleAnchorClick(e) {
+    e.preventDefault();
+    if (!this.comment.line) {
+      return;
+    }
+    this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        number: this.comment.line || FILE,
+        side: this.side,
+      },
+    }));
+  }
 
-    _messageTextChanged(newValue, oldValue) {
-      if (!this.comment || (this.comment && this.comment.id)) {
-        return;
+  _handleEdit(e) {
+    e.preventDefault();
+    this._messageText = this.comment.message;
+    this.editing = true;
+    this.$.reporting.recordDraftInteraction();
+  }
+
+  _handleSave(e) {
+    e.preventDefault();
+
+    // Ignore saves started while already saving.
+    if (this.disabled) {
+      return;
+    }
+    const timingLabel = this.comment.id ?
+      REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
+    const timer = this.$.reporting.getTimer(timingLabel);
+    this.set('comment.__editing', false);
+    return this.save().then(() => { timer.end(); });
+  }
+
+  _handleCancel(e) {
+    e.preventDefault();
+
+    if (!this.comment.message ||
+        this.comment.message.trim().length === 0 ||
+        !this.comment.id) {
+      this._fireDiscard();
+      return;
+    }
+    this._messageText = this.comment.message;
+    this.editing = false;
+  }
+
+  _fireDiscard() {
+    this.cancelDebouncer('fire-update');
+    this.dispatchEvent(new CustomEvent('comment-discard', {
+      detail: this._getEventPayload(),
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _handleFix() {
+    this.dispatchEvent(new CustomEvent('create-fix-comment', {
+      bubbles: true,
+      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();
+    this.$.reporting.recordDraftInteraction();
+
+    if (!this._messageText) {
+      this._discardDraft();
+      return;
+    }
+
+    this._openOverlay(this.confirmDiscardOverlay).then(() => {
+      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) {
+      throw Error('Cannot discard a non-draft comment.');
+    }
+    this.discarding = true;
+    this.editing = false;
+    this.disabled = true;
+    this._eraseDraftComment();
+
+    if (!this.comment.id) {
+      this.disabled = false;
+      this._fireDiscard();
+      return;
+    }
+
+    this._xhrPromise = this._deleteDraft(this.comment).then(response => {
+      this.disabled = false;
+      if (!response.ok) {
+        this.discarding = false;
+        return response;
       }
 
-      this.debounce('store', () => {
-        const message = this._messageText;
-        const commentLocation = {
-          changeNum: this.changeNum,
-          patchNum: this._getPatchNum(),
-          path: this.comment.path,
-          line: this.comment.line,
-          range: this.comment.range,
-        };
+      this._fireDiscard();
+    })
+        .catch(err => {
+          this.disabled = false;
+          throw err;
+        });
 
-        if ((!this._messageText || !this._messageText.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.$.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.$.storage.setDraftComment(commentLocation, message);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL);
-    },
+    return this._xhrPromise;
+  }
 
-    _handleAnchorClick(e) {
-      e.preventDefault();
-      if (!this.comment.line) {
-        return;
+  _closeConfirmDiscardOverlay() {
+    this._closeOverlay(this.confirmDiscardOverlay);
+  }
+
+  _getSavingMessage(numPending) {
+    if (numPending === 0) {
+      return SAVED_MESSAGE;
+    }
+    return [
+      SAVING_MESSAGE,
+      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--;
+
+    // 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);
+    this.debounce('draft-toast', () => {
+      // Note: the event is fired on the body rather than this element because
+      // this element may not be attached by the time this executes, in which
+      // case the event would not bubble.
+      document.body.dispatchEvent(new CustomEvent(
+          'show-alert', {detail: {message}, bubbles: true, composed: true}));
+    }, TOAST_DEBOUNCE_INTERVAL);
+  }
+
+  _saveDraft(draft) {
+    this._showStartRequest();
+    return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
+        .then(result => {
+          if (result.ok) {
+            this._showEndRequest();
+          } else {
+            this._handleFailedDraftRequest();
+          }
+          return result;
+        });
+  }
+
+  _deleteDraft(draft) {
+    this._showStartRequest();
+    return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
+        draft).then(result => {
+      if (result.ok) {
+        this._showEndRequest();
+      } else {
+        this._handleFailedDraftRequest();
       }
-      this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          number: this.comment.line || FILE,
-          side: this.side,
-        },
-      }));
-    },
+      return result;
+    });
+  }
 
-    _handleEdit(e) {
-      e.preventDefault();
-      this._messageText = this.comment.message;
-      this.editing = true;
-      this.$.reporting.recordDraftInteraction();
-    },
+  _getPatchNum() {
+    return this.isOnParent() ? 'PARENT' : this.patchNum;
+  }
 
-    _handleSave(e) {
-      e.preventDefault();
+  _loadLocalDraft(changeNum, patchNum, comment) {
+    // Polymer 2: check for undefined
+    if ([changeNum, patchNum, comment].some(arg => arg === undefined)) {
+      return;
+    }
 
-      // Ignore saves started while already saving.
-      if (this.disabled) {
-        return;
-      }
-      const timingLabel = this.comment.id ?
-        REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
-      const timer = this.$.reporting.getTimer(timingLabel);
-      this.set('comment.__editing', false);
-      return this.save().then(() => { timer.end(); });
-    },
+    // Only apply local drafts to comments that haven't been saved
+    // remotely, and haven't been given a default message already.
+    //
+    // Don't get local draft if there is another comment that is currently
+    // in an editing state.
+    if (!comment || comment.id || comment.message || comment.__otherEditing) {
+      delete comment.__otherEditing;
+      return;
+    }
 
-    _handleCancel(e) {
-      e.preventDefault();
+    const draft = this.$.storage.getDraftComment({
+      changeNum,
+      patchNum: this._getPatchNum(),
+      path: comment.path,
+      line: comment.line,
+      range: comment.range,
+    });
 
-      if (!this.comment.message ||
-          this.comment.message.trim().length === 0 ||
-          !this.comment.id) {
-        this._fireDiscard();
-        return;
-      }
-      this._messageText = this.comment.message;
-      this.editing = false;
-    },
+    if (draft) {
+      this.set('comment.message', draft.message);
+    }
+  }
 
-    _fireDiscard() {
-      this.cancelDebouncer('fire-update');
-      this.fire('comment-discard', this._getEventPayload());
-    },
+  _handleToggleResolved() {
+    this.$.reporting.recordDraftInteraction();
+    this.resolved = !this.resolved;
+    // Modify payload instead of this.comment, as this.comment is passed from
+    // the parent by ref.
+    const payload = this._getEventPayload();
+    payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
+    this.dispatchEvent(new CustomEvent('comment-update', {
+      detail: payload,
+      composed: true, bubbles: true,
+    }));
+    if (!this.editing) {
+      // Save the resolved state immediately.
+      this.save(payload.comment);
+    }
+  }
 
-    _handleFix() {
-      this.dispatchEvent(new CustomEvent('create-fix-comment', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      }));
-    },
+  _handleCommentDelete() {
+    this._openOverlay(this.confirmDeleteOverlay);
+  }
 
-    _handleDiscard(e) {
-      e.preventDefault();
-      this.$.reporting.recordDraftInteraction();
+  _handleCancelDeleteComment() {
+    this._closeOverlay(this.confirmDeleteOverlay);
+  }
 
-      if (!this._messageText) {
-        this._discardDraft();
-        return;
-      }
+  _openOverlay(overlay) {
+    dom(getRootElement()).appendChild(overlay);
+    return overlay.open();
+  }
 
-      this._openOverlay(this.confirmDiscardOverlay).then(() => {
-        this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
-            .resetFocus();
-      });
-    },
+  _computeAuthorName(comment, serverConfig) {
+    if ([comment, serverConfig].includes(undefined)) return '';
+    if (comment.robot_id) {
+      return comment.robot_id;
+    }
+    if (comment.author) {
+      return GrDisplayNameUtils.getDisplayName(serverConfig, comment.author);
+    }
+    return '';
+  }
 
-    _handleConfirmDiscard(e) {
-      e.preventDefault();
-      const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
-      this._closeConfirmDiscardOverlay();
-      return this._discardDraft().then(() => { timer.end(); });
-    },
+  _computeHideRunDetails(comment, collapsed) {
+    if (!comment) return true;
+    return !(comment.robot_id && comment.url && !collapsed);
+  }
 
-    _discardDraft() {
-      if (!this.comment.__draft) {
-        throw Error('Cannot discard a non-draft comment.');
-      }
-      this.discarding = true;
-      this.editing = false;
-      this.disabled = true;
-      this._eraseDraftComment();
+  _closeOverlay(overlay) {
+    dom(getRootElement()).removeChild(overlay);
+    overlay.close();
+  }
 
-      if (!this.comment.id) {
-        this.disabled = false;
-        this._fireDiscard();
-        return;
-      }
+  _handleConfirmDeleteComment() {
+    const dialog =
+        this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
+    this.$.restAPI.deleteComment(
+        this.changeNum, this.patchNum, this.comment.id, dialog.message)
+        .then(newComment => {
+          this._handleCancelDeleteComment();
+          this.comment = newComment;
+        });
+  }
+}
 
-      this._xhrPromise = this._deleteDraft(this.comment).then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          this.discarding = false;
-          return response;
-        }
-
-        this._fireDiscard();
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-      return this._xhrPromise;
-    },
-
-    _closeConfirmDiscardOverlay() {
-      this._closeOverlay(this.confirmDiscardOverlay);
-    },
-
-    _getSavingMessage(numPending) {
-      if (numPending === 0) {
-        return SAVED_MESSAGE;
-      }
-      return [
-        SAVING_MESSAGE,
-        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--;
-
-      // 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);
-      this.debounce('draft-toast', () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        document.body.dispatchEvent(new CustomEvent(
-            'show-alert', {detail: {message}, bubbles: true, composed: true}));
-      }, TOAST_DEBOUNCE_INTERVAL);
-    },
-
-    _saveDraft(draft) {
-      this._showStartRequest();
-      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
-          .then(result => {
-            if (result.ok) {
-              this._showEndRequest();
-            } else {
-              this._handleFailedDraftRequest();
-            }
-            return result;
-          });
-    },
-
-    _deleteDraft(draft) {
-      this._showStartRequest();
-      return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-          draft).then(result => {
-        if (result.ok) {
-          this._showEndRequest();
-        } else {
-          this._handleFailedDraftRequest();
-        }
-        return result;
-      });
-    },
-
-    _getPatchNum() {
-      return this.isOnParent() ? 'PARENT' : this.patchNum;
-    },
-
-    _loadLocalDraft(changeNum, patchNum, comment) {
-      // Polymer 2: check for undefined
-      if ([changeNum, patchNum, comment].some(arg => arg === undefined)) {
-        return;
-      }
-
-      // Only apply local drafts to comments that haven't been saved
-      // remotely, and haven't been given a default message already.
-      //
-      // Don't get local draft if there is another comment that is currently
-      // in an editing state.
-      if (!comment || comment.id || comment.message || comment.__otherEditing) {
-        delete comment.__otherEditing;
-        return;
-      }
-
-      const draft = this.$.storage.getDraftComment({
-        changeNum,
-        patchNum: this._getPatchNum(),
-        path: comment.path,
-        line: comment.line,
-        range: comment.range,
-      });
-
-      if (draft) {
-        this.set('comment.message', draft.message);
-      }
-    },
-
-    _handleToggleResolved() {
-      this.$.reporting.recordDraftInteraction();
-      this.resolved = !this.resolved;
-      // Modify payload instead of this.comment, as this.comment is passed from
-      // the parent by ref.
-      const payload = this._getEventPayload();
-      payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-      this.fire('comment-update', payload);
-      if (!this.editing) {
-        // 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();
-    },
-
-    _closeOverlay(overlay) {
-      Polymer.dom(Gerrit.getRootElement()).removeChild(overlay);
-      overlay.close();
-    },
-
-    _handleConfirmDeleteComment() {
-      const dialog =
-          this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
-      this.$.restAPI.deleteComment(
-          this.changeNum, this.patchNum, this.comment.id, dialog.message)
-          .then(newComment => {
-            this._handleCancelDeleteComment();
-            this.comment = newComment;
-          });
-    },
-  });
-})();
+customElements.define(GrComment.is, GrComment);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
new file mode 100644
index 0000000..fc79cba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
@@ -0,0 +1,462 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      font-family: var(--font-family);
+      padding: var(--spacing-m);
+    }
+    :host([disabled]) {
+      pointer-events: none;
+    }
+    :host([disabled]) .actions,
+    :host([disabled]) .robotActions,
+    :host([disabled]) .date {
+      opacity: 0.5;
+    }
+    :host([discarding]) {
+      display: none;
+    }
+    .header {
+      align-items: center;
+      cursor: pointer;
+      display: flex;
+      margin: calc(0px - var(--spacing-m)) calc(0px - var(--spacing-m)) 0
+        calc(0px - var(--spacing-m));
+      padding: var(--spacing-m);
+    }
+    .headerLeft > span {
+      font-weight: var(--font-weight-bold);
+    }
+    .container.collapsed .header {
+      margin-bottom: calc(0 - var(--spacing-m));
+    }
+    .headerMiddle {
+      color: var(--deemphasized-text-color);
+      flex: 1;
+      overflow: hidden;
+    }
+    .draftLabel,
+    .draftTooltip {
+      color: var(--deemphasized-text-color);
+      display: none;
+    }
+    .date {
+      justify-content: flex-end;
+      margin-left: 5px;
+      min-width: 4.5em;
+      text-align: right;
+      white-space: nowrap;
+    }
+    span.date {
+      color: var(--deemphasized-text-color);
+    }
+    span.date:hover {
+      text-decoration: underline;
+    }
+    .actions,
+    .robotActions {
+      display: flex;
+      justify-content: flex-end;
+      padding-top: 0;
+    }
+    .action {
+      margin-left: var(--spacing-l);
+    }
+    .rightActions {
+      display: flex;
+      justify-content: flex-end;
+    }
+    .rightActions gr-button {
+      --gr-button: {
+        height: 20px;
+        padding: 0 var(--spacing-s);
+      }
+    }
+    .editMessage {
+      display: none;
+      margin: var(--spacing-m) 0;
+      width: 100%;
+    }
+    .container:not(.draft) .actions .hideOnPublished {
+      display: none;
+    }
+    .draft .reply,
+    .draft .quote,
+    .draft .ack,
+    .draft .done {
+      display: none;
+    }
+    .draft .draftLabel,
+    .draft .draftTooltip {
+      display: inline;
+    }
+    .draft:not(.editing) .save,
+    .draft:not(.editing) .cancel {
+      display: none;
+    }
+    .editing .message,
+    .editing .reply,
+    .editing .quote,
+    .editing .ack,
+    .editing .done,
+    .editing .edit,
+    .editing .discard,
+    .editing .unresolved {
+      display: none;
+    }
+    .editing .editMessage {
+      display: block;
+    }
+    .show-hide {
+      margin-left: var(--spacing-s);
+    }
+    .robotId {
+      color: var(--deemphasized-text-color);
+      margin-bottom: var(--spacing-m);
+      margin-top: -0.4em;
+    }
+    .robotIcon {
+      margin-right: var(--spacing-xs);
+      /* because of the antenna of the robot, it looks off center even when it
+         is centered. artificially adjust margin to account for this. */
+      margin-top: -4px;
+    }
+    .runIdInformation {
+      margin: var(--spacing-m) 0;
+    }
+    .robotRun {
+      margin-left: var(--spacing-m);
+    }
+    .robotRunLink {
+      margin-left: var(--spacing-m);
+    }
+    input.show-hide {
+      display: none;
+    }
+    label.show-hide {
+      cursor: pointer;
+      display: block;
+    }
+    label.show-hide iron-icon {
+      vertical-align: top;
+    }
+    #container .collapsedContent {
+      display: none;
+    }
+    #container.collapsed {
+      padding-bottom: 3px;
+    }
+    #container.collapsed .collapsedContent {
+      display: block;
+      overflow: hidden;
+      padding-left: 5px;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    #container.collapsed .actions,
+    #container.collapsed gr-formatted-text,
+    #container.collapsed gr-textarea,
+    #container.collapsed .respectfulReviewTip {
+      display: none;
+    }
+    .resolve,
+    .unresolved {
+      align-items: center;
+      display: flex;
+      flex: 1;
+      margin: 0;
+    }
+    .resolve label {
+      color: var(--comment-text-color);
+    }
+    gr-dialog .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    .comment-extra-note {
+      color: var(--deemphasized-text-color);
+      border: 1px solid var(--deemphasized-text-color);
+      border-radius: var(--border-radius);
+      padding: 0px var(--spacing-s);
+    }
+    #deleteBtn {
+      display: none;
+      --gr-button: {
+        color: var(--deemphasized-text-color);
+        padding: 0;
+      }
+    }
+    #deleteBtn.showDeleteButtons {
+      display: block;
+    }
+
+    /** Disable select for the caret and actions */
+    .actions,
+    .show-hide {
+      -webkit-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+      user-select: none;
+    }
+
+    .respectfulReviewTip {
+      justify-content: space-between;
+      display: flex;
+      padding: var(--spacing-m);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin-bottom: var(--spacing-m);
+    }
+    .respectfulReviewTip div {
+      display: flex;
+    }
+    .respectfulReviewTip div iron-icon {
+      margin-right: var(--spacing-s);
+    }
+    .respectfulReviewTip a {
+      white-space: nowrap;
+      margin-right: var(--spacing-s);
+      padding-left: var(--spacing-m);
+      text-decoration: none;
+    }
+    .pointer {
+      cursor: pointer;
+    }
+  </style>
+  <div id="container" class="container">
+    <div class="header" id="header" on-click="_handleToggleCollapsed">
+      <div class="headerLeft">
+        <span class="authorName">
+          [[_computeAuthorName(comment, _serverConfig)]]
+        </span>
+        <span class="draftLabel">DRAFT</span>
+        <gr-tooltip-content
+          class="draftTooltip"
+          has-tooltip=""
+          title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key."
+          max-width="20em"
+          show-icon=""
+        ></gr-tooltip-content>
+      </div>
+      <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>
+      <template is="dom-if" if="[[comment.extraNote]]">
+        <span class="comment-extra-note">[[comment.extraNote]]</span>
+      </template>
+      <gr-button
+        id="deleteBtn"
+        link=""
+        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
+        hidden$="[[isRobotComment]]"
+        on-click="_handleCommentDelete"
+      >
+        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+      <span class="date" on-click="_handleAnchorClick">
+        <gr-date-formatter
+          has-tooltip=""
+          date-str="[[comment.updated]]"
+        ></gr-date-formatter>
+      </span>
+      <div class="show-hide">
+        <label class="show-hide">
+          <input
+            type="checkbox"
+            class="show-hide"
+            checked$="[[collapsed]]"
+            on-change="_handleToggleCollapsed"
+          />
+          <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
+          </iron-icon>
+        </label>
+      </div>
+    </div>
+    <div class="body">
+      <template is="dom-if" if="[[isRobotComment]]">
+        <div class="robotId" hidden$="[[collapsed]]">
+          [[comment.author.name]]
+        </div>
+      </template>
+      <template is="dom-if" if="[[editing]]">
+        <gr-textarea
+          id="editTextarea"
+          class="editMessage"
+          autocomplete="on"
+          code=""
+          disabled="{{disabled}}"
+          rows="4"
+          text="{{_messageText}}"
+        ></gr-textarea>
+        <template
+          is="dom-if"
+          if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
+        >
+          <div class="respectfulReviewTip">
+            <div>
+              <gr-tooltip-content
+                has-tooltip=""
+                title="Tips for respectful code reviews."
+              >
+                <iron-icon
+                  class="pointer"
+                  icon="gr-icons:lightbulb-outline"
+                ></iron-icon>
+              </gr-tooltip-content>
+              [[_respectfulReviewTip]]
+            </div>
+            <div>
+              <a
+                tabindex="-1"
+                on-click="_onRespectfulReadMoreClick"
+                href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
+                target="_blank"
+              >
+                Read more
+              </a>
+              <a
+                tabindex="-1"
+                class="close pointer"
+                on-click="_dismissRespectfulTip"
+                >Not helpful</a
+              >
+            </div>
+          </div>
+        </template>
+      </template>
+      <!--The message class is needed to ensure selectability from
+        gr-diff-selection.-->
+      <gr-formatted-text
+        class="message"
+        content="[[comment.message]]"
+        no-trailing-margin="[[!comment.__draft]]"
+        config="[[projectConfig.commentlinks]]"
+      ></gr-formatted-text>
+      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
+        <div class="action resolve hideOnPublished">
+          <label>
+            <input
+              type="checkbox"
+              id="resolvedCheckbox"
+              checked="[[resolved]]"
+              on-change="_handleToggleResolved"
+            />
+            Resolved
+          </label>
+        </div>
+        <div class="rightActions">
+          <gr-button
+            link=""
+            class="action cancel hideOnPublished"
+            on-click="_handleCancel"
+            >Cancel</gr-button
+          >
+          <gr-button
+            link=""
+            class="action discard hideOnPublished"
+            on-click="_handleDiscard"
+            >Discard</gr-button
+          >
+          <gr-button
+            link=""
+            class="action edit hideOnPublished"
+            on-click="_handleEdit"
+            >Edit</gr-button
+          >
+          <gr-button
+            link=""
+            disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
+            class="action save hideOnPublished"
+            on-click="_handleSave"
+            >Save</gr-button
+          >
+        </div>
+      </div>
+      <div class="robotActions" hidden$="[[!_showRobotActions]]">
+        <template is="dom-if" if="[[isRobotComment]]">
+          <gr-endpoint-decorator name="robot-comment-controls">
+            <gr-endpoint-param name="comment" value="[[comment]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+          <gr-button
+            link=""
+            secondary=""
+            class="action show-fix"
+            hidden$="[[_hasNoFix(comment)]]"
+            on-click="_handleShowFix"
+          >
+            Show Fix
+          </gr-button>
+          <template is="dom-if" if="[[!_hasHumanReply]]">
+            <gr-button
+              link=""
+              class="action fix"
+              on-click="_handleFix"
+              disabled="[[robotButtonDisabled]]"
+            >
+              Please Fix
+            </gr-button>
+          </template>
+        </template>
+      </div>
+    </div>
+  </div>
+  <template is="dom-if" if="[[_enableOverlay]]">
+    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
+      <gr-confirm-delete-comment-dialog
+        id="confirmDeleteComment"
+        on-confirm="_handleConfirmDeleteComment"
+        on-cancel="_handleCancelDeleteComment"
+      >
+      </gr-confirm-delete-comment-dialog>
+    </gr-overlay>
+    <gr-overlay id="confirmDiscardOverlay" with-backdrop="">
+      <gr-dialog
+        id="confirmDiscardDialog"
+        confirm-label="Discard"
+        confirm-on-enter=""
+        on-confirm="_handleConfirmDiscard"
+        on-cancel="_closeConfirmDiscardOverlay"
+      >
+        <div class="header" slot="header">
+          Discard comment
+        </div>
+        <div class="main" slot="main">
+          Are you sure you want to discard this draft comment?
+        </div>
+      </gr-dialog>
+    </gr-overlay>
+  </template>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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..56581d4 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
@@ -17,19 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/page/page.js"></script>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-comment.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="/node_modules/page/page.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -43,14 +38,16 @@
   </template>
 </test-fixture>
 
-<script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-comment.js';
+function isVisible(el) {
+  assert.ok(el);
+  return getComputedStyle(el).getPropertyValue('display') !== 'none';
+}
 
-  function isVisible(el) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') !== 'none';
-  }
-
-  suite('gr-comment tests', () => {
+suite('gr-comment tests', () => {
+  suite('basic tests', () => {
     let element;
     let sandbox;
     setup(() => {
@@ -78,34 +75,41 @@
     test('collapsible comments', () => {
       // When a comment (not draft) is loaded, it should be collapsed
       assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
       assert.isNotOk(element.textarea, 'textarea is not visible');
 
       // The header middle content is only visible when comments are collapsed.
       // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
 
       // When the header row is clicked, the comment should expand
       MockInteractions.tap(element.$.header);
       assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
       assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
     });
 
     test('clicking on date link fires event', () => {
       element.side = 'PARENT';
       const stub = sinon.stub();
       element.addEventListener('comment-anchor-tap', stub);
-      const dateEl = element.$$('.date');
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
       assert.ok(dateEl);
       MockInteractions.tap(dateEl);
 
@@ -165,23 +169,29 @@
 
     test('comment expand and collapse', () => {
       element.collapsed = true;
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
       assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
 
       element.collapsed = false;
       assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
       assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
     });
 
     suite('while editing', () => {
@@ -248,16 +258,27 @@
         assert.isTrue(element._handleSave.called);
       });
     });
+
+    test('extra note shown if exists', () => {
+      element.comment = {id: 'abc_123', extraNote: 'asd'};
+      flushAsynchronousOperations();
+      assert.equal(element.shadowRoot
+          .querySelector('.comment-extra-note')
+          .textContent, 'asd');
+    });
+
     test('delete comment button for non-admins is hidden', () => {
       element._isAdmin = false;
-      assert.isFalse(element.$$('.action.delete')
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
           .classList.contains('showDeleteButtons'));
     });
 
     test('delete comment button for admins with draft is hidden', () => {
       element._isAdmin = false;
       element.draft = true;
-      assert.isFalse(element.$$('.action.delete')
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
           .classList.contains('showDeleteButtons'));
     });
 
@@ -268,13 +289,16 @@
       element.changeNum = 42;
       element.patchNum = 0xDEADBEEF;
       element._isAdmin = true;
-      assert.isTrue(element.$$('.action.delete')
+      assert.isTrue(element.shadowRoot
+          .querySelector('.action.delete')
           .classList.contains('showDeleteButtons'));
-      MockInteractions.tap(element.$$('.action.delete'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.action.delete'));
       flush(() => {
         element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
           const dialog =
-              this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
+              window.confirmDeleteOverlay
+                  .querySelector('#confirmDeleteComment');
           dialog.message = 'removal reason';
           element._handleConfirmDeleteComment();
           assert.isTrue(element.$.restAPI.deleteComment.calledWith(
@@ -332,7 +356,8 @@
     test('edit reports interaction', () => {
       const reportStub = sandbox.stub(element.$.reporting,
           'recordDraftInteraction');
-      MockInteractions.tap(element.$$('.edit'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
       assert.isTrue(reportStub.calledOnce);
     });
 
@@ -340,7 +365,8 @@
       const reportStub = sandbox.stub(element.$.reporting,
           'recordDraftInteraction');
       element.draft = true;
-      MockInteractions.tap(element.$$('.discard'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.discard'));
       assert.isTrue(reportStub.calledOnce);
     });
   });
@@ -352,6 +378,7 @@
     setup(() => {
       stub('gr-rest-api-interface', {
         getAccount() { return Promise.resolve(null); },
+        getConfig() { return Promise.resolve({}); },
         saveDiffDraft() {
           return Promise.resolve({
             ok: true,
@@ -396,151 +423,260 @@
 
     test('button visibility states', () => {
       element.showActions = false;
-      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
       element.showActions = true;
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
       element.draft = true;
-      assert.isTrue(isVisible(element.$$('.edit')), 'edit is visible');
-      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
-      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
-      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
       element.editing = true;
       flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.$$('.discard')), 'discard not visible');
-      assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
-      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
-      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
       element.draft = false;
       element.editing = false;
       flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.$$('.discard')),
-          'discard is not visible');
-      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')),
+      'discard is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
       element.comment.id = 'foo';
       element.draft = true;
       element.editing = true;
       flushAsynchronousOperations();
-      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
-      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // Delete button is not hidden by default
+      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
 
       element.isRobotComment = true;
       element.draft = true;
-      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
       // It is not expected to see Robot comment drafts, but if they appear,
       // they will behave the same as non-drafts.
       element.draft = false;
-      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
 
       // A robot comment with run ID should display plain text.
       element.set(['comment', 'robot_run_id'], 'text');
       element.editing = false;
       element.collapsed = false;
       flushAsynchronousOperations();
-      assert.isNotOk(element.$$('.robotRun.link'));
-      assert.notEqual(getComputedStyle(element.$$('.robotRun.text')).display,
-          'none');
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotRun.link').textContent === 'Run Details');
 
       // A robot comment with run ID and url should display a link.
       element.set(['comment', 'url'], '/path/to/run');
       flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(element.$$('.robotRun.link')).display,
-          'none');
-      assert.equal(getComputedStyle(element.$$('.robotRun.text')).display,
-          'none');
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.robotRun.link')).display,
+      'none');
+
+      // Delete button is hidden for robot comments
+      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
     });
 
     test('collapsible drafts', () => {
       assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
       assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
 
       MockInteractions.tap(element.$.header);
       assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
       assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
 
       // When the edit button is pressed, should still see the actions
       // and also textarea
-      MockInteractions.tap(element.$$('.edit'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
       flushAsynchronousOperations();
       assert.isFalse(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
       assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
 
       // When toggle again, everything should be hidden except for textarea
       // and header middle content should be visible
       MockInteractions.tap(element.$.header);
       assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.$$('.actions')),
-          'actions are not visible');
-      assert.isFalse(isVisible(element.$$('gr-textarea')),
-          'textarea is not visible');
-      assert.isTrue(isVisible(element.$$('.collapsedContent')),
-          'header middle content is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-textarea')),
+      'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
 
       // When toggle again, textarea should remain open in the state it was
       // before
       MockInteractions.tap(element.$.header);
-      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
-          'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.$$('.actions')),
-          'actions are visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
       assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.$$('.collapsedContent')),
-          'header middle content is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+    });
+
+    test('robot comment layout', done => {
+      const comment = Object.assign({
+        robot_id: 'happy_robot_id',
+        url: '/robot/comment',
+        author: {
+          name: 'Happy Robot',
+        },
+      }, element.comment);
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        let runIdMessage;
+        runIdMessage = element.shadowRoot
+            .querySelector('.runIdMessage');
+        assert.isFalse(runIdMessage.hidden);
+
+        const runDetailsLink = element.shadowRoot
+            .querySelector('.robotRunLink');
+        assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
+
+        const robotServiceName = element.shadowRoot
+            .querySelector('.authorName');
+        assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
+
+        const authorName = element.shadowRoot
+            .querySelector('.robotId');
+        assert.isTrue(authorName.innerText === 'Happy Robot');
+
+        element.collapsed = true;
+        flushAsynchronousOperations();
+        runIdMessage = element.shadowRoot
+            .querySelector('.runIdMessage');
+        assert.isTrue(runIdMessage.hidden);
+        done();
+      });
+    });
+
+    test('author name fallback to email', done => {
+      const comment = Object.assign({
+        url: '/robot/comment',
+        author: {
+          email: 'test@test.com',
+        },
+      }, element.comment);
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        const authorName = element.shadowRoot
+            .querySelector('.authorName');
+        assert.equal(authorName.innerText.trim(), 'test@test.com');
+        done();
+      });
     });
 
     test('draft creation/cancellation', done => {
       assert.isFalse(element.editing);
-      MockInteractions.tap(element.$$('.edit'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
       assert.isTrue(element.editing);
 
       element._messageText = '';
       const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
 
       // Save should be disabled on an empty message.
-      let disabled = element.$$('.save').hasAttribute('disabled');
+      let disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
       element._messageText = '     ';
-      disabled = element.$$('.save').hasAttribute('disabled');
+      disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
 
       const updateStub = sinon.stub();
@@ -555,7 +691,8 @@
           done();
         }
       });
-      MockInteractions.tap(element.$$('.cancel'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.cancel'));
       element.flushDebouncer('fire-update');
       element._messageText = '';
       flushAsynchronousOperations();
@@ -654,35 +791,34 @@
     });
 
     test('draft saving/editing', done => {
-      const fireStub = sinon.stub(element, 'fire');
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
       const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
 
       element.draft = true;
-      MockInteractions.tap(element.$$('.edit'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
       element._messageText = 'good news, everyone!';
       element.flushDebouncer('fire-update');
       element.flushDebouncer('store');
-      assert(fireStub.calledWith('comment-update'),
-          'comment-update should be sent');
-      assert.isTrue(fireStub.calledOnce);
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update'),
+      assert.isTrue(dispatchEventStub.calledTwice);
 
       element._messageText = 'good news, everyone!';
       element.flushDebouncer('fire-update');
       element.flushDebouncer('store');
-      assert.isTrue(fireStub.calledOnce,
-          'No events should fire for text editing');
+      assert.isTrue(dispatchEventStub.calledTwice);
 
-      MockInteractions.tap(element.$$('.save'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
 
       assert.isTrue(element.disabled,
           'Element should be disabled when creating draft.');
 
       element._xhrPromise.then(draft => {
-        assert(fireStub.calledWith('comment-save'),
-            'comment-save should be sent');
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
         assert(cancelDebounce.calledWith('store'));
 
-        assert.deepEqual(fireStub.lastCall.args[1], {
+        assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
           comment: {
             __commentSide: 'right',
             __draft: true,
@@ -700,10 +836,12 @@
         assert.equal(draft.message, 'saved!');
         assert.isFalse(element.editing);
       }).then(() => {
-        MockInteractions.tap(element.$$('.edit'));
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.edit'));
         element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
             'a world where humans are killed on sight.';
-        MockInteractions.tap(element.$$('.save'));
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.save'));
         assert.isTrue(element.disabled,
             'Element should be disabled when updating draft.');
 
@@ -712,7 +850,7 @@
               'Element should be enabled when done updating draft.');
           assert.equal(draft.message, 'saved!');
           assert.isFalse(element.editing);
-          fireStub.restore();
+          dispatchEventStub.restore();
           done();
         });
       });
@@ -723,17 +861,20 @@
       element.showActions = true;
       element.draft = true;
       MockInteractions.tap(element.$.header);
-      MockInteractions.tap(element.$$('.edit'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
       element._messageText = 'good news, everyone!';
       element.flushDebouncer('fire-update');
       element.flushDebouncer('store');
 
       element.disabled = true;
-      MockInteractions.tap(element.$$('.save'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
       assert.isFalse(saveStub.called);
 
       element.disabled = false;
-      MockInteractions.tap(element.$$('.save'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
       assert.isTrue(saveStub.calledOnce);
     });
 
@@ -744,15 +885,18 @@
         assert.isFalse(save.called);
         done();
       });
-      MockInteractions.tap(element.$$('.resolve input'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.resolve input'));
     });
 
     test('resolved comment state indicated by checkbox', () => {
       sandbox.stub(element, 'save');
       element.comment = {unresolved: false};
-      assert.isTrue(element.$$('.resolve input').checked);
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
       element.comment = {unresolved: true};
-      assert.isFalse(element.$$('.resolve input').checked);
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
     });
 
     test('resolved checkbox saves with tap when !editing', () => {
@@ -760,12 +904,15 @@
       const save = sandbox.stub(element, 'save');
 
       element.comment = {unresolved: false};
-      assert.isTrue(element.$$('.resolve input').checked);
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
       element.comment = {unresolved: true};
-      assert.isFalse(element.$$('.resolve input').checked);
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
       assert.isFalse(save.called);
       MockInteractions.tap(element.$.resolvedCheckbox);
-      assert.isTrue(element.$$('.resolve input').checked);
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
       assert.isTrue(save.called);
     });
 
@@ -841,9 +988,310 @@
         done();
       });
       element.isRobotComment = true;
+      element.comments = [element.comment];
       flushAsynchronousOperations();
 
-      MockInteractions.tap(element.$$('.fix'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.fix'));
+    });
+
+    test('do not show Please Fix button if human reply exists', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __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.shadowRoot
+          .querySelector('robotActions gr-button'));
+    });
+
+    test('show Please Fix if no human reply', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+      ];
+      element.comment = element.comments[0];
+      flushAsynchronousOperations();
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.robotActions gr-button'));
+    });
+
+    test('_handleShowFix fires open-fix-preview event', done => {
+      element.addEventListener('open-fix-preview', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.comment = {fix_suggestions: [{}]};
+      element.isRobotComment = true;
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.show-fix'));
     });
   });
+
+  suite('respectful tips', () => {
+    let element;
+    let sandbox;
+    let clock;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+      });
+      clock = sinon.useFakeTimers();
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sandbox.restore();
+    });
+
+    test('show tip when no cached record', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+
+    test('add 14-day delays once dismissed', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
+        assert.isTrue(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.respectfulReviewTip .close'));
+        flushAsynchronousOperations();
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
+        done();
+      });
+    });
+
+    test('do not show tip when fall out of probability', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 3;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+
+    test('show tip when editing changed to true', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: false};
+      flush(() => {
+        assert.isFalse(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+
+        element.editing = true;
+        flush(() => {
+          assert.isTrue(respectfulGetStub.called);
+          assert.isTrue(respectfulSetStub.called);
+          assert.isTrue(
+              !!element.shadowRoot.querySelector('.respectfulReviewTip')
+          );
+          done();
+        });
+      });
+    });
+
+    test('no tip when cached record', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns({});
+      element = fixture('draft');
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+  });
+});
 </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
deleted file mode 100644
index 62ab307..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
+++ /dev/null
@@ -1,75 +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.
--->
-
-<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">
-
-<dom-module id="gr-confirm-delete-comment-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: .5;
-        pointer-events: none;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-      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);
-        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
-        confirm-label="Delete"
-        on-confirm="_handleConfirmTap"
-        on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Delete Comment</div>
-      <div class="main" slot="main">
-        <label for="messageInput">Enter comment delete reason</label>
-        <iron-autogrow-textarea
-            id="messageInput"
-            class="message"
-            autocomplete="on"
-            placeholder="<Insert reasoning here>"
-            bind-value="{{message}}"></iron-autogrow-textarea>
-      </div>
-    </gr-dialog>
-  </template>
-  <script src="gr-confirm-delete-comment-dialog.js"></script>
-</dom-module>
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..b0f387b 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
@@ -14,46 +14,63 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 
-  Polymer({
-    is: 'gr-confirm-delete-comment-dialog',
+import '../../../scripts/bundled-polymer.js';
+import '../gr-dialog/gr-dialog.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html.js';
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrConfirmDeleteCommentDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-confirm-delete-comment-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
       message: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  resetFocus() {
+    this.$.messageInput.textarea.focus();
+  }
 
-    resetFocus() {
-      this.$.messageInput.textarea.focus();
-    },
+  _handleConfirmTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {
+      detail: {reason: this.message},
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleConfirmTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', {reason: this.message}, {bubbles: false});
-    },
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, 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-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
new file mode 100644
index 0000000..2d0fa6f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    p {
+      margin-bottom: var(--spacing-l);
+    }
+    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="Delete"
+    on-confirm="_handleConfirmTap"
+    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"
+        class="message"
+        autocomplete="on"
+        placeholder="<Insert reasoning here>"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
deleted file mode 100644
index f58db39..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
+++ /dev/null
@@ -1,84 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-
-<dom-module id="gr-copy-clipboard">
-  <template>
-    <style include="shared-styles">
-      .text {
-        align-items: center;
-        display: flex;
-        flex-wrap: wrap;
-      }
-      .copyText {
-        flex-grow: 1;
-        margin-right: var(--spacing-s);
-      }
-      .hideInput {
-        display: none;
-      }
-      input#input {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        @apply --text-container-style;
-        width: 100%;
-      }
-      #icon {
-        height: 1.2em;
-        width: 1.2em;
-      }
-      gr-button {
-        --gr-button: {
-          padding: 1px 4px;
-        }
-      }
-
-    </style>
-    <div class="text">
-      <iron-input
-          class="copyText"
-          type="text"
-          bind-value="[[text]]"
-          on-tap="_handleInputClick"
-          readonly>
-        <input
-            id="input"
-            is="iron-input"
-            class$="[[_computeInputClass(hideInput)]]"
-            type="text"
-            bind-value="[[text]]"
-            on-click="_handleInputClick"
-            readonly>
-      </iron-input>
-      <gr-button id="button"
-          link
-          has-tooltip="[[hasTooltip]]"
-          class="copyToClipboard"
-          title="[[buttonTitle]]"
-          on-click="_copyToClipboard">
-        <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
-      </gr-button>
-    </div>
-  </template>
-  <script src="gr-copy-clipboard.js"></script>
-</dom-module>
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..0f6168e 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
@@ -14,15 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const COPY_TIMEOUT_MS = 1000;
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-copy-clipboard_html.js';
 
-  Polymer({
-    is: 'gr-copy-clipboard',
+const COPY_TIMEOUT_MS = 1000;
 
-    properties: {
+/** @extends Polymer.Element */
+class GrCopyClipboard extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-copy-clipboard'; }
+
+  static get properties() {
+    return {
       text: String,
       buttonTitle: String,
       hasTooltip: {
@@ -33,35 +48,40 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    focusOnCopy() {
-      this.$.button.focus();
-    },
+  focusOnCopy() {
+    this.$.button.focus();
+  }
 
-    _computeInputClass(hideInput) {
-      return hideInput ? 'hideInput' : '';
-    },
+  _computeInputClass(hideInput) {
+    return hideInput ? 'hideInput' : '';
+  }
 
-    _handleInputClick(e) {
-      e.preventDefault();
-      Polymer.dom(e).rootTarget.select();
-    },
+  _handleInputClick(e) {
+    e.preventDefault();
+    dom(e).rootTarget.select();
+  }
 
-    _copyToClipboard() {
-      if (this.hideInput) {
-        this.$.input.style.display = 'block';
-      }
-      this.$.input.focus();
-      this.$.input.select();
-      document.execCommand('copy');
-      if (this.hideInput) {
-        this.$.input.style.display = 'none';
-      }
-      this.$.icon.icon = 'gr-icons:check';
-      this.async(
-          () => this.$.icon.icon = 'gr-icons:content-copy',
-          COPY_TIMEOUT_MS);
-    },
-  });
-})();
+  _copyToClipboard(e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    if (this.hideInput) {
+      this.$.input.style.display = 'block';
+    }
+    this.$.input.focus();
+    this.$.input.select();
+    document.execCommand('copy');
+    if (this.hideInput) {
+      this.$.input.style.display = 'none';
+    }
+    this.$.icon.icon = 'gr-icons:check';
+    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_html.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
new file mode 100644
index 0000000..8378de5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .text {
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .copyText {
+      flex-grow: 1;
+      margin-right: var(--spacing-s);
+    }
+    .hideInput {
+      display: none;
+    }
+    input#input {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      @apply --text-container-style;
+      width: 100%;
+    }
+    /*
+       * Typically icons are 20px, which is the normal line-height.
+       * The copy icon is too prominent at 20px, so we choose 16px
+       * here, but add 2x2px padding below, so the entire
+       * component should still fit nicely into a normal inline
+       * layout flow.
+       */
+    #icon {
+      height: 16px;
+      width: 16px;
+    }
+    gr-button {
+      --gr-button: {
+        padding: 2px;
+      }
+    }
+  </style>
+  <div class="text">
+    <iron-input
+      class="copyText"
+      type="text"
+      bind-value="[[text]]"
+      on-tap="_handleInputClick"
+      readonly=""
+    >
+      <input
+        id="input"
+        is="iron-input"
+        class$="[[_computeInputClass(hideInput)]]"
+        type="text"
+        bind-value="[[text]]"
+        on-click="_handleInputClick"
+        readonly=""
+      />
+    </iron-input>
+    <gr-button
+      id="button"
+      link=""
+      has-tooltip="[[hasTooltip]]"
+      class="copyToClipboard"
+      title="[[buttonTitle]]"
+      on-click="_copyToClipboard"
+    >
+      <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+    </gr-button>
+  </div>
+`;
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..398f7f0 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-copy-clipboard.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,49 +31,75 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-copy-clipboard tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-copy-clipboard.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-copy-clipboard tests', () => {
+  let element;
+  let sandbox;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.text = `git fetch http://gerrit@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-      flushAsynchronousOperations();
-      flush(done);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('copy to clipboard', () => {
-      const clipboardSpy = sandbox.spy(element, '_copyToClipboard');
-      const copyBtn = element.$$('.copyToClipboard');
-      MockInteractions.tap(copyBtn);
-      assert.isTrue(clipboardSpy.called);
-    });
-
-    test('focusOnCopy', () => {
-      element.focusOnCopy();
-      assert.deepEqual(Polymer.dom(element.root).activeElement,
-          element.$$('.copyToClipboard'));
-    });
-
-    test('_handleInputClick', () => {
-      const inputElement = element.$$('input');
-      MockInteractions.tap(inputElement);
-      assert.equal(inputElement.selectionStart, 0);
-      assert.equal(inputElement.selectionEnd, element.text.length - 1);
-    });
-
-    test('hideInput', () => {
-      assert.notEqual(getComputedStyle(element.$.input).display, 'none');
-      element.hideInput = true;
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.$.input).display, 'none');
-    });
+  setup(done => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    flushAsynchronousOperations();
+    flush(done);
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('copy to clipboard', () => {
+    const clipboardSpy = sandbox.spy(element, '_copyToClipboard');
+    const copyBtn = element.shadowRoot
+        .querySelector('.copyToClipboard');
+    MockInteractions.tap(copyBtn);
+    assert.isTrue(clipboardSpy.called);
+  });
+
+  test('focusOnCopy', () => {
+    element.focusOnCopy();
+    assert.deepEqual(dom(element.root).activeElement,
+        element.shadowRoot
+            .querySelector('.copyToClipboard'));
+  });
+
+  test('_handleInputClick', () => {
+    // iron-input as parent should never be hidden as copy won't work
+    // on nested hidden elements
+    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
+
+    const inputElement = element.shadowRoot.querySelector('input');
+    MockInteractions.tap(inputElement);
+    assert.equal(inputElement.selectionStart, 0);
+    assert.equal(inputElement.selectionEnd, element.text.length - 1);
+  });
+
+  test('hideInput', () => {
+    // iron-input as parent should never be hidden as copy won't work
+    // on nested hidden elements
+    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
+
+    assert.notEqual(getComputedStyle(element.$.input).display, 'none');
+    element.hideInput = true;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(element.$.input).display, 'none');
+  });
+
+  test('stop events propagation', () => {
+    const divParent = document.createElement('div');
+    divParent.appendChild(element);
+    const clickStub = sinon.stub();
+    divParent.addEventListener('click', clickStub);
+    element.stopPropagation = true;
+    const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
+    MockInteractions.tap(copyBtn);
+    assert.isFalse(clickStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html
deleted file mode 100644
index b69c61aa..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html
+++ /dev/null
@@ -1,58 +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>
-  (function(window) {
-    'use strict';
-    const GrCountStringFormatter = window.GrCountStringFormatter || {};
-
-    /**
-     * Returns a count plus string that is pluralized when necessary.
-     *
-     * @param {number} count
-     * @param {string} noun
-     * @return {string}
-     */
-    GrCountStringFormatter.computePluralString = function(count, noun) {
-      return this.computeString(count, noun) + (count > 1 ? 's' : '');
-    };
-
-    /**
-     * Returns a count plus string that is not pluralized.
-     *
-     * @param {number} count
-     * @param {string} noun
-     * @return {string}
-     */
-    GrCountStringFormatter.computeString = function(count, noun) {
-      if (count === 0) { return ''; }
-      return count + ' ' + noun;
-    };
-
-    /**
-     * Returns a count plus arbitrary text.
-     *
-     * @param {number} count
-     * @param {string} text
-     * @return {string}
-     */
-    GrCountStringFormatter.computeShortString = function(count, text) {
-      if (count === 0) { return ''; }
-      return count + text;
-    };
-    window.GrCountStringFormatter = GrCountStringFormatter;
-  })(window);
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
new file mode 100644
index 0000000..1c3a689
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
@@ -0,0 +1,56 @@
+/**
+ * @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.
+ */
+export const GrCountStringFormatter = {
+  /**
+   * Returns a count plus string that is pluralized when necessary.
+   *
+   * @param {number} count
+   * @param {string} noun
+   * @return {string}
+   */
+  computePluralString(count, noun) {
+    return this.computeString(count, noun) + (count > 1 ? 's' : '');
+  },
+
+  /**
+   * Returns a count plus string that is not pluralized.
+   *
+   * @param {number} count
+   * @param {string} noun
+   * @return {string}
+   */
+  computeString(count, noun) {
+    if (count === 0) {
+      return '';
+    }
+    return count + ' ' + noun;
+  },
+
+  /**
+   * Returns a count plus arbitrary text.
+   *
+   * @param {number} count
+   * @param {string} text
+   * @return {string}
+   */
+  computeShortString(count, text) {
+    if (count === 0) {
+      return '';
+    }
+    return count + text;
+  },
+};
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..63435d2 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
@@ -17,41 +17,42 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-count-string-formatter.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-count-string-formatter tests', () => {
-    test('computeString', () => {
-      const noun = 'unresolved';
-      assert.equal(GrCountStringFormatter.computeString(0, noun), '');
-      assert.equal(GrCountStringFormatter.computeString(1, noun),
-          '1 unresolved');
-      assert.equal(GrCountStringFormatter.computeString(2, noun),
-          '2 unresolved');
-    });
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {GrCountStringFormatter} from './gr-count-string-formatter.js';
 
-    test('computeShortString', () => {
-      const noun = 'c';
-      assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
-      assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
-      assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
-    });
-
-    test('computePluralString', () => {
-      const noun = 'comment';
-      assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
-      assert.equal(GrCountStringFormatter.computePluralString(1, noun),
-          '1 comment');
-      assert.equal(GrCountStringFormatter.computePluralString(2, noun),
-          '2 comments');
-    });
+suite('gr-count-string-formatter tests', () => {
+  test('computeString', () => {
+    const noun = 'unresolved';
+    assert.equal(GrCountStringFormatter.computeString(0, noun), '');
+    assert.equal(GrCountStringFormatter.computeString(1, noun),
+        '1 unresolved');
+    assert.equal(GrCountStringFormatter.computeString(2, noun),
+        '2 unresolved');
   });
+
+  test('computeShortString', () => {
+    const noun = 'c';
+    assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
+    assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
+    assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
+  });
+
+  test('computePluralString', () => {
+    const noun = 'comment';
+    assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
+    assert.equal(GrCountStringFormatter.computePluralString(1, noun),
+        '1 comment');
+    assert.equal(GrCountStringFormatter.computePluralString(2, noun),
+        '2 comments');
+  });
+});
 </script>
 
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
deleted file mode 100644
index 94d7aaa..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.html
+++ /dev/null
@@ -1,23 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-cursor-manager">
-  <template></template>
-  <script src="gr-cursor-manager.js"></script>
-</dom-module>
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..222109e 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
@@ -14,18 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-cursor-manager_html.js';
 
-  const ScrollBehavior = {
-    NEVER: 'never',
-    KEEP_VISIBLE: 'keep-visible',
-  };
+const ScrollBehavior = {
+  NEVER: 'never',
+  KEEP_VISIBLE: 'keep-visible',
+};
 
-  Polymer({
-    is: 'gr-cursor-manager',
+/** @extends Polymer.Element */
+class GrCursorManager extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-cursor-manager'; }
+
+  static get properties() {
+    return {
       stops: {
         type: Array,
         value() {
@@ -85,256 +94,350 @@
         type: Boolean,
         value: false,
       },
-    },
 
-    detached() {
-      this.unsetCursor();
-    },
+      /**
+       * The scrollTopMargin defines height of invisible area at the top
+       * of the page. If cursor locates inside this margin - it is
+       * not visible, because it is covered by some other element.
+       */
+      scrollTopMargin: {
+        type: Number,
+        value: 0,
+      },
+    };
+  }
 
-    /**
-     * Move the cursor forward. Clipped to the ends of the stop list.
-     *
-     * @param {!Function=} opt_condition Optional stop condition. If a condition
-     *    is passed the cursor will continue to move in the specified direction
-     *    until the condition is met.
-     * @param {!Function=} opt_getTargetHeight Optional function to calculate the
-     *    height of the target's 'section'. The height of the target itself is
-     *    sometimes different, used by the diff cursor.
-     * @param {boolean=} opt_clipToTop When none of the next indices match, move
-     *     back to first instead of to last.
-     * @private
-     */
+  /** @override */
+  detached() {
+    super.detached();
+    this.unsetCursor();
+  }
 
-    next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
-      this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
-    },
+  /**
+   * Move the cursor forward. Clipped to the ends of the stop list.
+   *
+   * @param {!Function=} opt_condition Optional stop condition. If a condition
+   *    is passed the cursor will continue to move in the specified direction
+   *    until the condition is met.
+   * @param {!Function=} opt_getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param {boolean=} opt_clipToTop When none of the next indices match, move
+   *     back to first instead of to last.
+   * @private
+   */
 
-    previous(opt_condition) {
-      this._moveCursor(-1, opt_condition);
-    },
+  next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
+    this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
+  }
 
-    /**
-     * Set the cursor to an arbitrary element.
-     *
-     * @param {!HTMLElement} element
-     * @param {boolean=} opt_noScroll prevent any potential scrolling in response
-     *   setting the cursor.
-     */
-    setCursor(element, opt_noScroll) {
-      let behavior;
-      if (opt_noScroll) {
-        behavior = this.scrollBehavior;
-        this.scrollBehavior = ScrollBehavior.NEVER;
-      }
+  previous(opt_condition) {
+    this._moveCursor(-1, opt_condition);
+  }
 
-      this.unsetCursor();
-      this.target = element;
-      this._updateIndex();
-      this._decorateTarget();
+  /**
+   * Move the cursor to the row which is the closest to the viewport center
+   * in vertical direction.
+   * The method uses IntersectionObservers API. If browser
+   * doesn't support this API the method does nothing
+   *
+   * @param {!Function=} opt_condition Optional condition. If a condition
+   *    is passed only stops which meet conditions are taken into account.
+   */
+  moveToVisibleArea(opt_condition) {
+    if (!this.stops || !this._isIntersectionObserverSupported()) {
+      return;
+    }
+    const filteredStops = opt_condition ? this.stops.filter(opt_condition)
+      : this.stops;
+    const dims = this._getWindowDims();
+    const windowCenter =
+        Math.round((dims.innerHeight + this.scrollTopMargin) / 2);
 
-      if (opt_noScroll) { this.scrollBehavior = behavior; }
-    },
+    let closestToTheCenter = null;
+    let minDistanceToCenter = null;
+    let unobservedCount = filteredStops.length;
 
-    unsetCursor() {
-      this._unDecorateTarget();
-      this.index = -1;
-      this.target = null;
-      this._targetHeight = null;
-    },
+    const observer = new IntersectionObserver(entries => {
+      // This callback is called for the first time immediately.
+      // Typically it gets all observed stops at once, but
+      // sometimes can get them in several chunks.
+      entries.forEach(entry => {
+        observer.unobserve(entry.target);
 
-    isAtStart() {
-      return this.index === 0;
-    },
-
-    isAtEnd() {
-      return this.index === this.stops.length - 1;
-    },
-
-    moveToStart() {
-      if (this.stops.length) {
-        this.setCursor(this.stops[0]);
-      }
-    },
-
-    setCursorAtIndex(index, opt_noScroll) {
-      this.setCursor(this.stops[index], opt_noScroll);
-    },
-
-    /**
-     * Move the cursor forward or backward by delta. Clipped to the beginning or
-     * end of stop list.
-     *
-     * @param {number} delta either -1 or 1.
-     * @param {!Function=} opt_condition Optional stop condition. If a condition
-     *    is passed the cursor will continue to move in the specified direction
-     *    until the condition is met.
-     * @param {!Function=} opt_getTargetHeight Optional function to calculate the
-     *    height of the target's 'section'. The height of the target itself is
-     *    sometimes different, used by the diff cursor.
-     * @param {boolean=} opt_clipToTop When none of the next indices match, move
-     *     back to first instead of to last.
-     * @private
-     */
-    _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
-      if (!this.stops.length) {
-        this.unsetCursor();
-        return;
-      }
-
-      this._unDecorateTarget();
-
-      const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop);
-
-      let newTarget = null;
-      if (newIndex !== -1) {
-        newTarget = this.stops[newIndex];
-      }
-
-      this.index = newIndex;
-      this.target = newTarget;
-
-      if (!this.target) { return; }
-
-      if (opt_getTargetHeight) {
-        this._targetHeight = opt_getTargetHeight(newTarget);
-      } else {
-        this._targetHeight = newTarget.scrollHeight;
-      }
-
-      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.
-     *
-     * @param {number} delta either -1 or 1.
-     * @param {!Function=} opt_condition Optional stop condition.
-     * @param {boolean=} opt_clipToTop When none of the next indices match, move
-     *     back to first instead of to last.
-     * @return {number} the new index.
-     * @private
-     */
-    _getNextindex(delta, opt_condition, opt_clipToTop) {
-      if (!this.stops.length || this.index === -1) {
-        return -1;
-      }
-
-      let newIndex = this.index;
-      do {
-        newIndex = newIndex + delta;
-      } while (newIndex > 0 &&
-               newIndex < this.stops.length - 1 &&
-               opt_condition && !opt_condition(this.stops[newIndex]));
-
-      newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex));
-
-      // If we failed to satisfy the condition:
-      if (opt_condition && !opt_condition(this.stops[newIndex])) {
-        if (delta < 0 || opt_clipToTop) {
-          return 0;
-        } else if (delta > 0) {
-          return this.stops.length - 1;
-        }
-        return this.index;
-      }
-
-      return newIndex;
-    },
-
-    _updateIndex() {
-      if (!this.target) {
-        this.index = -1;
-        return;
-      }
-
-      const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
-      if (newIndex === -1) {
-        this.unsetCursor();
-      } else {
-        this.index = newIndex;
-      }
-    },
-
-    /**
-     * Calculate where the element is relative to the window.
-     *
-     * @param {!Object} target Target to scroll to.
-     * @return {number} Distance to top of the target.
-     */
-    _getTop(target) {
-      let top = target.offsetTop;
-      for (let offsetParent = target.offsetParent;
-        offsetParent;
-        offsetParent = offsetParent.offsetParent) {
-        top += offsetParent.offsetTop;
-      }
-      return top;
-    },
-
-    /**
-     * @return {boolean}
-     */
-    _targetIsVisible(top) {
-      const dims = this._getWindowDims();
-      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) {
-        return;
-      }
-
-      const dims = this._getWindowDims();
-      const top = this._getTop(this.target);
-      const bottomIsVisible = this._targetHeight ?
-        this._targetIsVisible(top + this._targetHeight) : true;
-      const scrollToValue = this._calculateScrollToValue(top, this.target);
-
-      if (this._targetIsVisible(top)) {
-        // Don't scroll if either the bottom is visible or if the position that
-        // would get scrolled to is higher up than the current position. this
-        // woulld cause less of the target content to be displayed than is
-        // already.
-        if (bottomIsVisible || scrollToValue < dims.scrollY) {
+        // In Edge it is recommended to use intersectionRatio instead of
+        // isIntersecting.
+        const isInsideViewport =
+            entry.isIntersecting || entry.intersectionRatio > 0;
+        if (!isInsideViewport) {
           return;
         }
+        const center = entry.boundingClientRect.top + Math.round(
+            entry.boundingClientRect.height / 2);
+        const distanceToWindowCenter = Math.abs(center - windowCenter);
+        if (minDistanceToCenter === null ||
+            distanceToWindowCenter < minDistanceToCenter) {
+          closestToTheCenter = entry.target;
+          minDistanceToCenter = distanceToWindowCenter;
+        }
+      });
+      unobservedCount -= entries.length;
+      if (unobservedCount == 0 && closestToTheCenter) {
+        // set cursor when all stops were observed.
+        // In most cases the target is visible, so scroll is not
+        // needed. But in rare cases the target can become invisible
+        // at this point (due to some scrolling in window).
+        // To avoid jumps set noScroll options.
+        this.setCursor(closestToTheCenter, true);
       }
+    });
+    filteredStops.forEach(stop => {
+      observer.observe(stop);
+    });
+  }
 
-      // Scroll the element to the middle of the window. Dividing by a third
-      // instead of half the inner height feels a bit better otherwise the
-      // element appears to be below the center of the window even when it
-      // isn't.
-      window.scrollTo(dims.scrollX, scrollToValue);
-    },
+  _isIntersectionObserverSupported() {
+    // The copy of this method exists in gr-app-element.js under the
+    // name _isCursorManagerSupportMoveToVisibleLine
+    // If you update this method, you must update gr-app-element.js
+    // as well.
+    return 'IntersectionObserver' in window;
+  }
 
-    _getWindowDims() {
-      return {
-        scrollX: window.scrollX,
-        scrollY: window.scrollY,
-        innerHeight: window.innerHeight,
-        pageYOffset: window.pageYOffset,
-      };
-    },
-  });
-})();
+  /**
+   * Set the cursor to an arbitrary element.
+   *
+   * @param {!HTMLElement} element
+   * @param {boolean=} opt_noScroll prevent any potential scrolling in response
+   *   setting the cursor.
+   */
+  setCursor(element, opt_noScroll) {
+    let behavior;
+    if (opt_noScroll) {
+      behavior = this.scrollBehavior;
+      this.scrollBehavior = ScrollBehavior.NEVER;
+    }
+
+    this.unsetCursor();
+    this.target = element;
+    this._updateIndex();
+    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
+   * end of stop list.
+   *
+   * @param {number} delta either -1 or 1.
+   * @param {!Function=} opt_condition Optional stop condition. If a condition
+   *    is passed the cursor will continue to move in the specified direction
+   *    until the condition is met.
+   * @param {!Function=} opt_getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param {boolean=} opt_clipToTop When none of the next indices match, move
+   *     back to first instead of to last.
+   * @private
+   */
+  _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
+    if (!this.stops.length) {
+      this.unsetCursor();
+      return;
+    }
+
+    this._unDecorateTarget();
+
+    const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop);
+
+    let newTarget = null;
+    if (newIndex !== -1) {
+      newTarget = this.stops[newIndex];
+    }
+
+    this.index = newIndex;
+    this.target = newTarget;
+
+    if (!this.target) { return; }
+
+    if (opt_getTargetHeight) {
+      this._targetHeight = opt_getTargetHeight(newTarget);
+    } else {
+      this._targetHeight = newTarget.scrollHeight;
+    }
+
+    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.
+   *
+   * @param {number} delta either -1 or 1.
+   * @param {!Function=} opt_condition Optional stop condition.
+   * @param {boolean=} opt_clipToTop When none of the next indices match, move
+   *     back to first instead of to last.
+   * @return {number} the new index.
+   * @private
+   */
+  _getNextindex(delta, opt_condition, opt_clipToTop) {
+    if (!this.stops.length) {
+      return -1;
+    }
+    let newIndex = this.index;
+    // If the cursor is not yet set and we are going backwards, start at the
+    // back.
+    if (this.index === -1 && delta < 0) {
+      newIndex = this.stops.length;
+    }
+    do {
+      newIndex = newIndex + delta;
+    } while ((delta > 0 || newIndex > 0) &&
+             (delta < 0 || newIndex < this.stops.length - 1) &&
+             opt_condition && !opt_condition(this.stops[newIndex]));
+
+    newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex));
+
+    // If we failed to satisfy the condition:
+    if (opt_condition && !opt_condition(this.stops[newIndex])) {
+      if (delta < 0 || opt_clipToTop) {
+        return 0;
+      } else if (delta > 0) {
+        return this.stops.length - 1;
+      }
+      return this.index;
+    }
+
+    return newIndex;
+  }
+
+  _updateIndex() {
+    if (!this.target) {
+      this.index = -1;
+      return;
+    }
+
+    const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
+    if (newIndex === -1) {
+      this.unsetCursor();
+    } else {
+      this.index = newIndex;
+    }
+  }
+
+  /**
+   * Calculate where the element is relative to the window.
+   *
+   * @param {!Object} target Target to scroll to.
+   * @return {number} Distance to top of the target.
+   */
+  _getTop(target) {
+    let top = target.offsetTop;
+    for (let offsetParent = target.offsetParent;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent) {
+      top += offsetParent.offsetTop;
+    }
+    return top;
+  }
+
+  /**
+   * @return {boolean}
+   */
+  _targetIsVisible(top) {
+    const dims = this._getWindowDims();
+    return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
+        top > (dims.pageYOffset + this.scrollTopMargin) &&
+        top < dims.pageYOffset + dims.innerHeight;
+  }
+
+  _calculateScrollToValue(top, target) {
+    const dims = this._getWindowDims();
+    return top + this.scrollTopMargin - (dims.innerHeight / 3) +
+        (target.offsetHeight / 2);
+  }
+
+  _scrollToTarget() {
+    if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
+      return;
+    }
+
+    const dims = this._getWindowDims();
+    const top = this._getTop(this.target);
+    const bottomIsVisible = this._targetHeight ?
+      this._targetIsVisible(top + this._targetHeight) : true;
+    const scrollToValue = this._calculateScrollToValue(top, this.target);
+
+    if (this._targetIsVisible(top)) {
+      // Don't scroll if either the bottom is visible or if the position that
+      // would get scrolled to is higher up than the current position. this
+      // woulld cause less of the target content to be displayed than is
+      // already.
+      if (bottomIsVisible || scrollToValue < dims.scrollY) {
+        return;
+      }
+    }
+
+    // Scroll the element to the middle of the window. Dividing by a third
+    // instead of half the inner height feels a bit better otherwise the
+    // element appears to be below the center of the window even when it
+    // isn't.
+    window.scrollTo(dims.scrollX, scrollToValue);
+  }
+
+  _getWindowDims() {
+    return {
+      scrollX: window.scrollX,
+      scrollY: window.scrollY,
+      innerHeight: window.innerHeight,
+      pageYOffset: window.pageYOffset,
+    };
+  }
+}
+
+customElements.define(GrCursorManager.is, GrCursorManager);
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
new file mode 100644
index 0000000..3ed33d1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html``;
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..98a7d24 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-cursor-manager.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -40,243 +37,267 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-cursor-manager tests', () => {
-    let sandbox;
-    let element;
-    let list;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-cursor-manager.js';
+suite('gr-cursor-manager tests', () => {
+  let sandbox;
+  let element;
+  let list;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    const fixtureElements = fixture('basic');
+    element = fixtureElements[0];
+    list = fixtureElements[1];
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('core cursor functionality', () => {
+    // The element is initialized into the proper state.
+    assert.isArray(element.stops);
+    assert.equal(element.stops.length, 0);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+
+    // Initialize the cursor with its stops.
+    element.stops = list.querySelectorAll('li');
+
+    // It should have the stops but it should not be targeting any of them.
+    assert.isNotNull(element.stops);
+    assert.equal(element.stops.length, 4);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+
+    // Select the third stop.
+    element.setCursor(list.children[2]);
+
+    // It should update its internal state and update the element's class.
+    assert.equal(element.index, 2);
+    assert.equal(element.target, list.children[2]);
+    assert.isTrue(list.children[2].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+
+    // Progress the cursor.
+    element.next();
+
+    // Confirm that the next stop is selected and that the previous stop is
+    // unselected.
+    assert.equal(element.index, 3);
+    assert.equal(element.target, list.children[3]);
+    assert.isTrue(element.isAtEnd());
+    assert.isFalse(list.children[2].classList.contains('targeted'));
+    assert.isTrue(list.children[3].classList.contains('targeted'));
+
+    // Progress the cursor.
+    element.next();
+
+    // We should still be at the end.
+    assert.equal(element.index, 3);
+    assert.equal(element.target, list.children[3]);
+    assert.isTrue(element.isAtEnd());
+
+    // Wind the cursor all the way back to the first stop.
+    element.previous();
+    element.previous();
+    element.previous();
+
+    // The element state should reflect the end of the list.
+    assert.equal(element.index, 0);
+    assert.equal(element.target, list.children[0]);
+    assert.isTrue(element.isAtStart());
+    assert.isTrue(list.children[0].classList.contains('targeted'));
+
+    const newLi = document.createElement('li');
+    newLi.textContent = 'Z';
+    list.insertBefore(newLi, list.children[0]);
+    element.stops = list.querySelectorAll('li');
+
+    assert.equal(element.index, 1);
+
+    // De-select all targets.
+    element.unsetCursor();
+
+    // There should now be no cursor target.
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isNotOk(element.target);
+    assert.equal(element.index, -1);
+  });
+
+  test('next() goes to first element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    element.next();
+
+    assert.equal(element.index, 0);
+    assert.equal(element.target, list.children[0]);
+    assert.isTrue(list.children[0].classList.contains('targeted'));
+    assert.isTrue(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
+  test('next() goes to first element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    element.previous();
+
+    const lastIndex = list.children.length - 1;
+    assert.equal(element.index, lastIndex);
+    assert.equal(element.target, list.children[lastIndex]);
+    assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isTrue(element.isAtEnd());
+  });
+
+  test('_moveCursor', () => {
+    // Initialize the cursor with its stops.
+    element.stops = list.querySelectorAll('li');
+    // Select the first stop.
+    element.setCursor(list.children[0]);
+    const getTargetHeight = sinon.stub();
+
+    // Move the cursor without an optional get target height function.
+    element._moveCursor(1);
+    assert.isFalse(getTargetHeight.called);
+
+    // Move the cursor with an optional get target height function.
+    element._moveCursor(1, null, getTargetHeight);
+    assert.isTrue(getTargetHeight.called);
+  });
+
+  test('_moveCursor from for invalid index does not check height', () => {
+    element.stops = [];
+    const getTargetHeight = sinon.stub();
+    element._moveCursor(1, () => false, getTargetHeight);
+    assert.isFalse(getTargetHeight.called);
+  });
+
+  test('opt_noScroll', () => {
+    sandbox.stub(element, '_targetIsVisible', () => false);
+    const scrollStub = sandbox.stub(window, 'scrollTo');
+    element.stops = list.querySelectorAll('li');
+    element.scrollBehavior = 'keep-visible';
+
+    element.setCursorAtIndex(1, true);
+    assert.isFalse(scrollStub.called);
+
+    element.setCursorAtIndex(2);
+    assert.isTrue(scrollStub.called);
+  });
+
+  test('_getNextindex', () => {
+    const isLetterB = function(row) {
+      return row.textContent === 'B';
+    };
+    element.stops = list.querySelectorAll('li');
+    // Start cursor at the first stop.
+    element.setCursor(list.children[0]);
+
+    // Move forward to meet the next condition.
+    assert.equal(element._getNextindex(1, isLetterB), 1);
+    element.index = 1;
+
+    // Nothing else meets the condition, should be at last stop.
+    assert.equal(element._getNextindex(1, isLetterB), 3);
+    element.index = 3;
+
+    // Should stay at last stop if try to proceed.
+    assert.equal(element._getNextindex(1, isLetterB), 3);
+
+    // Go back to the previous condition met. Should be back at.
+    // stop 1.
+    assert.equal(element._getNextindex(-1, isLetterB), 1);
+    element.index = 1;
+
+    // Go back. No more meet the condition. Should be at stop 0.
+    assert.equal(element._getNextindex(-1, isLetterB), 0);
+  });
+
+  test('focusOnMove prop', () => {
+    const listEls = list.querySelectorAll('li');
+    for (let i = 0; i < listEls.length; i++) {
+      sandbox.spy(listEls[i], 'focus');
+    }
+    element.stops = listEls;
+    element.setCursor(list.children[0]);
+
+    element.focusOnMove = false;
+    element.next();
+    assert.isFalse(element.target.focus.called);
+
+    element.focusOnMove = true;
+    element.next();
+    assert.isTrue(element.target.focus.called);
+  });
+
+  suite('_scrollToTarget', () => {
+    let scrollStub;
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      const fixtureElements = fixture('basic');
-      element = fixtureElements[0];
-      list = fixtureElements[1];
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('core cursor functionality', () => {
-      // The element is initialized into the proper state.
-      assert.isArray(element.stops);
-      assert.equal(element.stops.length, 0);
-      assert.equal(element.index, -1);
-      assert.isNotOk(element.target);
-
-      // Initialize the cursor with its stops.
-      element.stops = list.querySelectorAll('li');
-
-      // It should have the stops but it should not be targeting any of them.
-      assert.isNotNull(element.stops);
-      assert.equal(element.stops.length, 4);
-      assert.equal(element.index, -1);
-      assert.isNotOk(element.target);
-
-      // Select the third stop.
-      element.setCursor(list.children[2]);
-
-      // It should update its internal state and update the element's class.
-      assert.equal(element.index, 2);
-      assert.equal(element.target, list.children[2]);
-      assert.isTrue(list.children[2].classList.contains('targeted'));
-      assert.isFalse(element.isAtStart());
-      assert.isFalse(element.isAtEnd());
-
-      // Progress the cursor.
-      element.next();
-
-      // Confirm that the next stop is selected and that the previous stop is
-      // unselected.
-      assert.equal(element.index, 3);
-      assert.equal(element.target, list.children[3]);
-      assert.isTrue(element.isAtEnd());
-      assert.isFalse(list.children[2].classList.contains('targeted'));
-      assert.isTrue(list.children[3].classList.contains('targeted'));
-
-      // Progress the cursor.
-      element.next();
-
-      // We should still be at the end.
-      assert.equal(element.index, 3);
-      assert.equal(element.target, list.children[3]);
-      assert.isTrue(element.isAtEnd());
-
-      // Wind the cursor all the way back to the first stop.
-      element.previous();
-      element.previous();
-      element.previous();
-
-      // The element state should reflect the end of the list.
-      assert.equal(element.index, 0);
-      assert.equal(element.target, list.children[0]);
-      assert.isTrue(element.isAtStart());
-      assert.isTrue(list.children[0].classList.contains('targeted'));
-
-      const newLi = document.createElement('li');
-      newLi.textContent = 'Z';
-      list.insertBefore(newLi, list.children[0]);
-      element.stops = list.querySelectorAll('li');
-
-      assert.equal(element.index, 1);
-
-      // De-select all targets.
-      element.unsetCursor();
-
-      // There should now be no cursor target.
-      assert.isFalse(list.children[1].classList.contains('targeted'));
-      assert.isNotOk(element.target);
-      assert.equal(element.index, -1);
-    });
-
-
-    test('_moveCursor', () => {
-      // Initialize the cursor with its stops.
-      element.stops = list.querySelectorAll('li');
-      // Select the first stop.
-      element.setCursor(list.children[0]);
-      const getTargetHeight = sinon.stub();
-
-      // Move the cursor without an optional get target height function.
-      element._moveCursor(1);
-      assert.isFalse(getTargetHeight.called);
-
-      // Move the cursor with an optional get target height function.
-      element._moveCursor(1, null, getTargetHeight);
-      assert.isTrue(getTargetHeight.called);
-    });
-
-    test('_moveCursor from -1 does not check height', () => {
-      element.stops = list.querySelectorAll('li');
-      const getTargetHeight = sinon.stub();
-      element._moveCursor(1, () => false, getTargetHeight);
-      assert.isFalse(getTargetHeight.called);
-    });
-
-    test('opt_noScroll', () => {
-      sandbox.stub(element, '_targetIsVisible', () => false);
-      const scrollStub = sandbox.stub(window, 'scrollTo');
       element.stops = list.querySelectorAll('li');
       element.scrollBehavior = 'keep-visible';
 
-      element.setCursorAtIndex(1, true);
-      assert.isFalse(scrollStub.called);
+      // There is a target which has a targetNext
+      element.setCursor(list.children[0]);
+      element._moveCursor(1);
+      scrollStub = sandbox.stub(window, 'scrollTo');
+      window.innerHeight = 60;
+    });
 
-      element.setCursorAtIndex(2);
+    test('Called when top and bottom not visible', () => {
+      sandbox.stub(element, '_targetIsVisible').returns(false);
+      element._scrollToTarget();
       assert.isTrue(scrollStub.called);
     });
 
-    test('_getNextindex', () => {
-      const isLetterB = function(row) {
-        return row.textContent === 'B';
-      };
-      element.stops = list.querySelectorAll('li');
-      // Start cursor at the first stop.
-      element.setCursor(list.children[0]);
-
-      // Move forward to meet the next condition.
-      assert.equal(element._getNextindex(1, isLetterB), 1);
-      element.index = 1;
-
-      // Nothing else meets the condition, should be at last stop.
-      assert.equal(element._getNextindex(1, isLetterB), 3);
-      element.index = 3;
-
-      // Should stay at last stop if try to proceed.
-      assert.equal(element._getNextindex(1, isLetterB), 3);
-
-      // Go back to the previous condition met. Should be back at.
-      // stop 1.
-      assert.equal(element._getNextindex(-1, isLetterB), 1);
-      element.index = 1;
-
-      // Go back. No more meet the condition. Should be at stop 0.
-      assert.equal(element._getNextindex(-1, isLetterB), 0);
+    test('Not called when top and bottom visible', () => {
+      sandbox.stub(element, '_targetIsVisible').returns(true);
+      element._scrollToTarget();
+      assert.isFalse(scrollStub.called);
     });
 
-    test('focusOnMove prop', () => {
-      const listEls = list.querySelectorAll('li');
-      for (let i = 0; i < listEls.length; i++) {
-        sandbox.spy(listEls[i], 'focus');
-      }
-      element.stops = listEls;
-      element.setCursor(list.children[0]);
-
-      element.focusOnMove = false;
-      element.next();
-      assert.isFalse(element.target.focus.called);
-
-      element.focusOnMove = true;
-      element.next();
-      assert.isTrue(element.target.focus.called);
+    test('Called when top is visible, bottom is not, scroll is lower', () => {
+      const visibleStub = sandbox.stub(element, '_targetIsVisible',
+          () => visibleStub.callCount === 2);
+      sandbox.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 15,
+        innerHeight: 1000,
+        pageYOffset: 0,
+      });
+      sandbox.stub(element, '_calculateScrollToValue').returns(20);
+      element._scrollToTarget();
+      assert.isTrue(scrollStub.called);
+      assert.isTrue(scrollStub.calledWithExactly(123, 20));
+      assert.equal(visibleStub.callCount, 2);
     });
 
-    suite('_scrollToTarget', () => {
-      let scrollStub;
-      setup(() => {
-        element.stops = list.querySelectorAll('li');
-        element.scrollBehavior = 'keep-visible';
-
-        // There is a target which has a targetNext
-        element.setCursor(list.children[0]);
-        element._moveCursor(1);
-        scrollStub = sandbox.stub(window, 'scrollTo');
-        window.innerHeight = 60;
+    test('Called when top is visible, bottom not, scroll is higher', () => {
+      const visibleStub = sandbox.stub(element, '_targetIsVisible',
+          () => visibleStub.callCount === 2);
+      sandbox.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 1000,
+        pageYOffset: 0,
       });
+      sandbox.stub(element, '_calculateScrollToValue').returns(20);
+      element._scrollToTarget();
+      assert.isFalse(scrollStub.called);
+      assert.equal(visibleStub.callCount, 2);
+    });
 
-      test('Called when top and bottom not visible', () => {
-        sandbox.stub(element, '_targetIsVisible').returns(false);
-        element._scrollToTarget();
-        assert.isTrue(scrollStub.called);
+    test('_calculateScrollToValue', () => {
+      sandbox.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 300,
+        pageYOffset: 0,
       });
-
-      test('Not called when top and bottom visible', () => {
-        sandbox.stub(element, '_targetIsVisible').returns(true);
-        element._scrollToTarget();
-        assert.isFalse(scrollStub.called);
-      });
-
-      test('Called when top is visible, bottom is not, scroll is lower', () => {
-        const visibleStub = sandbox.stub(element, '_targetIsVisible',
-            () => visibleStub.callCount === 2);
-        sandbox.stub(element, '_getWindowDims').returns({
-          scrollX: 123,
-          scrollY: 15,
-          innerHeight: 1000,
-          pageYOffset: 0,
-        });
-        sandbox.stub(element, '_calculateScrollToValue').returns(20);
-        element._scrollToTarget();
-        assert.isTrue(scrollStub.called);
-        assert.isTrue(scrollStub.calledWithExactly(123, 20));
-        assert.equal(visibleStub.callCount, 2);
-      });
-
-      test('Called when top is visible, bottom not, scroll is higher', () => {
-        const visibleStub = sandbox.stub(element, '_targetIsVisible',
-            () => visibleStub.callCount === 2);
-        sandbox.stub(element, '_getWindowDims').returns({
-          scrollX: 123,
-          scrollY: 25,
-          innerHeight: 1000,
-          pageYOffset: 0,
-        });
-        sandbox.stub(element, '_calculateScrollToValue').returns(20);
-        element._scrollToTarget();
-        assert.isFalse(scrollStub.called);
-        assert.equal(visibleStub.callCount, 2);
-      });
-
-      test('_calculateScrollToValue', () => {
-        sandbox.stub(element, '_getWindowDims').returns({
-          scrollX: 123,
-          scrollY: 25,
-          innerHeight: 300,
-          pageYOffset: 0,
-        });
-        assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
-            905);
-      });
+      assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
+          905);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
deleted file mode 100644
index ae5a945..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
+++ /dev/null
@@ -1,39 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<script src="../../../scripts/util.js"></script>
-
-<dom-module id="gr-date-formatter">
-  <template>
-    <style include="shared-styles">
-      :host {
-        color: inherit;
-        display: inline;
-      }
-    </style>
-    <span>
-      [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative, showDateAndTime)]]
-    </span>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-date-formatter.js"></script>
-</dom-module>
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 545a7c3..6f19587 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,48 +14,68 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const Duration = {
-    HOUR: 1000 * 60 * 60,
-    DAY: 1000 * 60 * 60 * 24,
-  };
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-date-formatter_html.js';
+import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {util} from '../../../scripts/util.js';
+import moment from 'moment/src/moment.js';
 
-  const TimeFormats = {
-    TIME_12: 'h:mm A', // 2:14 PM
-    TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
-    TIME_24: 'HH:mm', // 14:14
-    TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
-  };
+const Duration = {
+  HOUR: 1000 * 60 * 60,
+  DAY: 1000 * 60 * 60 * 24,
+};
 
-  const DateFormats = {
-    STD: {
-      short: 'MMM DD', // Aug 29
-      full: 'MMM DD, YYYY', // Aug 29, 1997
-    },
-    US: {
-      short: 'MM/DD', // 08/29
-      full: 'MM/DD/YY', // 08/29/97
-    },
-    ISO: {
-      short: 'MM-DD', // 08-29
-      full: 'YYYY-MM-DD', // 1997-08-29
-    },
-    EURO: {
-      short: 'DD. MMM', // 29. Aug
-      full: 'DD.MM.YYYY', // 29.08.1997
-    },
-    UK: {
-      short: 'DD/MM', // 29/08
-      full: 'DD/MM/YYYY', // 29/08/1997
-    },
-  };
+const TimeFormats = {
+  TIME_12: 'h:mm A', // 2:14 PM
+  TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
+  TIME_24: 'HH:mm', // 14:14
+  TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
+};
 
-  Polymer({
-    is: 'gr-date-formatter',
+const DateFormats = {
+  STD: {
+    short: 'MMM DD', // Aug 29
+    full: 'MMM DD, YYYY', // Aug 29, 1997
+  },
+  US: {
+    short: 'MM/DD', // 08/29
+    full: 'MM/DD/YY', // 08/29/97
+  },
+  ISO: {
+    short: 'MM-DD', // 08-29
+    full: 'YYYY-MM-DD', // 1997-08-29
+  },
+  EURO: {
+    short: 'DD. MMM', // 29. Aug
+    full: 'DD.MM.YYYY', // 29.08.1997
+  },
+  UK: {
+    short: 'DD/MM', // 29/08
+    full: 'DD/MM/YYYY', // 29/08/1997
+  },
+};
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrDateFormatter extends mixinBehaviors( [
+  TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-date-formatter'; }
+
+  static get properties() {
+    return {
       dateStr: {
         type: String,
         value: null,
@@ -85,162 +105,162 @@
       _dateFormat: Object,
       _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();
+  }
 
-    attached() {
-      this._loadPreferences();
-    },
+  _getUtcOffsetString() {
+    return ' UTC' + moment().format('Z');
+  }
 
-    _getUtcOffsetString() {
-      return ' UTC' + moment().format('Z');
-    },
-
-    _loadPreferences() {
-      return this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) {
-          this._timeFormat = TimeFormats.TIME_24;
-          this._dateFormat = DateFormats.STD;
-          this._relative = false;
-          return;
-        }
-        return Promise.all([
-          this._loadTimeFormat(),
-          this._loadRelative(),
-        ]);
-      });
-    },
-
-    _loadTimeFormat() {
-      return this._getPreferences().then(preferences => {
-        const timeFormat = preferences && preferences.time_format;
-        const dateFormat = preferences && preferences.date_format;
-        this._decideTimeFormat(timeFormat);
-        this._decideDateFormat(dateFormat);
-      });
-    },
-
-    _decideTimeFormat(timeFormat) {
-      switch (timeFormat) {
-        case 'HHMM_12':
-          this._timeFormat = TimeFormats.TIME_12;
-          break;
-        case 'HHMM_24':
-          this._timeFormat = TimeFormats.TIME_24;
-          break;
-        default:
-          throw Error('Invalid time format: ' + timeFormat);
+  _loadPreferences() {
+    return this._getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        this._timeFormat = TimeFormats.TIME_24;
+        this._dateFormat = DateFormats.STD;
+        this._relative = false;
+        return;
       }
-    },
+      return Promise.all([
+        this._loadTimeFormat(),
+        this._loadRelative(),
+      ]);
+    });
+  }
 
-    _decideDateFormat(dateFormat) {
-      switch (dateFormat) {
-        case 'STD':
-          this._dateFormat = DateFormats.STD;
-          break;
-        case 'US':
-          this._dateFormat = DateFormats.US;
-          break;
-        case 'ISO':
-          this._dateFormat = DateFormats.ISO;
-          break;
-        case 'EURO':
-          this._dateFormat = DateFormats.EURO;
-          break;
-        case 'UK':
-          this._dateFormat = DateFormats.UK;
-          break;
-        default:
-          throw Error('Invalid date format: ' + dateFormat);
-      }
-    },
+  _loadTimeFormat() {
+    return this._getPreferences().then(preferences => {
+      const timeFormat = preferences && preferences.time_format;
+      const dateFormat = preferences && preferences.date_format;
+      this._decideTimeFormat(timeFormat);
+      this._decideDateFormat(dateFormat);
+    });
+  }
 
-    _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);
-      });
-    },
+  _decideTimeFormat(timeFormat) {
+    switch (timeFormat) {
+      case 'HHMM_12':
+        this._timeFormat = TimeFormats.TIME_12;
+        break;
+      case 'HHMM_24':
+        this._timeFormat = TimeFormats.TIME_24;
+        break;
+      default:
+        throw Error('Invalid time format: ' + timeFormat);
+    }
+  }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
+  _decideDateFormat(dateFormat) {
+    switch (dateFormat) {
+      case 'STD':
+        this._dateFormat = DateFormats.STD;
+        break;
+      case 'US':
+        this._dateFormat = DateFormats.US;
+        break;
+      case 'ISO':
+        this._dateFormat = DateFormats.ISO;
+        break;
+      case 'EURO':
+        this._dateFormat = DateFormats.EURO;
+        break;
+      case 'UK':
+        this._dateFormat = DateFormats.UK;
+        break;
+      default:
+        throw Error('Invalid date format: ' + dateFormat);
+    }
+  }
 
-    _getPreferences() {
-      return this.$.restAPI.getPreferences();
-    },
+  _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);
+    });
+  }
 
-    /**
-     * Return true if date is within 24 hours and on the same day.
-     */
-    _isWithinDay(now, date) {
-      const diff = -date.diff(now);
-      return diff < Duration.DAY && date.day() === now.getDay();
-    },
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
 
-    /**
-     * Returns true if date is from one to six months.
-     */
-    _isWithinHalfYear(now, date) {
-      const diff = -date.diff(now);
-      return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
-          diff < 180 * Duration.DAY;
-    },
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
 
-    _computeDateStr(
-        dateStr, timeFormat, dateFormat, relative, showDateAndTime
-    ) {
-      if (!dateStr || !timeFormat || !dateFormat) { return ''; }
-      const date = moment(util.parseDate(dateStr));
-      if (!date.isValid()) { return ''; }
-      if (relative) {
-        const dateFromNow = date.fromNow();
-        if (dateFromNow === 'a few seconds ago') {
-          return 'just now';
-        } else {
-          return dateFromNow;
-        }
-      }
-      const now = new Date();
-      let format = dateFormat.full;
-      if (this._isWithinDay(now, date)) {
-        format = timeFormat;
+  /**
+   * Return true if date is within 24 hours and on the same day.
+   */
+  _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.
+   */
+  _isWithinHalfYear(now, date) {
+    const diff = -date.diff(now);
+    return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
+      diff < 180 * Duration.DAY;
+  }
+
+  _computeDateStr(
+      dateStr, timeFormat, dateFormat, relative, showDateAndTime
+  ) {
+    if (!dateStr || !timeFormat || !dateFormat) { return ''; }
+    const date = moment(util.parseDate(dateStr));
+    if (!date.isValid()) { return ''; }
+    if (relative) {
+      const dateFromNow = date.fromNow();
+      if (dateFromNow === 'a few seconds ago') {
+        return 'just now';
       } else {
-        if (this._isWithinHalfYear(now, date)) {
-          format = dateFormat.short;
-        }
-        if (this.showDateAndTime) {
-          format = `${format} ${timeFormat}`;
-        }
+        return dateFromNow;
       }
-      return date.format(format);
-    },
-
-    _timeToSecondsFormat(timeFormat) {
-      return timeFormat === TimeFormats.TIME_12 ?
-        TimeFormats.TIME_12_WITH_SEC :
-        TimeFormats.TIME_24_WITH_SEC;
-    },
-
-    _computeFullDateStr(dateStr, timeFormat, dateFormat) {
-      // Polymer 2: check for undefined
-      if ([
-        dateStr,
-        timeFormat,
-        dateFormat,
-      ].some(arg => arg === undefined)) {
-        return undefined;
+    }
+    const now = new Date();
+    let format = dateFormat.full;
+    if (this._isWithinDay(now, date)) {
+      format = timeFormat;
+    } else {
+      if (this._isWithinHalfYear(now, date)) {
+        format = dateFormat.short;
       }
+      if (this.showDateAndTime) {
+        format = `${format} ${timeFormat}`;
+      }
+    }
+    return date.format(format);
+  }
 
-      if (!dateStr) { return ''; }
-      const date = moment(util.parseDate(dateStr));
-      if (!date.isValid()) { return ''; }
-      let format = dateFormat.full + ', ';
-      format += this._timeToSecondsFormat(timeFormat);
-      return date.format(format) + this._getUtcOffsetString();
-    },
-  });
-})();
+  _timeToSecondsFormat(timeFormat) {
+    return timeFormat === TimeFormats.TIME_12 ?
+      TimeFormats.TIME_12_WITH_SEC :
+      TimeFormats.TIME_24_WITH_SEC;
+  }
+
+  _computeFullDateStr(dateStr, timeFormat, dateFormat) {
+    // Polymer 2: check for undefined
+    if ([
+      dateStr,
+      timeFormat,
+      dateFormat,
+    ].some(arg => arg === undefined)) {
+      return undefined;
+    }
+
+    if (!dateStr) { return ''; }
+    const date = moment(util.parseDate(dateStr));
+    if (!date.isValid()) { return ''; }
+    let format = dateFormat.full + ', ';
+    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_html.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
new file mode 100644
index 0000000..2571065
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      color: inherit;
+      display: inline;
+    }
+  </style>
+  <span>
+    [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
+    showDateAndTime)]]
+  </span>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 d51b5d5..7169ef27 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-date-formatter.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,412 +31,418 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-date-formatter tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-date-formatter.js';
+import {util} from '../../../scripts/util.js';
+suite('gr-date-formatter tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  /**
+   * Parse server-formatter date and normalize into current timezone.
+   */
+  function normalizedDate(dateStr) {
+    const d = util.parseDate(dateStr);
+    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+    return d;
+  }
+
+  function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
+      expectedTooltip, done) {
+    // Normalize and convert the date to mimic server response.
+    dateStr = normalizedDate(dateStr)
+        .toJSON()
+        .replace('T', ' ')
+        .slice(0, -1);
+    sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
+    element.dateStr = dateStr;
+    flush(() => {
+      const span = element.shadowRoot
+          .querySelector('span');
+      assert.equal(span.textContent.trim(), expected);
+      assert.equal(element.title, expectedTooltip);
+      element.showDateAndTime = true;
+      flushAsynchronousOperations();
+      assert.equal(span.textContent.trim(), expectedWithDateAndTime);
+      done();
+    });
+  }
+
+  function stubRestAPI(preferences) {
+    const loggedInPromise = Promise.resolve(preferences !== null);
+    const preferencesPromise = Promise.resolve(preferences);
+    stub('gr-rest-api-interface', {
+      getLoggedIn: sinon.stub().returns(loggedInPromise),
+      getPreferences: sinon.stub().returns(preferencesPromise),
+    });
+    return Promise.all([loggedInPromise, preferencesPromise]);
+  }
+
+  suite('STD + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'STD',
+      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());
+      assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('Within 24 hours on same day', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          'Jul 29, 2015, 15:34:14', done);
     });
 
-    /**
-     * Parse server-formatter date and normalize into current timezone.
-     */
-    function normalizedDate(dateStr) {
-      const d = util.parseDate(dateStr);
-      d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
-      return d;
-    }
-
-    function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
-        expectedTooltip, done) {
-      // Normalize and convert the date to mimic server response.
-      dateStr = normalizedDate(dateStr)
-          .toJSON().replace('T', ' ').slice(0, -1);
-      sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
-      element.dateStr = dateStr;
-      flush(() => {
-        const span = element.$$('span');
-        assert.equal(span.textContent.trim(), expected);
-        assert.equal(element.title, expectedTooltip);
-        element.showDateAndTime = true;
-        flushAsynchronousOperations();
-        assert.equal(span.textContent.trim(), expectedWithDateAndTime);
-        done();
-      });
-    }
-
-    function stubRestAPI(preferences) {
-      const loggedInPromise = Promise.resolve(preferences !== null);
-      const preferencesPromise = Promise.resolve(preferences);
-      stub('gr-rest-api-interface', {
-        getLoggedIn: sinon.stub().returns(loggedInPromise),
-        getPreferences: sinon.stub().returns(preferencesPromise),
-      });
-      return Promise.all([loggedInPromise, preferencesPromise]);
-    }
-
-    suite('STD + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'STD',
-        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());
-        assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
-      });
-
-      test('Within 24 hours on same day', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            'Jul 29, 2015, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            'Jul 28',
-            'Jul 28 20:25',
-            'Jul 28, 2015, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            'Jun 15',
-            'Jun 15 03:25',
-            'Jun 15, 2015, 03:25:14', done);
-      });
-
-      test('More than six months', done => {
-        testDates('2015-09-15 20:34:00.000000000',
-            '2015-01-15 03:25:00.000000000',
-            'Jan 15, 2015',
-            'Jan 15, 2015 03:25',
-            'Jan 15, 2015, 03:25:00', done);
-      });
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          'Jul 28',
+          'Jul 28 20:25',
+          'Jul 28, 2015, 20:25:14', done);
     });
 
-    suite('US + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'US',
-        relative_date_in_change_table: false,
-      }).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',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '07/29/15, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '07/28',
-            '07/28 20:25',
-            '07/28/15, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '06/15',
-            '06/15 03:25',
-            '06/15/15, 03:25:14', done);
-      });
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          'Jun 15',
+          'Jun 15 03:25',
+          'Jun 15, 2015, 03:25:14', done);
     });
 
-    suite('ISO + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'ISO',
-        relative_date_in_change_table: false,
-      }).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',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '2015-07-29, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '07-28',
-            '07-28 20:25',
-            '2015-07-28, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '06-15',
-            '06-15 03:25',
-            '2015-06-15, 03:25:14', done);
-      });
-    });
-
-    suite('EURO + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'EURO',
-        relative_date_in_change_table: false,
-      }).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',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '29.07.2015, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '28. Jul',
-            '28. Jul 20:25',
-            '28.07.2015, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '15. Jun',
-            '15. Jun 03:25',
-            '15.06.2015, 03:25:14', done);
-      });
-    });
-
-    suite('UK + 24 hours time format preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_24',
-        date_format: 'UK',
-        relative_date_in_change_table: false,
-      }).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',
-            '2015-07-29 15:34:14.985000000',
-            '15:34',
-            '15:34',
-            '29/07/2015, 15:34:14', done);
-      });
-
-      test('Within 24 hours on different days', done => {
-        testDates('2015-07-29 03:34:14.985000000',
-            '2015-07-28 20:25:14.985000000',
-            '28/07',
-            '28/07 20:25',
-            '28/07/2015, 20:25:14', done);
-      });
-
-      test('More than 24 hours but less than six months', done => {
-        testDates('2015-07-29 20:34:14.985000000',
-            '2015-06-15 03:25:14.985000000',
-            '15/06',
-            '15/06 03:25',
-            '15/06/2015, 03:25:14', done);
-      });
-    });
-
-    suite('STD + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'STD'}
-        ).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',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            'Jul 29, 2015, 3:34:14 PM', done);
-      });
-    });
-
-    suite('US + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'US'}
-        ).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',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '07/29/15, 3:34:14 PM', done);
-      });
-    });
-
-    suite('ISO + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'ISO'}
-        ).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',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '2015-07-29, 3:34:14 PM', done);
-      });
-    });
-
-    suite('EURO + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'EURO'}
-        ).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',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '29.07.2015, 3:34:14 PM', done);
-      });
-    });
-
-    suite('UK + 12 hours time format preference', () => {
-      setup(() =>
-        // relative_date_in_change_table is not set when false.
-        stubRestAPI(
-            {time_format: 'HHMM_12', date_format: 'UK'}
-        ).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',
-            '2015-07-29 15:34:14.985000000',
-            '3:34 PM',
-            '3:34 PM',
-            '29/07/2015, 3:34:14 PM', done);
-      });
-    });
-
-    suite('relative date preference', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_12',
-        date_format: 'STD',
-        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',
-            '2015-07-29 15:34:14.985000000',
-            '5 hours ago',
-            '5 hours ago',
-            'Jul 29, 2015, 3:34:14 PM', done);
-      });
-
-      test('More than six months', done => {
-        testDates('2015-09-15 20:34:00.000000000',
-            '2015-01-15 03:25:00.000000000',
-            '8 months ago',
-            '8 months ago',
-            'Jan 15, 2015, 3:25:00 AM', done);
-      });
-    });
-
-    suite('logged in', () => {
-      setup(() => stubRestAPI({
-        time_format: 'HHMM_12',
-        date_format: 'US',
-        relative_date_in_change_table: true,
-      }).then(() => {
-        element = fixture('basic');
-        return element._loadPreferences();
-      }));
-
-      test('Preferences are respected', () => {
-        assert.equal(element._timeFormat, 'h:mm A');
-        assert.equal(element._dateFormat.short, 'MM/DD');
-        assert.equal(element._dateFormat.full, 'MM/DD/YY');
-        assert.isTrue(element._relative);
-      });
-    });
-
-    suite('logged out', () => {
-      setup(() => stubRestAPI(null).then(() => {
-        element = fixture('basic');
-        return element._loadPreferences();
-      }));
-
-      test('Default preferences are respected', () => {
-        assert.equal(element._timeFormat, 'HH:mm');
-        assert.equal(element._dateFormat.short, 'MMM DD');
-        assert.equal(element._dateFormat.full, 'MMM DD, YYYY');
-        assert.isFalse(element._relative);
-      });
+    test('More than six months', done => {
+      testDates('2015-09-15 20:34:00.000000000',
+          '2015-01-15 03:25:00.000000000',
+          'Jan 15, 2015',
+          'Jan 15, 2015 03:25',
+          'Jan 15, 2015, 03:25:00', done);
     });
   });
+
+  suite('US + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'US',
+      relative_date_in_change_table: false,
+    }).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',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '07/29/15, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '07/28',
+          '07/28 20:25',
+          '07/28/15, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '06/15',
+          '06/15 03:25',
+          '06/15/15, 03:25:14', done);
+    });
+  });
+
+  suite('ISO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'ISO',
+      relative_date_in_change_table: false,
+    }).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',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '2015-07-29, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '07-28',
+          '07-28 20:25',
+          '2015-07-28, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '06-15',
+          '06-15 03:25',
+          '2015-06-15, 03:25:14', done);
+    });
+  });
+
+  suite('EURO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'EURO',
+      relative_date_in_change_table: false,
+    }).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',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '29.07.2015, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '28. Jul',
+          '28. Jul 20:25',
+          '28.07.2015, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '15. Jun',
+          '15. Jun 03:25',
+          '15.06.2015, 03:25:14', done);
+    });
+  });
+
+  suite('UK + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'UK',
+      relative_date_in_change_table: false,
+    }).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',
+          '2015-07-29 15:34:14.985000000',
+          '15:34',
+          '15:34',
+          '29/07/2015, 15:34:14', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      testDates('2015-07-29 03:34:14.985000000',
+          '2015-07-28 20:25:14.985000000',
+          '28/07',
+          '28/07 20:25',
+          '28/07/2015, 20:25:14', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      testDates('2015-07-29 20:34:14.985000000',
+          '2015-06-15 03:25:14.985000000',
+          '15/06',
+          '15/06 03:25',
+          '15/06/2015, 03:25:14', done);
+    });
+  });
+
+  suite('STD + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'STD'}
+      ).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',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          'Jul 29, 2015, 3:34:14 PM', done);
+    });
+  });
+
+  suite('US + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'US'}
+      ).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',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '07/29/15, 3:34:14 PM', done);
+    });
+  });
+
+  suite('ISO + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'ISO'}
+      ).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',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '2015-07-29, 3:34:14 PM', done);
+    });
+  });
+
+  suite('EURO + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'EURO'}
+      ).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',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '29.07.2015, 3:34:14 PM', done);
+    });
+  });
+
+  suite('UK + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'UK'}
+      ).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',
+          '2015-07-29 15:34:14.985000000',
+          '3:34 PM',
+          '3:34 PM',
+          '29/07/2015, 3:34:14 PM', done);
+    });
+  });
+
+  suite('relative date preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'STD',
+      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',
+          '2015-07-29 15:34:14.985000000',
+          '5 hours ago',
+          '5 hours ago',
+          'Jul 29, 2015, 3:34:14 PM', done);
+    });
+
+    test('More than six months', done => {
+      testDates('2015-09-15 20:34:00.000000000',
+          '2015-01-15 03:25:00.000000000',
+          '8 months ago',
+          '8 months ago',
+          'Jan 15, 2015, 3:25:00 AM', done);
+    });
+  });
+
+  suite('logged in', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'US',
+      relative_date_in_change_table: true,
+    }).then(() => {
+      element = fixture('basic');
+      return element._loadPreferences();
+    }));
+
+    test('Preferences are respected', () => {
+      assert.equal(element._timeFormat, 'h:mm A');
+      assert.equal(element._dateFormat.short, 'MM/DD');
+      assert.equal(element._dateFormat.full, 'MM/DD/YY');
+      assert.isTrue(element._relative);
+    });
+  });
+
+  suite('logged out', () => {
+    setup(() => stubRestAPI(null).then(() => {
+      element = fixture('basic');
+      return element._loadPreferences();
+    }));
+
+    test('Default preferences are respected', () => {
+      assert.equal(element._timeFormat, 'HH:mm');
+      assert.equal(element._dateFormat.short, 'MMM DD');
+      assert.equal(element._dateFormat.full, 'MMM DD, YYYY');
+      assert.isFalse(element._relative);
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
deleted file mode 100644
index 2ef5539..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
+++ /dev/null
@@ -1,77 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-dialog">
-  <template>
-    <style include="shared-styles">
-      :host {
-        color: var(--primary-text-color);
-        display: block;
-        max-height: 90vh;
-      }
-      .container {
-        display: flex;
-        flex-direction: column;
-        max-height: 90vh;
-      }
-      header {
-        border-bottom: 1px solid var(--border-color);
-        flex-shrink: 0;
-        font-weight: var(--font-weight-bold);
-      }
-      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;
-      }
-      .hidden {
-        display: none;
-      }
-    </style>
-    <div class="container" on-keydown="_handleKeydown">
-      <header><slot name="header"></slot></header>
-      <main><slot name="main"></slot></main>
-      <footer>
-        <gr-button id="cancel" class$="[[_computeCancelClass(cancelLabel)]]" link on-click="_handleCancelTap">
-          [[cancelLabel]]
-        </gr-button>
-        <gr-button id="confirm" link primary on-click="_handleConfirm" disabled="[[disabled]]">
-          [[confirmLabel]]
-        </gr-button>
-      </footer>
-    </div>
-  </template>
-  <script src="gr-dialog.js"></script>
-</dom-module>
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..db64661 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
@@ -14,25 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-dialog',
+import '../gr-button/gr-button.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dialog_html.js';
 
-    /**
-     * Fired when the confirm button is pressed.
-     *
-     * @event confirm
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event cancel
-     */
+  static get is() { return 'gr-dialog'; }
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
 
-    properties: {
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  static get properties() {
+    return {
       confirmLabel: {
         type: String,
         value: 'Confirm',
@@ -50,40 +62,56 @@
         type: Boolean,
         value: false,
       },
-    },
+      confirmTooltip: {
+        type: String,
+        observer: '_handleConfirmTooltipUpdate',
+      },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
 
-    hostAttributes: {
-      role: 'dialog',
-    },
+  _handleConfirmTooltipUpdate(confirmTooltip) {
+    if (confirmTooltip) {
+      this.$.confirm.setAttribute('has-tooltip', true);
+    } else {
+      this.$.confirm.removeAttribute('has-tooltip');
+    }
+  }
 
-    _handleConfirm(e) {
-      if (this.disabled) { return; }
+  _handleConfirm(e) {
+    if (this.disabled) { return; }
 
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    },
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('confirm', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleCancelTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
+  _handleCancelTap(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('cancel', {
+      composed: true, bubbles: false,
+    }));
+  }
 
-    _handleKeydown(e) {
-      if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
-    },
+  _handleKeydown(e) {
+    if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
+  }
 
-    resetFocus() {
-      this.$.confirm.focus();
-    },
+  resetFocus() {
+    this.$.confirm.focus();
+  }
 
-    _computeCancelClass(cancelLabel) {
-      return cancelLabel.length ? '' : 'hidden';
-    },
-  });
-})();
+  _computeCancelClass(cancelLabel) {
+    return cancelLabel.length ? '' : 'hidden';
+  }
+}
+
+customElements.define(GrDialog.is, GrDialog);
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
new file mode 100644
index 0000000..b32f871
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      color: var(--primary-text-color);
+      display: block;
+      max-height: 90vh;
+      overflow: auto;
+    }
+    .container {
+      display: flex;
+      flex-direction: column;
+      max-height: 90vh;
+      padding: var(--spacing-xl);
+    }
+    header {
+      flex-shrink: 0;
+      padding-bottom: var(--spacing-xl);
+    }
+    main {
+      display: flex;
+      flex-shrink: 1;
+      width: 100%;
+      flex: 1;
+      /* IMPORTANT: required for firefox */
+      min-height: 0px;
+    }
+    main .overflow-container {
+      flex: 1;
+      overflow: auto;
+    }
+    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 class="font-h3"><slot name="header"></slot></header>
+    <main>
+      <div class="overflow-container">
+        <slot name="main"></slot>
+      </div>
+    </main>
+    <footer>
+      <slot name="footer"></slot>
+      <gr-button
+        id="cancel"
+        class$="[[_computeCancelClass(cancelLabel)]]"
+        link=""
+        on-click="_handleCancelTap"
+      >
+        [[cancelLabel]]
+      </gr-button>
+      <gr-button
+        id="confirm"
+        link=""
+        primary=""
+        on-click="_handleConfirm"
+        disabled="[[disabled]]"
+        title$="[[confirmTooltip]]"
+      >
+        [[confirmLabel]]
+      </gr-button>
+    </footer>
+  </div>
+`;
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..1060e82 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dialog.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,60 +31,81 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dialog tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-dialog.js';
+import {isHidden} from '../../../test/test-utils.js';
+suite('gr-dialog tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('events', done => {
+    let numEvents = 0;
+    function handler() { if (++numEvents == 2) { done(); } }
+
+    element.addEventListener('confirm', handler);
+    element.addEventListener('cancel', handler);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button[primary]'));
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button:not([primary])'));
+  });
+
+  test('confirmOnEnter', () => {
+    element.confirmOnEnter = false;
+    const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
+    const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
+    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
+        .querySelector('main'),
+    13, null, 'enter');
+    flushAsynchronousOperations();
+
+    assert.isTrue(handleKeydownSpy.called);
+    assert.isFalse(handleConfirmStub.called);
+
+    element.confirmOnEnter = true;
+    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
+        .querySelector('main'),
+    13, null, 'enter');
+    flushAsynchronousOperations();
+
+    assert.isTrue(handleConfirmStub.called);
+  });
+
+  test('resetFocus', () => {
+    const focusStub = sandbox.stub(element.$.confirm, 'focus');
+    element.resetFocus();
+    assert.isTrue(focusStub.calledOnce);
+  });
+
+  suite('tooltip', () => {
+    test('tooltip not added by default', () => {
+      assert.isNull(element.$.confirm.getAttribute('has-tooltip'));
     });
 
-    teardown(() => { sandbox.restore(); });
-
-    test('events', done => {
-      let numEvents = 0;
-      function handler() { if (++numEvents == 2) { done(); } }
-
-      element.addEventListener('confirm', handler);
-      element.addEventListener('cancel', handler);
-
-      MockInteractions.tap(element.$$('gr-button[primary]'));
-      MockInteractions.tap(element.$$('gr-button:not([primary])'));
-    });
-
-    test('confirmOnEnter', () => {
-      element.confirmOnEnter = false;
-      const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
-      const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
-      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
-          13, null, 'enter');
-      flushAsynchronousOperations();
-
-      assert.isTrue(handleKeydownSpy.called);
-      assert.isFalse(handleConfirmStub.called);
-
-      element.confirmOnEnter = true;
-      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
-          13, null, 'enter');
-      flushAsynchronousOperations();
-
-      assert.isTrue(handleConfirmStub.called);
-    });
-
-    test('resetFocus', () => {
-      const focusStub = sandbox.stub(element.$.confirm, 'focus');
-      element.resetFocus();
-      assert.isTrue(focusStub.calledOnce);
-    });
-
-    test('empty cancel label hides cancel btn', () => {
-      assert.isFalse(isHidden(element.$.cancel));
-      element.cancelLabel = '';
-      flushAsynchronousOperations();
-
-      assert.isTrue(isHidden(element.$.cancel));
+    test('tooltip added if confirm tooltip is passed', done => {
+      element.confirmTooltip = 'confirm tooltip';
+      flush(() => {
+        assert(element.$.confirm.getAttribute('has-tooltip'));
+        done();
+      });
     });
   });
+
+  test('empty cancel label hides cancel btn', () => {
+    assert.isFalse(isHidden(element.$.cancel));
+    element.cancelLabel = '';
+    flushAsynchronousOperations();
+
+    assert.isTrue(isHidden(element.$.cancel));
+  });
+});
 </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
deleted file mode 100644
index 9d85d44..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
+++ /dev/null
@@ -1,187 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-select/gr-select.html">
-
-<dom-module id="gr-diff-preferences">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles"></style>
-    <div id="diffPreferences" class="gr-form-styles">
-      <section>
-        <span class="title">Context</span>
-        <span class="value">
-          <gr-select
-              id="contextSelect"
-              bind-value="{{diffPrefs.context}}">
-            <select
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
-              <option value="3">3 lines</option>
-              <option value="10">10 lines</option>
-              <option value="25">25 lines</option>
-              <option value="50">50 lines</option>
-              <option value="75">75 lines</option>
-              <option value="100">100 lines</option>
-              <option value="-1">Whole file</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-      <section>
-        <span class="title">Fit to screen</span>
-        <span class="value">
-          <input
-              id="lineWrappingInput"
-              type="checkbox"
-              checked$="[[diffPrefs.line_wrapping]]"
-              on-change="_handleLineWrappingTap">
-        </span>
-      </section>
-      <section>
-        <span class="title">Diff width</span>
-        <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{diffPrefs.line_length}}"
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                id="columnsInput"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{diffPrefs.line_length}}"
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Tab width</span>
-        <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{diffPrefs.tab_size}}"
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                id="tabSizeInput"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{diffPrefs.tab_size}}"
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
-          </iron-input>
-        </span>
-      </section>
-      <section hidden$="[[!diffPrefs.font_size]]">
-        <span class="title">Font size</span>
-        <span class="value">
-          <iron-input
-              type="number"
-              prevent-invalid-input
-              allowed-pattern="[0-9]"
-              bind-value="{{diffPrefs.font_size}}"
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged">
-            <input
-                is="iron-input"
-                type="number"
-                id="fontSizeInput"
-                prevent-invalid-input
-                allowed-pattern="[0-9]"
-                bind-value="{{diffPrefs.font_size}}"
-                on-keypress="_handleDiffPrefsChanged"
-                on-change="_handleDiffPrefsChanged">
-          </iron-input>
-        </span>
-      </section>
-      <section>
-        <span class="title">Show tabs</span>
-        <span class="value">
-          <input
-              id="showTabsInput"
-              type="checkbox"
-              checked$="[[diffPrefs.show_tabs]]"
-              on-change="_handleShowTabsTap">
-        </span>
-      </section>
-      <section>
-        <span class="title">Show trailing whitespace</span>
-        <span class="value">
-          <input
-              id="showTrailingWhitespaceInput"
-              type="checkbox"
-              checked$="[[diffPrefs.show_whitespace_errors]]"
-              on-change="_handleShowTrailingWhitespaceTap">
-        </span>
-      </section>
-      <section>
-        <span class="title">Syntax highlighting</span>
-        <span class="value">
-          <input
-              id="syntaxHighlightInput"
-              type="checkbox"
-              checked$="[[diffPrefs.syntax_highlighting]]"
-              on-change="_handleSyntaxHighlightTap">
-        </span>
-      </section>
-      <section>
-        <span class="title">Automatically mark viewed files reviewed</span>
-        <span class="value">
-          <input
-              id="automaticReviewInput"
-              type="checkbox"
-              checked$="[[!diffPrefs.manual_review]]"
-              on-change="_handleAutomaticReviewTap">
-        </span>
-      </section>
-      <section>
-        <div class="pref">
-          <span class="title">Ignore Whitespace</span>
-          <span class="value">
-            <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
-              <select
-                  on-keypress="_handleDiffPrefsChanged"
-                  on-change="_handleDiffPrefsChanged">
-                <option value="IGNORE_NONE">None</option>
-                <option value="IGNORE_TRAILING">Trailing</option>
-                <option value="IGNORE_LEADING_AND_TRAILING">Leading & trailing</option>
-                <option value="IGNORE_ALL">All</option>
-              </select>
-            </gr-select>
-          </span>
-        </div>
-      </section>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-diff-preferences.js"></script>
-</dom-module>
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..00f9078 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
@@ -14,13 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-diff-preferences',
+import '@polymer/iron-input/iron-input.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-select/gr-select.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-diff-preferences_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrDiffPreferences extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-diff-preferences'; }
+
+  static get properties() {
+    return {
       hasUnsavedChanges: {
         type: Boolean,
         notify: true,
@@ -29,50 +44,52 @@
 
       /** @type {?} */
       diffPrefs: Object,
-    },
+    };
+  }
 
-    loadData() {
-      return this.$.restAPI.getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      });
-    },
+  loadData() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
 
-    _handleDiffPrefsChanged() {
-      this.hasUnsavedChanges = true;
-    },
+  _handleDiffPrefsChanged() {
+    this.hasUnsavedChanges = true;
+  }
 
-    _handleLineWrappingTap() {
-      this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
-      this._handleDiffPrefsChanged();
-    },
+  _handleLineWrappingTap() {
+    this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
+    this._handleDiffPrefsChanged();
+  }
 
-    _handleShowTabsTap() {
-      this.set('diffPrefs.show_tabs', this.$.showTabsInput.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();
-    },
+  _handleShowTrailingWhitespaceTap() {
+    this.set('diffPrefs.show_whitespace_errors',
+        this.$.showTrailingWhitespaceInput.checked);
+    this._handleDiffPrefsChanged();
+  }
 
-    _handleSyntaxHighlightTap() {
-      this.set('diffPrefs.syntax_highlighting',
-          this.$.syntaxHighlightInput.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();
-    },
+  _handleAutomaticReviewTap() {
+    this.set('diffPrefs.manual_review',
+        !this.$.automaticReviewInput.checked);
+    this._handleDiffPrefsChanged();
+  }
 
-    save() {
-      return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(res => {
-        this.hasUnsavedChanges = false;
-      });
-    },
-  });
-})();
+  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_html.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
new file mode 100644
index 0000000..3ea40d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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>
+      <span class="value">
+        <gr-select id="contextSelect" bind-value="{{diffPrefs.context}}">
+          <select
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          >
+            <option value="3">3 lines</option>
+            <option value="10">10 lines</option>
+            <option value="25">25 lines</option>
+            <option value="50">50 lines</option>
+            <option value="75">75 lines</option>
+            <option value="100">100 lines</option>
+            <option value="-1">Whole file</option>
+          </select>
+        </gr-select>
+      </span>
+    </section>
+    <section>
+      <span class="title">Fit to screen</span>
+      <span class="value">
+        <input
+          id="lineWrappingInput"
+          type="checkbox"
+          checked$="[[diffPrefs.line_wrapping]]"
+          on-change="_handleLineWrappingTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Diff width</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{diffPrefs.line_length}}"
+          on-keypress="_handleDiffPrefsChanged"
+          on-change="_handleDiffPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            id="columnsInput"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{diffPrefs.line_length}}"
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Tab width</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{diffPrefs.tab_size}}"
+          on-keypress="_handleDiffPrefsChanged"
+          on-change="_handleDiffPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            id="tabSizeInput"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{diffPrefs.tab_size}}"
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section hidden$="[[!diffPrefs.font_size]]">
+      <span class="title">Font size</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{diffPrefs.font_size}}"
+          on-keypress="_handleDiffPrefsChanged"
+          on-change="_handleDiffPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            id="fontSizeInput"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{diffPrefs.font_size}}"
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Show tabs</span>
+      <span class="value">
+        <input
+          id="showTabsInput"
+          type="checkbox"
+          checked$="[[diffPrefs.show_tabs]]"
+          on-change="_handleShowTabsTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Show trailing whitespace</span>
+      <span class="value">
+        <input
+          id="showTrailingWhitespaceInput"
+          type="checkbox"
+          checked$="[[diffPrefs.show_whitespace_errors]]"
+          on-change="_handleShowTrailingWhitespaceTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Syntax highlighting</span>
+      <span class="value">
+        <input
+          id="syntaxHighlightInput"
+          type="checkbox"
+          checked$="[[diffPrefs.syntax_highlighting]]"
+          on-change="_handleSyntaxHighlightTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Automatically mark viewed files reviewed</span>
+      <span class="value">
+        <input
+          id="automaticReviewInput"
+          type="checkbox"
+          checked$="[[!diffPrefs.manual_review]]"
+          on-change="_handleAutomaticReviewTap"
+        />
+      </span>
+    </section>
+    <section>
+      <div class="pref">
+        <span class="title">Ignore Whitespace</span>
+        <span class="value">
+          <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
+            <select
+              on-keypress="_handleDiffPrefsChanged"
+              on-change="_handleDiffPrefsChanged"
+            >
+              <option value="IGNORE_NONE">None</option>
+              <option value="IGNORE_TRAILING">Trailing</option>
+              <option value="IGNORE_LEADING_AND_TRAILING"
+                >Leading &amp; trailing</option
+              >
+              <option value="IGNORE_ALL">All</option>
+            </select>
+          </gr-select>
+        </span>
+      </div>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..2750d67 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-diff-preferences.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,92 +31,94 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-diff-preferences tests', () => {
-    let element;
-    let sandbox;
-    let diffPreferences;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-diff-preferences.js';
+suite('gr-diff-preferences tests', () => {
+  let element;
+  let sandbox;
+  let diffPreferences;
 
-    function valueOf(title, fieldsetid) {
-      const sections = element.$[fieldsetid].querySelectorAll('section');
-      let titleEl;
-      for (let i = 0; i < sections.length; i++) {
-        titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent.trim() === title) {
-          return sections[i].querySelector('.value');
-        }
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
       }
     }
+  }
 
-    setup(() => {
-      diffPreferences = {
-        context: 10,
-        line_wrapping: false,
-        line_length: 100,
-        tab_size: 8,
-        font_size: 12,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        manual_review: false,
-        ignore_whitespace: 'IGNORE_NONE',
-      };
+  setup(() => {
+    diffPreferences = {
+      context: 10,
+      line_wrapping: false,
+      line_length: 100,
+      tab_size: 8,
+      font_size: 12,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      manual_review: false,
+      ignore_whitespace: 'IGNORE_NONE',
+    };
 
-      stub('gr-rest-api-interface', {
-        getDiffPreferences() {
-          return Promise.resolve(diffPreferences);
-        },
-      });
-
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      return element.loadData();
+    stub('gr-rest-api-interface', {
+      getDiffPreferences() {
+        return Promise.resolve(diffPreferences);
+      },
     });
 
-    teardown(() => { sandbox.restore(); });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    return element.loadData();
+  });
 
-    test('renders', () => {
-      // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Context', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.context);
-      assert.equal(valueOf('Fit to screen', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.line_wrapping);
-      assert.equal(valueOf('Diff width', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.line_length);
-      assert.equal(valueOf('Tab width', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.tab_size);
-      assert.equal(valueOf('Font size', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.font_size);
-      assert.equal(valueOf('Show tabs', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.show_tabs);
-      assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.show_whitespace_errors);
-      assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
-          .firstElementChild.checked, diffPreferences.syntax_highlighting);
-      assert.equal(
-          valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
-              .firstElementChild.checked, !diffPreferences.manual_review);
-      assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
-          .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
+  teardown(() => { sandbox.restore(); });
 
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Context', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.context);
+    assert.equal(valueOf('Fit to screen', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.line_wrapping);
+    assert.equal(valueOf('Diff width', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.line_length);
+    assert.equal(valueOf('Tab width', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.tab_size);
+    assert.equal(valueOf('Font size', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.font_size);
+    assert.equal(valueOf('Show tabs', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.show_tabs);
+    assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.show_whitespace_errors);
+    assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.syntax_highlighting);
+    assert.equal(
+        valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
+            .firstElementChild.checked, !diffPreferences.manual_review);
+    assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sandbox.stub(element.$.restAPI, 'saveDiffPreferences')
+        .returns(Promise.resolve());
+    const showTrailingWhitespaceCheckbox =
+        valueOf('Show trailing whitespace', 'diffPreferences')
+            .firstElementChild;
+    showTrailingWhitespaceCheckbox.checked = false;
+    element._handleShowTrailingWhitespaceTap();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    return element.save().then(() => {
       assert.isFalse(element.hasUnsavedChanges);
     });
-
-    test('save changes', () => {
-      sandbox.stub(element.$.restAPI, 'saveDiffPreferences')
-          .returns(Promise.resolve());
-      const showTrailingWhitespaceCheckbox =
-          valueOf('Show trailing whitespace', 'diffPreferences')
-              .firstElementChild;
-      showTrailingWhitespaceCheckbox.checked = false;
-      element._handleShowTrailingWhitespaceTap();
-
-      assert.isTrue(element.hasUnsavedChanges);
-
-      // Save the change.
-      return element.save().then(() => {
-        assert.isFalse(element.hasUnsavedChanges);
-      });
-    });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
deleted file mode 100644
index 14a65b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
+++ /dev/null
@@ -1,86 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="/bower_components/paper-tabs/paper-tabs.html">
-<link rel="import" href="../../shared/gr-shell-command/gr-shell-command.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-download-commands">
-  <template>
-    <style include="shared-styles">
-      paper-tabs {
-        height: 3rem;
-        margin-bottom: var(--spacing-m);
-        --paper-tabs-selection-bar-color: var(--link-color);
-      }
-      paper-tab {
-        max-width: 15rem;
-        text-transform: uppercase;
-        --paper-tab-ink: var(--link-color);
-      }
-      label,
-      input {
-        display: block;
-      }
-      label {
-        font-weight: var(--font-weight-bold);
-      }
-      .schemes {
-        display: flex;
-        justify-content: space-between;
-      }
-      .commands {
-        display: flex;
-        flex-direction: column;
-      }
-      gr-shell-command {
-        width: 60em;
-        margin-bottom: var(--spacing-m);
-      }
-      .hidden {
-        display: none;
-      }
-    </style>
-    <div class="schemes">
-      <paper-tabs
-          id="downloadTabs"
-          class$="[[_computeShowTabs(schemes)]]"
-          selected="[[_computeSelected(schemes, selectedScheme)]]"
-          on-selected-changed="_handleTabChange">
-        <template is="dom-repeat" items="[[schemes]]" as="scheme">
-          <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
-        </template>
-      </paper-tabs>
-    </div>
-    <div class="commands" hidden$="[[!schemes.length]]" hidden>
-      <template is="dom-repeat"
-          items="[[commands]]"
-          as="command">
-        <gr-shell-command
-            label=[[command.title]]
-            command=[[command.command]]></gr-shell-command>
-      </template>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-download-commands.js"></script>
-</dom-module>
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..fcc09c4 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
@@ -14,13 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-download-commands',
+import '@polymer/paper-tabs/paper-tabs.js';
+import '../gr-shell-command/gr-shell-command.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-download-commands_html.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrDownloadCommands extends mixinBehaviors( [
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-download-commands'; }
+
+  static get properties() {
+    return {
       commands: Array,
       _loggedIn: {
         type: Boolean,
@@ -32,54 +52,54 @@
         type: String,
         notify: true,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.RESTClientBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+  }
 
-    attached() {
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-    },
+  focusOnCopy() {
+    this.shadowRoot.querySelector('gr-shell-command').focusOnCopy();
+  }
 
-    focusOnCopy() {
-      this.$$('gr-shell-command').focusOnCopy();
-    },
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
 
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
-
-    _loggedInChanged(loggedIn) {
-      if (!loggedIn) { return; }
-      return this.$.restAPI.getPreferences().then(prefs => {
-        if (prefs.download_scheme) {
-          // Note (issue 5180): normalize the download scheme with lower-case.
-          this.selectedScheme = prefs.download_scheme.toLowerCase();
-        }
-      });
-    },
-
-    _handleTabChange(e) {
-      const scheme = this.schemes[e.detail.value];
-      if (scheme && scheme !== this.selectedScheme) {
-        this.set('selectedScheme', scheme);
-        if (this._loggedIn) {
-          this.$.restAPI.savePreferences(
-              {download_scheme: this.selectedScheme});
-        }
+  _loggedInChanged(loggedIn) {
+    if (!loggedIn) { return; }
+    return this.$.restAPI.getPreferences().then(prefs => {
+      if (prefs.download_scheme) {
+        // Note (issue 5180): normalize the download scheme with lower-case.
+        this.selectedScheme = prefs.download_scheme.toLowerCase();
       }
-    },
+    });
+  }
 
-    _computeSelected(schemes, selectedScheme) {
-      return (schemes.findIndex(scheme => scheme === selectedScheme) || 0)
-          + '';
-    },
+  _handleTabChange(e) {
+    const scheme = this.schemes[e.detail.value];
+    if (scheme && scheme !== this.selectedScheme) {
+      this.set('selectedScheme', scheme);
+      if (this._loggedIn) {
+        this.$.restAPI.savePreferences(
+            {download_scheme: this.selectedScheme});
+      }
+    }
+  }
 
-    _computeShowTabs(schemes) {
-      return schemes.length > 1 ? '' : 'hidden';
-    },
-  });
-})();
+  _computeSelected(schemes, selectedScheme) {
+    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_html.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
new file mode 100644
index 0000000..7248e65
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    paper-tabs {
+      height: 3rem;
+      margin-bottom: var(--spacing-m);
+      --paper-tabs-selection-bar-color: var(--link-color);
+    }
+    paper-tab {
+      max-width: 15rem;
+      text-transform: uppercase;
+      --paper-tab-ink: var(--link-color);
+    }
+    label,
+    input {
+      display: block;
+    }
+    label {
+      font-weight: var(--font-weight-bold);
+    }
+    .schemes {
+      display: flex;
+      justify-content: space-between;
+    }
+    .commands {
+      display: flex;
+      flex-direction: column;
+    }
+    gr-shell-command {
+      width: 60em;
+      margin-bottom: var(--spacing-m);
+    }
+    .hidden {
+      display: none;
+    }
+  </style>
+  <div class="schemes">
+    <paper-tabs
+      id="downloadTabs"
+      class$="[[_computeShowTabs(schemes)]]"
+      selected="[[_computeSelected(schemes, selectedScheme)]]"
+      on-selected-changed="_handleTabChange"
+    >
+      <template is="dom-repeat" items="[[schemes]]" as="scheme">
+        <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
+      </template>
+    </paper-tabs>
+  </div>
+  <div class="commands" hidden$="[[!schemes.length]]" hidden="">
+    <template is="dom-repeat" items="[[commands]]" as="command">
+      <gr-shell-command
+        label="[[command.title]]"
+        command="[[command.command]]"
+      ></gr-shell-command>
+    </template>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..237fbe0 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-download-commands.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,115 +31,125 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-download-commands', () => {
-    let element;
-    let sandbox;
-    const SCHEMES = ['http', 'repo', 'ssh'];
-    const COMMANDS = [{
-      title: 'Checkout',
-      command: `git fetch http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git checkout FETCH_HEAD`,
-    }, {
-      title: 'Cherry Pick',
-      command: `git fetch http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
-    }, {
-      title: 'Format Patch',
-      command: `git fetch http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
-    }, {
-      title: 'Pull',
-      command: `git pull http://andybons@localhost:8080/a/test-project
-          refs/changes/05/5/1`,
-    }];
-    const SELECTED_SCHEME = 'http';
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-download-commands.js';
+import {isHidden} from '../../../test/test-utils.js';
+suite('gr-download-commands', () => {
+  let element;
+  let sandbox;
+  const SCHEMES = ['http', 'repo', 'ssh'];
+  const COMMANDS = [{
+    title: 'Checkout',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`,
+  }, {
+    title: 'Cherry Pick',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
+  }, {
+    title: 'Format Patch',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
+  }, {
+    title: 'Pull',
+    command: `git pull http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1`,
+  }];
+  const SELECTED_SCHEME = 'http';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('unauthenticated', () => {
+    setup(done => {
+      element = fixture('basic');
+      element.schemes = SCHEMES;
+      element.commands = COMMANDS;
+      element.selectedScheme = SELECTED_SCHEME;
+      flushAsynchronousOperations();
+      flush(done);
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('focusOnCopy', () => {
+      const focusStub = sandbox.stub(element.shadowRoot
+          .querySelector('gr-shell-command'),
+      'focusOnCopy');
+      element.focusOnCopy();
+      assert.isTrue(focusStub.called);
     });
 
-    suite('unauthenticated', () => {
-      setup(done => {
-        element = fixture('basic');
-        element.schemes = SCHEMES;
-        element.commands = COMMANDS;
-        element.selectedScheme = SELECTED_SCHEME;
-        flushAsynchronousOperations();
-        flush(done);
+    test('element visibility', () => {
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('paper-tabs')));
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('.commands')));
+
+      element.schemes = [];
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('paper-tabs')));
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('.commands')));
+    });
+
+    test('tab selection', done => {
+      assert.equal(element.$.downloadTabs.selected, '0');
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('[data-scheme="ssh"]'));
+      flushAsynchronousOperations();
+      assert.equal(element.selectedScheme, 'ssh');
+      assert.equal(element.$.downloadTabs.selected, '2');
+      done();
+    });
+
+    test('loads scheme from preferences', done => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'repo'});
+        },
       });
-
-      test('focusOnCopy', () => {
-        const focusStub = sandbox.stub(element.$$('gr-shell-command'),
-            'focusOnCopy');
-        element.focusOnCopy();
-        assert.isTrue(focusStub.called);
-      });
-
-      test('element visibility', () => {
-        assert.isFalse(isHidden(element.$$('paper-tabs')));
-        assert.isFalse(isHidden(element.$$('.commands')));
-
-        element.schemes = [];
-        assert.isTrue(isHidden(element.$$('paper-tabs')));
-        assert.isTrue(isHidden(element.$$('.commands')));
-      });
-
-      test('tab selection', done => {
-        assert.equal(element.$.downloadTabs.selected, '0');
-        MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
-        flushAsynchronousOperations();
-        assert.equal(element.selectedScheme, 'ssh');
-        assert.equal(element.$.downloadTabs.selected, '2');
+      element._loggedIn = true;
+      assert.isTrue(element.$.restAPI.getPreferences.called);
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
         done();
       });
+    });
 
-      test('loads scheme from preferences', done => {
-        stub('gr-rest-api-interface', {
-          getPreferences() {
-            return Promise.resolve({download_scheme: 'repo'});
-          },
-        });
-        element._loggedIn = true;
-        assert.isTrue(element.$.restAPI.getPreferences.called);
-        element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-          assert.equal(element.selectedScheme, 'repo');
-          done();
-        });
+    test('normalize scheme from preferences', done => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'REPO'});
+        },
       });
-
-      test('normalize scheme from preferences', done => {
-        stub('gr-rest-api-interface', {
-          getPreferences() {
-            return Promise.resolve({download_scheme: 'REPO'});
-          },
-        });
-        element._loggedIn = true;
-        element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-          assert.equal(element.selectedScheme, 'repo');
-          done();
-        });
-      });
-
-      test('saves scheme to preferences', () => {
-        element._loggedIn = true;
-        const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
-            () => { return Promise.resolve(); });
-
-        flushAsynchronousOperations();
-
-        const repoTab = element.$$('paper-tab[data-scheme="repo"]');
-
-        MockInteractions.tap(repoTab);
-
-        assert.isTrue(savePrefsStub.called);
-        assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
-            repoTab.getAttribute('data-scheme'));
+      element._loggedIn = true;
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
+        done();
       });
     });
+
+    test('saves scheme to preferences', () => {
+      element._loggedIn = true;
+      const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
+          () => Promise.resolve());
+
+      flushAsynchronousOperations();
+
+      const repoTab = element.shadowRoot
+          .querySelector('paper-tab[data-scheme="repo"]');
+
+      MockInteractions.tap(repoTab);
+
+      assert.isTrue(savePrefsStub.called);
+      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
+          repoTab.getAttribute('data-scheme'));
+    });
   });
+});
 </script>
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
deleted file mode 100644
index 98d7bf6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ /dev/null
@@ -1,183 +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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="/bower_components/paper-item/paper-item.html">
-<link rel="import" href="/bower_components/paper-listbox/paper-listbox.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-
-<dom-module id="gr-dropdown-list">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: inline-block;
-      }
-      #triggerText {
-        -moz-user-select: text;
-        -ms-user-select: text;
-        -webkit-user-select: text;
-        user-select: text;
-      }
-      .dropdown-trigger {
-        cursor: pointer;
-        padding: 0;
-      }
-      .dropdown-content {
-        background-color: var(--dropdown-background-color);
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
-        max-height: 70vh;
-        margin-top: var(--spacing-xxl);
-        min-width: 266px;
-        @apply --dropdown-content-style;
-      }
-      paper-listbox {
-        --paper-listbox: {
-          padding: 0;
-        }
-      }
-      paper-item {
-        cursor: pointer;
-        flex-direction: column;
-        font-size: inherit;
-        --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);
-      }
-      paper-item:not(:last-of-type) {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .bottomContent {
-        color: var(--deemphasized-text-color);
-      }
-      .bottomContent,
-      .topContent {
-        display: flex;
-        justify-content: space-between;
-        flex-direction: row;
-        width: 100%;
-      }
-       gr-button {
-        --gr-button: {
-          @apply --trigger-style;
-        }
-      }
-      gr-date-formatter {
-        color: var(--deemphasized-text-color);
-        margin-left: var(--spacing-xxl);
-        white-space: nowrap;
-      }
-      gr-select {
-        display: none;
-      }
-      /* Because the iron dropdown 'area' includes the trigger, and the entire
-       width of the dropdown, we want to treat tapping the area above the
-       dropdown content as if it is tapping whatever content is underneath it.
-       The next two styles allow this to happen. */
-      iron-dropdown {
-        max-width: none;
-        pointer-events: none;
-      }
-      paper-listbox {
-        pointer-events: auto;
-      }
-      @media only screen and (max-width: 50em) {
-        gr-select {
-          display: inline;
-          @apply --gr-select-style;
-        }
-        gr-button,
-        iron-dropdown {
-          display: none;
-        }
-        select {
-          @apply --native-select-style;
-        }
-      }
-    </style>
-    <gr-button
-        disabled="[[disabled]]"
-        down-arrow
-        link
-        id="trigger"
-        class="dropdown-trigger"
-        on-click="_showDropdownTapHandler"
-        slot="dropdown-trigger">
-      <span id="triggerText">[[text]]</span>
-    </gr-button>
-    <iron-dropdown
-        id="dropdown"
-        vertical-align="top"
-        allow-outside-scroll="true"
-        on-click="_handleDropdownClick">
-      <paper-listbox
-          class="dropdown-content"
-          slot="dropdown-content"
-          attr-for-selected="value"
-          selected="{{value}}"
-          on-tap="_handleDropdownTap">
-        <template is="dom-repeat"
-            items="[[items]]"
-            initial-count="[[initialCount]]">
-          <paper-item
-              disabled="[[item.disabled]]"
-              value="[[item.value]]">
-            <div class="topContent">
-              <div>[[item.text]]</div>
-              <template is="dom-if" if="[[item.date]]">
-                  <gr-date-formatter
-                      date-str="[[item.date]]"></gr-date-formatter>
-              </template>
-            </div>
-            <template is="dom-if" if="[[item.bottomText]]">
-              <div class="bottomContent">
-                <div>[[item.bottomText]]</div>
-              </div>
-            </template>
-          </paper-item>
-        </template>
-      </paper-listbox>
-    </iron-dropdown>
-    <gr-select bind-value="{{value}}">
-      <select>
-        <template is="dom-repeat" items="[[items]]">
-          <option
-              disabled$="[[item.disabled]]"
-              value="[[item.value]]">
-            [[_computeMobileText(item)]]
-          </option>
-        </template>
-      </select>
-    </gr-select>
-  </template>
-  <script src="gr-dropdown-list.js"></script>
-</dom-module>
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..6b250de 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
@@ -14,49 +14,65 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '@polymer/paper-item/paper-item.js';
+import '@polymer/paper-listbox/paper-listbox.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import '../gr-date-formatter/gr-date-formatter.js';
+import '../gr-select/gr-select.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dropdown-list_html.js';
+
+/**
+ * fired when the selected value of the dropdown changes
+ *
+ * @event {change}
+ */
+
+const Defs = {};
+
+/**
+ * Requred values are text and value. mobileText and triggerText will
+ * fall back to text if not provided.
+ *
+ * If bottomText is not provided, nothing will display on the second
+ * line.
+ *
+ * If date is not provided, nothing will be displayed in its place.
+ *
+ * @typedef {{
+ *    text: string,
+ *    value: (string|number),
+ *    bottomText: (string|undefined),
+ *    triggerText: (string|undefined),
+ *    mobileText: (string|undefined),
+ *    date: (!Date|undefined),
+ * }}
+ */
+Defs.item;
+
+/** @extends Polymer.Element */
+class GrDropdownList extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-dropdown-list'; }
   /**
-   * fired when the selected value of the dropdown changes
+   * Fired when the selected value changes
    *
-   * @event {change}
+   * @event value-change
+   *
+   * @property {string|number} value
    */
 
-  const Defs = {};
-
-  /**
-   * Requred values are text and value. mobileText and triggerText will
-   * fall back to text if not provided.
-   *
-   * If bottomText is not provided, nothing will display on the second
-   * line.
-   *
-   * If date is not provided, nothing will be displayed in its place.
-   *
-   * @typedef {{
-   *    text: string,
-   *    value: (string|number),
-   *    bottomText: (string|undefined),
-   *    triggerText: (string|undefined),
-   *    mobileText: (string|undefined),
-   *    date: (!Date|undefined),
-   * }}
-   */
-  Defs.item;
-
-  Polymer({
-    is: 'gr-dropdown-list',
-
-    /**
-     * Fired when the selected value changes
-     *
-     * @event value-change
-     *
-     * @property {string|number} value
-     */
-
-    properties: {
+  static get properties() {
+    return {
       initialCount: Number,
       /** @type {!Array<!Defs.item>} */
       items: Object,
@@ -69,62 +85,64 @@
         type: String,
         notify: true,
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_handleValueChange(value, items)',
-    ],
+    ];
+  }
 
-    /**
-     * Handle a click on the iron-dropdown element.
-     *
-     * @param {!Event} e
-     */
-    _handleDropdownClick(e) {
-      // async is needed so that that the click event is fired before the
-      // dropdown closes (This was a bug for touch devices).
-      this.async(() => {
-        this.$.dropdown.close();
-      }, 1);
-    },
+  /**
+   * Handle a click on the iron-dropdown element.
+   *
+   * @param {!Event} e
+   */
+  _handleDropdownClick(e) {
+    // async is needed so that that the click event is fired before the
+    // dropdown closes (This was a bug for touch devices).
+    this.async(() => {
+      this.$.dropdown.close();
+    }, 1);
+  }
 
-    /**
-     * Handle a click on the button to open the dropdown.
-     *
-     * @param {!Event} e
-     */
-    _showDropdownTapHandler(e) {
-      this._open();
-    },
+  /**
+   * Handle a click on the button to open the dropdown.
+   *
+   * @param {!Event} e
+   */
+  _showDropdownTapHandler(e) {
+    this._open();
+  }
 
-    /**
-     * Open the dropdown.
-     */
-    _open() {
-      this.$.dropdown.open();
-    },
+  /**
+   * Open the dropdown.
+   */
+  _open() {
+    this.$.dropdown.open();
+  }
 
-    _computeMobileText(item) {
-      return item.mobileText ? item.mobileText : item.text;
-    },
+  _computeMobileText(item) {
+    return item.mobileText ? item.mobileText : item.text;
+  }
 
-    _handleValueChange(value, items) {
-      // Polymer 2: check for undefined
-      if ([value, items].some(arg => arg === undefined)) {
-        return;
-      }
+  _handleValueChange(value, items) {
+    // Polymer 2: check for undefined
+    if ([value, items].some(arg => arg === undefined)) {
+      return;
+    }
 
-      if (!value) { return; }
-      const selectedObj = items.find(item => {
-        return item.value + '' === value + '';
-      });
-      if (!selectedObj) { return; }
-      this.text = selectedObj.triggerText? selectedObj.triggerText :
-        selectedObj.text;
-      this.dispatchEvent(new CustomEvent('value-change', {
-        detail: {value},
-        bubbles: false,
-      }));
-    },
-  });
-})();
+    if (!value) { return; }
+    const selectedObj = items.find(item => item.value + '' === value + '');
+    if (!selectedObj) { return; }
+    this.text = selectedObj.triggerText? selectedObj.triggerText :
+      selectedObj.text;
+    this.dispatchEvent(new CustomEvent('value-change', {
+      detail: {value},
+      bubbles: false,
+    }));
+  }
+}
+
+customElements.define(GrDropdownList.is, GrDropdownList);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
new file mode 100644
index 0000000..0f80af2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+    }
+    #triggerText {
+      -moz-user-select: text;
+      -ms-user-select: text;
+      -webkit-user-select: text;
+      user-select: text;
+    }
+    .dropdown-trigger {
+      cursor: pointer;
+      padding: 0;
+    }
+    .dropdown-content {
+      background-color: var(--dropdown-background-color);
+      box-shadow: var(--elevation-level-2);
+      max-height: 70vh;
+      margin-top: var(--spacing-xxl);
+      min-width: 266px;
+      @apply --dropdown-content-style;
+    }
+    paper-listbox {
+      --paper-listbox: {
+        padding: 0;
+      }
+    }
+    paper-item {
+      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);
+    }
+    paper-item:not(:last-of-type) {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .bottomContent {
+      color: var(--deemphasized-text-color);
+    }
+    .bottomContent,
+    .topContent {
+      display: flex;
+      justify-content: space-between;
+      flex-direction: row;
+      width: 100%;
+    }
+    gr-button {
+      --gr-button: {
+        @apply --trigger-style;
+      }
+    }
+    gr-date-formatter {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-xxl);
+      white-space: nowrap;
+    }
+    gr-select {
+      display: none;
+    }
+    /* Because the iron dropdown 'area' includes the trigger, and the entire
+       width of the dropdown, we want to treat tapping the area above the
+       dropdown content as if it is tapping whatever content is underneath it.
+       The next two styles allow this to happen. */
+    iron-dropdown {
+      max-width: none;
+      pointer-events: none;
+    }
+    paper-listbox {
+      pointer-events: auto;
+    }
+    @media only screen and (max-width: 50em) {
+      gr-select {
+        display: inline;
+        @apply --gr-select-style;
+      }
+      gr-button,
+      iron-dropdown {
+        display: none;
+      }
+      select {
+        @apply --native-select-style;
+      }
+    }
+  </style>
+  <gr-button
+    disabled="[[disabled]]"
+    down-arrow=""
+    link=""
+    id="trigger"
+    class="dropdown-trigger"
+    on-click="_showDropdownTapHandler"
+    slot="dropdown-trigger"
+  >
+    <span id="triggerText">[[text]]</span>
+  </gr-button>
+  <iron-dropdown
+    id="dropdown"
+    vertical-align="top"
+    allow-outside-scroll="true"
+    on-click="_handleDropdownClick"
+  >
+    <paper-listbox
+      class="dropdown-content"
+      slot="dropdown-content"
+      attr-for-selected="data-value"
+      selected="{{value}}"
+      on-tap="_handleDropdownTap"
+    >
+      <template
+        is="dom-repeat"
+        items="[[items]]"
+        initial-count="[[initialCount]]"
+      >
+        <paper-item disabled="[[item.disabled]]" data-value$="[[item.value]]">
+          <div class="topContent">
+            <div>[[item.text]]</div>
+            <template is="dom-if" if="[[item.date]]">
+              <gr-date-formatter date-str="[[item.date]]"></gr-date-formatter>
+            </template>
+          </div>
+          <template is="dom-if" if="[[item.bottomText]]">
+            <div class="bottomContent">
+              <div>[[item.bottomText]]</div>
+            </div>
+          </template>
+        </paper-item>
+      </template>
+    </paper-listbox>
+  </iron-dropdown>
+  <gr-select bind-value="{{value}}">
+    <select>
+      <template is="dom-repeat" items="[[items]]">
+        <option disabled$="[[item.disabled]]" value="[[item.value]]">
+          [[_computeMobileText(item)]]
+        </option>
+      </template>
+    </select>
+  </gr-select>
+`;
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..b64d5f7 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dropdown-list.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,139 +31,142 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dropdown-list tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-dropdown-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-dropdown-list tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('tap on trigger opens menu', () => {
-      sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isTrue(element.$.dropdown.opened);
-    });
+  test('tap on trigger opens menu', () => {
+    sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
+    assert.isFalse(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isTrue(element.$.dropdown.opened);
+  });
 
-    test('_computeMobileText', () => {
-      const item = {
+  test('_computeMobileText', () => {
+    const item = {
+      value: 1,
+      text: 'text',
+    };
+    assert.equal(element._computeMobileText(item), item.text);
+    item.mobileText = 'mobile text';
+    assert.equal(element._computeMobileText(item), item.mobileText);
+  });
+
+  test('options are selected and laid out correctly', done => {
+    element.value = 2;
+    element.items = [
+      {
         value: 1,
-        text: 'text',
-      };
-      assert.equal(element._computeMobileText(item), item.text);
-      item.mobileText = 'mobile text';
-      assert.equal(element._computeMobileText(item), item.mobileText);
-    });
+        text: 'Top Text 1',
+      },
+      {
+        value: 2,
+        bottomText: 'Bottom Text 2',
+        triggerText: 'Button Text 2',
+        text: 'Top Text 2',
+        mobileText: 'Mobile Text 2',
+      },
+      {
+        value: 3,
+        disabled: true,
+        bottomText: 'Bottom Text 3',
+        triggerText: 'Button Text 3',
+        date: '2017-08-18 23:11:42.569000000',
+        text: 'Top Text 3',
+        mobileText: 'Mobile Text 3',
+      },
+    ];
+    assert.equal(element.shadowRoot
+        .querySelector('paper-listbox').selected, element.value);
+    assert.equal(element.text, 'Button Text 2');
+    flush(() => {
+      const items = dom(element.root).querySelectorAll('paper-item');
+      const mobileItems = dom(element.root).querySelectorAll('option');
+      assert.equal(items.length, 3);
+      assert.equal(mobileItems.length, 3);
 
-    test('options are selected and laid out correctly', done => {
-      element.value = 2;
-      element.items = [
-        {
-          value: 1,
-          text: 'Top Text 1',
-        },
-        {
-          value: 2,
-          bottomText: 'Bottom Text 2',
-          triggerText: 'Button Text 2',
-          text: 'Top Text 2',
-          mobileText: 'Mobile Text 2',
-        },
-        {
-          value: 3,
-          disabled: true,
-          bottomText: 'Bottom Text 3',
-          triggerText: 'Button Text 3',
-          date: '2017-08-18 23:11:42.569000000',
-          text: 'Top Text 3',
-          mobileText: 'Mobile Text 3',
-        },
-      ];
-      assert.equal(element.$$('paper-listbox').selected, element.value);
-      assert.equal(element.text, 'Button Text 2');
-      flush(() => {
-        const items = Polymer.dom(element.root).querySelectorAll('paper-item');
-        const mobileItems = Polymer.dom(element.root).querySelectorAll('option');
-        assert.equal(items.length, 3);
-        assert.equal(mobileItems.length, 3);
+      // First Item
+      // The first item should be disabled, has no bottom text, and no date.
+      assert.isFalse(!!items[0].disabled);
+      assert.isFalse(mobileItems[0].disabled);
+      assert.isFalse(items[0].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[0].selected);
 
-        // First Item
-        // The first item should be disabled, has no bottom text, and no date.
-        assert.isFalse(!!items[0].disabled);
-        assert.isFalse(mobileItems[0].disabled);
-        assert.isFalse(items[0].classList.contains('iron-selected'));
-        assert.isFalse(mobileItems[0].selected);
+      assert.isNotOk(dom(items[0]).querySelector('gr-date-formatter'));
+      assert.isNotOk(dom(items[0]).querySelector('.bottomContent'));
+      assert.equal(items[0].dataset.value, element.items[0].value);
+      assert.equal(mobileItems[0].value, element.items[0].value);
+      assert.equal(dom(items[0]).querySelector('.topContent div')
+          .innerText, element.items[0].text);
 
-        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(mobileItems[0].value, element.items[0].value);
-        assert.equal(Polymer.dom(items[0]).querySelector('.topContent div')
-            .innerText, element.items[0].text);
+      // Since no mobile specific text, it should fall back to text.
+      assert.equal(mobileItems[0].text, element.items[0].text);
 
-        // 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);
+      assert.isFalse(mobileItems[1].disabled);
+      assert.isTrue(items[1].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[1].selected);
 
+      assert.isNotOk(dom(items[1]).querySelector('gr-date-formatter'));
+      assert.isOk(dom(items[1]).querySelector('.bottomContent'));
+      assert.equal(items[1].dataset.value, element.items[1].value);
+      assert.equal(mobileItems[1].value, element.items[1].value);
+      assert.equal(dom(items[1]).querySelector('.topContent div')
+          .innerText, element.items[1].text);
 
-        // Second Item
-        // The second item should have top text, bottom text, and no date.
-        assert.isFalse(!!items[1].disabled);
-        assert.isFalse(mobileItems[1].disabled);
-        assert.isTrue(items[1].classList.contains('iron-selected'));
-        assert.isTrue(mobileItems[1].selected);
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[1].text, element.items[1].mobileText);
 
-        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(mobileItems[1].value, element.items[1].value);
-        assert.equal(Polymer.dom(items[1]).querySelector('.topContent div')
-            .innerText, element.items[1].text);
+      // Since this item is selected, and it has triggerText defined, that
+      // should be used.
+      assert.equal(element.text, element.items[1].triggerText);
 
-        // Since there is mobile specific text, it should that.
-        assert.equal(mobileItems[1].text, element.items[1].mobileText);
+      // Third item
+      // The third item should be disabled, and have a date, and bottom content.
+      assert.isTrue(!!items[2].disabled);
+      assert.isTrue(mobileItems[2].disabled);
+      assert.isFalse(items[2].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[2].selected);
 
-        // Since this item is selected, and it has triggerText defined, that
-        // should be used.
-        assert.equal(element.text, element.items[1].triggerText);
+      assert.isOk(dom(items[2]).querySelector('gr-date-formatter'));
+      assert.isOk(dom(items[2]).querySelector('.bottomContent'));
+      assert.equal(items[2].dataset.value, element.items[2].value);
+      assert.equal(mobileItems[2].value, element.items[2].value);
+      assert.equal(dom(items[2]).querySelector('.topContent div')
+          .innerText, element.items[2].text);
 
-        // Third item
-        // The third item should be disabled, and have a date, and bottom content.
-        assert.isTrue(!!items[2].disabled);
-        assert.isTrue(mobileItems[2].disabled);
-        assert.isFalse(items[2].classList.contains('iron-selected'));
-        assert.isFalse(mobileItems[2].selected);
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[2].text, element.items[2].mobileText);
 
-        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(mobileItems[2].value, element.items[2].value);
-        assert.equal(Polymer.dom(items[2]).querySelector('.topContent div')
-            .innerText, element.items[2].text);
+      // Select a new item.
+      MockInteractions.tap(items[0]);
+      flushAsynchronousOperations();
+      assert.equal(element.value, 1);
+      assert.isTrue(items[0].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[0].selected);
 
-        // Since there is mobile specific text, it should that.
-        assert.equal(mobileItems[2].text, element.items[2].mobileText);
-
-        // Select a new item.
-        MockInteractions.tap(items[0]);
-        flushAsynchronousOperations();
-        assert.equal(element.value, 1);
-        assert.isTrue(items[0].classList.contains('iron-selected'));
-        assert.isTrue(mobileItems[0].selected);
-
-        // Since no triggerText, the fallback is used.
-        assert.equal(element.text, element.items[0].text);
-        done();
-      });
+      // Since no triggerText, the fallback is used.
+      assert.equal(element.text, element.items[0].text);
+      done();
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
deleted file mode 100644
index d76721f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ /dev/null
@@ -1,168 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-dropdown">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: inline-block;
-      }
-      .dropdown-trigger {
-        text-decoration: none;
-        width: 100%;
-      }
-      .dropdown-content {
-        background-color: var(--dropdown-background-color);
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
-      }
-      gr-button {
-        @apply --gr-button;
-      }
-      gr-avatar {
-        height: 2em;
-        width: 2em;
-        vertical-align: middle;
-      }
-      gr-button[link]:focus {
-        outline: 5px auto -webkit-focus-ring-color;
-      }
-      ul {
-        list-style: none;
-      }
-      .topContent,
-      li {
-        border-bottom: 1px solid var(--border-color);
-      }
-      li:last-of-type {
-        border: none;
-      }
-      li .itemAction {
-        cursor: pointer;
-        display: block;
-        padding: var(--spacing-m) var(--spacing-l);
-      }
-      li .itemAction {
-        @apply --gr-dropdown-item;
-      }
-      li .itemAction.disabled {
-        color: var(--deemphasized-text-color);
-        cursor: default;
-      }
-      li .itemAction:link,
-      li .itemAction:visited {
-        text-decoration: none;
-      }
-      li .itemAction:not(.disabled):hover {
-        background-color: var(--hover-background-color);
-      }
-      li:focus,
-      li.selected {
-        background-color: var(--selection-background-color);
-        outline: none;
-      }
-      li:focus .itemAction,
-      li.selected .itemAction {
-        background-color: transparent;
-      }
-      .topContent {
-        display: block;
-        padding: var(--spacing-m) var(--spacing-l);
-        @apply --gr-dropdown-item;
-      }
-      .bold-text {
-        font-weight: var(--font-weight-bold);
-      }
-    </style>
-    <gr-button
-        link="[[link]]"
-        class="dropdown-trigger" id="trigger"
-        down-arrow="[[downArrow]]"
-        on-click="_dropdownTriggerTapHandler">
-      <slot></slot>
-    </gr-button>
-    <iron-dropdown id="dropdown"
-        vertical-align="top"
-        vertical-offset="[[verticalOffset]]"
-        allow-outside-scroll="true"
-        horizontal-align="[[horizontalAlign]]"
-        on-click="_handleDropdownClick">
-      <div class="dropdown-content" slot="dropdown-content">
-        <ul>
-          <template is="dom-if" if="[[topContent]]">
-            <div class="topContent">
-              <template
-                  is="dom-repeat"
-                  items="[[topContent]]"
-                  as="item"
-                  initial-count="75">
-                <div
-                    class$="[[_getClassIfBold(item.bold)]] top-item"
-                    tabindex="-1">
-                  [[item.text]]
-                </div>
-              </template>
-            </div>
-          </template>
-          <template
-              is="dom-repeat"
-              items="[[items]]"
-              as="link"
-              initial-count="75">
-            <li tabindex="-1">
-              <gr-tooltip-content
-                  has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
-                  title$="[[link.tooltip]]">
-                <span
-                    class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
-                    data-id$="[[link.id]]"
-                    on-click="_handleItemTap"
-                    hidden$="[[link.url]]"
-                    tabindex="-1">[[link.name]]</span>
-                <a
-                    class="itemAction"
-                    href$="[[_computeLinkURL(link)]]"
-                    download$="[[_computeIsDownload(link)]]"
-                    rel$="[[_computeLinkRel(link)]]"
-                    target$="[[link.target]]"
-                    hidden$="[[!link.url]]"
-                    tabindex="-1">[[link.name]]</a>
-              </gr-tooltip-content>
-            </li>
-          </template>
-        </ul>
-      </div>
-    </iron-dropdown>
-    <gr-cursor-manager
-        id="cursor"
-        cursor-target-class="selected"
-        scroll-behavior="never"
-        focus-on-move
-        stops="[[_listElements]]"></gr-cursor-manager>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-dropdown.js"></script>
-</dom-module>
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..12a2025 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -14,28 +14,52 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  const REL_NOOPENER = 'noopener';
-  const REL_EXTERNAL = 'external';
+import '../../../scripts/bundled-polymer.js';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '../gr-button/gr-button.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import '../gr-tooltip-content/gr-tooltip-content.js';
+import '../../../styles/shared-styles.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-dropdown_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  Polymer({
-    is: 'gr-dropdown',
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
 
-    /**
-     * Fired when a non-link dropdown item with the given ID is tapped.
-     *
-     * @event tap-item-<id>
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrDropdown extends mixinBehaviors( [
+  BaseUrlBehavior,
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when a non-link dropdown item is tapped.
-     *
-     * @event tap-item
-     */
+  static get is() { return 'gr-dropdown'; }
+  /**
+   * Fired when a non-link dropdown item with the given ID is tapped.
+   *
+   * @event tap-item-<id>
+   */
 
-    properties: {
+  /**
+   * Fired when a non-link dropdown item is tapped.
+   *
+   * @event tap-item
+   */
+
+  static get properties() {
+    return {
       items: {
         type: Array,
         observer: '_resetCursorStops',
@@ -76,237 +100,236 @@
         type: Array,
         value() { return []; },
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
+  get keyBindings() {
+    return {
       'down': '_handleDown',
       'enter space': '_handleEnter',
       'tab': '_handleTab',
       'up': '_handleUp',
-    },
+    };
+  }
 
-    /**
-     * Handle the up key.
-     *
-     * @param {!Event} e
-     */
-    _handleUp(e) {
-      if (this.$.dropdown.opened) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.$.cursor.previous();
-      } else {
-        this._open();
-      }
-    },
-
-    /**
-     * Handle the down key.
-     *
-     * @param {!Event} e
-     */
-    _handleDown(e) {
-      if (this.$.dropdown.opened) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.$.cursor.next();
-      } else {
-        this._open();
-      }
-    },
-
-    /**
-     * Handle the tab key.
-     *
-     * @param {!Event} e
-     */
-    _handleTab(e) {
-      if (this.$.dropdown.opened) {
-        // Tab in a native select is a no-op. Emulate this.
-        e.preventDefault();
-        e.stopPropagation();
-      }
-    },
-
-    /**
-     * Handle the enter key.
-     *
-     * @param {!Event} e
-     */
-    _handleEnter(e) {
+  /**
+   * Handle the up key.
+   *
+   * @param {!Event} e
+   */
+  _handleUp(e) {
+    if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
-      if (this.$.dropdown.opened) {
-        // TODO(milutin): This solution is not particularly robust in general.
-        // Since gr-tooltip-content click on shadow dom is not propagated down,
-        // we have to target `a` inside it.
-        const el = this.$.cursor.target.querySelector(':not([hidden]) a');
-        if (el) { el.click(); }
-      } else {
-        this._open();
-      }
-    },
+      this.$.cursor.previous();
+    } else {
+      this._open();
+    }
+  }
 
-    /**
-     * Handle a click on the iron-dropdown element.
-     *
-     * @param {!Event} e
-     */
-    _handleDropdownClick(e) {
+  /**
+   * Handle the down key.
+   *
+   * @param {!Event} e
+   */
+  _handleDown(e) {
+    if (this.$.dropdown.opened) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.cursor.next();
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Handle the tab key.
+   *
+   * @param {!Event} e
+   */
+  _handleTab(e) {
+    if (this.$.dropdown.opened) {
+      // Tab in a native select is a no-op. Emulate this.
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  }
+
+  /**
+   * Handle the enter key.
+   *
+   * @param {!Event} e
+   */
+  _handleEnter(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this.$.dropdown.opened) {
+      // TODO(milutin): This solution is not particularly robust in general.
+      // Since gr-tooltip-content click on shadow dom is not propagated down,
+      // we have to target `a` inside it.
+      const el = this.$.cursor.target.querySelector(':not([hidden]) a');
+      if (el) { el.click(); }
+    } else {
+      this._open();
+    }
+  }
+
+  /**
+   * Handle a click on the iron-dropdown element.
+   *
+   * @param {!Event} e
+   */
+  _handleDropdownClick(e) {
+    this._close();
+  }
+
+  /**
+   * Hanlde a click on the button to open the dropdown.
+   *
+   * @param {!Event} e
+   */
+  _dropdownTriggerTapHandler(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    if (this.$.dropdown.opened) {
       this._close();
-    },
+    } else {
+      this._open();
+    }
+  }
 
-    /**
-     * Hanlde a click on the button to open the dropdown.
-     *
-     * @param {!Event} e
-     */
-    _dropdownTriggerTapHandler(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      if (this.$.dropdown.opened) {
-        this._close();
-      } else {
-        this._open();
+  /**
+   * Open the dropdown and initialize the cursor.
+   */
+  _open() {
+    this.$.dropdown.open();
+    this._resetCursorStops();
+    this.$.cursor.setCursorAtIndex(0);
+    this.$.cursor.target.focus();
+  }
+
+  _close() {
+    // async is needed so that that the click event is fired before the
+    // dropdown closes (This was a bug for touch devices).
+    this.async(() => {
+      this.$.dropdown.close();
+    }, 1);
+  }
+
+  /**
+   * Get the class for a top-content item based on the given boolean.
+   *
+   * @param {boolean} bold Whether the item is bold.
+   * @return {string} The class for the top-content item.
+   */
+  _getClassIfBold(bold) {
+    return bold ? 'bold-text' : '';
+  }
+
+  /**
+   * Build a URL for the given host and path. The base URL will be only added,
+   * if it is not already included in the path.
+   *
+   * @param {!string} host
+   * @param {!string} path
+   * @return {!string} The scheme-relative URL.
+   */
+  _computeURLHelper(host, path) {
+    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
+   * URL if one is present. Note: the URL will be scheme-relative but absolute
+   * with regard to the host.
+   *
+   * @param {!string} path The path for the URL.
+   * @return {!string} The scheme-relative URL.
+   */
+  _computeRelativeURL(path) {
+    const host = window.location.host;
+    return this._computeURLHelper(host, path);
+  }
+
+  /**
+   * Compute the URL for a link object.
+   *
+   * @param {!Object} link The object describing the link.
+   * @return {!string} The URL.
+   */
+  _computeLinkURL(link) {
+    if (typeof link.url === 'undefined') {
+      return '';
+    }
+    if (link.target || !link.url.startsWith('/')) {
+      return link.url;
+    }
+    return this._computeRelativeURL(link.url);
+  }
+
+  /**
+   * Compute the value for the rel attribute of an anchor for the given link
+   * object. If the link has a target value, then the rel must be "noopener"
+   * for security reasons.
+   *
+   * @param {!Object} link The object describing the link.
+   * @return {?string} The rel value for the link.
+   */
+  _computeLinkRel(link) {
+    // Note: noopener takes precedence over external.
+    if (link.target) { return REL_NOOPENER; }
+    if (link.external) { return REL_EXTERNAL; }
+    return null;
+  }
+
+  /**
+   * Handle a click on an item of the dropdown.
+   *
+   * @param {!Event} e
+   */
+  _handleItemTap(e) {
+    const id = e.target.getAttribute('data-id');
+    const item = this.items.find(item => item.id === id);
+    if (id && !this.disabledIds.includes(id)) {
+      if (item) {
+        this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
       }
-    },
+      this.dispatchEvent(new CustomEvent('tap-item-' + id));
+    }
+  }
 
-    /**
-     * Open the dropdown and initialize the cursor.
-     */
-    _open() {
-      this.$.dropdown.open();
-      this._resetCursorStops();
-      this.$.cursor.setCursorAtIndex(0);
-      this.$.cursor.target.focus();
-    },
+  /**
+   * If a dropdown item is shown as a button, get the class for the button.
+   *
+   * @param {string} id
+   * @param {!Object} disabledIdsRecord The change record for the disabled IDs
+   *     list.
+   * @return {!string} The class for the item button.
+   */
+  _computeDisabledClass(id, disabledIdsRecord) {
+    return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
+  }
 
-    _close() {
-      // async is needed so that that the click event is fired before the
-      // dropdown closes (This was a bug for touch devices).
-      this.async(() => {
-        this.$.dropdown.close();
-      }, 1);
-    },
+  /**
+   * Recompute the stops for the dropdown item cursor.
+   */
+  _resetCursorStops() {
+    if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
+      flush();
+      this._listElements = Array.from(
+          dom(this.root).querySelectorAll('li'));
+    }
+  }
 
-    /**
-     * Get the class for a top-content item based on the given boolean.
-     *
-     * @param {boolean} bold Whether the item is bold.
-     * @return {string} The class for the top-content item.
-     */
-    _getClassIfBold(bold) {
-      return bold ? 'bold-text' : '';
-    },
+  _computeHasTooltip(tooltip) {
+    return !!tooltip;
+  }
 
-    /**
-     * Build a URL for the given host and path. The base URL will be only added,
-     * if it is not already included in the path.
-     *
-     * @param {!string} host
-     * @param {!string} path
-     * @return {!string} The scheme-relative URL.
-     */
-    _computeURLHelper(host, path) {
-      const base = path.startsWith(this.getBaseUrl()) ?
-        '' : this.getBaseUrl();
-      return '//' + host + base + path;
-    },
+  _computeIsDownload(link) {
+    return !!link.download;
+  }
+}
 
-    /**
-     * Build a scheme-relative URL for the current host. Will include the base
-     * URL if one is present. Note: the URL will be scheme-relative but absolute
-     * with regard to the host.
-     *
-     * @param {!string} path The path for the URL.
-     * @return {!string} The scheme-relative URL.
-     */
-    _computeRelativeURL(path) {
-      const host = window.location.host;
-      return this._computeURLHelper(host, path);
-    },
-
-    /**
-     * Compute the URL for a link object.
-     *
-     * @param {!Object} link The object describing the link.
-     * @return {!string} The URL.
-     */
-    _computeLinkURL(link) {
-      if (typeof link.url === 'undefined') {
-        return '';
-      }
-      if (link.target || !link.url.startsWith('/')) {
-        return link.url;
-      }
-      return this._computeRelativeURL(link.url);
-    },
-
-    /**
-     * Compute the value for the rel attribute of an anchor for the given link
-     * object. If the link has a target value, then the rel must be "noopener"
-     * for security reasons.
-     *
-     * @param {!Object} link The object describing the link.
-     * @return {?string} The rel value for the link.
-     */
-    _computeLinkRel(link) {
-      // Note: noopener takes precedence over external.
-      if (link.target) { return REL_NOOPENER; }
-      if (link.external) { return REL_EXTERNAL; }
-      return null;
-    },
-
-    /**
-     * Handle a click on an item of the dropdown.
-     *
-     * @param {!Event} e
-     */
-    _handleItemTap(e) {
-      const id = e.target.getAttribute('data-id');
-      const item = this.items.find(item => item.id === id);
-      if (id && !this.disabledIds.includes(id)) {
-        if (item) {
-          this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
-        }
-        this.dispatchEvent(new CustomEvent('tap-item-' + id));
-      }
-    },
-
-    /**
-     * If a dropdown item is shown as a button, get the class for the button.
-     *
-     * @param {string} id
-     * @param {!Object} disabledIdsRecord The change record for the disabled IDs
-     *     list.
-     * @return {!string} The class for the item button.
-     */
-    _computeDisabledClass(id, disabledIdsRecord) {
-      return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
-    },
-
-    /**
-     * Recompute the stops for the dropdown item cursor.
-     */
-    _resetCursorStops() {
-      if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
-        Polymer.dom.flush();
-        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_html.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
new file mode 100644
index 0000000..d0b0d09
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+    }
+    .dropdown-trigger {
+      text-decoration: none;
+      width: 100%;
+    }
+    .dropdown-content {
+      background-color: var(--dropdown-background-color);
+      box-shadow: var(--elevation-level-2);
+    }
+    gr-button {
+      @apply --gr-button;
+    }
+    gr-avatar {
+      height: 2em;
+      width: 2em;
+      vertical-align: middle;
+    }
+    gr-button[link]:focus {
+      outline: 5px auto -webkit-focus-ring-color;
+    }
+    ul {
+      list-style: none;
+    }
+    .topContent,
+    li {
+      border-bottom: 1px solid var(--border-color);
+    }
+    li:last-of-type {
+      border: none;
+    }
+    li .itemAction {
+      cursor: pointer;
+      display: block;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    li .itemAction {
+      @apply --gr-dropdown-item;
+    }
+    li .itemAction.disabled {
+      color: var(--deemphasized-text-color);
+      cursor: default;
+    }
+    li .itemAction:link,
+    li .itemAction:visited {
+      text-decoration: none;
+    }
+    li .itemAction:not(.disabled):hover {
+      background-color: var(--hover-background-color);
+    }
+    li:focus,
+    li.selected {
+      background-color: var(--selection-background-color);
+      outline: none;
+    }
+    li:focus .itemAction,
+    li.selected .itemAction {
+      background-color: transparent;
+    }
+    .topContent {
+      display: block;
+      padding: var(--spacing-m) var(--spacing-l);
+      @apply --gr-dropdown-item;
+    }
+    .bold-text {
+      font-weight: var(--font-weight-bold);
+    }
+  </style>
+  <gr-button
+    link="[[link]]"
+    class="dropdown-trigger"
+    id="trigger"
+    down-arrow="[[downArrow]]"
+    on-click="_dropdownTriggerTapHandler"
+  >
+    <slot></slot>
+  </gr-button>
+  <iron-dropdown
+    id="dropdown"
+    vertical-align="top"
+    vertical-offset="[[verticalOffset]]"
+    allow-outside-scroll="true"
+    horizontal-align="[[horizontalAlign]]"
+    on-click="_handleDropdownClick"
+  >
+    <div class="dropdown-content" slot="dropdown-content">
+      <ul>
+        <template is="dom-if" if="[[topContent]]">
+          <div class="topContent">
+            <template
+              is="dom-repeat"
+              items="[[topContent]]"
+              as="item"
+              initial-count="75"
+            >
+              <div
+                class$="[[_getClassIfBold(item.bold)]] top-item"
+                tabindex="-1"
+              >
+                [[item.text]]
+              </div>
+            </template>
+          </div>
+        </template>
+        <template
+          is="dom-repeat"
+          items="[[items]]"
+          as="link"
+          initial-count="75"
+        >
+          <li tabindex="-1">
+            <gr-tooltip-content
+              has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
+              title$="[[link.tooltip]]"
+            >
+              <span
+                class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
+                data-id$="[[link.id]]"
+                on-click="_handleItemTap"
+                hidden$="[[link.url]]"
+                tabindex="-1"
+                >[[link.name]]</span
+              >
+              <a
+                class="itemAction"
+                href$="[[_computeLinkURL(link)]]"
+                download$="[[_computeIsDownload(link)]]"
+                rel$="[[_computeLinkRel(link)]]"
+                target$="[[link.target]]"
+                hidden$="[[!link.url]]"
+                tabindex="-1"
+                >[[link.name]]</a
+              >
+            </gr-tooltip-content>
+          </li>
+        </template>
+      </ul>
+    </div>
+  </iron-dropdown>
+  <gr-cursor-manager
+    id="cursor"
+    cursor-target-class="selected"
+    scroll-behavior="never"
+    focus-on-move=""
+    stops="[[_listElements]]"
+  ></gr-cursor-manager>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..d17cc1a 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-dropdown.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,173 +31,178 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-dropdown tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-dropdown.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-dropdown tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeIsDownload', () => {
+    assert.isTrue(element._computeIsDownload({download: true}));
+    assert.isFalse(element._computeIsDownload({download: false}));
+  });
+
+  test('tap on trigger opens menu, then closes', () => {
+    sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
+    sandbox.stub(element, '_close', () => { element.$.dropdown.close(); });
+    assert.isFalse(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isTrue(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isFalse(element.$.dropdown.opened);
+  });
+
+  test('_computeURLHelper', () => {
+    const path = '/test';
+    const host = 'http://www.testsite.com';
+    const computedPath = element._computeURLHelper(host, path);
+    assert.equal(computedPath, '//http://www.testsite.com/test');
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+        element._computeLinkURL({url: 'http://example.com/test'}),
+        'http://example.com/test');
+    assert.equal(
+        element._computeLinkURL({url: 'https://example.com/test'}),
+        'https://example.com/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test'}),
+        '//' + window.location.host + '/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test', target: '_blank'}),
+        '/test');
+  });
+
+  test('link rel', () => {
+    let link = {url: '/test'};
+    assert.isNull(element._computeLinkRel(link));
+
+    link = {url: '/test', target: '_blank'};
+    assert.equal(element._computeLinkRel(link), 'noopener');
+
+    link = {url: '/test', external: true};
+    assert.equal(element._computeLinkRel(link), 'external');
+
+    link = {url: '/test', target: '_blank', external: true};
+    assert.equal(element._computeLinkRel(link), 'noopener');
+  });
+
+  test('_getClassIfBold', () => {
+    let bold = true;
+    assert.equal(element._getClassIfBold(bold), 'bold-text');
+
+    bold = false;
+    assert.equal(element._getClassIfBold(bold), '');
+  });
+
+  test('Top text exists and is bolded correctly', () => {
+    element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
+    flushAsynchronousOperations();
+    const topItems = dom(element.root).querySelectorAll('.top-item');
+    assert.equal(topItems.length, 2);
+    assert.isTrue(topItems[0].classList.contains('bold-text'));
+    assert.isFalse(topItems[1].classList.contains('bold-text'));
+  });
+
+  test('non link items', () => {
+    const item0 = {name: 'item one', id: 'foo'};
+    element.items = [item0, {name: 'item two', id: 'bar'}];
+    const fooTapped = sandbox.stub();
+    const tapped = sandbox.stub();
+    element.addEventListener('tap-item-foo', fooTapped);
+    element.addEventListener('tap-item', tapped);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.itemAction'));
+    assert.isTrue(fooTapped.called);
+    assert.isTrue(tapped.called);
+    assert.deepEqual(tapped.lastCall.args[0].detail, item0);
+  });
+
+  test('disabled non link item', () => {
+    element.items = [{name: 'item one', id: 'foo'}];
+    element.disabledIds = ['foo'];
+
+    const stub = sandbox.stub();
+    const tapped = sandbox.stub();
+    element.addEventListener('tap-item-foo', stub);
+    element.addEventListener('tap-item', tapped);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.itemAction'));
+    assert.isFalse(stub.called);
+    assert.isFalse(tapped.called);
+  });
+
+  test('properly sets tooltips', () => {
+    element.items = [
+      {name: 'item one', id: 'foo', tooltip: 'hello'},
+      {name: 'item two', id: 'bar'},
+    ];
+    element.disabledIds = [];
+    flushAsynchronousOperations();
+    const tooltipContents = dom(element.root)
+        .querySelectorAll('iron-dropdown li gr-tooltip-content');
+    assert.equal(tooltipContents.length, 2);
+    assert.isTrue(tooltipContents[0].hasTooltip);
+    assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
+    assert.isFalse(tooltipContents[1].hasTooltip);
+  });
+
+  suite('keyboard navigation', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeIsDownload', () => {
-      assert.isTrue(element._computeIsDownload({download: true}));
-      assert.isFalse(element._computeIsDownload({download: false}));
-    });
-
-    test('tap on trigger opens menu, then closes', () => {
-      sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
-      sandbox.stub(element, '_close', () => { element.$.dropdown.close(); });
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isFalse(element.$.dropdown.opened);
-    });
-
-    test('_computeURLHelper', () => {
-      const path = '/test';
-      const host = 'http://www.testsite.com';
-      const computedPath = element._computeURLHelper(host, path);
-      assert.equal(computedPath, '//http://www.testsite.com/test');
-    });
-
-    test('link URLs', () => {
-      assert.equal(
-          element._computeLinkURL({url: 'http://example.com/test'}),
-          'http://example.com/test');
-      assert.equal(
-          element._computeLinkURL({url: 'https://example.com/test'}),
-          'https://example.com/test');
-      assert.equal(
-          element._computeLinkURL({url: '/test'}),
-          '//' + window.location.host + '/test');
-      assert.equal(
-          element._computeLinkURL({url: '/test', target: '_blank'}),
-          '/test');
-    });
-
-    test('link rel', () => {
-      let link = {url: '/test'};
-      assert.isNull(element._computeLinkRel(link));
-
-      link = {url: '/test', target: '_blank'};
-      assert.equal(element._computeLinkRel(link), 'noopener');
-
-      link = {url: '/test', external: true};
-      assert.equal(element._computeLinkRel(link), 'external');
-
-      link = {url: '/test', target: '_blank', external: true};
-      assert.equal(element._computeLinkRel(link), 'noopener');
-    });
-
-    test('_getClassIfBold', () => {
-      let bold = true;
-      assert.equal(element._getClassIfBold(bold), 'bold-text');
-
-      bold = false;
-      assert.equal(element._getClassIfBold(bold), '');
-    });
-
-    test('Top text exists and is bolded correctly', () => {
-      element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
-      flushAsynchronousOperations();
-      const topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
-      assert.equal(topItems.length, 2);
-      assert.isTrue(topItems[0].classList.contains('bold-text'));
-      assert.isFalse(topItems[1].classList.contains('bold-text'));
-    });
-
-    test('non link items', () => {
-      const item0 = {name: 'item one', id: 'foo'};
-      element.items = [item0, {name: 'item two', id: 'bar'}];
-      const fooTapped = sandbox.stub();
-      const tapped = sandbox.stub();
-      element.addEventListener('tap-item-foo', fooTapped);
-      element.addEventListener('tap-item', tapped);
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.$$('.itemAction'));
-      assert.isTrue(fooTapped.called);
-      assert.isTrue(tapped.called);
-      assert.deepEqual(tapped.lastCall.args[0].detail, item0);
-    });
-
-    test('disabled non link item', () => {
-      element.items = [{name: 'item one', id: 'foo'}];
-      element.disabledIds = ['foo'];
-
-      const stub = sandbox.stub();
-      const tapped = sandbox.stub();
-      element.addEventListener('tap-item-foo', stub);
-      element.addEventListener('tap-item', tapped);
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.$$('.itemAction'));
-      assert.isFalse(stub.called);
-      assert.isFalse(tapped.called);
-    });
-
-    test('properly sets tooltips', () => {
       element.items = [
-        {name: 'item one', id: 'foo', tooltip: 'hello'},
+        {name: 'item one', id: 'foo'},
         {name: 'item two', id: 'bar'},
       ];
-      element.disabledIds = [];
       flushAsynchronousOperations();
-      const tooltipContents = Polymer.dom(element.root)
-          .querySelectorAll('iron-dropdown li gr-tooltip-content');
-      assert.equal(tooltipContents.length, 2);
-      assert.isTrue(tooltipContents[0].hasTooltip);
-      assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
-      assert.isFalse(tooltipContents[1].hasTooltip);
     });
 
-    suite('keyboard navigation', () => {
-      setup(() => {
-        element.items = [
-          {name: 'item one', id: 'foo'},
-          {name: 'item two', id: 'bar'},
-        ];
-        flushAsynchronousOperations();
-      });
+    test('down', () => {
+      const stub = sandbox.stub(element.$.cursor, 'next');
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(stub.called);
+    });
 
-      test('down', () => {
-        const stub = sandbox.stub(element.$.cursor, 'next');
-        assert.isFalse(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 40);
-        assert.isTrue(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 40);
-        assert.isTrue(stub.called);
-      });
+    test('up', () => {
+      const stub = sandbox.stub(element.$.cursor, 'previous');
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(stub.called);
+    });
 
-      test('up', () => {
-        const stub = sandbox.stub(element.$.cursor, 'previous');
-        assert.isFalse(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 38);
-        assert.isTrue(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 38);
-        assert.isTrue(stub.called);
-      });
+    test('enter/space', () => {
+      // Because enter and space are handled by the same fn, we need only to
+      // test one.
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      assert.isTrue(element.$.dropdown.opened);
 
-      test('enter/space', () => {
-        // Because enter and space are handled by the same fn, we need only to
-        // test one.
-        assert.isFalse(element.$.dropdown.opened);
-        MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
-        assert.isTrue(element.$.dropdown.opened);
-
-        const el = element.$.cursor.target.querySelector(':not([hidden]) a');
-        const stub = sandbox.stub(el, 'click');
-        MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
-        assert.isTrue(stub.called);
-      });
+      const el = element.$.cursor.target.querySelector(':not([hidden]) a');
+      const stub = sandbox.stub(el, 'click');
+      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      assert.isTrue(stub.called);
     });
   });
+});
 </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
deleted file mode 100644
index 45ddfc8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ /dev/null
@@ -1,76 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-storage/gr-storage.html">
-<link rel="import" href="../gr-button/gr-button.html">
-
-<dom-module id="gr-editable-content">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([disabled]) iron-autogrow-textarea {
-        opacity: .5;
-      }
-      .viewer {
-        background-color: var(--view-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        padding: var(--spacing-m);
-      }
-      .editor iron-autogrow-textarea {
-        background-color: var(--view-background-color);
-        width: 100%;
-
-        --iron-autogrow-textarea: {
-          padding: var(--spacing-m);
-          box-sizing: border-box;
-          overflow-y: hidden;
-          white-space: pre;
-        };
-      }
-      .editButtons {
-        display: flex;
-        justify-content: space-between;
-      }
-    </style>
-    <div class="viewer" hidden$="[[editing]]">
-      <slot></slot>
-    </div>
-    <div class="editor" hidden$="[[!editing]]">
-      <iron-autogrow-textarea
-          autocomplete="on"
-          bind-value="{{_newContent}}"
-          disabled="[[disabled]]"></iron-autogrow-textarea>
-      <div class="editButtons">
-        <gr-button primary
-            on-click="_handleSave"
-            disabled="[[_saveDisabled]]">Save</gr-button>
-        <gr-button
-            on-click="_handleCancel"
-            disabled="[[disabled]]">Cancel</gr-button>
-      </div>
-    </div>
-    <gr-storage id="storage"></gr-storage>
-  </template>
-  <script src="gr-editable-content.js"></script>
-</dom-module>
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 ee41103..804eb16 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
@@ -14,37 +14,52 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const RESTORED_MESSAGE = 'Content restored from a previous edit.';
-  const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/shared-styles.js';
+import '../gr-storage/gr-storage.js';
+import '../gr-button/gr-button.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-editable-content_html.js';
 
-  Polymer({
-    is: 'gr-editable-content',
+const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-    /**
-     * Fired when the save button is pressed.
-     *
-     * @event editable-content-save
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrEditableContent extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when the cancel button is pressed.
-     *
-     * @event editable-content-cancel
-     */
+  static get is() { return 'gr-editable-content'; }
+  /**
+   * Fired when the save button is pressed.
+   *
+   * @event editable-content-save
+   */
 
-    /**
-     * Fired when content is restored from storage.
-     *
-     * @event show-alert
-     */
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event editable-content-cancel
+   */
 
-    properties: {
+  /**
+   * Fired when content is restored from storage.
+   *
+   * @event show-alert
+   */
+
+  static get properties() {
+    return {
       content: {
         notify: true,
         type: String,
+        observer: '_contentChanged',
       },
       disabled: {
         reflectToAttribute: true,
@@ -68,100 +83,102 @@
         type: String,
         observer: '_newContentChanged',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  _contentChanged(content) {
+    /* A changed content means that either a different change has been loaded
+     * or new content was saved. Either way, let's reset the component.
+     */
+    this.editing = false;
+    this._newContent = '';
+  }
 
-    focusTextarea() {
-      this.$$('iron-autogrow-textarea').textarea.focus();
-    },
+  focusTextarea() {
+    this.shadowRoot.querySelector('iron-autogrow-textarea').textarea.focus();
+  }
 
-    _newContentChanged(newContent, oldContent) {
-      if (!this.storageKey) { return; }
+  _newContentChanged(newContent, oldContent) {
+    if (!this.storageKey) { return; }
 
-      this.debounce('store', () => {
-        if (newContent.length) {
-          this.$.storage.setEditableContentItem(this.storageKey, newContent);
-        } else {
-          // This does not really happen, because we don't clear newContent
-          // after saving (see below). So this only occurs when the user clears
-          // all the content in the editable textarea. But <gr-storage> cleans
-          // up itself after one day, so we are not so concerned about leaving
-          // some garbage behind.
-          this.$.storage.eraseEditableContentItem(this.storageKey);
-        }
-      }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    },
-
-    _editingChanged(editing) {
-      // This method is for initializing _newContent when you start editing.
-      // Restoring content from local storage is not perfect and has
-      // some issues:
-      //
-      // 1. When you start editing in multiple tabs, then we are vulnerable to
-      // race conditions between the tabs.
-      // 2. The stored content is keyed by revision, so when you upload a new
-      // patchset and click "reload" and then click "cancel" on the content-
-      // editable, then you won't be able to recover the content anymore.
-      //
-      // Because of these issues we believe that it is better to only recover
-      // content from local storage when you enter editing mode for the first
-      // time. Otherwise it is better to just keep the last editing state from
-      // the same session.
-      if (!editing || this._newContent) {
-        return;
+    this.debounce('store', () => {
+      if (newContent.length) {
+        this.$.storage.setEditableContentItem(this.storageKey, newContent);
+      } else {
+        // This does not really happen, because we don't clear newContent
+        // after saving (see below). So this only occurs when the user clears
+        // all the content in the editable textarea. But <gr-storage> cleans
+        // up itself after one day, so we are not so concerned about leaving
+        // some garbage behind.
+        this.$.storage.eraseEditableContentItem(this.storageKey);
       }
+    }, STORAGE_DEBOUNCE_INTERVAL_MS);
+  }
 
-      let content;
-      if (this.storageKey) {
-        const storedContent =
-            this.$.storage.getEditableContentItem(this.storageKey);
-        if (storedContent && storedContent.message) {
-          content = storedContent.message;
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: RESTORED_MESSAGE},
-            bubbles: true,
-            composed: true,
-          }));
-        }
+  _editingChanged(editing) {
+    // This method is for initializing _newContent when you start editing.
+    // Restoring content from local storage is not perfect and has
+    // some issues:
+    //
+    // 1. When you start editing in multiple tabs, then we are vulnerable to
+    // race conditions between the tabs.
+    // 2. The stored content is keyed by revision, so when you upload a new
+    // patchset and click "reload" and then click "cancel" on the content-
+    // editable, then you won't be able to recover the content anymore.
+    //
+    // Because of these issues we believe that it is better to only recover
+    // content from local storage when you enter editing mode for the first
+    // time. Otherwise it is better to just keep the last editing state from
+    // the same session.
+    if (!editing || this._newContent) {
+      return;
+    }
+
+    let content;
+    if (this.storageKey) {
+      const storedContent =
+          this.$.storage.getEditableContentItem(this.storageKey);
+      if (storedContent && storedContent.message) {
+        content = storedContent.message;
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {message: RESTORED_MESSAGE},
+          bubbles: true,
+          composed: true,
+        }));
       }
-      if (!content) {
-        content = this.content || '';
-      }
+    }
+    if (!content) {
+      content = this.content || '';
+    }
 
-      // TODO(wyatta) switch linkify sequence, see issue 5526.
-      this._newContent = this.removeZeroWidthSpace ?
-        content.replace(/^R=\u200B/gm, 'R=') :
-        content;
-    },
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    this._newContent = this.removeZeroWidthSpace ?
+      content.replace(/^R=\u200B/gm, 'R=') :
+      content;
+  }
 
-    _computeSaveDisabled(disabled, content, newContent) {
-      // Polymer 2: check for undefined
-      if ([
-        disabled,
-        content,
-        newContent,
-      ].some(arg => arg === undefined)) {
-        return true;
-      }
+  _computeSaveDisabled(disabled, content, newContent) {
+    return disabled || !newContent || content === newContent;
+  }
 
-      return disabled || !newContent || content === newContent;
-    },
+  _handleSave(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('editable-content-save', {
+      detail: {content: this._newContent},
+      composed: true, bubbles: true,
+    }));
+    // It would be nice, if we would set this._newContent = undefined here,
+    // but we can only do that when we are sure that the save operation has
+    // succeeded.
+  }
 
-    _handleSave(e) {
-      e.preventDefault();
-      this.fire('editable-content-save', {content: this._newContent});
-      // It would be nice, if we would set this._newContent = undefined here,
-      // but we can only do that when we are sure that the save operation has
-      // succeeded.
-    },
+  _handleCancel(e) {
+    e.preventDefault();
+    this.editing = false;
+    this.dispatchEvent(new CustomEvent('editable-content-cancel', {
+      composed: true, bubbles: true,
+    }));
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
new file mode 100644
index 0000000..24eb0b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) iron-autogrow-textarea {
+      opacity: 0.5;
+    }
+    .viewer {
+      background-color: var(--view-background-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: {
+        box-sizing: border-box;
+        padding: var(--spacing-m);
+        overflow-y: hidden;
+        white-space: pre;
+      }
+    }
+    .editButtons {
+      display: flex;
+      justify-content: space-between;
+    }
+  </style>
+  <div class="viewer" hidden$="[[editing]]">
+    <slot></slot>
+  </div>
+  <div class="editor" hidden$="[[!editing]]">
+    <iron-autogrow-textarea
+      autocomplete="on"
+      bind-value="{{_newContent}}"
+      disabled="[[disabled]]"
+    ></iron-autogrow-textarea>
+    <div class="editButtons">
+      <gr-button primary="" on-click="_handleSave" disabled="[[_saveDisabled]]"
+        >Save</gr-button
+      >
+      <gr-button on-click="_handleCancel" disabled="[[disabled]]"
+        >Cancel</gr-button
+      >
+    </div>
+  </div>
+  <gr-storage id="storage"></gr-storage>
+`;
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 ee5adb0..c50920e 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
@@ -17,18 +17,20 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-editable-content.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<!-- Can't use absolute path below for mock-interaction.js.
+Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
+actually /node_modules directory). Also, wct patches some files to load modules from /components.
+With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
+/node_modules/...) though this is actually the same file. This leads to a run-time error.
+-->
+<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,123 +38,129 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-editable-content tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-editable-content.js';
+suite('gr-editable-content tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('save event', done => {
+    element.content = '';
+    element._newContent = 'foo';
+    element.addEventListener('editable-content-save', e => {
+      assert.equal(e.detail.content, 'foo');
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button[primary]'));
+  });
+
+  test('cancel event', done => {
+    element.addEventListener('editable-content-cancel', () => {
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button:not([primary])'));
+  });
+
+  test('enabling editing keeps old content', () => {
+    element.content = 'current content';
+    element._newContent = 'old content';
+    element.editing = true;
+    assert.equal(element._newContent, 'old content');
+  });
+
+  test('disabling editing does not update edit field contents', () => {
+    element.content = 'current content';
+    element.editing = true;
+    element._newContent = 'stale content';
+    element.editing = false;
+    assert.equal(element._newContent, 'stale content');
+  });
+
+  test('zero width spaces are removed properly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'R=\u200Btest@google.com';
+    element.editing = true;
+    assert.equal(element._newContent, 'R=test@google.com');
+  });
+
+  suite('editing', () => {
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('save event', done => {
-      element.content = '';
-      element._newContent = 'foo';
-      element.addEventListener('editable-content-save', e => {
-        assert.equal(e.detail.content, 'foo');
-        done();
-      });
-      MockInteractions.tap(element.$$('gr-button[primary]'));
-    });
-
-    test('cancel event', done => {
-      element.addEventListener('editable-content-cancel', () => {
-        done();
-      });
-      MockInteractions.tap(element.$$('gr-button:not([primary])'));
-    });
-
-    test('enabling editing keeps old content', () => {
-      element.content = 'current content';
-      element._newContent = 'old content';
-      element.editing = true;
-      assert.equal(element._newContent, 'old content');
-    });
-
-    test('disabling editing does not update edit field contents', () => {
       element.content = 'current content';
       element.editing = true;
-      element._newContent = 'stale content';
-      element.editing = false;
-      assert.equal(element._newContent, 'stale content');
     });
 
-    test('zero width spaces are removed properly', () => {
-      element.removeZeroWidthSpace = true;
-      element.content = 'R=\u200Btest@google.com';
-      element.editing = true;
-      assert.equal(element._newContent, 'R=test@google.com');
+    test('save button is disabled initially', () => {
+      assert.isTrue(element.shadowRoot
+          .querySelector('gr-button[primary]').disabled);
     });
 
-    suite('editing', () => {
-      setup(() => {
-        element.content = 'current content';
-        element.editing = true;
-      });
-
-      test('save button is disabled initially', () => {
-        assert.isTrue(element.$$('gr-button[primary]').disabled);
-      });
-
-      test('save button is enabled when content changes', () => {
-        element._newContent = 'new content';
-        assert.isFalse(element.$$('gr-button[primary]').disabled);
-      });
-    });
-
-    suite('storageKey and related behavior', () => {
-      let dispatchSpy;
-      setup(() => {
-        element.content = 'current content';
-        element.storageKey = 'test';
-        dispatchSpy = sandbox.spy(element, 'dispatchEvent');
-      });
-
-      test('editing toggled to true, has stored data', () => {
-        sandbox.stub(element.$.storage, 'getEditableContentItem')
-            .returns({message: 'stored content'});
-        element.editing = true;
-
-        assert.equal(element._newContent, 'stored content');
-        assert.isTrue(dispatchSpy.called);
-        assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
-      });
-
-      test('editing toggled to true, has no stored data', () => {
-        sandbox.stub(element.$.storage, 'getEditableContentItem')
-            .returns({});
-        element.editing = true;
-
-        assert.equal(element._newContent, 'current content');
-        assert.isFalse(dispatchSpy.called);
-      });
-
-      test('edits are cached', () => {
-        const storeStub =
-            sandbox.stub(element.$.storage, 'setEditableContentItem');
-        const eraseStub =
-            sandbox.stub(element.$.storage, 'eraseEditableContentItem');
-        element.editing = true;
-
-        element._newContent = 'new content';
-        flushAsynchronousOperations();
-        element.flushDebouncer('store');
-
-        assert.isTrue(storeStub.called);
-        assert.deepEqual(
-            [element.storageKey, element._newContent],
-            storeStub.lastCall.args);
-
-        element._newContent = '';
-        flushAsynchronousOperations();
-        element.flushDebouncer('store');
-
-        assert.isTrue(eraseStub.called);
-        assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
-      });
+    test('save button is enabled when content changes', () => {
+      element._newContent = 'new content';
+      assert.isFalse(element.shadowRoot
+          .querySelector('gr-button[primary]').disabled);
     });
   });
+
+  suite('storageKey and related behavior', () => {
+    let dispatchSpy;
+    setup(() => {
+      element.content = 'current content';
+      element.storageKey = 'test';
+      dispatchSpy = sandbox.spy(element, 'dispatchEvent');
+    });
+
+    test('editing toggled to true, has stored data', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'stored content'});
+      element.editing = true;
+
+      assert.equal(element._newContent, 'stored content');
+      assert.isTrue(dispatchSpy.called);
+      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+    });
+
+    test('editing toggled to true, has no stored data', () => {
+      sandbox.stub(element.$.storage, 'getEditableContentItem')
+          .returns({});
+      element.editing = true;
+
+      assert.equal(element._newContent, 'current content');
+      assert.isFalse(dispatchSpy.called);
+    });
+
+    test('edits are cached', () => {
+      const storeStub =
+          sandbox.stub(element.$.storage, 'setEditableContentItem');
+      const eraseStub =
+          sandbox.stub(element.$.storage, 'eraseEditableContentItem');
+      element.editing = true;
+
+      element._newContent = 'new content';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.deepEqual(
+          [element.storageKey, element._newContent],
+          storeStub.lastCall.args);
+
+      element._newContent = '';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(eraseStub.called);
+      assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
+    });
+  });
+});
 </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
deleted file mode 100644
index 78465e1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ /dev/null
@@ -1,106 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="/bower_components/paper-input/paper-input.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-button/gr-button.html">
-
-<dom-module id="gr-editable-label">
-  <template>
-    <style include="shared-styles">
-      :host {
-        align-items: center;
-        display: inline-flex;
-      }
-      :host([uppercase]) label {
-        text-transform: uppercase;
-      }
-      input,
-      label {
-        width: 100%;
-      }
-      label {
-        color: var(--deemphasized-text-color);
-        display: inline-block;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        @apply --label-style;
-      }
-      label.editable {
-        color: var(--link-color);
-        cursor: pointer;
-      }
-      #dropdown {
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
-      }
-      .inputContainer {
-        background-color: var(--dialog-background-color);
-        padding: var(--spacing-m);
-        @apply --input-style;
-      }
-      .buttons {
-        display: flex;
-        justify-content: flex-end;
-        padding-top: var(--spacing-l);
-        width: 100%;
-      }
-      .buttons gr-button {
-        margin-left: var(--spacing-m);
-      }
-      paper-input {
-        --paper-input-container: {
-          padding: 0;
-          min-width: 15em;
-        }
-        --paper-input-container-input: {
-          font-size: inherit;
-        }
-        --paper-input-container-focus-color: var(--link-color);
-      }
-    </style>
-      <label
-          class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-          title$="[[_computeLabel(value, placeholder)]]"
-          on-click="_showDropdown">[[_computeLabel(value, placeholder)]]</label>
-      <iron-dropdown id="dropdown"
-          vertical-align="auto"
-          horizontal-align="auto"
-          vertical-offset="[[_verticalOffset]]"
-          allow-outside-scroll="true"
-          on-iron-overlay-canceled="_cancel">
-        <div class="dropdown-content" slot="dropdown-content">
-          <div class="inputContainer">
-            <paper-input
-                id="input"
-                label="[[labelText]]"
-                maxlength="[[maxLength]]"
-                value="{{_inputText}}"></paper-input>
-            <div class="buttons">
-              <gr-button link id="cancelBtn" on-click="_cancel">cancel</gr-button>
-              <gr-button link id="saveBtn" on-click="_save">save</gr-button>
-            </div>
-          </div>
-        </div>
-    </iron-dropdown>
-  </template>
-  <script src="gr-editable-label.js"></script>
-</dom-module>
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..8669f03 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
@@ -14,22 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const AWAIT_MAX_ITERS = 10;
-  const AWAIT_STEP = 5;
+import {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
+import '@polymer/iron-dropdown/iron-dropdown.js';
+import '@polymer/paper-input/paper-input.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-editable-label_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  Polymer({
-    is: 'gr-editable-label',
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
 
-    /**
-     * Fired when the value is changed.
-     *
-     * @event changed
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrEditableLabel extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-editable-label'; }
+  /**
+   * Fired when the value is changed.
+   *
+   * @event changed
+   */
+
+  static get properties() {
+    return {
       labelText: String,
       editing: {
         type: Boolean,
@@ -64,131 +85,135 @@
         readOnly: true,
         value: -30,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('tabindex', '0');
+  }
 
-    keyBindings: {
+  get keyBindings() {
+    return {
       enter: '_handleEnter',
       esc: '_handleEsc',
-    },
+    };
+  }
 
-    hostAttributes: {
-      tabindex: '0',
-    },
+  _usePlaceholder(value, placeholder) {
+    return (!value || !value.length) && placeholder;
+  }
 
-    _usePlaceholder(value, placeholder) {
-      return (!value || !value.length) && placeholder;
-    },
+  _computeLabel(value, placeholder) {
+    if (this._usePlaceholder(value, placeholder)) {
+      return placeholder;
+    }
+    return value;
+  }
 
-    _computeLabel(value, placeholder) {
-      if (this._usePlaceholder(value, placeholder)) {
-        return placeholder;
-      }
-      return value;
-    },
+  _showDropdown() {
+    if (this.readOnly || this.editing) { return; }
+    return this._open().then(() => {
+      this._nativeInput.focus();
+      if (!this.$.input.value) { return; }
+      this._nativeInput.setSelectionRange(0, this.$.input.value.length);
+    });
+  }
 
-    _showDropdown() {
-      if (this.readOnly || this.editing) { return; }
-      return this._open().then(() => {
-        this._nativeInput.focus();
-        if (!this.$.input.value) { return; }
-        this._nativeInput.setSelectionRange(0, this.$.input.value.length);
-      });
-    },
+  open() {
+    return this._open().then(() => {
+      this._nativeInput.focus();
+    });
+  }
 
-    open() {
-      return this._open().then(() => {
-        this._nativeInput.focus();
-      });
-    },
+  _open(...args) {
+    this.$.dropdown.open();
+    this._inputText = this.value;
+    this.editing = true;
 
-    _open(...args) {
-      this.$.dropdown.open();
-      this._inputText = this.value;
-      this.editing = true;
+    return new Promise(resolve => {
+      IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
+      this._awaitOpen(resolve);
+    });
+  }
 
-      return new Promise(resolve => {
-        Polymer.IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
-        this._awaitOpen(resolve);
-      });
-    },
+  /**
+   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+   * opening. Eventually replace with a direct way to listen to the overlay.
+   */
+  _awaitOpen(fn) {
+    let iters = 0;
+    const step = () => {
+      this.async(() => {
+        if (this.$.dropdown.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        }
+      }, AWAIT_STEP);
+    };
+    step.call(this);
+  }
 
-    /**
-     * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-     * opening. Eventually replace with a direct way to listen to the overlay.
-     */
-    _awaitOpen(fn) {
-      let iters = 0;
-      const step = () => {
-        this.async(() => {
-          if (this.$.dropdown.style.display !== 'none') {
-            fn.call(this);
-          } else if (iters++ < AWAIT_MAX_ITERS) {
-            step.call(this);
-          }
-        }, AWAIT_STEP);
-      };
-      step.call(this);
-    },
+  _id() {
+    return this.getAttribute('id') || 'global';
+  }
 
-    _id() {
-      return this.getAttribute('id') || 'global';
-    },
+  _save() {
+    if (!this.editing) { return; }
+    this.$.dropdown.close();
+    this.value = this._inputText;
+    this.editing = false;
+    this.dispatchEvent(new CustomEvent('changed', {
+      detail: this.value,
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _save() {
-      if (!this.editing) { return; }
-      this.$.dropdown.close();
-      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;
+  }
 
-    _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;
+  }
 
-    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);
+    const target = dom(e).rootTarget;
+    if (target === this._nativeInput) {
+      e.preventDefault();
+      this._save();
+    }
+  }
 
-    _handleEnter(e) {
-      e = this.getKeyboardEvent(e);
-      const target = Polymer.dom(e).rootTarget;
-      if (target === this._nativeInput) {
-        e.preventDefault();
-        this._save();
-      }
-    },
+  _handleEsc(e) {
+    e = this.getKeyboardEvent(e);
+    const target = dom(e).rootTarget;
+    if (target === this._nativeInput) {
+      e.preventDefault();
+      this._cancel();
+    }
+  }
 
-    _handleEsc(e) {
-      e = this.getKeyboardEvent(e);
-      const target = Polymer.dom(e).rootTarget;
-      if (target === this._nativeInput) {
-        e.preventDefault();
-        this._cancel();
-      }
-    },
+  _computeLabelClass(readOnly, value, placeholder) {
+    const classes = [];
+    if (!readOnly) { classes.push('editable'); }
+    if (this._usePlaceholder(value, placeholder)) {
+      classes.push('placeholder');
+    }
+    return classes.join(' ');
+  }
 
-    _computeLabelClass(readOnly, value, placeholder) {
-      const classes = [];
-      if (!readOnly) { classes.push('editable'); }
-      if (this._usePlaceholder(value, placeholder)) {
-        classes.push('placeholder');
-      }
-      return classes.join(' ');
-    },
+  _updateTitle(value) {
+    this.setAttribute('title', this._computeLabel(value, this.placeholder));
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
new file mode 100644
index 0000000..a226e30
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: inline-flex;
+    }
+    :host([uppercase]) label {
+      text-transform: uppercase;
+    }
+    input,
+    label {
+      width: 100%;
+    }
+    label {
+      color: var(--deemphasized-text-color);
+      display: inline-block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      @apply --label-style;
+    }
+    label.editable {
+      color: var(--link-color);
+      cursor: pointer;
+    }
+    #dropdown {
+      box-shadow: var(--elevation-level-2);
+    }
+    .inputContainer {
+      background-color: var(--dialog-background-color);
+      padding: var(--spacing-m);
+      @apply --input-style;
+    }
+    .buttons {
+      display: flex;
+      justify-content: flex-end;
+      padding-top: var(--spacing-l);
+      width: 100%;
+    }
+    .buttons gr-button {
+      margin-left: var(--spacing-m);
+    }
+    paper-input {
+      --paper-input-container: {
+        padding: 0;
+        min-width: 15em;
+      }
+      --paper-input-container-input: {
+        font-size: inherit;
+      }
+      --paper-input-container-focus-color: var(--link-color);
+    }
+  </style>
+  <label
+    class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
+    title$="[[_computeLabel(value, placeholder)]]"
+    on-click="_showDropdown"
+    >[[_computeLabel(value, placeholder)]]</label
+  >
+  <iron-dropdown
+    id="dropdown"
+    vertical-align="auto"
+    horizontal-align="auto"
+    vertical-offset="[[_verticalOffset]]"
+    allow-outside-scroll="true"
+    on-iron-overlay-canceled="_cancel"
+  >
+    <div class="dropdown-content" slot="dropdown-content">
+      <div class="inputContainer">
+        <paper-input
+          id="input"
+          label="[[labelText]]"
+          maxlength="[[maxLength]]"
+          value="{{_inputText}}"
+        ></paper-input>
+        <div class="buttons">
+          <gr-button link="" id="cancelBtn" on-click="_cancel"
+            >cancel</gr-button
+          >
+          <gr-button link="" id="saveBtn" on-click="_save">save</gr-button>
+        </div>
+      </div>
+    </div>
+  </iron-dropdown>
+`;
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..5673194 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
@@ -17,18 +17,20 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-editable-label.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<!-- Can't use absolute path below for mock-interaction.js.
+Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
+actually /node_modules directory). Also, wct patches some files to load modules from /components.
+With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
+/node_modules/...) though this is actually the same file. This leads to a run-time error.
+-->
+<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -53,171 +55,173 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-editable-label tests', () => {
-    let element;
-    let elementNoPlaceholder;
-    let input;
-    let label;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-editable-label.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-editable-label tests', () => {
+  let element;
+  let elementNoPlaceholder;
+  let input;
+  let label;
+  let sandbox;
 
-    setup(done => {
-      element = fixture('basic');
-      elementNoPlaceholder = fixture('no-placeholder');
+  setup(done => {
+    element = fixture('basic');
+    elementNoPlaceholder = fixture('no-placeholder');
 
-      label = element.$$('label');
-      sandbox = sinon.sandbox.create();
-      flush(() => {
-        // In Polymer 2 inputElement isn't nativeInput anymore
-        input = element.$.input.$.nativeInput || element.$.input.inputElement;
-        done();
-      });
+    label = element.shadowRoot
+        .querySelector('label');
+    sandbox = sinon.sandbox.create();
+    flush(() => {
+      // In Polymer 2 inputElement isn't nativeInput anymore
+      input = element.$.input.$.nativeInput || element.$.input.inputElement;
+      done();
     });
+  });
 
-    teardown(() => {
-      sandbox.restore();
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('element render', () => {
+    // The dropdown is closed and the label is visible:
+    assert.isFalse(element.$.dropdown.opened);
+    assert.isTrue(label.classList.contains('editable'));
+    assert.equal(label.textContent, 'value text');
+    const focusSpy = sandbox.spy(input, 'focus');
+    const showSpy = sandbox.spy(element, '_showDropdown');
+
+    MockInteractions.tap(label);
+
+    return showSpy.lastCall.returnValue.then(() => {
+      // The dropdown is open (which covers up the label):
+      assert.isTrue(element.$.dropdown.opened);
+      assert.isTrue(focusSpy.called);
+      assert.equal(input.value, 'value text');
     });
+  });
 
-    test('element render', () => {
-      // The dropdown is closed and the label is visible:
-      assert.isFalse(element.$.dropdown.opened);
-      assert.isTrue(label.classList.contains('editable'));
-      assert.equal(label.textContent, 'value text');
-      const focusSpy = sandbox.spy(input, 'focus');
-      const showSpy = sandbox.spy(element, '_showDropdown');
+  test('title with placeholder', done => {
+    assert.equal(element.title, 'value text');
+    element.value = '';
 
-      MockInteractions.tap(label);
-
-      return showSpy.lastCall.returnValue.then(() => {
-        // The dropdown is open (which covers up the label):
-        assert.isTrue(element.$.dropdown.opened);
-        assert.isTrue(focusSpy.called);
-        assert.equal(input.value, 'value text');
-      });
+    element.async(() => {
+      assert.equal(element.title, 'label text');
+      done();
     });
+  });
 
-    test('title with placeholder', done => {
+  test('title without placeholder', done => {
+    assert.equal(elementNoPlaceholder.title, '');
+    element.value = 'value text';
+
+    element.async(() => {
       assert.equal(element.title, 'value text');
-      element.value = '';
-
-      element.async(() => {
-        assert.equal(element.title, 'label text');
-        done();
-      });
+      done();
     });
+  });
 
-    test('title without placeholder', done => {
-      assert.equal(elementNoPlaceholder.title, '');
-      element.value = 'value text';
+  test('edit value', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
 
-      element.async(() => {
-        assert.equal(element.title, 'value text');
-        done();
-      });
-    });
+    MockInteractions.tap(label);
 
-    test('edit value', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isTrue(editedStub.called);
+      assert.equal(input.value, 'new text');
       assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isTrue(editedStub.called);
-        assert.equal(input.value, 'new text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press enter:
-      MockInteractions.keyDownOn(input, 13);
+      done();
     });
 
-    test('save button', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
+    // Press enter:
+    MockInteractions.keyDownOn(input, 13);
+  });
+
+  test('save button', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isTrue(editedStub.called);
+      assert.equal(input.value, 'new text');
       assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isTrue(editedStub.called);
-        assert.equal(input.value, 'new text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press enter:
-      MockInteractions.tap(element.$.saveBtn, 13);
+      done();
     });
 
+    // Press enter:
+    MockInteractions.tap(element.$.saveBtn, 13);
+  });
 
-    test('edit and then escape key', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
+  test('edit and then escape key', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isFalse(editedStub.called);
+      // Text changes sould be discarded.
+      assert.equal(input.value, 'value text');
       assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isFalse(editedStub.called);
-        // Text changes sould be discarded.
-        assert.equal(input.value, 'value text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press escape:
-      MockInteractions.keyDownOn(input, 27);
+      done();
     });
 
-    test('cancel button', done => {
-      const editedStub = sandbox.stub();
-      element.addEventListener('changed', editedStub);
+    // Press escape:
+    MockInteractions.keyDownOn(input, 27);
+  });
+
+  test('cancel button', done => {
+    const editedStub = sandbox.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isFalse(editedStub.called);
+      // Text changes sould be discarded.
+      assert.equal(input.value, 'value text');
       assert.isFalse(element.editing);
-
-      MockInteractions.tap(label);
-
-      Polymer.dom.flush();
-
-      assert.isTrue(element.editing);
-      element._inputText = 'new text';
-
-      assert.isFalse(editedStub.called);
-
-      element.async(() => {
-        assert.isFalse(editedStub.called);
-        // Text changes sould be discarded.
-        assert.equal(input.value, 'value text');
-        assert.isFalse(element.editing);
-        done();
-      });
-
-      // Press escape:
-      MockInteractions.tap(element.$.cancelBtn);
+      done();
     });
+
+    // Press escape:
+    MockInteractions.tap(element.$.cancelBtn);
   });
 
   suite('gr-editable-label read-only tests', () => {
@@ -226,7 +230,8 @@
 
     setup(() => {
       element = fixture('read-only');
-      label = element.$$('label');
+      label = element.shadowRoot
+          .querySelector('label');
     });
 
     test('disallows edit when read-only', () => {
@@ -234,7 +239,7 @@
       assert.isFalse(element.$.dropdown.opened);
       MockInteractions.tap(label);
 
-      Polymer.dom.flush();
+      flush$0();
 
       // The dropdown is still closed.
       assert.isFalse(element.$.dropdown.opened);
@@ -244,4 +249,5 @@
       assert.isFalse(label.classList.contains('editable'));
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js b/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
new file mode 100644
index 0000000..cfe4c4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {EventEmitter} from '../gr-event-interface/gr-event-interface.js';
+
+// TODO(dmfilippov): move to appContext
+export const gerritEventEmitter = new EventEmitter();
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
index f763457..7705874 100644
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
@@ -15,129 +15,120 @@
  * limitations under the License.
  */
 
-(function(window) {
-  'use strict';
-
-  // Avoid duplicate registeration
-  if (window.EventEmitter) return;
-
-  /**
-   * An lite implementation of
-   * https://nodejs.org/api/events.html#events_class_eventemitter.
-   *
-   * This is unrelated to the native DOM events, you should use it when you want
-   * to enable EventEmitter interface on any class.
-   *
-   * @example
-   *
-   * class YourClass extends EventEmitter {
-   *   // now all instance of YourClass will have this EventEmitter interface
-   * }
-   *
-   */
-  class EventEmitter {
-    constructor() {
-      /**
-       * Shared events map from name to the listeners.
-       *
-       * @type {!Object<string, Array<eventCallback>>}
-       */
-      this._listenersMap = new Map();
-    }
-
+/**
+ * An lite implementation of
+ * https://nodejs.org/api/events.html#events_class_eventemitter.
+ *
+ * This is unrelated to the native DOM events, you should use it when you want
+ * to enable EventEmitter interface on any class.
+ *
+ * @example
+ *
+ * class YourClass extends EventEmitter {
+ *   // now all instance of YourClass will have this EventEmitter interface
+ * }
+ *
+ */
+export class EventEmitter {
+  constructor() {
     /**
-     * Register an event listener to an event.
+     * Shared events map from name to the listeners.
      *
-     * @param {string} eventName
-     * @param {eventCallback} cb
-     * @returns {Function} Unsubscribe method
+     * @type {!Object<string, Array<eventCallback>>}
      */
-    addListener(eventName, cb) {
-      if (!eventName || !cb) {
-        console.warn('A valid eventname and callback is required!');
-        return;
-      }
-
-      const listeners = this._listenersMap.get(eventName) || [];
-      listeners.push(cb);
-      this._listenersMap.set(eventName, listeners);
-
-      return () => {
-        this.off(eventName, cb);
-      };
-    }
-
-    // Alias for addListener.
-    on(eventName, cb) {
-      return this.addListener(eventName, cb);
-    }
-
-    // Attach event handler only once. Automatically removed.
-    once(eventName, cb) {
-      const onceWrapper = (...args) => {
-        cb(...args);
-        this.off(eventName, onceWrapper);
-      };
-      return this.on(eventName, onceWrapper);
-    }
-
-    /**
-     * De-register an event listener to an event.
-     *
-     * @param {string} eventName
-     * @param {eventCallback} cb
-     */
-    removeListener(eventName, cb) {
-      let listeners = this._listenersMap.get(eventName) || [];
-      listeners = listeners.filter(listener => listener !== cb);
-      this._listenersMap.set(eventName, listeners);
-    }
-
-    // Alias to removeListener
-    off(eventName, cb) {
-      this.removeListener(eventName, cb);
-    }
-
-    /**
-     * Synchronously calls each of the listeners registered for
-     * the event named eventName, in the order they were registered,
-     * passing the supplied detail to each.
-     *
-     * Returns true if the event had listeners, false otherwise.
-     *
-     * @param {string} eventName
-     * @param {*} detail
-     */
-    emit(eventName, detail) {
-      const listeners = this._listenersMap.get(eventName) || [];
-      for (const listener of listeners) {
-        try {
-          listener(detail);
-        } catch (e) {
-          console.error(e);
-        }
-      }
-      return listeners.length !== 0;
-    }
-
-    // Alias to emit.
-    dispatch(eventName, detail) {
-      return this.emit(eventName, detail);
-    }
-
-    /**
-     * Remove listeners for a specific event or all.
-     *
-     * @param {string} eventName if not provided, will remove all
-     */
-    removeAllListeners(eventName) {
-      if (eventName) {
-        this._listenersMap.set(eventName, []);
-      } else {
-        this._listenersMap = new Map();
-      }
-    }
+    this._listenersMap = new Map();
   }
 
-  window.EventEmitter = EventEmitter;
-})(window);
\ No newline at end of file
+  /**
+   * Register an event listener to an event.
+   *
+   * @param {string} eventName
+   * @param {eventCallback} cb
+   * @returns {Function} Unsubscribe method
+   */
+  addListener(eventName, cb) {
+    if (!eventName || !cb) {
+      console.warn('A valid eventname and callback is required!');
+      return;
+    }
+
+    const listeners = this._listenersMap.get(eventName) || [];
+    listeners.push(cb);
+    this._listenersMap.set(eventName, listeners);
+
+    return () => {
+      this.off(eventName, cb);
+    };
+  }
+
+  // Alias for addListener.
+  on(eventName, cb) {
+    return this.addListener(eventName, cb);
+  }
+
+  // Attach event handler only once. Automatically removed.
+  once(eventName, cb) {
+    const onceWrapper = (...args) => {
+      cb(...args);
+      this.off(eventName, onceWrapper);
+    };
+    return this.on(eventName, onceWrapper);
+  }
+
+  /**
+   * De-register an event listener to an event.
+   *
+   * @param {string} eventName
+   * @param {eventCallback} cb
+   */
+  removeListener(eventName, cb) {
+    let listeners = this._listenersMap.get(eventName) || [];
+    listeners = listeners.filter(listener => listener !== cb);
+    this._listenersMap.set(eventName, listeners);
+  }
+
+  // Alias to removeListener
+  off(eventName, cb) {
+    this.removeListener(eventName, cb);
+  }
+
+  /**
+   * Synchronously calls each of the listeners registered for
+   * the event named eventName, in the order they were registered,
+   * passing the supplied detail to each.
+   *
+   * Returns true if the event had listeners, false otherwise.
+   *
+   * @param {string} eventName
+   * @param {*} detail
+   */
+  emit(eventName, detail) {
+    const listeners = this._listenersMap.get(eventName) || [];
+    for (const listener of listeners) {
+      try {
+        listener(detail);
+      } catch (e) {
+        console.error(e);
+      }
+    }
+    return listeners.length !== 0;
+  }
+
+  // Alias to emit.
+  dispatch(eventName, detail) {
+    return this.emit(eventName, detail);
+  }
+
+  /**
+   * Remove listeners for a specific event or all.
+   *
+   * @param {string} eventName if not provided, will remove all
+   */
+  removeAllListeners(eventName) {
+    if (eventName) {
+      this._listenersMap.set(eventName, []);
+    } else {
+      this._listenersMap = new Map();
+    }
+  }
+}
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..74936ad 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
@@ -17,14 +17,11 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <title>gr-api-interface</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.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-js-api-interface/gr-js-api-interface.html">
-
-<script>void(0);</script>
+<script src="../../../node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -32,117 +29,124 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-event-interface tests', () => {
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../gr-js-api-interface/gr-js-api-interface.js';
+import {EventEmitter} from './gr-event-interface.js';
+import {_testOnly_initGerritPluginApi} from '../gr-js-api-interface/gr-gerrit.js';
 
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-event-interface tests', () => {
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('test on Gerrit', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
+      fixture('basic');
+      pluginApi.removeAllListeners();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('communicate between plugin and Gerrit', done => {
+      const eventName = 'test-plugin-event';
+      let p;
+      pluginApi.on(eventName, e => {
+        assert.equal(e.value, 'test');
+        assert.equal(e.plugin, p);
+        done();
+      });
+      pluginApi.install(plugin => {
+        p = plugin;
+        pluginApi.emit(eventName, {value: 'test', plugin});
+      }, '0.1',
+      'http://test.com/plugins/testplugin/static/test.js');
     });
 
-    suite('test on Gerrit', () => {
-      setup(() => {
-        fixture('basic');
-        Gerrit.removeAllListeners();
+    test('listen on events from core', done => {
+      const eventName = 'test-plugin-event';
+      pluginApi.on(eventName, e => {
+        assert.equal(e.value, 'test');
+        done();
       });
 
-      test('communicate between plugin and Gerrit', done => {
-        const eventName = 'test-plugin-event';
-        let p;
-        Gerrit.on(eventName, e => {
-          assert.equal(e.value, 'test');
-          assert.equal(e.plugin, p);
+      pluginApi.emit(eventName, {value: 'test'});
+    });
+
+    test('communicate across plugins', done => {
+      const eventName = 'test-plugin-event';
+      pluginApi.install(plugin => {
+        pluginApi.on(eventName, e => {
+          assert.equal(e.plugin.getPluginName(), 'testB');
           done();
         });
-        Gerrit.install(plugin => {
-          p = plugin;
-          Gerrit.emit(eventName, {value: 'test', plugin});
-        }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-      });
+      }, '0.1',
+      'http://test.com/plugins/testA/static/testA.js');
 
-      test('listen on events from core', done => {
-        const eventName = 'test-plugin-event';
-        Gerrit.on(eventName, e => {
-          assert.equal(e.value, 'test');
-          done();
-        });
-
-        Gerrit.emit(eventName, {value: 'test'});
-      });
-
-      test('communicate across plugins', done => {
-        const eventName = 'test-plugin-event';
-        Gerrit.install(plugin => {
-          Gerrit.on(eventName, e => {
-            assert.equal(e.plugin.getPluginName(), 'testB');
-            done();
-          });
-        }, '0.1',
-        'http://test.com/plugins/testA/static/testA.js');
-
-        Gerrit.install(plugin => {
-          Gerrit.emit(eventName, {plugin});
-        }, '0.1',
-        'http://test.com/plugins/testB/static/testB.js');
-      });
-    });
-
-    suite('test on interfaces', () => {
-      let testObj;
-
-      class TestClass extends EventEmitter {
-      }
-
-      setup(() => {
-        testObj = new TestClass();
-      });
-
-      test('on', () => {
-        const cbStub = sinon.stub();
-        testObj.on('test', cbStub);
-        testObj.emit('test');
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledTwice);
-      });
-
-      test('once', () => {
-        const cbStub = sinon.stub();
-        testObj.once('test', cbStub);
-        testObj.emit('test');
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledOnce);
-      });
-
-      test('unsubscribe', () => {
-        const cbStub = sinon.stub();
-        const unsubscribe = testObj.on('test', cbStub);
-        testObj.emit('test');
-        unsubscribe();
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledOnce);
-      });
-
-      test('off', () => {
-        const cbStub = sinon.stub();
-        testObj.on('test', cbStub);
-        testObj.emit('test');
-        testObj.off('test', cbStub);
-        testObj.emit('test');
-        assert.isTrue(cbStub.calledOnce);
-      });
-
-      test('removeAllListeners', () => {
-        const cbStub = sinon.stub();
-        testObj.on('test', cbStub);
-        testObj.removeAllListeners('test');
-        testObj.emit('test');
-        assert.isTrue(cbStub.notCalled);
-      });
+      pluginApi.install(plugin => {
+        pluginApi.emit(eventName, {plugin});
+      }, '0.1',
+      'http://test.com/plugins/testB/static/testB.js');
     });
   });
-</script>
\ No newline at end of file
+
+  suite('test on interfaces', () => {
+    let testObj;
+
+    class TestClass extends EventEmitter {
+    }
+
+    setup(() => {
+      testObj = new TestClass();
+    });
+
+    test('on', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.emit('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledTwice);
+    });
+
+    test('once', () => {
+      const cbStub = sinon.stub();
+      testObj.once('test', cbStub);
+      testObj.emit('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('unsubscribe', () => {
+      const cbStub = sinon.stub();
+      const unsubscribe = testObj.on('test', cbStub);
+      testObj.emit('test');
+      unsubscribe();
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('off', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.emit('test');
+      testObj.off('test', cbStub);
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('removeAllListeners', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.removeAllListeners('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.notCalled);
+    });
+  });
+});
+</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
deleted file mode 100644
index 226092f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
+++ /dev/null
@@ -1,52 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-fixed-panel">
-  <template>
-    <style include="shared-styles">
-      :host {
-        box-sizing: border-box;
-        display: block;
-        min-height: var(--header-height);
-        position: relative;
-      }
-      header {
-        background: inherit;
-        border: inherit;
-        display: inline;
-        height: inherit;
-      }
-      .floating {
-        left: 0;
-        position: fixed;
-        width: 100%;
-        will-change: top;
-      }
-      .fixedAtTop {
-        border-bottom: 1px solid #a4a4a4;
-        box-shadow: 0 4px 4px rgba(0,0,0,0.1);
-      }
-    </style>
-    <header id="header" class$="[[_computeHeaderClass(_headerFloating, _topLast)]]">
-      <slot></slot>
-    </header>
-  </template>
-  <script src="gr-fixed-panel.js"></script>
-</dom-module>
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..0d19f00 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
@@ -14,14 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-fixed-panel',
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-fixed-panel_html.js';
 
-    properties: {
-      floatingDisabled: Boolean,
+/** @extends Polymer.Element */
+class GrFixedPanel extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-fixed-panel'; }
+
+  static get properties() {
+    return {
+      floatingDisabled: {
+        type: Boolean,
+        value: false,
+      },
       readyForMeasure: {
         type: Boolean,
         observer: '_readyForMeasureObserver',
@@ -54,143 +68,177 @@
         type: Object,
         value: null,
       },
+      /**
+       * If place before any other content defines how much
+       * of the content below it is covered by this panel
+       */
+      floatingHeight: {
+        type: Number,
+        value: 0,
+        notify: true,
+      },
+
       _webComponentsReady: Boolean,
-    },
+    };
+  }
 
-    attached() {
-      if (this.floatingDisabled) {
-        return;
-      }
-      // Enable content measure unless blocked by param.
-      if (this.readyForMeasure !== false) {
-        this.readyForMeasure = true;
-      }
-      this.listen(window, 'resize', 'update');
-      this.listen(window, 'scroll', '_updateOnScroll');
-      this._observer = new MutationObserver(this.update.bind(this));
-      this._observer.observe(this.$.header, {childList: true, subtree: true});
-    },
+  static get observers() {
+    return [
+      '_updateFloatingHeight(floatingDisabled, _isMeasured, _headerHeight)',
+    ];
+  }
 
-    detached() {
-      this.unlisten(window, 'scroll', '_updateOnScroll');
-      this.unlisten(window, 'resize', 'update');
-      if (this._observer) {
-        this._observer.disconnect();
-      }
-    },
+  _updateFloatingHeight(floatingDisabled, isMeasured, headerHeight) {
+    if ([
+      floatingDisabled,
+      isMeasured,
+      headerHeight,
+    ].some(arg => arg === undefined)) {
+      return;
+    }
+    this.floatingHeight =
+        (!floatingDisabled && isMeasured) ? headerHeight : 0;
+  }
 
-    _readyForMeasureObserver(readyForMeasure) {
-      if (readyForMeasure) {
-        this.update();
-      }
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    if (this.floatingDisabled) {
+      return;
+    }
+    // Enable content measure unless blocked by param.
+    if (this.readyForMeasure !== false) {
+      this.readyForMeasure = true;
+    }
+    this.listen(window, 'resize', 'update');
+    this.listen(window, 'scroll', '_updateOnScroll');
+    this._observer = new MutationObserver(this.update.bind(this));
+    this._observer.observe(this.$.header, {childList: true, subtree: true});
+  }
 
-    _computeHeaderClass(headerFloating, topLast) {
-      const fixedAtTop = this.keepOnScroll && topLast === 0;
-      return [
-        headerFloating ? 'floating' : '',
-        fixedAtTop ? 'fixedAtTop' : '',
-      ].join(' ');
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_updateOnScroll');
+    this.unlisten(window, 'resize', 'update');
+    if (this._observer) {
+      this._observer.disconnect();
+    }
+  }
 
-    unfloat() {
-      if (this.floatingDisabled) {
-        return;
-      }
-      this.$.header.style.top = '';
-      this._headerFloating = false;
-      this.updateStyles({'--header-height': ''});
-    },
+  _readyForMeasureObserver(readyForMeasure) {
+    if (readyForMeasure) {
+      this.update();
+    }
+  }
 
-    update() {
-      this.debounce('update', () => {
-        this._updateDebounced();
-      }, 100);
-    },
+  _computeHeaderClass(headerFloating, topLast) {
+    const fixedAtTop = this.keepOnScroll && topLast === 0;
+    return [
+      headerFloating ? 'floating' : '',
+      fixedAtTop ? 'fixedAtTop' : '',
+    ].join(' ');
+  }
 
-    _updateOnScroll() {
-      this.debounce('update', () => {
-        this._updateDebounced();
-      });
-    },
+  unfloat() {
+    if (this.floatingDisabled) {
+      return;
+    }
+    this.$.header.style.top = '';
+    this._headerFloating = false;
+    this.updateStyles({'--header-height': ''});
+  }
 
-    _updateDebounced() {
-      if (this.floatingDisabled) {
-        return;
-      }
-      this._isMeasured = false;
-      this._maybeFloatHeader();
-      this._reposition();
-    },
+  update() {
+    this.debounce('update', () => {
+      this._updateDebounced();
+    }, 100);
+  }
 
-    _getElementTop() {
-      return this.getBoundingClientRect().top;
-    },
+  _updateOnScroll() {
+    this.debounce('update', () => {
+      this._updateDebounced();
+    });
+  }
 
-    _reposition() {
-      if (!this._headerFloating) {
-        return;
-      }
-      const header = this.$.header;
-      // Since the outer element is relative positioned, can  use its top
-      // to determine how to position the inner header element.
-      const elemTop = this._getElementTop();
-      let newTop;
-      if (this.keepOnScroll && elemTop < 0) {
-        // Should stick to the top.
-        newTop = 0;
+  _updateDebounced() {
+    if (this.floatingDisabled) {
+      return;
+    }
+    this._isMeasured = false;
+    this._maybeFloatHeader();
+    this._reposition();
+  }
+
+  _getElementTop() {
+    return this.getBoundingClientRect().top;
+  }
+
+  _reposition() {
+    if (!this._headerFloating) {
+      return;
+    }
+    const header = this.$.header;
+    // Since the outer element is relative positioned, can  use its top
+    // to determine how to position the inner header element.
+    const elemTop = this._getElementTop();
+    let newTop;
+    if (this.keepOnScroll && elemTop < 0) {
+      // Should stick to the top.
+      newTop = 0;
+    } else {
+      // Keep in line with the outer element.
+      newTop = elemTop;
+    }
+    // Initialize top style if it doesn't exist yet.
+    if (!header.style.top && this._topLast === newTop) {
+      header.style.top = newTop;
+    }
+    if (this._topLast !== newTop) {
+      if (newTop === undefined) {
+        header.style.top = '';
       } else {
-        // Keep in line with the outer element.
-        newTop = elemTop;
+        header.style.top = newTop + 'px';
       }
-      // Initialize top style if it doesn't exist yet.
-      if (!header.style.top && this._topLast === newTop) {
-        header.style.top = newTop;
-      }
-      if (this._topLast !== newTop) {
-        if (newTop === undefined) {
-          header.style.top = '';
-        } else {
-          header.style.top = newTop + 'px';
-        }
-        this._topLast = newTop;
-      }
-    },
+      this._topLast = newTop;
+    }
+  }
 
-    _measure() {
-      if (this._isMeasured) {
-        return; // Already measured.
-      }
-      const rect = this.$.header.getBoundingClientRect();
-      if (rect.height === 0 && rect.width === 0) {
-        return; // Not ready for measurement yet.
-      }
-      const top = document.body.scrollTop + rect.top;
-      this._topLast = top;
-      this._headerHeight = rect.height;
-      this._topInitial =
-        this.getBoundingClientRect().top + document.body.scrollTop;
-      this._isMeasured = true;
-    },
+  _measure() {
+    if (this._isMeasured) {
+      return; // Already measured.
+    }
+    const rect = this.$.header.getBoundingClientRect();
+    if (rect.height === 0 && rect.width === 0) {
+      return; // Not ready for measurement yet.
+    }
+    const top = document.body.scrollTop + rect.top;
+    this._topLast = top;
+    this._headerHeight = rect.height;
+    this._topInitial =
+      this.getBoundingClientRect().top + document.body.scrollTop;
+    this._isMeasured = true;
+  }
 
-    _isFloatingNeeded() {
-      return this.keepOnScroll ||
-        document.body.scrollWidth > document.body.clientWidth;
-    },
+  _isFloatingNeeded() {
+    return this.keepOnScroll ||
+      document.body.scrollWidth > document.body.clientWidth;
+  }
 
-    _maybeFloatHeader() {
-      if (!this._isFloatingNeeded()) {
-        return;
-      }
-      this._measure();
-      if (this._isMeasured) {
-        this._floatHeader();
-      }
-    },
+  _maybeFloatHeader() {
+    if (!this._isFloatingNeeded()) {
+      return;
+    }
+    this._measure();
+    if (this._isMeasured) {
+      this._floatHeader();
+    }
+  }
 
-    _floatHeader() {
-      this.updateStyles({'--header-height': this._headerHeight + 'px'});
-      this._headerFloating = true;
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
new file mode 100644
index 0000000..61e8b24
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      box-sizing: border-box;
+      display: block;
+      min-height: var(--header-height);
+      position: relative;
+    }
+    header {
+      background: inherit;
+      border: inherit;
+      display: inline;
+      height: inherit;
+    }
+    .floating {
+      left: 0;
+      position: fixed;
+      width: 100%;
+      will-change: top;
+    }
+    .fixedAtTop {
+      border-bottom: 1px solid #a4a4a4;
+      box-shadow: var(--elevation-level-2);
+    }
+  </style>
+  <header
+    id="header"
+    class$="[[_computeHeaderClass(_headerFloating, _topLast)]]"
+  >
+    <slot></slot>
+  </header>
+`;
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..ef31382 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
@@ -17,16 +17,21 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-fixed-panel.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<style>
+  /* Prevent horizontal scrolling on page.
+   New version of web-component-tester creates body with margins */
+  body {
+    margin: 0px;
+    padding: 0px;
+  }
+</style>
 
 <test-fixture id="basic">
   <template>
@@ -36,82 +41,84 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-fixed-panel', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-fixed-panel.js';
+suite('gr-fixed-panel', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.readyForMeasure = true;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('can be disabled with floatingDisabled', () => {
+    element.floatingDisabled = true;
+    sandbox.stub(element, '_reposition');
+    window.dispatchEvent(new CustomEvent('resize'));
+    element.flushDebouncer('update');
+    assert.isFalse(element._reposition.called);
+  });
+
+  test('header is the height of the content', () => {
+    assert.equal(element.getBoundingClientRect().height, 100);
+  });
+
+  test('scroll triggers _reposition', () => {
+    sandbox.stub(element, '_reposition');
+    window.dispatchEvent(new CustomEvent('scroll'));
+    element.flushDebouncer('update');
+    assert.isTrue(element._reposition.called);
+  });
+
+  suite('_reposition', () => {
+    const getHeaderTop = function() {
+      return element.$.header.style.top;
+    };
+
+    const emulateScrollY = function(distance) {
+      element._getElementTop.returns(element._headerTopInitial - distance);
+      element._updateDebounced();
+      element.flushDebouncer('scroll');
+    };
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.readyForMeasure = true;
+      element._headerTopInitial = 10;
+      sandbox.stub(element, '_getElementTop')
+          .returns(element._headerTopInitial);
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('scrolls header along with document', () => {
+      emulateScrollY(20);
+      // No top property is set when !_headerFloating.
+      assert.equal(getHeaderTop(), '');
     });
 
-    test('can be disabled with floatingDisabled', () => {
-      element.floatingDisabled = true;
-      sandbox.stub(element, '_reposition');
-      window.dispatchEvent(new CustomEvent('resize'));
-      element.flushDebouncer('update');
-      assert.isFalse(element._reposition.called);
+    test('does not stick to the top by default', () => {
+      emulateScrollY(150);
+      // No top property is set when !_headerFloating.
+      assert.equal(getHeaderTop(), '');
     });
 
-    test('header is the height of the content', () => {
-      assert.equal(element.getBoundingClientRect().height, 100);
+    test('sticks to the top if enabled', () => {
+      element.keepOnScroll = true;
+      emulateScrollY(120);
+      assert.equal(getHeaderTop(), '0px');
     });
 
-    test('scroll triggers _reposition', () => {
-      sandbox.stub(element, '_reposition');
-      window.dispatchEvent(new CustomEvent('scroll'));
-      element.flushDebouncer('update');
-      assert.isTrue(element._reposition.called);
-    });
-
-    suite('_reposition', () => {
-      const getHeaderTop = function() {
-        return element.$.header.style.top;
-      };
-
-      const emulateScrollY = function(distance) {
-        element._getElementTop.returns(element._headerTopInitial - distance);
-        element._updateDebounced();
-        element.flushDebouncer('scroll');
-      };
-
-      setup(() => {
-        element._headerTopInitial = 10;
-        sandbox.stub(element, '_getElementTop')
-            .returns(element._headerTopInitial);
-      });
-
-      test('scrolls header along with document', () => {
-        emulateScrollY(20);
-        // No top property is set when !_headerFloating.
-        assert.equal(getHeaderTop(), '');
-      });
-
-      test('does not stick to the top by default', () => {
-        emulateScrollY(150);
-        // No top property is set when !_headerFloating.
-        assert.equal(getHeaderTop(), '');
-      });
-
-      test('sticks to the top if enabled', () => {
-        element.keepOnScroll = true;
-        emulateScrollY(120);
-        assert.equal(getHeaderTop(), '0px');
-      });
-
-      test('drops a shadow when fixed to the top', () => {
-        element.keepOnScroll = true;
-        emulateScrollY(5);
-        assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
-        emulateScrollY(120);
-        assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
-      });
+    test('drops a shadow when fixed to the top', () => {
+      element.keepOnScroll = true;
+      emulateScrollY(5);
+      assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
+      emulateScrollY(120);
+      assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
deleted file mode 100644
index a98952c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
+++ /dev/null
@@ -1,63 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-linked-text/gr-linked-text.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-formatted-text">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        font-family: var(--font-family);
-      }
-      p,
-      ul,
-      blockquote,
-      gr-linked-text.pre {
-        margin: 0 0 var(--spacing-m) 0;
-      }
-      p,
-      ul,
-      blockquote {
-        max-width: var(--gr-formatted-text-prose-max-width, none);
-      }
-      :host(.noTrailingMargin) p:last-child,
-      :host(.noTrailingMargin) ul:last-child,
-      :host(.noTrailingMargin) blockquote:last-child,
-      :host(.noTrailingMargin) gr-linked-text.pre:last-child {
-        margin: 0;
-      }
-      blockquote {
-        border-left: 1px solid #aaa;
-        padding: 0 var(--spacing-m);
-      }
-      li {
-        list-style-type: disc;
-        margin-left: var(--spacing-xl);
-      }
-      gr-linked-text.pre {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        line-height: var(--line-height-code);
-      }
-
-    </style>
-    <div id="container"></div>
-  </template>
-  <script src="gr-formatted-text.js"></script>
-</dom-module>
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..139e09c 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
@@ -14,16 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // eslint-disable-next-line no-unused-vars
-  const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
+import '../gr-linked-text/gr-linked-text.js';
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-formatted-text_html.js';
 
-  Polymer({
-    is: 'gr-formatted-text',
+// eslint-disable-next-line no-unused-vars
+const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
+const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
-    properties: {
+/** @extends Polymer.Element */
+class GrFormattedText extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-formatted-text'; }
+
+  static get properties() {
+    return {
       content: {
         type: String,
         observer: '_contentChanged',
@@ -33,266 +47,263 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_contentOrConfigChanged(content, config)',
-    ],
+    ];
+  }
 
-    ready() {
-      if (this.noTrailingMargin) {
-        this.classList.add('noTrailingMargin');
-      }
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    if (this.noTrailingMargin) {
+      this.classList.add('noTrailingMargin');
+    }
+  }
 
-    /**
-     * Get the plain text as it appears in the generated DOM.
-     *
-     * This differs from the `content` property in that it will not include
-     * formatting markers such as > characters to make quotes or * and - markers
-     * to make list items.
-     *
-     * @return {string}
-     */
-    getTextContent() {
-      return this._blocksToText(this._computeBlocks(this.content));
-    },
+  _contentChanged(content) {
+    // In the case where the config may not be set (perhaps due to the
+    // request for it still being in flight), set the content anyway to
+    // prevent waiting on the config to display the text.
+    if (this.config) { return; }
+    this._contentOrConfigChanged(content);
+  }
 
-    _contentChanged(content) {
-      // In the case where the config may not be set (perhaps due to the
-      // request for it still being in flight), set the content anyway to
-      // 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.
+   */
+  _contentOrConfigChanged(content) {
+    const container = dom(this.$.container);
 
-    /**
-     * Given a source string, update the DOM inside #container.
-     */
-    _contentOrConfigChanged(content) {
-      const container = Polymer.dom(this.$.container);
+    // Remove existing content.
+    while (container.firstChild) {
+      container.removeChild(container.firstChild);
+    }
 
-      // Remove existing content.
-      while (container.firstChild) {
-        container.removeChild(container.firstChild);
+    // Add new content.
+    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
+   * has a `type` property which takes any of the follwoing values.
+   * * 'paragraph'
+   * * 'quote' (Block quote.)
+   * * 'pre' (Pre-formatted text.)
+   * * 'list' (Unordered list.)
+   * * 'code' (code blocks.)
+   *
+   * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
+   * property that maps to a string of the block's content.
+   *
+   * For blocks of type 'list', there is an `items` property that maps to a
+   * list of strings representing the list items.
+   *
+   * For blocks of type 'quote', there is a `blocks` property that maps to a
+   * list of blocks contained in the quote.
+   *
+   * NOTE: Strings appearing in all block objects are NOT escaped.
+   *
+   * @param {string} content
+   * @return {!Array<!Object>}
+   */
+  _computeBlocks(content) {
+    if (!content) { return []; }
+
+    const result = [];
+    const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      if (!lines[i].length) {
+        continue;
       }
 
-      // Add new content.
-      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
-     * has a `type` property which takes any of the follwoing values.
-     * * 'paragraph'
-     * * 'quote' (Block quote.)
-     * * 'pre' (Pre-formatted text.)
-     * * 'list' (Unordered list.)
-     *
-     * For blocks of type 'paragraph' and 'pre' there is a `text` property that
-     * maps to a string of the block's content.
-     *
-     * For blocks of type 'list', there is an `items` property that maps to a
-     * list of strings representing the list items.
-     *
-     * For blocks of type 'quote', there is a `blocks` property that maps to a
-     * list of blocks contained in the quote.
-     *
-     * NOTE: Strings appearing in all block objects are NOT escaped.
-     *
-     * @param {string} content
-     * @return {!Array<!Object>}
-     */
-    _computeBlocks(content) {
-      if (!content) { return []; }
-
-      const result = [];
-      const split = content.split('\n\n');
-      let p;
-
-      for (let i = 0; i < split.length; i++) {
-        p = split[i];
-        if (!p.length) { continue; }
-
-        if (this._isQuote(p)) {
-          result.push(this._makeQuote(p));
-        } else if (this._isPreFormat(p)) {
-          result.push({type: 'pre', text: p});
-        } else if (this._isList(p)) {
-          this._makeList(p, result);
-        } else {
-          result.push({type: 'paragraph', text: p});
+      if (this._isCodeMarkLine(lines[i])) {
+        // handle multi-line code
+        let nextI = i+1;
+        while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
+          nextI++;
         }
-      }
-      return result;
-    },
 
-    /**
-     * Take a block of comment text that contains a list and potentially
-     * a paragraph (but does not contain blank lines), generate appropriate
-     * block objects and append them to the output list.
-     *
-     * In simple cases, this will generate a single list block. For example, on
-     * the following input.
-     *
-     *    * Item one.
-     *    * Item two.
-     *    * item three.
-     *
-     * However, if the list starts with a paragraph, it will need to also
-     * generate that paragraph. Consider the following input.
-     *
-     *    A bit of text describing the context of the list:
-     *    * List item one.
-     *    * List item two.
-     *    * Et cetera.
-     *
-     * In this case, `_makeList` generates a paragraph block object
-     * containing the non-bullet-prefixed text, followed by a list block.
-     *
-     * @param {!string} p The block containing the list (as well as a
-     *   potential paragraph).
-     * @param {!Array<!Object>} out The list of blocks to append to.
-     */
-    _makeList(p, out) {
-      let block = null;
-      let inList = false;
-      let inParagraph = false;
-      const lines = p.split('\n');
-      let line;
-
-      for (let i = 0; i < lines.length; i++) {
-        line = lines[i];
-
-        if (line[0] === '-' || line[0] === '*') {
-          // The next line looks like a list item. If not building a list
-          // already, then create one. Remove the list item marker (* or -) from
-          // the line.
-          if (!inList) {
-            if (inParagraph) {
-              // Add the finished paragraph block to the result.
-              inParagraph = false;
-              if (block !== null) {
-                out.push(block);
-              }
-            }
-            inList = true;
-            block = {type: 'list', items: []};
-          }
-          line = line.substring(1).trim();
-        } else if (!inList) {
-          // Otherwise, if a list has not yet been started, but the next line
-          // does not look like a list item, then add the line to a paragraph
-          // block. If a paragraph block has not yet been started, then create
-          // one.
-          if (!inParagraph) {
-            inParagraph = true;
-            block = {type: 'paragraph', text: ''};
-          } else {
-            block.text += ' ';
-          }
-          block.text += line;
+        if (this._isCodeMarkLine(lines[nextI])) {
+          result.push({
+            type: 'code',
+            text: lines.slice(i+1, nextI).join('\n'),
+          });
+          i = nextI;
           continue;
         }
-        block.items.push(line);
+
+        // otherwise treat it as regular line and continue
+        // check for other cases
       }
-      if (block !== null) {
-        out.push(block);
+
+      if (this._isSingleLineCode(lines[i])) {
+        // no guard check as _isSingleLineCode tested on the pattern
+        const codeContent = lines[i].match(CODE_MARKER_PATTERN)[2];
+        result.push({type: 'code', text: codeContent});
+      } else if (this._isList(lines[i])) {
+        let nextI = i + 1;
+        while (this._isList(lines[nextI])) {
+          nextI++;
+        }
+        result.push(this._makeList(lines.slice(i, nextI)));
+        i = nextI - 1;
+      } else if (this._isQuote(lines[i])) {
+        let nextI = i + 1;
+        while (this._isQuote(lines[nextI])) {
+          nextI++;
+        }
+        const blockLines = lines.slice(i, nextI)
+            .map(l => l.replace(/^[ ]?>[ ]?/, ''));
+        result.push({
+          type: 'quote',
+          blocks: this._computeBlocks(blockLines.join('\n')),
+        });
+        i = nextI - 1;
+      } else if (this._isPreFormat(lines[i])) {
+        let nextI = i + 1;
+        // include pre or all regular lines but stop at next new line
+        while (this._isPreFormat(lines[nextI])
+         || (this._isRegularLine(lines[nextI]) && lines[nextI].length)) {
+          nextI++;
+        }
+        result.push({
+          type: 'pre',
+          text: lines.slice(i, nextI).join('\n'),
+        });
+        i = nextI - 1;
+      } else {
+        let nextI = i + 1;
+        while (this._isRegularLine(lines[nextI])) {
+          nextI++;
+        }
+        result.push({
+          type: 'paragraph',
+          text: lines.slice(i, nextI).join('\n'),
+        });
+        i = nextI - 1;
       }
-    },
+    }
 
-    _makeQuote(p) {
-      const quotedLines = p
-          .split('\n')
-          .map(l => l.replace(/^[ ]?>[ ]?/, ''))
-          .join('\n');
-      return {
-        type: 'quote',
-        blocks: this._computeBlocks(quotedLines),
-      };
-    },
+    return result;
+  }
 
-    _isQuote(p) {
-      return p.startsWith('> ') || p.startsWith(' > ');
-    },
+  /**
+   * Take a block of comment text that contains a list, generate appropriate
+   * block objects and append them to the output list.
+   *
+   * * Item one.
+   * * Item two.
+   * * item three.
+   *
+   * TODO(taoalpha): maybe we should also support nested list
+   *
+   * @param {!Array<string>} lines The block containing the list.
+   */
+  _makeList(lines) {
+    const block = {type: 'list', items: []};
+    let line;
 
-    _isPreFormat(p) {
-      return p.includes('\n ') || p.includes('\n\t') ||
-          p.startsWith(' ') || p.startsWith('\t');
-    },
+    for (let i = 0; i < lines.length; i++) {
+      line = lines[i];
+      line = line.substring(1).trim();
+      block.items.push(line);
+    }
+    return block;
+  }
 
-    _isList(p) {
-      return p.includes('\n- ') || p.includes('\n* ') ||
-          p.startsWith('- ') || p.startsWith('* ');
-    },
+  _isRegularLine(line) {
+    // line can not be recognized by existing patterns
+    if (line === undefined) return false;
+    return !this._isQuote(line) && !this._isCodeMarkLine(line)
+    && !this._isSingleLineCode(line) && !this._isList(line) &&
+    !this._isPreFormat(line);
+  }
 
-    /**
-     * @param {string} content
-     * @param {boolean=} opt_isPre
-     */
-    _makeLinkedText(content, opt_isPre) {
-      const text = document.createElement('gr-linked-text');
-      text.config = this.config;
-      text.content = content;
-      text.pre = true;
-      if (opt_isPre) {
-        text.classList.add('pre');
+  _isQuote(line) {
+    return line && (line.startsWith('> ') || line.startsWith(' > '));
+  }
+
+  _isCodeMarkLine(line) {
+    return line && line.trim() === '```';
+  }
+
+  _isSingleLineCode(line) {
+    return line && CODE_MARKER_PATTERN.test(line);
+  }
+
+  _isPreFormat(line) {
+    return line && /^[ \t]/.test(line);
+  }
+
+  _isList(line) {
+    return line && /^[-*] /.test(line);
+  }
+
+  /**
+   * @param {string} content
+   * @param {boolean=} opt_isPre
+   */
+  _makeLinkedText(content, opt_isPre) {
+    const text = document.createElement('gr-linked-text');
+    text.config = this.config;
+    text.content = content;
+    text.pre = true;
+    if (opt_isPre) {
+      text.classList.add('pre');
+    }
+    return text;
+  }
+
+  /**
+   * Map an array of block objects to an array of DOM nodes.
+   *
+   * @param  {!Array<!Object>} blocks
+   * @return {!Array<!HTMLElement>}
+   */
+  _computeNodes(blocks) {
+    return blocks.map(block => {
+      if (block.type === 'paragraph') {
+        const p = document.createElement('p');
+        p.appendChild(this._makeLinkedText(block.text));
+        return p;
       }
-      return text;
-    },
 
-    /**
-     * Map an array of block objects to an array of DOM nodes.
-     *
-     * @param  {!Array<!Object>} blocks
-     * @return {!Array<!HTMLElement>}
-     */
-    _computeNodes(blocks) {
-      return blocks.map(block => {
-        if (block.type === 'paragraph') {
-          const p = document.createElement('p');
-          p.appendChild(this._makeLinkedText(block.text));
-          return p;
+      if (block.type === 'quote') {
+        const bq = document.createElement('blockquote');
+        for (const node of this._computeNodes(block.blocks)) {
+          bq.appendChild(node);
         }
+        return bq;
+      }
 
-        if (block.type === 'quote') {
-          const bq = document.createElement('blockquote');
-          for (const node of this._computeNodes(block.blocks)) {
-            bq.appendChild(node);
-          }
-          return bq;
-        }
+      if (block.type === 'code') {
+        const code = document.createElement('code');
+        code.textContent = block.text;
+        return code;
+      }
 
-        if (block.type === 'pre') {
-          return this._makeLinkedText(block.text, true);
-        }
+      if (block.type === 'pre') {
+        return this._makeLinkedText(block.text, true);
+      }
 
-        if (block.type === 'list') {
-          const ul = document.createElement('ul');
-          for (const item of block.items) {
-            const li = document.createElement('li');
-            li.appendChild(this._makeLinkedText(item));
-            ul.appendChild(li);
-          }
-          return ul;
+      if (block.type === 'list') {
+        const ul = document.createElement('ul');
+        for (const item of block.items) {
+          const li = document.createElement('li');
+          li.appendChild(this._makeLinkedText(item));
+          ul.appendChild(li);
         }
-      });
-    },
+        return ul;
+      }
+    });
+  }
+}
 
-    _blocksToText(blocks) {
-      return blocks.map(block => {
-        if (block.type === 'paragraph' || block.type === 'pre') {
-          return block.text;
-        }
-        if (block.type === 'quote') {
-          return this._blocksToText(block.blocks);
-        }
-        if (block.type === 'list') {
-          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_html.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
new file mode 100644
index 0000000..5cb8670
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      font-family: var(--font-family);
+    }
+    p,
+    ul,
+    code,
+    blockquote,
+    gr-linked-text.pre {
+      margin: 0 0 var(--spacing-m) 0;
+    }
+    p,
+    ul,
+    code,
+    blockquote {
+      max-width: var(--gr-formatted-text-prose-max-width, none);
+    }
+    :host(.noTrailingMargin) p:last-child,
+    :host(.noTrailingMargin) ul:last-child,
+    :host(.noTrailingMargin) blockquote:last-child,
+    :host(.noTrailingMargin) gr-linked-text.pre:last-child {
+      margin: 0;
+    }
+    code,
+    blockquote {
+      border-left: 1px solid #aaa;
+      padding: 0 var(--spacing-m);
+    }
+    code {
+      display: block;
+      white-space: pre-wrap;
+      color: var(--deemphasized-text-color);
+    }
+    li {
+      list-style-type: disc;
+      margin-left: var(--spacing-xl);
+    }
+    gr-linked-text.pre {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: var(--line-height-code);
+    }
+  </style>
+  <div id="container"></div>
+`;
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..083eac4 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-formatted-text.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,351 +31,396 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-formatted-text tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-formatted-text.js';
+suite('gr-formatted-text tests', () => {
+  let element;
+  let sandbox;
 
-    function assertBlock(result, index, type, text) {
-      assert.equal(result[index].type, type);
-      assert.equal(result[index].text, text);
-    }
+  function assertBlock(result, index, type, text) {
+    assert.equal(result[index].type, type);
+    assert.equal(result[index].text, text);
+  }
 
-    function assertListBlock(result, resultIndex, itemIndex, text) {
-      assert.equal(result[resultIndex].type, 'list');
-      assert.equal(result[resultIndex].items[itemIndex], text);
-    }
+  function assertListBlock(result, resultIndex, itemIndex, text) {
+    assert.equal(result[resultIndex].type, 'list');
+    assert.equal(result[resultIndex].items[itemIndex], text);
+  }
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('parse null undefined and empty', () => {
-      assert.lengthOf(element._computeBlocks(null), 0);
-      assert.lengthOf(element._computeBlocks(undefined), 0);
-      assert.lengthOf(element._computeBlocks(''), 0);
-    });
-
-    test('parse simple', () => {
-      const comment = 'Para1';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'paragraph', comment);
-    });
-
-    test('parse multiline para', () => {
-      const comment = 'Para 1\nStill para 1';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'paragraph', comment);
-    });
-
-    test('parse para break', () => {
-      const comment = 'Para 1\n\nPara 2\n\nPara 3';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'Para 1');
-      assertBlock(result, 1, 'paragraph', 'Para 2');
-      assertBlock(result, 2, 'paragraph', 'Para 3');
-    });
-
-    test('parse quote', () => {
-      const comment = '> Quote text';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-    });
-
-    test('parse quote lead space', () => {
-      const comment = ' > Quote text';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-    });
-
-    test('parse excludes empty', () => {
-      const comment = 'Para 1\n\n\n\nPara 2';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'Para 1');
-      assertBlock(result, 1, 'paragraph', 'Para 2');
-    });
-
-    test('parse multiline quote', () => {
-      const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph',
-          'Quote line 1\nQuote line 2\nQuote line 3\n');
-    });
-
-    test('parse pre', () => {
-      const comment = '    Four space indent.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'pre', comment);
-    });
-
-    test('parse one space pre', () => {
-      const comment = ' One space indent.\n Another line.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'pre', comment);
-    });
-
-    test('parse tab pre', () => {
-      const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'pre', comment);
-    });
-
-    test('parse intermediate leading whitespace pre', () => {
-      const comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertBlock(result, 0, 'pre', comment);
-    });
-
-    test('parse star list', () => {
-      const comment = '* Item 1\n* Item 2\n* Item 3';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertListBlock(result, 0, 0, 'Item 1');
-      assertListBlock(result, 0, 1, 'Item 2');
-      assertListBlock(result, 0, 2, 'Item 3');
-    });
-
-    test('parse dash list', () => {
-      const comment = '- Item 1\n- Item 2\n- Item 3';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertListBlock(result, 0, 0, 'Item 1');
-      assertListBlock(result, 0, 1, 'Item 2');
-      assertListBlock(result, 0, 2, 'Item 3');
-    });
-
-    test('parse mixed list', () => {
-      const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assertListBlock(result, 0, 0, 'Item 1');
-      assertListBlock(result, 0, 1, 'Item 2');
-      assertListBlock(result, 0, 2, 'Item 3');
-      assertListBlock(result, 0, 3, 'Item 4');
-    });
-
-    test('parse mixed block types', () => {
-      const comment = 'Paragraph\nacross\na\nfew\nlines.' +
-          '\n\n' +
-          '> Quote\n> across\n> not many lines.' +
-          '\n\n' +
-          'Another paragraph' +
-          '\n\n' +
-          '* Series\n* of\n* list\n* items' +
-          '\n\n' +
-          'Yet another paragraph' +
-          '\n\n' +
-          '\tPreformatted text.' +
-          '\n\n' +
-          'Parting words.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 7);
-      assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.');
-
-      assert.equal(result[1].type, 'quote');
-      assert.lengthOf(result[1].blocks, 1);
-      assertBlock(result[1].blocks, 0, 'paragraph',
-          'Quote\nacross\nnot many lines.');
-
-      assertBlock(result, 2, 'paragraph', 'Another paragraph');
-      assertListBlock(result, 3, 0, 'Series');
-      assertListBlock(result, 3, 1, 'of');
-      assertListBlock(result, 3, 2, 'list');
-      assertListBlock(result, 3, 3, 'items');
-      assertBlock(result, 4, 'paragraph', 'Yet another paragraph');
-      assertBlock(result, 5, 'pre', '\tPreformatted text.');
-      assertBlock(result, 6, 'paragraph', 'Parting words.');
-    });
-
-    test('bullet list 1', () => {
-      const comment = 'A\n\n* line 1\n* 2nd line';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-    });
-
-    test('bullet list 2', () => {
-      const comment = 'A\n\n* line 1\n* 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-      assertBlock(result, 2, 'paragraph', 'B');
-    });
-
-    test('bullet list 3', () => {
-      const comment = '* line 1\n* 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertListBlock(result, 0, 0, 'line 1');
-      assertListBlock(result, 0, 1, '2nd line');
-      assertBlock(result, 1, 'paragraph', 'B');
-    });
-
-    test('bullet list 4', () => {
-      const comment = 'To see this bug, you have to:\n' +
-          '* Be on IMAP or EAS (not on POP)\n' +
-          '* Be very unlucky\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
-      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-      assertListBlock(result, 1, 1, 'Be very unlucky');
-    });
-
-    test('bullet list 5', () => {
-      const comment = 'To see this bug,\n' +
-          'you have to:\n' +
-          '* Be on IMAP or EAS (not on POP)\n' +
-          '* Be very unlucky\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
-      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-      assertListBlock(result, 1, 1, 'Be very unlucky');
-    });
-
-    test('dash list 1', () => {
-      const comment = 'A\n\n- line 1\n- 2nd line';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-    });
-
-    test('dash list 2', () => {
-      const comment = 'A\n\n- line 1\n- 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertListBlock(result, 1, 0, 'line 1');
-      assertListBlock(result, 1, 1, '2nd line');
-      assertBlock(result, 2, 'paragraph', 'B');
-    });
-
-    test('dash list 3', () => {
-      const comment = '- line 1\n- 2nd line\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertListBlock(result, 0, 0, 'line 1');
-      assertListBlock(result, 0, 1, '2nd line');
-      assertBlock(result, 1, 'paragraph', 'B');
-    });
-
-    test('pre format 1', () => {
-      const comment = 'A\n\n  This is pre\n  formatted';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-    });
-
-    test('pre format 2', () => {
-      const comment = 'A\n\n  This is pre\n  formatted\n\nbut this is not';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-      assertBlock(result, 2, 'paragraph', 'but this is not');
-    });
-
-    test('pre format 3', () => {
-      const comment = 'A\n\n  Q\n    <R>\n  S\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'A');
-      assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
-      assertBlock(result, 2, 'paragraph', 'B');
-    });
-
-    test('pre format 4', () => {
-      const comment = '  Q\n    <R>\n  S\n\nB';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
-      assertBlock(result, 1, 'paragraph', 'B');
-    });
-
-    test('quote 1', () => {
-      const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 2);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 1);
-      assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
-      assertBlock(result, 1, 'paragraph', 'See above.');
-    });
-
-    test('quote 2', () => {
-      const comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 3);
-      assertBlock(result, 0, 'paragraph', 'See this said:');
-      assert.equal(result[1].type, 'quote');
-      assert.lengthOf(result[1].blocks, 1);
-      assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
-      assertBlock(result, 2, 'paragraph', 'OK?');
-    });
-
-    test('nested quotes', () => {
-      const comment = ' > > prior\n > \n > next\n';
-      const result = element._computeBlocks(comment);
-      assert.lengthOf(result, 1);
-      assert.equal(result[0].type, 'quote');
-      assert.lengthOf(result[0].blocks, 2);
-      assert.equal(result[0].blocks[0].type, 'quote');
-      assert.lengthOf(result[0].blocks[0].blocks, 1);
-      assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
-      assertBlock(result[0].blocks, 1, 'paragraph', 'next\n');
-    });
-
-    test('getTextContent', () => {
-      const comment = 'Paragraph\n\n  pre\n\n* List\n* Of\n* Items\n\n> Quote';
-      element.content = comment;
-      const result = element.getTextContent();
-      const expected = 'Paragraph\n\n  pre\n\nList\nOf\nItems\n\nQuote';
-      assert.equal(result, expected);
-    });
-
-    test('_computeNodes called without config', () => {
-      const computeNodesSpy = sandbox.spy(element, '_computeNodes');
-      element.content = 'some text';
-      assert.isTrue(computeNodesSpy.called);
-    });
-
-    test('_contentOrConfigChanged called with config', () => {
-      const contentStub = sandbox.stub(element, '_contentChanged');
-      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
-      element.content = 'some text';
-      element.config = {};
-      assert.isTrue(contentStub.called);
-      assert.isTrue(contentConfigStub.called);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('parse null undefined and empty', () => {
+    assert.lengthOf(element._computeBlocks(null), 0);
+    assert.lengthOf(element._computeBlocks(undefined), 0);
+    assert.lengthOf(element._computeBlocks(''), 0);
+  });
+
+  test('parse simple', () => {
+    const comment = 'Para1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse multiline para', () => {
+    const comment = 'Para 1\nStill para 1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse para break without special blocks', () => {
+    const comment = 'Para 1\n\nPara 2\n\nPara 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse quote', () => {
+    const comment = '> Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+  });
+
+  test('parse quote lead space', () => {
+    const comment = ' > Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+  });
+
+  test('parse multiline quote', () => {
+    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph',
+        'Quote line 1\nQuote line 2\nQuote line 3');
+  });
+
+  test('parse pre', () => {
+    const comment = '    Four space indent.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse one space pre', () => {
+    const comment = ' One space indent.\n Another line.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse tab pre', () => {
+    const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse star list', () => {
+    const comment = '* Item 1\n* Item 2\n* Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+  });
+
+  test('parse dash list', () => {
+    const comment = '- Item 1\n- Item 2\n- Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+  });
+
+  test('parse mixed list', () => {
+    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+    assertListBlock(result, 0, 3, 'Item 4');
+  });
+
+  test('parse mixed block types', () => {
+    const comment = 'Paragraph\nacross\na\nfew\nlines.' +
+        '\n\n' +
+        '> Quote\n> across\n> not many lines.' +
+        '\n\n' +
+        'Another paragraph' +
+        '\n\n' +
+        '* Series\n* of\n* list\n* items' +
+        '\n\n' +
+        'Yet another paragraph' +
+        '\n\n' +
+        '\tPreformatted text.' +
+        '\n\n' +
+        'Parting words.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 7);
+    assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n');
+
+    assert.equal(result[1].type, 'quote');
+    assert.lengthOf(result[1].blocks, 1);
+    assertBlock(result[1].blocks, 0, 'paragraph',
+        'Quote\nacross\nnot many lines.');
+
+    assertBlock(result, 2, 'paragraph', 'Another paragraph\n');
+    assertListBlock(result, 3, 0, 'Series');
+    assertListBlock(result, 3, 1, 'of');
+    assertListBlock(result, 3, 2, 'list');
+    assertListBlock(result, 3, 3, 'items');
+    assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n');
+    assertBlock(result, 5, 'pre', '\tPreformatted text.');
+    assertBlock(result, 6, 'paragraph', 'Parting words.');
+  });
+
+  test('bullet list 1', () => {
+    const comment = 'A\n\n* line 1\n* 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A\n');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+  });
+
+  test('bullet list 2', () => {
+    const comment = 'A\n* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('bullet list 3', () => {
+    const comment = '* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result, 0, 0, 'line 1');
+    assertListBlock(result, 0, 1, '2nd line');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('bullet list 4', () => {
+    const comment = 'To see this bug, you have to:\n' +
+        '* Be on IMAP or EAS (not on POP)\n' +
+        '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
+    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+    assertListBlock(result, 1, 1, 'Be very unlucky');
+  });
+
+  test('bullet list 5', () => {
+    const comment = 'To see this bug,\n' +
+        'you have to:\n' +
+        '* Be on IMAP or EAS (not on POP)\n' +
+        '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:');
+    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+    assertListBlock(result, 1, 1, 'Be very unlucky');
+  });
+
+  test('dash list 1', () => {
+    const comment = 'A\n- line 1\n- 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+  });
+
+  test('dash list 2', () => {
+    const comment = 'A\n- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('dash list 3', () => {
+    const comment = '- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result, 0, 0, 'line 1');
+    assertListBlock(result, 0, 1, '2nd line');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('nested list will NOT be recognized', () => {
+    // will be rendered as two separate lists
+    const comment = '- line 1\n  - line with indentation\n- line 2';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertListBlock(result, 0, 0, 'line 1');
+    assert.equal(result[1].type, 'pre');
+    assertListBlock(result, 2, 0, 'line 2');
+  });
+
+  test('pre format 1', () => {
+    const comment = 'A\n  This is pre\n  formatted';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+  });
+
+  test('pre format 2', () => {
+    const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+    assertBlock(result, 2, 'paragraph', 'but this is not');
+  });
+
+  test('pre format 3', () => {
+    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('pre format 4', () => {
+    const comment = '  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('quote 1', () => {
+    const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
+    assertBlock(result, 1, 'paragraph', 'See above.');
+  });
+
+  test('quote 2', () => {
+    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'See this said:');
+    assert.equal(result[1].type, 'quote');
+    assert.lengthOf(result[1].blocks, 1);
+    assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
+    assertBlock(result, 2, 'paragraph', 'OK?');
+  });
+
+  test('nested quotes', () => {
+    const comment = ' > > prior\n > \n > next\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 2);
+    assert.equal(result[0].blocks[0].type, 'quote');
+    assert.lengthOf(result[0].blocks[0].blocks, 1);
+    assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
+    assertBlock(result[0].blocks, 1, 'paragraph', 'next');
+  });
+
+  test('code 1', () => {
+    const comment = '```\n// test code\n```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'code');
+    assert.equal(result[0].text, '// test code');
+  });
+
+  test('code 2', () => {
+    const comment = 'test code\n```// test code```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'code');
+    assert.equal(result[1].text, '// test code');
+  });
+
+  test('code 3', () => {
+    const comment = 'test code\n```// test code```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'code');
+    assert.equal(result[1].text, '// test code');
+  });
+
+  test('not a code', () => {
+    const comment = 'test code\n```// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code\n```// test code');
+  });
+
+  test('not a code 2', () => {
+    const comment = 'test code\n```\n// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'paragraph');
+    assert.equal(result[1].text, '```\n// test code');
+  });
+
+  test('mix all 1', () => {
+    const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
+      '```// test code```\n\n> reference is here';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 5);
+    assert.equal(result[0].type, 'pre');
+    assert.equal(result[1].type, 'list');
+    assert.equal(result[2].type, 'paragraph');
+    assert.equal(result[3].type, 'code');
+    assert.equal(result[4].type, 'quote');
+  });
+
+  test('_computeNodes called without config', () => {
+    const computeNodesSpy = sandbox.spy(element, '_computeNodes');
+    element.content = 'some text';
+    assert.isTrue(computeNodesSpy.called);
+  });
+
+  test('_contentOrConfigChanged called with config', () => {
+    const contentStub = sandbox.stub(element, '_contentChanged');
+    const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    element.config = {};
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
new file mode 100644
index 0000000..0bc9cb7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../gr-avatar/gr-avatar.js';
+import '../gr-button/gr-button.js';
+import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-hovercard-account_html.js';
+
+/** @extends Polymer.Element */
+class GrHovercardAccount extends GestureEventListeners(
+    hovercardBehaviorMixin(LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-hovercard-account'; }
+
+  static get properties() {
+    return {
+      account: Object,
+      voteableText: String,
+      attention: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+    };
+  }
+}
+
+customElements.define(GrHovercardAccount.is, GrHovercardAccount);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
new file mode 100644
index 0000000..8d14ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../gr-hovercard/gr-hovercard-shared-style.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="gr-hovercard-shared-style">
+    .top,
+    .attention,
+    .status,
+    .voteable {
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    .top {
+      display: flex;
+      padding-top: var(--spacing-xl);
+      min-width: 300px;
+    }
+    gr-avatar {
+      height: 48px;
+      width: 48px;
+      margin-right: var(--spacing-l);
+    }
+    .title,
+    .email {
+      color: var(--deemphasized-text-color);
+    }
+    .status iron-icon {
+      width: 14px;
+      height: 14px;
+      vertical-align: top;
+      position: relative;
+      top: 2px;
+    }
+    .action {
+      border-top: 1px solid var(--border-color);
+      padding: var(--spacing-s) var(--spacing-l);
+      --gr-button: {
+        padding: var(--spacing-s) 0;
+      }
+    }
+    :host(:not([attention])) .attention {
+      display: none;
+    }
+    .attention {
+      background-color: var(--emphasis-color);
+    }
+    .attention iron-icon {
+      vertical-align: top;
+    }
+  </style>
+  <div id="container" role="tooltip" tabindex="-1">
+    <div class="top">
+      <div class="avatar">
+        <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
+      </div>
+      <div class="account">
+        <h3 class="name">[[account.name]]</h3>
+        <div class="email">[[account.email]]</div>
+      </div>
+    </div>
+    <template is="dom-if" if="[[account.status]]">
+      <div class="status">
+        <span class="title">
+          <iron-icon icon="gr-icons:calendar"></iron-icon>
+          Status:
+        </span>
+        <span class="value">[[account.status]]</span>
+      </div>
+    </template>
+    <template is="dom-if" if="[[voteableText]]">
+      <div class="voteable">
+        <span class="title">Voteable:</span>
+        <span class="value">[[voteableText]]</span>
+      </div>
+    </template>
+    <div class="attention">
+      <iron-icon icon="gr-icons:attention"></iron-icon>
+      <span>It is this user's turn to take action.</span>
+    </div>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
new file mode 100644
index 0000000..be0f2b2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2020 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport"
+      content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-hovercard-account</title>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script src="../../../node_modules/iron-test-helpers/mock-interactions.js" type="module"></script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-hovercard-account class="hovered"></gr-hovercard-account>
+  </template>
+</test-fixture>
+
+
+<script type="module">
+  import '../../../test/common-test-setup.js';
+  import './gr-hovercard-account.js';
+
+  suite('gr-hovercard-account tests', () => {
+    let element;
+    const ACCOUNT = {
+      email: 'kermit@gmail.com',
+      username: 'kermit',
+      name: 'Kermit The Frog',
+      _account_id: '31415926535',
+    };
+
+    setup(() => {
+      element = fixture('basic');
+      element.account = Object.assign({}, ACCOUNT);
+    });
+
+    test('account name is shown', () => {
+      assert.equal(element.shadowRoot.querySelector('.name').innerText,
+          'Kermit The Frog');
+    });
+
+    test('account status is not shown if the property is not set', () => {
+      assert.isNull(element.shadowRoot.querySelector('.status'));
+    });
+
+    test('account status is displayed', () => {
+      element.account = Object.assign({status: 'OOO'}, ACCOUNT);
+      flushAsynchronousOperations();
+      assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
+          'OOO');
+    });
+
+    test('voteable div is not shown if the property is not set', () => {
+      assert.isNull(element.shadowRoot.querySelector('.voteable'));
+    });
+
+    test('voteable div is displayed', () => {
+      element.voteableText = 'CodeReview: +2';
+      flushAsynchronousOperations();
+      assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
+          element.voteableText);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
new file mode 100644
index 0000000..a4d00224
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
@@ -0,0 +1,396 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import '../../../styles/shared-styles.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {getRootElement} from '../../../scripts/rootElement.js';
+
+const HOVER_CLASS = 'hovered';
+const HIDE_CLASS = 'hide';
+
+/**
+ * When the hovercard is positioned diagonally (bottom-left, bottom-right,
+ * top-left, or top-right), we add additional (invisible) padding so that the
+ * area that a user can hover over to access the hovercard is larger.
+ */
+const DIAGONAL_OVERFLOW = 15;
+
+/**
+ * How long should be wait before showing the hovercard when the user hovers
+ * over the element?
+ */
+const SHOW_DELAY_MS = 500;
+
+/**
+ * The mixin for gr-hovercard-behavior.
+ *
+ * @example
+ *
+ * // LegacyElementMixin is still needed to support the old lifecycles
+ * // TODO: Replace old life cycles with new ones.
+ *
+ * class YourComponent extends hovercardBehaviorMixin(
+ *  LegacyElementMixin(PolymerElement)
+ * ) {
+ *   static get is() { return ''; }
+ *   static get template() { return html``; }
+ * }
+ *
+ * customElements.define(GrHovercard.is, GrHovercard);
+ *
+ * @see gr-hovercard.js
+ *
+ * // following annotations are required for polylint
+ * @polymer
+ * @mixinFunction
+ */
+export const hovercardBehaviorMixin = superClass => class extends superClass {
+  static get properties() {
+    return {
+      /**
+       * @type {?}
+       */
+      _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',
+      },
+
+      /**
+       * 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: 'right',
+      },
+
+      container: Object,
+      /**
+       * ID for the container element.
+       *
+       * @type {string}
+       */
+      containerId: {
+        type: String,
+        value: 'gr-hovercard-container',
+      },
+    };
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    if (!this._target) { this._target = this.target; }
+    this.listen(this._target, 'mouseenter', 'showDelayed');
+    this.listen(this._target, 'focus', 'showDelayed');
+    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 = getRootElement()
+        .querySelector('#' + this.containerId);
+
+    if (this.container) { return; }
+
+    // If it does not exist, create and initialize the hovercard container.
+    this.container = document.createElement('div');
+    this.container.setAttribute('id', this.containerId);
+    getRootElement().appendChild(this.container);
+  }
+
+  removeListeners() {
+    this.unlisten(this._target, 'mouseenter', 'show');
+    this.unlisten(this._target, 'focus', 'show');
+    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
+   * the `for` property).
+   *
+   * @type {HTMLElement}
+   */
+  get target() {
+    const parentNode = dom(this).parentNode;
+    // If the parentNode is a document fragment, then we need to use the host.
+    const ownerRoot = dom(this).getOwnerRoot();
+    let target;
+    if (this.for) {
+      target = dom(ownerRoot).querySelector('#' + this.for);
+    } else {
+      target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
+        ownerRoot.host :
+        parentNode;
+    }
+    return target;
+  }
+
+  /**
+   * Hides/closes the hovercard. This occurs when the user triggers the
+   * `mouseleave` event on the hovercard's `target` element (as long as the
+   * user is not hovering over the hovercard).
+   *
+   * @param {Event} e DOM Event (e.g. `mouseleave` event)
+   */
+  hide(e) {
+    this._isScheduledToShow = false;
+    if (!this._isShowing) {
+      return;
+    }
+
+    // If the user is now hovering over the hovercard or the user is returning
+    // from the hovercard but now hovering over the target (to stop an annoying
+    // flicker effect), just return.
+    if (e.toElement === this ||
+        (e.fromElement === this && e.toElement === this._target)) {
+      return;
+    }
+
+    // Mark that the hovercard is not visible and do not allow focusing
+    this._isShowing = false;
+
+    // Clear styles in preparation for the next time we need to show the card
+    this.classList.remove(HOVER_CLASS);
+
+    // Reset and remove the hovercard from the DOM
+    this.style.cssText = '';
+    this.$.container.setAttribute('tabindex', -1);
+
+    // Remove the hovercard from the container, given that it is still a child
+    // of the container.
+    if (this.container.contains(this)) {
+      this.container.removeChild(this);
+    }
+  }
+
+  /**
+   * Shows/opens the hovercard with a fixed delay.
+   */
+  showDelayed() {
+    this.showDelayedBy(SHOW_DELAY_MS);
+  }
+
+  /**
+   * Shows/opens the hovercard with the given delay.
+   */
+  showDelayedBy(delayMs) {
+    if (this._isShowing || this._isScheduledToShow) return;
+    this._isScheduledToShow = true;
+    setTimeout(() => {
+      // This happens when the mouse leaves the target before the delay is over.
+      if (!this._isScheduledToShow) return;
+      this._isScheduledToShow = false;
+      this.show();
+    }, delayMs);
+  }
+
+  /**
+   * Shows/opens the hovercard. This occurs when the user triggers the
+   * `mousenter` event on the hovercard's `target` element.
+   */
+  show() {
+    if (this._isShowing) {
+      return;
+    }
+
+    // Mark that the hovercard is now visible
+    this._isShowing = true;
+    this.setAttribute('tabindex', 0);
+
+    // Add it to the DOM and calculate its position
+    this.container.appendChild(this);
+    // We temporarily hide the hovercard until we have found the correct
+    // position for it.
+    this.classList.add(HIDE_CLASS);
+    this.classList.add(HOVER_CLASS);
+    // Make sure that the hovercard actually rendered and all dom-if
+    // statements processed, so that we can measure the (invisible)
+    // hovercard properly in updatePosition().
+    flush();
+    this.updatePosition();
+    this.classList.remove(HIDE_CLASS);
+  }
+
+  updatePosition() {
+    const positionsToTry = new Set(
+        [this.position, 'right', 'bottom-right', 'top-right',
+          'bottom', 'top', 'bottom-left', 'top-left', 'left']);
+    for (const position of positionsToTry) {
+      this.updatePositionTo(position);
+      if (this._isInsideViewport()) return;
+    }
+    console.warn('Could not find a visible position for the hovercard.');
+  }
+
+  _isInsideViewport() {
+    const thisRect = this.getBoundingClientRect();
+    if (thisRect.top < 0) return false;
+    if (thisRect.left < 0) return false;
+    const docuRect = document.documentElement.getBoundingClientRect();
+    if (thisRect.bottom > docuRect.height) return false;
+    if (thisRect.right > docuRect.width) return false;
+    return true;
+  }
+
+  /**
+   * Updates the hovercard's position based the current position of the `target`
+   * element.
+   *
+   * The hovercard is supposed to stay open if the user hovers over it.
+   * To keep it open when the user moves away from the target, the bounding
+   * rects of the target and hovercard must touch or overlap.
+   *
+   * NOTE: You do not need to directly call this method unless you need to
+   * update the position of the tooltip while it is already visible (the
+   * target element has moved and the tooltip is still open).
+   */
+  updatePositionTo(position) {
+    if (!this._target) { return; }
+
+    // Make sure that thisRect will not get any paddings and such included
+    // in the width and height of the bounding client rect.
+    this.style.cssText = '';
+
+    const docuRect = document.documentElement.getBoundingClientRect();
+    const targetRect = this._target.getBoundingClientRect();
+    const thisRect = this.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - docuRect.left;
+    const targetTop = targetRect.top - docuRect.top;
+
+    let hovercardLeft;
+    let hovercardTop;
+    const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
+    let cssText = '';
+
+    switch (position) {
+      case 'top':
+        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+        hovercardTop = targetTop - thisRect.height - this.offset;
+        cssText += `padding-bottom:${this.offset
+        }px; margin-bottom:-${this.offset}px;`;
+        break;
+      case 'bottom':
+        hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+        hovercardTop = targetTop + targetRect.height + this.offset;
+        cssText +=
+            `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
+        break;
+      case 'left':
+        hovercardLeft = targetLeft - thisRect.width - this.offset;
+        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+        cssText +=
+            `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
+        break;
+      case 'right':
+        hovercardLeft = targetLeft + targetRect.width + this.offset;
+        hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+        cssText +=
+            `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
+        break;
+      case 'bottom-right':
+        hovercardLeft = targetLeft + targetRect.width + this.offset;
+        hovercardTop = targetTop + targetRect.height + this.offset;
+        cssText += `padding-top:${diagonalPadding}px;`;
+        cssText += `padding-left:${diagonalPadding}px;`;
+        cssText += `margin-left:-${diagonalPadding}px;`;
+        cssText += `margin-top:-${diagonalPadding}px;`;
+        break;
+      case 'bottom-left':
+        hovercardLeft = targetLeft - thisRect.width - this.offset;
+        hovercardTop = targetTop + targetRect.height + this.offset;
+        cssText += `padding-top:${diagonalPadding}px;`;
+        cssText += `padding-right:${diagonalPadding}px;`;
+        cssText += `margin-right:-${diagonalPadding}px;`;
+        cssText += `margin-top:-${diagonalPadding}px;`;
+        break;
+      case 'top-left':
+        hovercardLeft = targetLeft - thisRect.width - this.offset;
+        hovercardTop = targetTop - thisRect.height - this.offset;
+        cssText += `padding-bottom:${diagonalPadding}px;`;
+        cssText += `padding-right:${diagonalPadding}px;`;
+        cssText += `margin-bottom:-${diagonalPadding}px;`;
+        cssText += `margin-right:-${diagonalPadding}px;`;
+        break;
+      case 'top-right':
+        hovercardLeft = targetLeft + targetRect.width + this.offset;
+        hovercardTop = targetTop - thisRect.height - this.offset;
+        cssText += `padding-bottom:${diagonalPadding}px;`;
+        cssText += `padding-left:${diagonalPadding}px;`;
+        cssText += `margin-bottom:-${diagonalPadding}px;`;
+        cssText += `margin-left:-${diagonalPadding}px;`;
+        break;
+    }
+
+    cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
+    this.style.cssText = cssText;
+  }
+
+  /**
+   * Responds to a change in the `for` value and gets the updated `target`
+   * element for the hovercard.
+   *
+   * @private
+   */
+  _forChanged() {
+    this._target = this.target;
+  }
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js
new file mode 100644
index 0000000..5fb1add
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** The shared styles for all hover cards. */
+const GrHoverCardSharedStyle = document.createElement('dom-module');
+GrHoverCardSharedStyle.innerHTML =
+  `<template>
+    <style include="shared-styles">
+      :host {
+        position: absolute;
+        display: none;
+        z-index: 200;
+      }
+      :host(.hovered) {
+        display: block;
+      }
+      :host(.hide) {
+        visibility: hidden;
+      }
+      /* You have to use a <div class="container"> in your hovercard in order
+         to pick up this consistent styling. */
+      #container {
+        background: var(--dialog-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-5);
+      }
+    </style>
+  </template>`;
+
+GrHoverCardSharedStyle.register('gr-hovercard-shared-style');
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
deleted file mode 100644
index c77ea81..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-hovercard">
-  <template>
-    <style include="shared-styles">
-      :host {
-        box-sizing: border-box;
-        opacity: 0;
-        position: absolute;
-        transition: opacity 200ms;
-        visibility: hidden;
-        z-index: 100;
-      }
-      :host(.hovered) {
-        visibility: visible;
-        opacity: 1;
-      }
-      #hovercard {
-        background: var(--dialog-background-color);
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
-        padding: var(--spacing-l);
-      }
-    </style>
-    <div id="hovercard" role="tooltip" tabindex="-1">
-      <slot></slot>
-    </div>
-  </template>
-  <script src="../../../scripts/rootElement.js"></script>
-  <script src="gr-hovercard.js"></script>
-</dom-module>
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 5692b38..e77a4c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -14,308 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
-  const HOVER_CLASS = 'hovered';
+import '../../../scripts/bundled-polymer.js';
 
-  /**
-   * When the hovercard is positioned diagonally (bottom-left, bottom-right,
-   * top-left, or top-right), we add additional (invisible) padding so that the
-   * area that a user can hover over to access the hovercard is larger.
-   */
-  const DIAGONAL_OVERFLOW = 15;
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-hovercard_html.js';
+import {hovercardBehaviorMixin} from './gr-hovercard-behavior.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import './gr-hovercard-shared-style.js';
 
-  Polymer({
-    is: 'gr-hovercard',
+/** @extends Polymer.Element */
+class GrHovercard extends GestureEventListeners(
+    hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /**
-       * @type {?}
-       */
-      _target: Object,
+  static get is() { return 'gr-hovercard'; }
+}
 
-      /**
-       * 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,
-      },
-
-      /**
-       * 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',
-      },
-    },
-
-    listeners: {
-      mouseleave: 'hide',
-    },
-
-    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');
-    },
-
-    ready() {
-      // First, check to see if the container has already been created.
-      this.container = Gerrit.getRootElement()
-          .querySelector('#' + this.containerId);
-
-      if (this.container) { return; }
-
-      // If it does not exist, create and initialize the hovercard container.
-      this.container = document.createElement('div');
-      this.container.setAttribute('id', this.containerId);
-      Gerrit.getRootElement().appendChild(this.container);
-    },
-
-    removeListeners() {
-      this.unlisten(this._target, 'mouseenter', 'show');
-      this.unlisten(this._target, 'focus', 'show');
-      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
-     * the `for` property).
-     *
-     * @type {HTMLElement}
-     */
-    get target() {
-      const parentNode = Polymer.dom(this).parentNode;
-      // If the parentNode is a document fragment, then we need to use the host.
-      const ownerRoot = Polymer.dom(this).getOwnerRoot();
-      let target;
-      if (this.for) {
-        target = Polymer.dom(ownerRoot).querySelector('#' + this.for);
-      } else {
-        target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
-          ownerRoot.host :
-          parentNode;
-      }
-      return target;
-    },
-
-    /**
-     * Hides/closes the hovercard. This occurs when the user triggers the
-     * `mouseleave` event on the hovercard's `target` element (as long as the
-     * user is not hovering over the hovercard).
-     *
-     * @param {Event} e DOM Event (e.g. `mouseleave` event)
-     */
-    hide(e) {
-      const targetRect = this._target.getBoundingClientRect();
-      const x = e.clientX;
-      const y = e.clientY;
-      if (x > targetRect.left && x < targetRect.right && y > targetRect.top &&
-          y < targetRect.bottom) {
-        // Sometimes the hovercard itself obscures the mouse pointer, and
-        // that generates a mouseleave event. We don't want to hide the hovercard
-        // in that situation.
-        return;
-      }
-
-      // If the hovercard is already hidden or the user is now hovering over the
-      //  hovercard or the user is returning from the hovercard but now hovering
-      //  over the target (to stop an annoying flicker effect), just return.
-      if (!this._isShowing || e.relatedTarget === this ||
-          (e.target === this && e.relatedTarget === this._target)) {
-        return;
-      }
-
-      // Mark that the hovercard is not visible and do not allow focusing
-      this._isShowing = false;
-
-      // Clear styles in preparation for the next time we need to show the card
-      this.classList.remove(HOVER_CLASS);
-
-      // Reset and remove the hovercard from the DOM
-      this.style.cssText = '';
-      this.$.hovercard.setAttribute('tabindex', -1);
-
-      // Remove the hovercard from the container, given that it is still a child
-      // of the container.
-      if (this.container.contains(this)) {
-        this.container.removeChild(this);
-      }
-    },
-
-    /**
-     * Shows/opens the hovercard. This occurs when the user triggers the
-     * `mousenter` event on the hovercard's `target` element.
-     *
-     * @param {Event} e DOM Event (e.g., `mouseenter` event)
-     */
-    show(e) {
-      if (this._isShowing) {
-        return;
-      }
-
-      // Mark that the hovercard is now visible
-      this._isShowing = true;
-      this.setAttribute('tabindex', 0);
-
-      // Add it to the DOM and calculate its position
-      this.container.appendChild(this);
-      this.updatePosition();
-
-      // Trigger the transition
-      this.classList.add(HOVER_CLASS);
-    },
-
-    /**
-     * Updates the hovercard's position based on the `position` attribute
-     * and the current position of the `target` element.
-     *
-     * The hovercard is supposed to stay open if the user hovers over it.
-     * To keep it open when the user moves away from the target, the bounding
-     * rects of the target and hovercard must touch or overlap.
-     *
-     * NOTE: You do not need to directly call this method unless you need to
-     * update the position of the tooltip while it is already visible (the
-     * target element has moved and the tooltip is still open).
-     */
-    updatePosition() {
-      if (!this._target) { return; }
-
-      // Calculate the necessary measurements and positions
-      const parentRect = document.documentElement.getBoundingClientRect();
-      const targetRect = this._target.getBoundingClientRect();
-      const thisRect = this.getBoundingClientRect();
-
-      const targetLeft = targetRect.left - parentRect.left;
-      const targetTop = targetRect.top - parentRect.top;
-
-      let hovercardLeft;
-      let hovercardTop;
-      const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
-      let cssText = '';
-
-      // Find the top and left position values based on the position attribute
-      // of the hovercard.
-      switch (this.position) {
-        case 'top':
-          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
-          hovercardTop = targetTop - thisRect.height - this.offset;
-          cssText += `padding-bottom:${this.offset
-          }px; margin-bottom:-${this.offset}px;`;
-          break;
-        case 'bottom':
-          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
-          hovercardTop = targetTop + targetRect.height + this.offset;
-          cssText +=
-              `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
-          break;
-        case 'left':
-          hovercardLeft = targetLeft - thisRect.width - this.offset;
-          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-          cssText +=
-              `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
-          break;
-        case 'right':
-          hovercardLeft = targetRect.right + this.offset;
-          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-          cssText +=
-              `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
-          break;
-        case 'bottom-right':
-          hovercardLeft = targetRect.left + targetRect.width + this.offset;
-          hovercardTop = targetRect.top + targetRect.height + this.offset;
-          cssText += `padding-top:${diagonalPadding}px;`;
-          cssText += `padding-left:${diagonalPadding}px;`;
-          cssText += `margin-left:-${diagonalPadding}px;`;
-          cssText += `margin-top:-${diagonalPadding}px;`;
-          break;
-        case 'bottom-left':
-          hovercardLeft = targetRect.left - thisRect.width - this.offset;
-          hovercardTop = targetRect.top + targetRect.height + this.offset;
-          cssText += `padding-top:${diagonalPadding}px;`;
-          cssText += `padding-right:${diagonalPadding}px;`;
-          cssText += `margin-right:-${diagonalPadding}px;`;
-          cssText += `margin-top:-${diagonalPadding}px;`;
-          break;
-        case 'top-left':
-          hovercardLeft = targetRect.left - thisRect.width - this.offset;
-          hovercardTop = targetRect.top - thisRect.height - this.offset;
-          cssText += `padding-bottom:${diagonalPadding}px;`;
-          cssText += `padding-right:${diagonalPadding}px;`;
-          cssText += `margin-bottom:-${diagonalPadding}px;`;
-          cssText += `margin-right:-${diagonalPadding}px;`;
-          break;
-        case 'top-right':
-          hovercardLeft = targetRect.left + targetRect.width + this.offset;
-          hovercardTop = targetRect.top - thisRect.height - this.offset;
-          cssText += `padding-bottom:${diagonalPadding}px;`;
-          cssText += `padding-left:${diagonalPadding}px;`;
-          cssText += `margin-bottom:-${diagonalPadding}px;`;
-          cssText += `margin-left:-${diagonalPadding}px;`;
-          break;
-      }
-
-      // Prevent hovercard from appearing outside the viewport.
-      // TODO(kaspern): fix hovercard appearing outside viewport on bottom and
-      // right.
-      if (hovercardLeft < 0) { hovercardLeft = 0; }
-      if (hovercardTop < 0) { hovercardTop = 0; }
-      // 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`
-     * element for the hovercard.
-     *
-     * @private
-     */
-    _forChanged() {
-      this._target = this.target;
-    },
-  });
-})();
+customElements.define(GrHovercard.is, GrHovercard);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
new file mode 100644
index 0000000..67a3545
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="gr-hovercard-shared-style">
+    #container {
+      padding: var(--spacing-l);
+    }
+  </style>
+  <div id="container" role="tooltip" tabindex="-1">
+    <slot></slot>
+  </div>
+`;
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..21f692d 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
@@ -17,18 +17,20 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-hovercard.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<!-- Can't use absolute path below for mock-interaction.js.
+Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
+actually /node_modules directory). Also, wct patches some files to load modules from /components.
+With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
+/node_modules/...) though this is actually the same file. This leads to a run-time error.
+-->
+<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
 <button id="foo">Hello</button>
 <test-fixture id="basic">
@@ -37,86 +39,121 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-hovercard tests', () => {
-    let element;
-    let sandbox;
-    // For css animations
-    const TRANSITION_TIME = 500;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-hovercard.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-hovercard tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('updatePosition', () => {
-      // Test that the correct style properties have at least been set.
-      element.position = 'bottom';
-      element.updatePosition();
-      assert.typeOf(element.style.getPropertyValue('left'), 'string');
-      assert.typeOf(element.style.getPropertyValue('top'), 'string');
-      assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
-      assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
-
-      const parentRect = document.documentElement.getBoundingClientRect();
-      const targetRect = element._target.getBoundingClientRect();
-      const thisRect = element.getBoundingClientRect();
-
-      const targetLeft = targetRect.left - parentRect.left;
-      const targetTop = targetRect.top - parentRect.top;
-
-      const pixelCompare = pixel =>
-        Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
-
-      assert.equal(
-          pixelCompare(element.style.left),
-          pixelCompare(
-              (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
-      assert.equal(
-          pixelCompare(element.style.top),
-          pixelCompare(
-              (targetTop + targetRect.height + element.offset) + 'px'));
-    });
-
-    test('hide', done => {
-      element.hide({});
-      setTimeout(() => {
-        const style = getComputedStyle(element);
-        assert.isFalse(element._isShowing);
-        assert.isFalse(element.classList.contains('hovered'));
-        assert.equal(style.opacity, '0');
-        assert.equal(style.visibility, 'hidden');
-        assert.notEqual(element.container, Polymer.dom(element).parentNode);
-        done();
-      }, TRANSITION_TIME);
-    });
-
-    test('show', done => {
-      element.show({});
-      setTimeout(() => {
-        const style = getComputedStyle(element);
-        assert.isTrue(element._isShowing);
-        assert.isTrue(element.classList.contains('hovered'));
-        assert.equal(style.opacity, '1');
-        assert.equal(style.visibility, 'visible');
-        done();
-      }, TRANSITION_TIME);
-    });
-
-    test('card shows on enter and hides on leave', done => {
-      const button = Polymer.dom(document).querySelector('button');
-      assert.isFalse(element._isShowing);
-      button.addEventListener('mouseenter', event => {
-        assert.isTrue(element._isShowing);
-        button.dispatchEvent(new CustomEvent('mouseleave'));
-      });
-      button.addEventListener('mouseleave', event => {
-        assert.isFalse(element._isShowing);
-        done();
-      });
-      button.dispatchEvent(new CustomEvent('mouseenter'));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('updatePosition', () => {
+    // Test that the correct style properties have at least been set.
+    element.position = 'bottom';
+    element.updatePosition();
+    assert.typeOf(element.style.getPropertyValue('left'), 'string');
+    assert.typeOf(element.style.getPropertyValue('top'), 'string');
+    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
+    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
+
+    const parentRect = document.documentElement.getBoundingClientRect();
+    const targetRect = element._target.getBoundingClientRect();
+    const thisRect = element.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - parentRect.left;
+    const targetTop = targetRect.top - parentRect.top;
+
+    const pixelCompare = pixel =>
+      Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
+
+    assert.equal(
+        pixelCompare(element.style.left),
+        pixelCompare(
+            (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
+    assert.equal(
+        pixelCompare(element.style.top),
+        pixelCompare(
+            (targetTop + targetRect.height + element.offset) + 'px'));
+  });
+
+  test('hide', () => {
+    element.hide({});
+    const style = getComputedStyle(element);
+    assert.isFalse(element._isShowing);
+    assert.isFalse(element.classList.contains('hovered'));
+    assert.equal(style.display, 'none');
+    assert.notEqual(element.container, dom(element).parentNode);
+  });
+
+  test('show', () => {
+    element.show({});
+    const style = getComputedStyle(element);
+    assert.isTrue(element._isShowing);
+    assert.isTrue(element.classList.contains('hovered'));
+    assert.equal(style.opacity, '1');
+    assert.equal(style.visibility, 'visible');
+  });
+
+  test('showDelayed does not show immediately', done => {
+    element.showDelayedBy(100);
+    setTimeout(() => {
+      assert.isFalse(element._isShowing);
+      done();
+    }, 0);
+  });
+
+  test('showDelayed shows after delay', done => {
+    element.showDelayedBy(1);
+    setTimeout(() => {
+      assert.isTrue(element._isShowing);
+      done();
+    }, 10);
+  });
+
+  test('card is scheduled to show on enter and hides on leave', done => {
+    const button = dom(document).querySelector('button');
+    assert.isFalse(element._isShowing);
+    const enterHandler = event => {
+      assert.isTrue(element._isScheduledToShow);
+      button.dispatchEvent(new CustomEvent('mouseleave'));
+    };
+    const leaveHandler = event => {
+      assert.isFalse(element._isScheduledToShow);
+      assert.isFalse(element._isShowing);
+      button.removeEventListener('mouseenter', enterHandler);
+      button.removeEventListener('mouseleave', leaveHandler);
+      done();
+    };
+    button.addEventListener('mouseenter', enterHandler);
+    button.addEventListener('mouseleave', leaveHandler);
+    button.dispatchEvent(new CustomEvent('mouseenter'));
+  });
+
+  test('card should disappear on click', done => {
+    const button = dom(document).querySelector('button');
+    assert.isFalse(element._isShowing);
+    const enterHandler = event => {
+      assert.isTrue(element._isScheduledToShow);
+      // click to hide
+      MockInteractions.tap(button);
+    };
+    const leaveHandler = event => {
+      assert.isFalse(element._isScheduledToShow);
+      assert.isFalse(element._isShowing);
+      button.removeEventListener('mouseenter', enterHandler);
+      button.removeEventListener('click', leaveHandler);
+      done();
+    };
+    button.addEventListener('mouseenter', enterHandler);
+    button.addEventListener('click', leaveHandler);
+    button.dispatchEvent(new CustomEvent('mouseenter'));
+  });
+});
 </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
deleted file mode 100644
index 7bd6f48..0000000
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ /dev/null
@@ -1,89 +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.
--->
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="/bower_components/iron-iconset-svg/iron-iconset-svg.html">
-
-<iron-iconset-svg name="gr-icons" size="24">
-  <svg>
-    <defs>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <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 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 -->
-      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"/></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="abandon"><path d="M17.65675,17.65725 C14.77275,20.54125 10.23775,20.75625 7.09875,18.31525 L18.31475,7.09925 C20.75575,10.23825 20.54075,14.77325 17.65675,17.65725 M6.34275,6.34325 C9.22675,3.45925 13.76275,3.24425 16.90075,5.68525 L5.68475,16.90125 C3.24375,13.76325 3.45875,9.22725 6.34275,6.34325 M19.07075,4.92925 C15.16575,1.02425 8.83375,1.02425 4.92875,4.92925 C1.02375,8.83425 1.02375,15.16625 4.92875,19.07125 C8.83375,22.97625 15.16575,22.97625 19.07075,19.07125 C22.97575,15.16625 22.97575,8.83425 19.07075,4.92925"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="edit"><path d="M3,17.2525 L3,21.0025 L6.75,21.0025 L17.81,9.9425 L14.06,6.1925 L3,17.2525 L3,17.2525 Z M20.71,7.0425 C21.1,6.6525 21.1,6.0225 20.71,5.6325 L18.37,3.2925 C17.98,2.9025 17.35,2.9025 16.96,3.2925 L15.13,5.1225 L18.88,8.8725 L20.71,7.0425 L20.71,7.0425 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebase"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebaseEdit"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <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>
-      <!-- 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 -->
-      <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"/></g>
-    </defs>
-  </svg>
-</iron-iconset-svg>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
new file mode 100644
index 0000000..84d0d2a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-icon/iron-icon.js';
+import '@polymer/iron-iconset-svg/iron-iconset-svg.js';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<iron-iconset-svg name="gr-icons" size="24">
+  <svg>
+    <defs>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
+      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
+      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
+      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <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><path d="M0 0h24v24H0V0z" fill="none"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
+      <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
+      <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></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 SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></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)"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="abandon"><path d="M17.65675,17.65725 C14.77275,20.54125 10.23775,20.75625 7.09875,18.31525 L18.31475,7.09925 C20.75575,10.23825 20.54075,14.77325 17.65675,17.65725 M6.34275,6.34325 C9.22675,3.45925 13.76275,3.24425 16.90075,5.68525 L5.68475,16.90125 C3.24375,13.76325 3.45875,9.22725 6.34275,6.34325 M19.07075,4.92925 C15.16575,1.02425 8.83375,1.02425 4.92875,4.92925 C1.02375,8.83425 1.02375,15.16625 4.92875,19.07125 C8.83375,22.97625 15.16575,22.97625 19.07075,19.07125 C22.97575,15.16625 22.97575,8.83425 19.07075,4.92925"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="edit"><path d="M3,17.2525 L3,21.0025 L6.75,21.0025 L17.81,9.9425 L14.06,6.1925 L3,17.2525 L3,17.2525 Z M20.71,7.0425 C21.1,6.6525 21.1,6.0225 20.71,5.6325 L18.37,3.2925 C17.98,2.9025 17.35,2.9025 16.96,3.2925 L15.13,5.1225 L18.88,8.8725 L20.71,7.0425 L20.71,7.0425 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="rebase"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="rebaseEdit"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <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 -->
+      <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
+      <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
+      <g id="attention"><path d="M5.5 19 l9 0 c.67 0 1.27 -.33 1.63 -.84 L20.5 12 l-4.37 -6.16 c-.36 -.51 -.96 -.84 -1.63 -.84 l-9 0 L9 12 z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
+      <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
+    </defs>
+  </svg>
+</iron-iconset-svg>`;
+
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
index 708e5b6..6514aa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
@@ -14,60 +14,58 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  /**
-   * Used to create a context for GrAnnotationActionsInterface.
-   *
-   * @param {HTMLElement} contentEl The DIV.contentText element of the line
-   *     content to apply the annotation to using annotateRange.
-   * @param {HTMLElement} lineNumberEl The TD element of the line number to
-   *     apply the annotation to using annotateLineNumber.
-   * @param {GrDiffLine} line The line object.
-   * @param {string} path The file path (eg: /COMMIT_MSG').
-   * @param {string} changeNum The Gerrit change number.
-   * @param {string} patchNum The Gerrit patch number.
-   */
-  function GrAnnotationActionsContext(
-      contentEl, lineNumberEl, line, path, changeNum, patchNum) {
-    this._contentEl = contentEl;
-    this._lineNumberEl = lineNumberEl;
+import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation.js';
 
-    this.line = line;
-    this.path = path;
-    this.changeNum = parseInt(changeNum);
-    this.patchNum = parseInt(patchNum);
+/**
+ * Used to create a context for GrAnnotationActionsInterface.
+ *
+ * @param {HTMLElement} contentEl The DIV.contentText element of the line
+ *     content to apply the annotation to using annotateRange.
+ * @param {HTMLElement} lineNumberEl The TD element of the line number to
+ *     apply the annotation to using annotateLineNumber.
+ * @param {GrDiffLine} line The line object.
+ * @param {string} path The file path (eg: /COMMIT_MSG').
+ * @param {string} changeNum The Gerrit change number.
+ * @param {string} patchNum The Gerrit patch number.
+ */
+export function GrAnnotationActionsContext(
+    contentEl, lineNumberEl, line, path, changeNum, patchNum) {
+  this._contentEl = contentEl;
+  this._lineNumberEl = lineNumberEl;
+
+  this.line = line;
+  this.path = path;
+  this.changeNum = parseInt(changeNum);
+  this.patchNum = parseInt(patchNum);
+}
+
+/**
+ * Method to add annotations to a content line.
+ *
+ * @param {number} offset The char offset where the update starts.
+ * @param {number} length The number of chars that the update covers.
+ * @param {GrStyleObject} styleObject The style object for the range.
+ * @param {string} side The side of the update. ('left' or 'right')
+ */
+GrAnnotationActionsContext.prototype.annotateRange = function(
+    offset, length, styleObject, side) {
+  if (this._contentEl && this._contentEl.getAttribute('data-side') == side) {
+    GrAnnotation.annotateElement(this._contentEl, offset, length,
+        styleObject.getClassName(this._contentEl));
   }
+};
 
-  /**
-   * Method to add annotations to a content line.
-   *
-   * @param {number} offset The char offset where the update starts.
-   * @param {number} length The number of chars that the update covers.
-   * @param {GrStyleObject} styleObject The style object for the range.
-   * @param {string} side The side of the update. ('left' or 'right')
-   */
-  GrAnnotationActionsContext.prototype.annotateRange = function(
-      offset, length, styleObject, side) {
-    if (this._contentEl && this._contentEl.getAttribute('data-side') == side) {
-      GrAnnotation.annotateElement(this._contentEl, offset, length,
-          styleObject.getClassName(this._contentEl));
-    }
-  };
+/**
+ * Method to add a CSS class to the line number TD element.
+ *
+ * @param {GrStyleObject} styleObject The style object for the range.
+ * @param {string} side The side of the update. ('left' or 'right')
+ */
+GrAnnotationActionsContext.prototype.annotateLineNumber = function(
+    styleObject, side) {
+  if (this._lineNumberEl && this._lineNumberEl.classList.contains(side)) {
+    styleObject.apply(this._lineNumberEl);
+  }
+};
 
-  /**
-   * Method to add a CSS class to the line number TD element.
-   *
-   * @param {GrStyleObject} styleObject The style object for the range.
-   * @param {string} side The side of the update. ('left' or 'right')
-   */
-  GrAnnotationActionsContext.prototype.annotateLineNumber = function(
-      styleObject, side) {
-    if (this._lineNumberEl && this._lineNumberEl.classList.contains(side)) {
-      styleObject.apply(this._lineNumberEl);
-    }
-  };
-
-  window.GrAnnotationActionsContext = GrAnnotationActionsContext;
-})(window);
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..a14612b 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script src="../../diff/gr-diff-highlight/gr-annotation.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,67 +31,74 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-annotation-actions-context tests', () => {
-    let instance;
-    let sandbox;
-    let el;
-    let lineNumberEl;
-    let plugin;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation.js';
+import {GrAnnotationActionsContext} from './gr-annotation-actions-context.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
+suite('gr-annotation-actions-context tests', () => {
+  let instance;
+  let sandbox;
+  let el;
+  let lineNumberEl;
+  let plugin;
 
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      el = document.createElement('div');
-      el.textContent = str;
-      el.setAttribute('data-side', 'right');
-      lineNumberEl = document.createElement('td');
-      lineNumberEl.classList.add('right');
-      document.body.appendChild(el);
-      instance = new GrAnnotationActionsContext(
-          el, lineNumberEl, line, 'dummy/path', '123', '1');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('test annotateRange', () => {
-      annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      const start = 0;
-      const end = 100;
-      const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-      // Assert annotateElement is not called when side is different.
-      instance.annotateRange(start, end, cssStyleObject, 'left');
-      assert.equal(annotateElementSpy.callCount, 0);
-
-      // Assert annotateElement is called once when side is the same.
-      instance.annotateRange(start, end, cssStyleObject, 'right');
-      assert.equal(annotateElementSpy.callCount, 1);
-      const args = annotateElementSpy.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], start);
-      assert.equal(args[2], end);
-      assert.equal(args[3], cssStyleObject.getClassName(el));
-    });
-
-    test('test annotateLineNumber', () => {
-      const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-      const className = cssStyleObject.getClassName(lineNumberEl);
-
-      // Assert that css class is *not* applied when side is different.
-      instance.annotateLineNumber(cssStyleObject, 'left');
-      assert.isFalse(lineNumberEl.classList.contains(className));
-
-      // Assert that css class is applied when side is the same.
-      instance.annotateLineNumber(cssStyleObject, 'right');
-      assert.isTrue(lineNumberEl.classList.contains(className));
-    });
+    const str = 'lorem ipsum blah blah';
+    const line = {text: str};
+    el = document.createElement('div');
+    el.textContent = str;
+    el.setAttribute('data-side', 'right');
+    lineNumberEl = document.createElement('td');
+    lineNumberEl.classList.add('right');
+    document.body.appendChild(el);
+    instance = new GrAnnotationActionsContext(
+        el, lineNumberEl, line, 'dummy/path', '123', '1');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('test annotateRange', () => {
+    const annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+    const start = 0;
+    const end = 100;
+    const cssStyleObject = plugin.styles().css('background-color: #000000');
+
+    // Assert annotateElement is not called when side is different.
+    instance.annotateRange(start, end, cssStyleObject, 'left');
+    assert.equal(annotateElementSpy.callCount, 0);
+
+    // Assert annotateElement is called once when side is the same.
+    instance.annotateRange(start, end, cssStyleObject, 'right');
+    assert.equal(annotateElementSpy.callCount, 1);
+    const args = annotateElementSpy.getCalls()[0].args;
+    assert.equal(args[0], el);
+    assert.equal(args[1], start);
+    assert.equal(args[2], end);
+    assert.equal(args[3], cssStyleObject.getClassName(el));
+  });
+
+  test('test annotateLineNumber', () => {
+    const cssStyleObject = plugin.styles().css('background-color: #000000');
+
+    const className = cssStyleObject.getClassName(lineNumberEl);
+
+    // Assert that css class is *not* applied when side is different.
+    instance.annotateLineNumber(cssStyleObject, 'left');
+    assert.isFalse(lineNumberEl.classList.contains(className));
+
+    // Assert that css class is applied when side is the same.
+    instance.annotateLineNumber(cssStyleObject, 'right');
+    assert.isTrue(lineNumberEl.classList.contains(className));
+  });
+});
 </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..3b24404 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
@@ -14,215 +14,213 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import {GrAnnotationActionsContext} from './gr-annotation-actions-context.js';
 
-  function GrAnnotationActionsInterface(plugin) {
-    this.plugin = plugin;
-    // Return this instance when there is an annotatediff event.
-    plugin.on('annotatediff', this);
+/** @constructor */
+export function GrAnnotationActionsInterface(plugin) {
+  this.plugin = plugin;
+  // Return this instance when there is an annotatediff event.
+  plugin.on('annotatediff', this);
 
-    // Collect all annotation layers instantiated by getLayer. Will be used when
-    // notifying their listeners in the notify function.
-    this._annotationLayers = [];
+  // Collect all annotation layers instantiated by getLayer. Will be used when
+  // notifying their listeners in the notify function.
+  this._annotationLayers = [];
 
-    this._coverageProvider = null;
+  this._coverageProvider = null;
 
-    // Default impl is a no-op.
-    this._addLayerFunc = annotationActionsContext => {};
+  // Default impl is a no-op.
+  this._addLayerFunc = annotationActionsContext => {};
+}
+
+/**
+ * Register a function to call to apply annotations. Plugins should use
+ * GrAnnotationActionsContext.annotateRange and
+ * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
+ * line content or the line number.
+ *
+ * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
+ *     that will be called when the AnnotationLayer is ready to annotate.
+ */
+GrAnnotationActionsInterface.prototype.addLayer = function(addLayerFunc) {
+  this._addLayerFunc = addLayerFunc;
+  return this;
+};
+
+/**
+ * The specified function will be called with a notify function for the plugin
+ * to call when it has all required data for annotation. Optional.
+ *
+ * @param {function(function(String, Number, Number, String))} notifyFunc See
+ *     doc of the notify function below to see what it does.
+ */
+GrAnnotationActionsInterface.prototype.addNotifier = function(notifyFunc) {
+  // Register the notify function with the plugin's function.
+  notifyFunc(this.notify.bind(this));
+  return this;
+};
+
+/**
+ * The specified function will be called when a gr-diff component is built,
+ * and feeds the returned coverage data into the diff. Optional.
+ *
+ * Be sure to call this only once and only from one plugin. Multiple coverage
+ * providers are not supported. A second call will just overwrite the
+ * provider of the first call.
+ *
+ * @param {function(changeNum, path, basePatchNum, patchNum):
+ * !Promise<!Array<!Gerrit.CoverageRange>>} coverageProvider
+ * @return {GrAnnotationActionsInterface}
+ */
+GrAnnotationActionsInterface.prototype.setCoverageProvider = function(
+    coverageProvider) {
+  if (this._coverageProvider) {
+    console.warn('Overwriting an existing coverage provider.');
   }
+  this._coverageProvider = coverageProvider;
+  return this;
+};
 
-  /**
-   * Register a function to call to apply annotations. Plugins should use
-   * GrAnnotationActionsContext.annotateRange and
-   * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
-   * line content or the line number.
-   *
-   * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
-   *     that will be called when the AnnotationLayer is ready to annotate.
-   */
-  GrAnnotationActionsInterface.prototype.addLayer = function(addLayerFunc) {
-    this._addLayerFunc = addLayerFunc;
-    return this;
-  };
+/**
+ * Used by Gerrit to look up the coverage provider. Not intended to be called
+ * by plugins.
+ */
+GrAnnotationActionsInterface.prototype.getCoverageProvider = function() {
+  return this._coverageProvider;
+};
 
-  /**
-   * The specified function will be called with a notify function for the plugin
-   * to call when it has all required data for annotation. Optional.
-   *
-   * @param {function(function(String, Number, Number, String))} notifyFunc See
-   *     doc of the notify function below to see what it does.
-   */
-  GrAnnotationActionsInterface.prototype.addNotifier = function(notifyFunc) {
-    // Register the notify function with the plugin's function.
-    notifyFunc(this.notify.bind(this));
-    return this;
-  };
-
-  /**
-   * The specified function will be called when a gr-diff component is built,
-   * and feeds the returned coverage data into the diff. Optional.
-   *
-   * Be sure to call this only once and only from one plugin. Multiple coverage
-   * providers are not supported. A second call will just overwrite the
-   * provider of the first call.
-   *
-   * @param {function(changeNum, path, basePatchNum, patchNum):
-   * !Promise<!Array<!Gerrit.CoverageRange>>} coverageProvider
-   * @return {GrAnnotationActionsInterface}
-   */
-  GrAnnotationActionsInterface.prototype.setCoverageProvider = function(
-      coverageProvider) {
-    if (this._coverageProvider) {
-      console.warn('Overwriting an existing coverage provider.');
+/**
+ * Returns a checkbox HTMLElement that can be used to toggle annotations
+ * on/off. The checkbox will be initially disabled. Plugins should enable it
+ * when data is ready and should add a click handler to toggle CSS on/off.
+ *
+ * Note1: Calling this method from multiple plugins will only work for the
+ *        1st call. It will print an error message for all subsequent calls
+ *        and will not invoke their onAttached functions.
+ * Note2: This method will be deprecated and eventually removed when
+ *        https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
+ *        implemented.
+ *
+ * @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.
+ */
+GrAnnotationActionsInterface.prototype.enableToggleCheckbox = function(
+    checkboxLabel, onAttached) {
+  this.plugin.hook('annotation-toggler').onAttached(element => {
+    if (!element.content.hidden) {
+      console.error(
+          element.content.id + ' is already enabled. Cannot re-enable.');
+      return;
     }
-    this._coverageProvider = coverageProvider;
-    return this;
-  };
+    element.content.removeAttribute('hidden');
 
-  /**
-   * Used by Gerrit to look up the coverage provider. Not intended to be called
-   * by plugins.
-   */
-  GrAnnotationActionsInterface.prototype.getCoverageProvider = function() {
-    return this._coverageProvider;
-  };
-
-  /**
-   * Returns a checkbox HTMLElement that can be used to toggle annotations
-   * on/off. The checkbox will be initially disabled. Plugins should enable it
-   * when data is ready and should add a click handler to toggle CSS on/off.
-   *
-   * Note1: Calling this method from multiple plugins will only work for the
-   *        1st call. It will print an error message for all subsequent calls
-   *        and will not invoke their onAttached functions.
-   * Note2: This method will be deprecated and eventually removed when
-   *        https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
-   *        implemented.
-   *
-   * @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.
-   */
-  GrAnnotationActionsInterface.prototype.enableToggleCheckbox = function(
-      checkboxLabel, onAttached) {
-    this.plugin.hook('annotation-toggler').onAttached(element => {
-      if (!element.content.hidden) {
-        console.error(
-            element.content.id + ' is already enabled. Cannot re-enable.');
-        return;
-      }
-      element.content.removeAttribute('hidden');
-
-      const label = element.content.querySelector('#annotation-label');
-      if (checkboxLabel) {
-        label.textContent = checkboxLabel;
-      } else {
-        label.textContent = 'Enable';
-      }
-      const checkbox = element.content.querySelector('#annotation-checkbox');
-      onAttached(checkbox);
-    });
-    return this;
-  };
-
-  /**
-   * The notify function will call the listeners of all required annotation
-   * 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').
-   */
-  GrAnnotationActionsInterface.prototype.notify = function(
-      path, startRange, endRange, side) {
-    for (const annotationLayer of this._annotationLayers) {
-      // Notify only the annotation layer that is associated with the specified
-      // path.
-      if (annotationLayer._path === path) {
-        annotationLayer.notifyListeners(startRange, endRange, side);
-        break;
-      }
+    const label = element.content.querySelector('#annotation-label');
+    if (checkboxLabel) {
+      label.textContent = checkboxLabel;
+    } else {
+      label.textContent = 'Enable';
     }
-  };
+    const checkbox = element.content.querySelector('#annotation-checkbox');
+    onAttached(checkbox);
+  });
+  return this;
+};
 
-  /**
-   * 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.
-   */
-  GrAnnotationActionsInterface.prototype.getLayer = function(
-      path, changeNum, patchNum) {
-    const annotationLayer = new AnnotationLayer(path, changeNum, patchNum,
-        this._addLayerFunc);
-    this._annotationLayers.push(annotationLayer);
-    return annotationLayer;
-  };
-
-  /**
-   * 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.
-   * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
-   *     that will be called when the AnnotationLayer is ready to annotate.
-   */
-  function AnnotationLayer(path, changeNum, patchNum, addLayerFunc) {
-    this._path = path;
-    this._changeNum = changeNum;
-    this._patchNum = patchNum;
-    this._addLayerFunc = addLayerFunc;
-
-    this._listeners = [];
+/**
+ * The notify function will call the listeners of all required annotation
+ * 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').
+ */
+GrAnnotationActionsInterface.prototype.notify = function(
+    path, startRange, endRange, side) {
+  for (const annotationLayer of this._annotationLayers) {
+    // Notify only the annotation layer that is associated with the specified
+    // path.
+    if (annotationLayer._path === path) {
+      annotationLayer.notifyListeners(startRange, endRange, side);
+      break;
+    }
   }
+};
 
-  /**
-   * Register a listener for layer updates.
-   *
-   * @param {function(Number, Number, String)} 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.
-   */
-  AnnotationLayer.prototype.addListener = function(fn) {
-    this._listeners.push(fn);
-  };
+/**
+ * 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.
+ */
+GrAnnotationActionsInterface.prototype.getLayer = function(
+    path, changeNum, patchNum) {
+  const annotationLayer = new AnnotationLayer(path, changeNum, patchNum,
+      this._addLayerFunc);
+  this._annotationLayers.push(annotationLayer);
+  return annotationLayer;
+};
 
-  /**
-   * Layer method to add annotations to a line.
-   *
-   * @param {HTMLElement} contentEl The DIV.contentText element of the line
-   *     content to apply the annotation to using annotateRange.
-   * @param {HTMLElement} lineNumberEl The TD element of the line number to
-   *     apply the annotation to using annotateLineNumber.
-   * @param {GrDiffLine} line The line object.
-   */
-  AnnotationLayer.prototype.annotate = function(contentEl, lineNumberEl, line) {
-    const annotationActionsContext = new GrAnnotationActionsContext(
-        contentEl, lineNumberEl, line, this._path, this._changeNum,
-        this._patchNum);
-    this._addLayerFunc(annotationActionsContext);
-  };
+/**
+ * Used to create an instance of the Annotation Layer interface.
+ *
+ * @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.
+ */
+function AnnotationLayer(path, changeNum, patchNum, addLayerFunc) {
+  this._path = path;
+  this._changeNum = changeNum;
+  this._patchNum = patchNum;
+  this._addLayerFunc = addLayerFunc;
 
-  /**
-   * 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')
-   */
-  AnnotationLayer.prototype.notifyListeners = function(
-      startRange, endRange, side) {
-    for (const listener of this._listeners) {
-      listener(startRange, endRange, side);
-    }
-  };
+  this._listeners = [];
+}
 
-  window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
-})(window);
+/**
+ * Register a listener for layer updates.
+ *
+ * @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.
+ */
+AnnotationLayer.prototype.addListener = function(fn) {
+  this._listeners.push(fn);
+};
+
+/**
+ * Layer method to add annotations to a line.
+ *
+ * @param {HTMLElement} contentEl The DIV.contentText element of the line
+ *     content to apply the annotation to using annotateRange.
+ * @param {HTMLElement} lineNumberEl The TD element of the line number to
+ *     apply the annotation to using annotateLineNumber.
+ * @param {GrDiffLine} line The line object.
+ */
+AnnotationLayer.prototype.annotate = function(contentEl, lineNumberEl, line) {
+  const annotationActionsContext = new GrAnnotationActionsContext(
+      contentEl, lineNumberEl, line, this._path, this._changeNum,
+      this._patchNum);
+  this._addLayerFunc(annotationActionsContext);
+};
+
+/**
+ * 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')
+ */
+AnnotationLayer.prototype.notifyListeners = function(
+    startRange, endRange, side) {
+  for (const listener of this._listeners) {
+    listener(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..e72fc63 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
@@ -17,15 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../change/gr-change-actions/gr-change-actions.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <test-fixture id="basic">
   <template>
     <span hidden id="annotation-span">
@@ -37,151 +35,158 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-annotation-actions-js-api tests', () => {
-    let annotationActions;
-    let sandbox;
-    let plugin;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      annotationActions = plugin.annotationApi();
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      annotationActions = null;
-      sandbox.restore();
-    });
+suite('gr-annotation-actions-js-api tests', () => {
+  let annotationActions;
+  let sandbox;
+  let plugin;
 
-    test('add/get layer', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      el = document.createElement('div');
-      el.textContent = str;
-      const changeNum = 1234;
-      const patchNum = 2;
-      let testLayerFuncCalled = false;
-
-      const testLayerFunc = context => {
-        testLayerFuncCalled = true;
-        assert.equal(context.line, line);
-        assert.equal(context.changeNum, changeNum);
-        assert.equal(context.patchNum, 2);
-      };
-      annotationActions.addLayer(testLayerFunc);
-
-      const annotationLayer = annotationActions.getLayer(
-          '/dummy/path', changeNum, patchNum);
-
-      const lineNumberEl = document.createElement('td');
-      annotationLayer.annotate(el, lineNumberEl, line);
-      assert.isTrue(testLayerFuncCalled);
-    });
-
-    test('add notifier', () => {
-      const path1 = '/dummy/path1';
-      const path2 = '/dummy/path2';
-      const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
-      const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
-      const layer1Spy = sandbox.spy(annotationLayer1, 'notifyListeners');
-      const layer2Spy = sandbox.spy(annotationLayer2, 'notifyListeners');
-
-      let notify;
-      const notifyFunc = n => {
-        notifyFuncCalled = true;
-        notify = n;
-      };
-      annotationActions.addNotifier(notifyFunc);
-      assert.isTrue(notifyFuncCalled);
-
-      // Assert that no layers are invoked with a different path.
-      notify('/dummy/path3', 0, 10, 'right');
-      assert.isFalse(layer1Spy.called);
-      assert.isFalse(layer2Spy.called);
-
-      // Assert that only the 1st layer is invoked with path1.
-      notify(path1, 0, 10, 'right');
-      assert.isTrue(layer1Spy.called);
-      assert.isFalse(layer2Spy.called);
-
-      // Reset spies.
-      layer1Spy.reset();
-      layer2Spy.reset();
-
-      // Assert that only the 2nd layer is invoked with path2.
-      notify(path2, 0, 20, 'left');
-      assert.isFalse(layer1Spy.called);
-      assert.isTrue(layer2Spy.called);
-    });
-
-    test('toggle checkbox', () => {
-      fakeEl = {content: fixture('basic')};
-      const hookStub = {onAttached: sandbox.stub()};
-      sandbox.stub(plugin, 'hook').returns(hookStub);
-
-      let checkbox;
-      let onAttachedFuncCalled = false;
-      const onAttachedFunc = c => {
-        checkbox = c;
-        onAttachedFuncCalled = true;
-      };
-      annotationActions.enableToggleCheckbox('test label', onAttachedFunc);
-      emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-      emulateAttached();
-
-      // Assert that onAttachedFunc is called and HTML elements have the
-      // expected state.
-      assert.isTrue(onAttachedFuncCalled);
-      assert.equal(checkbox.id, 'annotation-checkbox');
-      assert.isTrue(checkbox.disabled);
-      assert.equal(document.getElementById('annotation-label').textContent,
-          'test label');
-      assert.isFalse(document.getElementById('annotation-span').hidden);
-
-      // Assert that error is shown if we try to enable checkbox again.
-      onAttachedFuncCalled = false;
-      annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
-      const errorStub = sandbox.stub(
-          console, 'error', (msg, err) => undefined);
-      emulateAttached();
-      assert.isTrue(
-          errorStub.calledWith(
-              'annotation-span is already enabled. Cannot re-enable.'));
-      // Assert that onAttachedFunc is not called and the label has not changed.
-      assert.isFalse(onAttachedFuncCalled);
-      assert.equal(document.getElementById('annotation-label').textContent,
-          'test label');
-    });
-
-    test('layer notify listeners', () => {
-      const annotationLayer = annotationActions.getLayer(
-          '/dummy/path', 1, 2);
-      let listenerCalledTimes = 0;
-      const startRange = 10;
-      const endRange = 20;
-      const side = 'right';
-      const listener = (st, end, s) => {
-        listenerCalledTimes++;
-        assert.equal(st, startRange);
-        assert.equal(end, endRange);
-        assert.equal(s, side);
-      };
-
-      // Notify with 0 listeners added.
-      annotationLayer.notifyListeners(startRange, endRange, side);
-      assert.equal(listenerCalledTimes, 0);
-
-      // Add 1 listener.
-      annotationLayer.addListener(listener);
-      annotationLayer.notifyListeners(startRange, endRange, side);
-      assert.equal(listenerCalledTimes, 1);
-
-      // Add 1 more listener. Total 2 listeners.
-      annotationLayer.addListener(listener);
-      annotationLayer.notifyListeners(startRange, endRange, side);
-      assert.equal(listenerCalledTimes, 3);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    annotationActions = plugin.annotationApi();
   });
+
+  teardown(() => {
+    annotationActions = null;
+    sandbox.restore();
+  });
+
+  test('add/get layer', () => {
+    const str = 'lorem ipsum blah blah';
+    const line = {text: str};
+    const el = document.createElement('div');
+    el.textContent = str;
+    const changeNum = 1234;
+    const patchNum = 2;
+    let testLayerFuncCalled = false;
+
+    const testLayerFunc = context => {
+      testLayerFuncCalled = true;
+      assert.equal(context.line, line);
+      assert.equal(context.changeNum, changeNum);
+      assert.equal(context.patchNum, 2);
+    };
+    annotationActions.addLayer(testLayerFunc);
+
+    const annotationLayer = annotationActions.getLayer(
+        '/dummy/path', changeNum, patchNum);
+
+    const lineNumberEl = document.createElement('td');
+    annotationLayer.annotate(el, lineNumberEl, line);
+    assert.isTrue(testLayerFuncCalled);
+  });
+
+  test('add notifier', () => {
+    const path1 = '/dummy/path1';
+    const path2 = '/dummy/path2';
+    const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
+    const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
+    const layer1Spy = sandbox.spy(annotationLayer1, 'notifyListeners');
+    const layer2Spy = sandbox.spy(annotationLayer2, 'notifyListeners');
+
+    let notify;
+    let notifyFuncCalled;
+    const notifyFunc = n => {
+      notifyFuncCalled = true;
+      notify = n;
+    };
+    annotationActions.addNotifier(notifyFunc);
+    assert.isTrue(notifyFuncCalled);
+
+    // Assert that no layers are invoked with a different path.
+    notify('/dummy/path3', 0, 10, 'right');
+    assert.isFalse(layer1Spy.called);
+    assert.isFalse(layer2Spy.called);
+
+    // Assert that only the 1st layer is invoked with path1.
+    notify(path1, 0, 10, 'right');
+    assert.isTrue(layer1Spy.called);
+    assert.isFalse(layer2Spy.called);
+
+    // Reset spies.
+    layer1Spy.reset();
+    layer2Spy.reset();
+
+    // Assert that only the 2nd layer is invoked with path2.
+    notify(path2, 0, 20, 'left');
+    assert.isFalse(layer1Spy.called);
+    assert.isTrue(layer2Spy.called);
+  });
+
+  test('toggle checkbox', () => {
+    const fakeEl = {content: fixture('basic')};
+    const hookStub = {onAttached: sandbox.stub()};
+    sandbox.stub(plugin, 'hook').returns(hookStub);
+
+    let checkbox;
+    let onAttachedFuncCalled = false;
+    const onAttachedFunc = c => {
+      checkbox = c;
+      onAttachedFuncCalled = true;
+    };
+    annotationActions.enableToggleCheckbox('test label', onAttachedFunc);
+    const emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
+    emulateAttached();
+
+    // Assert that onAttachedFunc is called and HTML elements have the
+    // expected state.
+    assert.isTrue(onAttachedFuncCalled);
+    assert.equal(checkbox.id, 'annotation-checkbox');
+    assert.isTrue(checkbox.disabled);
+    assert.equal(document.getElementById('annotation-label').textContent,
+        'test label');
+    assert.isFalse(document.getElementById('annotation-span').hidden);
+
+    // Assert that error is shown if we try to enable checkbox again.
+    onAttachedFuncCalled = false;
+    annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
+    const errorStub = sandbox.stub(
+        console, 'error', (msg, err) => undefined);
+    emulateAttached();
+    assert.isTrue(
+        errorStub.calledWith(
+            'annotation-span is already enabled. Cannot re-enable.'));
+    // Assert that onAttachedFunc is not called and the label has not changed.
+    assert.isFalse(onAttachedFuncCalled);
+    assert.equal(document.getElementById('annotation-label').textContent,
+        'test label');
+  });
+
+  test('layer notify listeners', () => {
+    const annotationLayer = annotationActions.getLayer(
+        '/dummy/path', 1, 2);
+    let listenerCalledTimes = 0;
+    const startRange = 10;
+    const endRange = 20;
+    const side = 'right';
+    const listener = (st, end, s) => {
+      listenerCalledTimes++;
+      assert.equal(st, startRange);
+      assert.equal(end, endRange);
+      assert.equal(s, side);
+    };
+
+    // Notify with 0 listeners added.
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 0);
+
+    // Add 1 listener.
+    annotationLayer.addListener(listener);
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 1);
+
+    // Add 1 more listener. Total 2 listeners.
+    annotationLayer.addListener(listener);
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 3);
+  });
+});
 </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..5b58a5c 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
@@ -15,98 +15,90 @@
  * limitations under the License.
  */
 
-(function(window) {
-  'use strict';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  const PRELOADED_PROTOCOL = 'preloaded:';
-  const PLUGIN_LOADING_TIMEOUT_MS = 10000;
+export const PRELOADED_PROTOCOL = 'preloaded:';
+export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
-  let _restAPI;
-  function getRestAPI() {
-    if (!_restAPI) {
-      _restAPI = document.createElement('gr-rest-api-interface');
-    }
-    return _restAPI;
+let _restAPI;
+export function getRestAPI() {
+  if (!_restAPI) {
+    _restAPI = document.createElement('gr-rest-api-interface');
   }
+  return _restAPI;
+}
 
-  function getBaseUrl() {
-    return Gerrit.BaseUrlBehavior.getBaseUrl();
-  }
+export function getBaseUrl() {
+  return BaseUrlBehavior.getBaseUrl();
+}
 
-  /**
-   * Retrieves the name of the plugin base on the url.
-   *
-   * @param {string|URL} url
-   */
-  function getPluginNameFromUrl(url) {
-    if (!(url instanceof URL)) {
-      try {
-        url = new URL(url);
-      } catch (e) {
-        console.warn(e);
-        return null;
-      }
-    }
-    if (url.protocol === PRELOADED_PROTOCOL) {
-      return url.pathname;
-    }
-    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
-    const pathname = url.pathname.replace(base, '');
-    // Site theme is server from predefined path.
-    if (pathname === '/static/gerrit-theme.html') {
-      return 'gerrit-theme';
-    } else if (!pathname.startsWith('/plugins')) {
-      console.warn('Plugin not being loaded from /plugins base path:',
-          url.href, '— Unable to determine name.');
+/**
+ * Retrieves the name of the plugin base on the url.
+ *
+ * @param {string|URL} url
+ */
+export function getPluginNameFromUrl(url) {
+  if (!(url instanceof URL)) {
+    try {
+      url = new URL(url);
+    } catch (e) {
+      console.warn(e);
       return null;
     }
-
-    // Pathname should normally look like this:
-    // /plugins/PLUGINNAME/static/SCRIPTNAME.html
-    // Or, for app/samples:
-    // /plugins/PLUGINNAME.html
-    // TODO(taoalpha): guard with a regex
-    return pathname.split('/')[2].split('.')[0];
+  }
+  if (url.protocol === PRELOADED_PROTOCOL) {
+    return url.pathname;
+  }
+  const base = BaseUrlBehavior.getBaseUrl();
+  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';
+  } else if (!pathname.startsWith('/plugins')) {
+    console.warn('Plugin not being loaded from /plugins base path:',
+        url.href, '— Unable to determine name.');
+    return null;
   }
 
-  // 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);
-          } else {
-            return Promise.reject(response.status);
-          }
-        });
-      } else {
-        return getRestAPI().getResponseObject(response);
-      }
-    }).then(response => {
-      if (opt_callback) {
-        opt_callback(response);
-      }
-      return response;
-    });
-  }
+  // Pathname should normally look like this:
+  // /plugins/PLUGINNAME/static/SCRIPTNAME.html
+  // Or, for app/samples:
+  // /plugins/PLUGINNAME.html
+  // TODO(taoalpha): guard with a regex
+  return pathname.split('/')[2].split('.')[0];
+}
 
+// TODO(taoalpha): to be deprecated.
+export 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(new Error(text));
+            } else {
+              return Promise.reject(new Error(response.status));
+            }
+          });
+        } else {
+          return getRestAPI().getResponseObject(response);
+        }
+      })
+      .then(response => {
+        if (opt_callback) {
+          opt_callback(response);
+        }
+        return response;
+      });
+}
 
-  // TEST only methods / properties
+// TEST only methods / properties
 
-  function testOnly_resetInternalState() {
-    _restAPI = undefined;
-  }
+export function testOnly_resetInternalState() {
+  _restAPI = undefined;
+}
 
-  window._apiUtils = {
-    getPluginNameFromUrl,
-    send,
-    getRestAPI,
-    getBaseUrl,
-    PRELOADED_PROTOCOL,
-    PLUGIN_LOADING_TIMEOUT_MS,
-
-    // TEST only methods
-    testOnly_resetInternalState,
-  };
-})(window);
\ No newline at end of file
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..d01566a 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
@@ -17,62 +17,69 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {getPluginNameFromUrl} from './gr-api-utils.js';
 
-  const PRELOADED_PROTOCOL = 'preloaded:';
+const PRELOADED_PROTOCOL = 'preloaded:';
 
-  suite('gr-api-utils tests', () => {
-    suite('test getPluginNameFromUrl', () => {
-      const {getPluginNameFromUrl} = window._apiUtils;
+suite('gr-api-utils tests', () => {
+  suite('test getPluginNameFromUrl', () => {
+    test('with empty string', () => {
+      assert.equal(getPluginNameFromUrl(''), null);
+    });
 
-      test('with empty string', () => {
-        assert.equal(getPluginNameFromUrl(''), null);
-      });
+    test('with invalid url', () => {
+      assert.equal(getPluginNameFromUrl('test'), null);
+    });
 
-      test('with invalid url', () => {
-        assert.equal(getPluginNameFromUrl('test'), null);
-      });
+    test('with random invalid url', () => {
+      assert.equal(getPluginNameFromUrl('http://example.com'), null);
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/static/a.html'),
+          null
+      );
+    });
 
-      test('with random invalid url', () => {
-        assert.equal(getPluginNameFromUrl('http://example.com'), null);
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/static/a.html'),
-            null
-        );
-      });
+    test('with valid urls', () => {
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/plugins/a.html'),
+          'a'
+      );
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
+          'a'
+      );
+    });
 
-      test('with valid urls', () => {
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/plugins/a.html'),
-            'a'
-        );
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
-            'a'
-        );
-      });
+    test('with preloaded urls', () => {
+      assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
+    });
 
-      test('with preloaded urls', () => {
-        assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
-      });
+    test('with gerrit-theme override', () => {
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
+          'gerrit-theme'
+      );
+    });
 
-      test('with gerrit-theme override', () => {
-        assert.equal(
-            getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
-            '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
+});
+</script>
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..8ab97f8 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
@@ -14,128 +14,122 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  /**
-   * Ensure GrChangeActionsInterface instance has access to gr-change-actions
-   * element and retrieve if the interface was created before element.
-   *
-   * @param {!GrChangeActionsInterface} api
-   */
-  function ensureEl(api) {
-    if (!api._el) {
-      const sharedApiElement = document.createElement('gr-js-api-interface');
-      setEl(api, sharedApiElement.getElement(
-          sharedApiElement.Element.CHANGE_ACTIONS));
-    }
+/**
+ * Ensure GrChangeActionsInterface instance has access to gr-change-actions
+ * element and retrieve if the interface was created before element.
+ *
+ * @param {!GrChangeActionsInterface} api
+ */
+function ensureEl(api) {
+  if (!api._el) {
+    const sharedApiElement = document.createElement('gr-js-api-interface');
+    setEl(api, sharedApiElement.getElement(
+        sharedApiElement.Element.CHANGE_ACTIONS));
   }
+}
 
-  /**
-   * Set gr-change-actions element to a GrChangeActionsInterface instance.
-   *
-   * @param {!GrChangeActionsInterface} api
-   * @param {!Element} el gr-change-actions
-   */
-  function setEl(api, el) {
-    if (!el) {
-      console.warn('changeActions() is not ready');
-      return;
-    }
-    api._el = el;
-    api.RevisionActions = el.RevisionActions;
-    api.ChangeActions = el.ChangeActions;
-    api.ActionType = el.ActionType;
+/**
+ * Set gr-change-actions element to a GrChangeActionsInterface instance.
+ *
+ * @param {!GrChangeActionsInterface} api
+ * @param {!Element} el gr-change-actions
+ */
+function setEl(api, el) {
+  if (!el) {
+    console.warn('changeActions() is not ready');
+    return;
   }
+  api._el = el;
+  api.RevisionActions = el.RevisionActions;
+  api.ChangeActions = el.ChangeActions;
+  api.ActionType = el.ActionType;
+}
 
-  function GrChangeActionsInterface(plugin, el) {
-    this.plugin = plugin;
-    setEl(this, el);
-  }
+export function GrChangeActionsInterface(plugin, el) {
+  this.plugin = plugin;
+  setEl(this, el);
+}
 
-  GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
-    ensureEl(this);
-    if (this._el.primaryActionKeys.includes(key)) { return; }
+GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
+  ensureEl(this);
+  if (this._el.primaryActionKeys.includes(key)) { return; }
 
-    this._el.push('primaryActionKeys', key);
-  };
+  this._el.push('primaryActionKeys', key);
+};
 
-  GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
-    ensureEl(this);
-    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(k => {
-      return k !== key;
-    });
-  };
+GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
+  ensureEl(this);
+  this._el.primaryActionKeys = this._el.primaryActionKeys
+      .filter(k => k !== key);
+};
 
-  GrChangeActionsInterface.prototype.hideQuickApproveAction = function() {
-    ensureEl(this);
-    this._el.hideQuickApproveAction();
-  };
+GrChangeActionsInterface.prototype.hideQuickApproveAction = function() {
+  ensureEl(this);
+  this._el.hideQuickApproveAction();
+};
 
-  GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
-      overflow) {
-    ensureEl(this);
-    return this._el.setActionOverflow(type, key, overflow);
-  };
+GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
+    overflow) {
+  ensureEl(this);
+  return this._el.setActionOverflow(type, key, overflow);
+};
 
-  GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
-      priority) {
-    ensureEl(this);
-    return this._el.setActionPriority(type, key, priority);
-  };
+GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
+    priority) {
+  ensureEl(this);
+  return this._el.setActionPriority(type, key, priority);
+};
 
-  GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
-      hidden) {
-    ensureEl(this);
-    return this._el.setActionHidden(type, key, hidden);
-  };
+GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
+    hidden) {
+  ensureEl(this);
+  return this._el.setActionHidden(type, key, hidden);
+};
 
-  GrChangeActionsInterface.prototype.add = function(type, label) {
-    ensureEl(this);
-    return this._el.addActionButton(type, label);
-  };
+GrChangeActionsInterface.prototype.add = function(type, label) {
+  ensureEl(this);
+  return this._el.addActionButton(type, label);
+};
 
-  GrChangeActionsInterface.prototype.remove = function(key) {
-    ensureEl(this);
-    return this._el.removeActionButton(key);
-  };
+GrChangeActionsInterface.prototype.remove = function(key) {
+  ensureEl(this);
+  return this._el.removeActionButton(key);
+};
 
-  GrChangeActionsInterface.prototype.addTapListener = function(key, handler) {
-    ensureEl(this);
-    this._el.addEventListener(key + '-tap', handler);
-  };
+GrChangeActionsInterface.prototype.addTapListener = function(key, handler) {
+  ensureEl(this);
+  this._el.addEventListener(key + '-tap', handler);
+};
 
-  GrChangeActionsInterface.prototype.removeTapListener = function(key,
-      handler) {
-    ensureEl(this);
-    this._el.removeEventListener(key + '-tap', handler);
-  };
+GrChangeActionsInterface.prototype.removeTapListener = function(key,
+    handler) {
+  ensureEl(this);
+  this._el.removeEventListener(key + '-tap', handler);
+};
 
-  GrChangeActionsInterface.prototype.setLabel = function(key, text) {
-    ensureEl(this);
-    this._el.setActionButtonProp(key, 'label', text);
-  };
+GrChangeActionsInterface.prototype.setLabel = function(key, text) {
+  ensureEl(this);
+  this._el.setActionButtonProp(key, 'label', text);
+};
 
-  GrChangeActionsInterface.prototype.setTitle = function(key, text) {
-    ensureEl(this);
-    this._el.setActionButtonProp(key, 'title', text);
-  };
+GrChangeActionsInterface.prototype.setTitle = function(key, text) {
+  ensureEl(this);
+  this._el.setActionButtonProp(key, 'title', text);
+};
 
-  GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
-    ensureEl(this);
-    this._el.setActionButtonProp(key, 'enabled', enabled);
-  };
+GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
+  ensureEl(this);
+  this._el.setActionButtonProp(key, 'enabled', enabled);
+};
 
-  GrChangeActionsInterface.prototype.setIcon = function(key, icon) {
-    ensureEl(this);
-    this._el.setActionButtonProp(key, 'icon', icon);
-  };
+GrChangeActionsInterface.prototype.setIcon = function(key, icon) {
+  ensureEl(this);
+  this._el.setActionButtonProp(key, 'icon', icon);
+};
 
-  GrChangeActionsInterface.prototype.getActionDetails = function(action) {
-    ensureEl(this);
-    return this._el.getActionDetails(action) ||
-      this._el.getActionDetails(this.plugin.getPluginName() + '~' + action);
-  };
-
-  window.GrChangeActionsInterface = GrChangeActionsInterface;
-})(window);
+GrChangeActionsInterface.prototype.getActionDetails = function(action) {
+  ensureEl(this);
+  return this._el.getActionDetails(action) ||
+    this._el.getActionDetails(this.plugin.getPluginName() + '~' + action);
+};
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..1d5e423 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
@@ -17,20 +17,17 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
 breaking changes to gr-change-actions won’t be noticed.
 -->
-<link rel="import" href="../../change/gr-change-actions/gr-change-actions.html">
-
-<script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
@@ -38,182 +35,198 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-js-api-interface tests', () => {
-    let element;
-    let changeActions;
-    let plugin;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    // Because deepEqual doesn’t behave in Safari.
-    function assertArraysEqual(actual, expected) {
-      assert.equal(actual.length, expected.length);
-      for (let i = 0; i < actual.length; i++) {
-        assert.equal(actual[i], expected[i]);
-      }
+suite('gr-js-api-interface tests', () => {
+  let element;
+  let changeActions;
+  let plugin;
+
+  // Because deepEqual doesn’t behave in Safari.
+  function assertArraysEqual(actual, expected) {
+    assert.equal(actual.length, expected.length);
+    for (let i = 0; i < actual.length; i++) {
+      assert.equal(actual[i], expected[i]);
     }
+  }
 
-    suite('early init', () => {
-      setup(() => {
-        Gerrit._testOnly_resetPlugins();
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        // Mimic all plugins loaded.
-        Gerrit._loadPlugins([]);
-        changeActions = plugin.changeActions();
-        element = fixture('basic');
+  suite('early init', () => {
+    setup(() => {
+      resetPlugins();
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      // Mimic all plugins loaded.
+      pluginLoader.loadPlugins([]);
+      changeActions = plugin.changeActions();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      changeActions = null;
+      resetPlugins();
+    });
+
+    test('does not throw', ()=> {
+      assert.doesNotThrow(() => {
+        changeActions.add('change', 'foo');
       });
+    });
+  });
 
-      teardown(() => {
-        changeActions = null;
-        Gerrit._testOnly_resetPlugins();
-      });
+  suite('normal init', () => {
+    setup(() => {
+      resetPlugins();
+      element = fixture('basic');
+      sinon.stub(element, '_editStatusChanged');
+      element.change = {};
+      element._hasKnownChainState = false;
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeActions = plugin.changeActions();
+      // Mimic all plugins loaded.
+      pluginLoader.loadPlugins([]);
+    });
 
-      test('does not throw', ()=> {
-        assert.doesNotThrow(() => {
-          changeActions.add('change', 'foo');
+    teardown(() => {
+      changeActions = null;
+      resetPlugins();
+    });
+
+    test('property existence', () => {
+      const properties = [
+        'ActionType',
+        'ChangeActions',
+        'RevisionActions',
+      ];
+      for (const p of properties) {
+        assertArraysEqual(changeActions[p], element[p]);
+      }
+    });
+
+    test('add/remove primary action keys', () => {
+      element.primaryActionKeys = [];
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
+      changeActions.removePrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('baz');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, []);
+    });
+
+    test('action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const handler = sinon.spy();
+      changeActions.addTapListener(key, handler);
+      flush(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.removeTapListener(key, handler);
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.remove(key);
+        flush(() => {
+          assert.isNull(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
+          done();
         });
       });
     });
 
-    suite('normal init', () => {
-      setup(() => {
-        Gerrit._testOnly_resetPlugins();
-        element = fixture('basic');
-        sinon.stub(element, '_editStatusChanged');
-        element.change = {};
-        element._hasKnownChainState = false;
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        changeActions = plugin.changeActions();
-        // Mimic all plugins loaded.
-        Gerrit._loadPlugins([]);
-      });
-
-      teardown(() => {
-        changeActions = null;
-        Gerrit._testOnly_resetPlugins();
-      });
-
-      test('property existence', () => {
-        const properties = [
-          'ActionType',
-          'ChangeActions',
-          'RevisionActions',
-        ];
-        for (const p of properties) {
-          assertArraysEqual(changeActions[p], element[p]);
-        }
-      });
-
-      test('add/remove primary action keys', () => {
-        element.primaryActionKeys = [];
-        changeActions.addPrimaryActionKey('foo');
-        assertArraysEqual(element.primaryActionKeys, ['foo']);
-        changeActions.addPrimaryActionKey('foo');
-        assertArraysEqual(element.primaryActionKeys, ['foo']);
-        changeActions.addPrimaryActionKey('bar');
-        assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
-        changeActions.removePrimaryActionKey('foo');
-        assertArraysEqual(element.primaryActionKeys, ['bar']);
-        changeActions.removePrimaryActionKey('baz');
-        assertArraysEqual(element.primaryActionKeys, ['bar']);
-        changeActions.removePrimaryActionKey('bar');
-        assertArraysEqual(element.primaryActionKeys, []);
-      });
-
-      test('action buttons', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-        const handler = sinon.spy();
-        changeActions.addTapListener(key, handler);
+    test('action button properties', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.equal(button.getAttribute('data-label'), 'Bork!');
+        assert.isNotOk(button.disabled);
+        changeActions.setLabel(key, 'Yo');
+        changeActions.setTitle(key, 'Yo hint');
+        changeActions.setEnabled(key, false);
+        changeActions.setIcon(key, 'pupper');
         flush(() => {
-          MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
-          assert(handler.calledOnce);
-          changeActions.removeTapListener(key, handler);
-          MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
-          assert(handler.calledOnce);
-          changeActions.remove(key);
-          flush(() => {
-            assert.isNull(element.$$('[data-action-key="' + key + '"]'));
-            done();
-          });
+          assert.equal(button.getAttribute('data-label'), 'Yo');
+          assert.equal(button.getAttribute('title'), 'Yo hint');
+          assert.isTrue(button.disabled);
+          assert.equal(dom(button).querySelector('iron-icon').icon,
+              'gr-icons:pupper');
+          done();
         });
       });
+    });
 
-      test('action button properties', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+    test('hide action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.isFalse(button.hasAttribute('hidden'));
+        changeActions.setActionHidden(
+            changeActions.ActionType.REVISION, key, true);
         flush(() => {
-          const button = element.$$('[data-action-key="' + key + '"]');
-          assert.isOk(button);
-          assert.equal(button.getAttribute('data-label'), 'Bork!');
-          assert.isNotOk(button.disabled);
-          changeActions.setLabel(key, 'Yo');
-          changeActions.setTitle(key, 'Yo hint');
-          changeActions.setEnabled(key, false);
-          changeActions.setIcon(key, 'pupper');
-          flush(() => {
-            assert.equal(button.getAttribute('data-label'), 'Yo');
-            assert.equal(button.getAttribute('title'), 'Yo hint');
-            assert.isTrue(button.disabled);
-            assert.equal(Polymer.dom(button).querySelector('iron-icon').icon,
-                'gr-icons:pupper');
-            done();
-          });
+          const button = element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]');
+          assert.isNotOk(button);
+          done();
         });
       });
+    });
 
-      test('hide action buttons', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+    test('move action button to overflow', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        assert.isTrue(element.$.moreActions.hidden);
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        changeActions.setActionOverflow(
+            changeActions.ActionType.REVISION, key, true);
         flush(() => {
-          const button = element.$$('[data-action-key="' + key + '"]');
-          assert.isOk(button);
-          assert.isFalse(button.hasAttribute('hidden'));
-          changeActions.setActionHidden(
-              changeActions.ActionType.REVISION, key, true);
-          flush(() => {
-            const button = element.$$('[data-action-key="' + key + '"]');
-            assert.isNotOk(button);
-            done();
-          });
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
+          assert.isFalse(element.$.moreActions.hidden);
+          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+          done();
         });
       });
+    });
 
-      test('move action button to overflow', done => {
-        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+    test('change actions priority', done => {
+      const key1 =
+        changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const key2 =
+        changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+      flush(() => {
+        let buttons =
+          dom(element.root).querySelectorAll('[data-action-key]');
+        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+        changeActions.setActionPriority(
+            changeActions.ActionType.REVISION, key1, 10);
         flush(() => {
-          assert.isTrue(element.$.moreActions.hidden);
-          assert.isOk(element.$$('[data-action-key="' + key + '"]'));
-          changeActions.setActionOverflow(
-              changeActions.ActionType.REVISION, key, true);
-          flush(() => {
-            assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
-            assert.isFalse(element.$.moreActions.hidden);
-            assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
-            done();
-          });
-        });
-      });
-
-      test('change actions priority', done => {
-        const key1 =
-          changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-        const key2 =
-          changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
-        flush(() => {
-          let buttons =
-            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-          assert.equal(buttons[0].getAttribute('data-action-key'), key1);
-          assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-          changeActions.setActionPriority(
-              changeActions.ActionType.REVISION, key1, 10);
-          flush(() => {
-            buttons =
-              Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-            assert.equal(buttons[0].getAttribute('data-action-key'), key2);
-            assert.equal(buttons[1].getAttribute('data-action-key'), key1);
-            done();
-          });
+          buttons =
+            dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+          done();
         });
       });
     });
   });
+});
 </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..da0157f 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
@@ -14,84 +14,61 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  /**
-   * Ensure GrChangeReplyInterface instance has access to gr-reply-dialog
-   * element and retrieve if the interface was created before element.
-   *
-   * @param {!GrChangeReplyInterfaceOld} api
-   */
-  function ensureEl(api) {
-    if (!api._el) {
-      const sharedApiElement = document.createElement('gr-js-api-interface');
-      api._el = sharedApiElement.getElement(
-          sharedApiElement.Element.REPLY_DIALOG);
-    }
-  }
-
-  /**
-   * @deprecated
-   */
-  function GrChangeReplyInterfaceOld(el) {
-    this._el = el;
-  }
-
-  GrChangeReplyInterfaceOld.prototype.getLabelValue = function(label) {
-    ensureEl(this);
-    return this._el.getLabelValue(label);
-  };
-
-  GrChangeReplyInterfaceOld.prototype.setLabelValue = function(label, value) {
-    ensureEl(this);
-    this._el.setLabelValue(label, value);
-  };
-
-  GrChangeReplyInterfaceOld.prototype.send = function(opt_includeComments) {
-    ensureEl(this);
-    return this._el.send(opt_includeComments);
-  };
-
-  function GrChangeReplyInterface(plugin, el) {
-    GrChangeReplyInterfaceOld.call(this, el);
+/**
+ * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
+ */
+export class GrChangeReplyInterface {
+  constructor(plugin) {
     this.plugin = plugin;
-    this._hookName = (plugin.getPluginName() || 'test') + '-autogenerated-'
-      + String(Math.random()).split('.')[1];
+    this._sharedApiEl = Plugin._sharedAPIElement;
   }
-  GrChangeReplyInterface.prototype._hookName = '';
-  GrChangeReplyInterface.prototype._hookClass = null;
-  GrChangeReplyInterface.prototype._hookPromise = null;
 
-  GrChangeReplyInterface.prototype =
-    Object.create(GrChangeReplyInterfaceOld.prototype);
-  GrChangeReplyInterface.prototype.constructor = GrChangeReplyInterface;
+  get _el() {
+    return this._sharedApiEl.getElement(
+        this._sharedApiEl.Element.REPLY_DIALOG);
+  }
 
-  GrChangeReplyInterface.prototype.addReplyTextChangedCallback =
-    function(handler) {
-      this.plugin.hook('reply-text').onAttached(el => {
-        if (!el.content) { return; }
-        el.content.addEventListener('value-changed', e => {
-          handler(e.detail.value);
-        });
-      });
-    };
+  getLabelValue(label) {
+    return this._el.getLabelValue(label);
+  }
 
-  GrChangeReplyInterface.prototype.addLabelValuesChangedCallback =
-    function(handler) {
-      this.plugin.hook('reply-label-scores').onAttached(el => {
-        if (!el.content) { return; }
+  setLabelValue(label, value) {
+    this._el.setLabelValue(label, value);
+  }
 
-        el.content.addEventListener('labels-changed', e => {
-          console.log('labels-changed', e.detail);
-          handler(e.detail);
-        });
-      });
-    };
+  send(opt_includeComments) {
+    this._el.send(opt_includeComments);
+  }
 
-  GrChangeReplyInterface.prototype.showMessage = function(message) {
+  addReplyTextChangedCallback(handler) {
+    const hookApi = this.plugin.hook('reply-text');
+    const registeredHandler = e => handler(e.detail.value);
+    hookApi.onAttached(el => {
+      if (!el.content) { return; }
+      el.content.addEventListener('value-changed', registeredHandler);
+    });
+    hookApi.onDetached(el => {
+      if (!el.content) { return; }
+      el.content.removeEventListener('value-changed', registeredHandler);
+    });
+  }
+
+  addLabelValuesChangedCallback(handler) {
+    const hookApi = this.plugin.hook('reply-label-scores');
+    const registeredHandler = e => handler(e.detail);
+    hookApi.onAttached(el => {
+      if (!el.content) { return; }
+      el.content.addEventListener('labels-changed', registeredHandler);
+    });
+
+    hookApi.onDetached(el => {
+      if (!el.content) { return; }
+      el.content.removeEventListener('labels-changed', registeredHandler);
+    });
+  }
+
+  showMessage(message) {
     return this._el.setPluginMessage(message);
-  };
-
-  window.GrChangeReplyInterface = GrChangeReplyInterface;
-})(window);
+  }
+}
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..0360f85 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
@@ -17,20 +17,17 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
 breaking changes to gr-reply-dialog won’t be noticed.
 -->
-<link rel="import" href="../../change/gr-reply-dialog/gr-reply-dialog.html">
-
-<script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
@@ -38,85 +35,91 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-reply-js-api tests', () => {
-    let element;
-    let sandbox;
-    let changeReply;
-    let plugin;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import '../../change/gr-reply-dialog/gr-reply-dialog.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-reply-js-api tests', () => {
+  let element;
+  let sandbox;
+  let changeReply;
+  let plugin;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve(null); },
+    });
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('early init', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getAccount() { return Promise.resolve(null); },
-      });
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+      element = fixture('basic');
     });
 
     teardown(() => {
-      sandbox.restore();
+      changeReply = null;
     });
 
-    suite('early init', () => {
-      setup(() => {
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        changeReply = plugin.changeReply();
-        element = fixture('basic');
-      });
+    test('works', () => {
+      sandbox.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
 
-      teardown(() => {
-        changeReply = null;
-      });
+      sandbox.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
 
-      test('works', () => {
-        sandbox.stub(element, 'getLabelValue').returns('+123');
-        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+      sandbox.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
 
-        sandbox.stub(element, 'setLabelValue');
-        changeReply.setLabelValue('My-Label', '+1337');
-        assert.isTrue(
-            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-        sandbox.stub(element, 'send');
-        changeReply.send(false);
-        assert.isTrue(element.send.calledWithExactly(false));
-
-        sandbox.stub(element, 'setPluginMessage');
-        changeReply.showMessage('foobar');
-        assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-      });
-    });
-
-    suite('normal init', () => {
-      setup(() => {
-        element = fixture('basic');
-        Gerrit.install(p => { plugin = p; }, '0.1',
-            'http://test.com/plugins/testplugin/static/test.js');
-        changeReply = plugin.changeReply();
-      });
-
-      teardown(() => {
-        changeReply = null;
-      });
-
-      test('works', () => {
-        sandbox.stub(element, 'getLabelValue').returns('+123');
-        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-        sandbox.stub(element, 'setLabelValue');
-        changeReply.setLabelValue('My-Label', '+1337');
-        assert.isTrue(
-            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-        sandbox.stub(element, 'send');
-        changeReply.send(false);
-        assert.isTrue(element.send.calledWithExactly(false));
-
-        sandbox.stub(element, 'setPluginMessage');
-        changeReply.showMessage('foobar');
-        assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-      });
+      sandbox.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
     });
   });
+
+  suite('normal init', () => {
+    setup(() => {
+      element = fixture('basic');
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+    });
+
+    teardown(() => {
+      changeReply = null;
+    });
+
+    test('works', () => {
+      sandbox.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      sandbox.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+      sandbox.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
+
+      sandbox.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
+    });
+  });
+});
 </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..ef57ae9 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
@@ -20,146 +20,135 @@
  * should be defined or linked here.
  */
 
-(function(window) {
-  'use strict';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
+import {getRestAPI, send} from './gr-api-utils.js';
 
-  // Import utils methods
-  const {
-    send,
-    getRestAPI,
-  } = window._apiUtils;
-
-  /**
-   * Trigger the preinstalls for bundled plugins.
-   * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
-   */
-  function flushPreinstalls() {
-    if (window.Gerrit.flushPreinstalls) {
-      window.Gerrit.flushPreinstalls();
-    }
+/**
+ * Trigger the preinstalls for bundled plugins.
+ * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
+ */
+function flushPreinstalls() {
+  if (window.Gerrit.flushPreinstalls) {
+    window.Gerrit.flushPreinstalls();
   }
-  flushPreinstalls();
+}
+export const _testOnly_flushPreinstalls = flushPreinstalls;
 
+export function initGerritPluginApi() {
   window.Gerrit = window.Gerrit || {};
-  const Gerrit = window.Gerrit;
-  Gerrit._pluginLoader = new PluginLoader();
+  flushPreinstalls();
+  initGerritPluginsMethods(window.Gerrit);
+  // Preloaded plugins should be installed after Gerrit.install() is set,
+  // since plugin preloader substitutes Gerrit.install() temporarily.
+  // (Gerrit.install() is set in initGerritPluginsMethods)
+  pluginLoader.installPreloadedPlugins();
+}
 
-  Gerrit._endpoints = new GrPluginEndpoints();
+export function _testOnly_initGerritPluginApi() {
+  initGerritPluginApi();
+  return window.Gerrit;
+}
 
-  // Provide reset plugins function to clear installed plugins between tests.
-  const app = document.querySelector('#app');
-  if (!app) {
-    // No gr-app found (running tests)
-    const {
-      testOnly_resetInternalState,
-    } = window._apiUtils;
-    Gerrit._testOnly_installPreloadedPlugins = (...args) => Gerrit._pluginLoader
-        .installPreloadedPlugins(...args);
-    Gerrit._testOnly_flushPreinstalls = flushPreinstalls;
-    Gerrit._testOnly_resetPlugins = () => {
-      testOnly_resetInternalState();
-      Gerrit._endpoints = new GrPluginEndpoints();
-      Gerrit._pluginLoader = new PluginLoader();
-    };
-  }
+export function deprecatedDelete(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(new Error(text));
+            } else {
+              return Promise.reject(new Error(response.status));
+            }
+          });
+        }
+        if (opt_callback) {
+          opt_callback(response);
+        }
+        return response;
+      });
+}
 
+function initGerritPluginsMethods(globalGerritObj) {
   /**
    * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
    * the documentation how to replace it accordingly.
    */
-  Gerrit.css = function(rulesStr) {
+  globalGerritObj.css = function(rulesStr) {
     console.warn('Gerrit.css(rulesStr) is deprecated!',
         'Use plugin.styles().css(rulesStr)');
-    if (!Gerrit._customStyleSheet) {
+    if (!globalGerritObj._customStyleSheet) {
       const styleEl = document.createElement('style');
       document.head.appendChild(styleEl);
-      Gerrit._customStyleSheet = styleEl.sheet;
+      globalGerritObj._customStyleSheet = styleEl.sheet;
     }
 
     const name = '__pg_js_api_class_' +
-        Gerrit._customStyleSheet.cssRules.length;
-    Gerrit._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
+        globalGerritObj._customStyleSheet.cssRules.length;
+    globalGerritObj._customStyleSheet
+        .insertRule('.' + name + '{' + rulesStr + '}', 0);
     return name;
   };
 
-  Gerrit.install = function(callback, opt_version, opt_src) {
-    Gerrit._pluginLoader.install(callback, opt_version, opt_src);
+  globalGerritObj.install = function(callback, opt_version, opt_src) {
+    pluginLoader.install(callback, opt_version, opt_src);
   };
 
-  Gerrit.getLoggedIn = function() {
+  globalGerritObj.getLoggedIn = function() {
     console.warn('Gerrit.getLoggedIn() is deprecated! ' +
         'Use plugin.restApi().getLoggedIn()');
     return document.createElement('gr-rest-api-interface').getLoggedIn();
   };
 
-  Gerrit.get = function(url, callback) {
+  globalGerritObj.get = function(url, callback) {
     console.warn('.get() is deprecated! Use plugin.restApi().get()');
     send('GET', url, callback);
   };
 
-  Gerrit.post = function(url, payload, callback) {
+  globalGerritObj.post = function(url, payload, callback) {
     console.warn('.post() is deprecated! Use plugin.restApi().post()');
     send('POST', url, callback, payload);
   };
 
-  Gerrit.put = function(url, payload, callback) {
+  globalGerritObj.put = function(url, payload, callback) {
     console.warn('.put() is deprecated! Use plugin.restApi().put()');
     send('PUT', url, callback, payload);
   };
 
-  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);
-          }
-        });
-      }
-      if (opt_callback) {
-        opt_callback(response);
-      }
-      return response;
-    });
+  globalGerritObj.delete = function(url, opt_callback) {
+    deprecatedDelete(url, opt_callback);
   };
 
-  Gerrit.awaitPluginsLoaded = function() {
-    return Gerrit._pluginLoader.awaitPluginsLoaded();
+  globalGerritObj.awaitPluginsLoaded = function() {
+    return pluginLoader.awaitPluginsLoaded();
   };
 
   // TODO(taoalpha): consider removing these proxy methods
-  // and using _pluginLoader directly
-
-  Gerrit._loadPlugins = function(plugins, opt_option) {
-    Gerrit._pluginLoader.loadPlugins(plugins, opt_option);
+  // and using pluginLoader directly
+  globalGerritObj._loadPlugins = function(plugins, opt_option) {
+    pluginLoader.loadPlugins(plugins, opt_option);
   };
 
-  Gerrit._arePluginsLoaded = function() {
-    return Gerrit._pluginLoader.arePluginsLoaded;
+  globalGerritObj._arePluginsLoaded = function() {
+    return pluginLoader.arePluginsLoaded();
   };
 
-  Gerrit._isPluginPreloaded = function(url) {
-    return Gerrit._pluginLoader.isPluginPreloaded(url);
+  globalGerritObj._isPluginPreloaded = function(url) {
+    return pluginLoader.isPluginPreloaded(url);
   };
 
-  Gerrit._isPluginEnabled = function(pathOrUrl) {
-    return Gerrit._pluginLoader.isPluginEnabled(pathOrUrl);
+  globalGerritObj._isPluginEnabled = function(pathOrUrl) {
+    return pluginLoader.isPluginEnabled(pathOrUrl);
   };
 
-  Gerrit._isPluginLoaded = function(pathOrUrl) {
-    return Gerrit._pluginLoader.isPluginLoaded(pathOrUrl);
+  globalGerritObj._isPluginLoaded = function(pathOrUrl) {
+    return pluginLoader.isPluginLoaded(pathOrUrl);
   };
 
-  // Preloaded plugins should be installed after Gerrit.install() is set,
-  // since plugin preloader substitutes Gerrit.install() temporarily.
-  Gerrit._pluginLoader.installPreloadedPlugins();
-
   // TODO(taoalpha): List all internal supported event names.
   // Also convert this to inherited class once we move Gerrit to class.
-  Gerrit._eventEmitter = new EventEmitter();
+  globalGerritObj._eventEmitter = gerritEventEmitter;
   ['addListener',
     'dispatch',
     'emit',
@@ -191,6 +180,7 @@
      *   });
      * });
      */
-    Gerrit[method] = Gerrit._eventEmitter[method].bind(Gerrit._eventEmitter);
+    globalGerritObj[method] = gerritEventEmitter[method]
+        .bind(gerritEventEmitter);
   });
-})(window);
\ No newline at end of file
+}
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..2d87497 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,67 +31,75 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-gerrit tests', () => {
-    let element;
-    let sandbox;
-    let sendStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
-    setup(() => {
-      this.clock = sinon.useFakeTimers();
-      sandbox = sinon.sandbox.create();
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({name: 'Judy Hopps'});
-        },
-        send(...args) {
-          return sendStub(...args);
-        },
-      });
-      element = fixture('basic');
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-gerrit tests', () => {
+  let element;
+  let sandbox;
+  let sendStub;
+
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+    sandbox = sinon.sandbox.create();
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    window.clock.restore();
+    sandbox.restore();
+    element._removeEventCallbacks();
+    resetPlugins();
+  });
+
+  suite('proxy methods', () => {
+    test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
+      const stubFn = sandbox.stub();
+      sandbox.stub(
+          pluginLoader,
+          'isPluginEnabled',
+          (...args) => stubFn(...args)
+      );
+      pluginApi._isPluginEnabled('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
     });
 
-    teardown(() => {
-      this.clock.restore();
-      sandbox.restore();
-      element._removeEventCallbacks();
-      Gerrit._testOnly_resetPlugins();
+    test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
+      const stubFn = sandbox.stub();
+      sandbox.stub(
+          pluginLoader,
+          'isPluginLoaded',
+          (...args) => stubFn(...args)
+      );
+      pluginApi._isPluginLoaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
     });
 
-    suite('proxy methods', () => {
-      test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
-        const stubFn = sandbox.stub();
-        sandbox.stub(
-            Gerrit._pluginLoader,
-            'isPluginEnabled',
-            (...args) => stubFn(...args)
-        );
-        Gerrit._isPluginEnabled('test_plugin');
-        assert.isTrue(stubFn.calledWith('test_plugin'));
-      });
-
-      test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
-        const stubFn = sandbox.stub();
-        sandbox.stub(
-            Gerrit._pluginLoader,
-            'isPluginLoaded',
-            (...args) => stubFn(...args)
-        );
-        Gerrit._isPluginLoaded('test_plugin');
-        assert.isTrue(stubFn.calledWith('test_plugin'));
-      });
-
-      test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
-        const stubFn = sandbox.stub();
-        sandbox.stub(
-            Gerrit._pluginLoader,
-            'isPluginPreloaded',
-            (...args) => stubFn(...args)
-        );
-        Gerrit._isPluginPreloaded('test_plugin');
-        assert.isTrue(stubFn.calledWith('test_plugin'));
-      });
+    test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
+      const stubFn = sandbox.stub();
+      sandbox.stub(
+          pluginLoader,
+          'isPluginPreloaded',
+          (...args) => stubFn(...args)
+      );
+      pluginApi._isPluginPreloaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
new file mode 100644
index 0000000..997e08c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
@@ -0,0 +1,323 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../scripts/bundled-polymer.js';
+
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+
+// Note: for new events, naming convention should be: `a-b`
+const EventType = {
+  HISTORY: 'history',
+  LABEL_CHANGE: 'labelchange',
+  SHOW_CHANGE: 'showchange',
+  SUBMIT_CHANGE: 'submitchange',
+  SHOW_REVISION_ACTIONS: 'show-revision-actions',
+  COMMIT_MSG_EDIT: 'commitmsgedit',
+  COMMENT: 'comment',
+  REVERT: 'revert',
+  REVERT_SUBMISSION: 'revert_submission',
+  POST_REVERT: 'postrevert',
+  ANNOTATE_DIFF: 'annotatediff',
+  ADMIN_MENU_LINKS: 'admin-menu-links',
+  HIGHLIGHTJS_LOADED: 'highlightjs-loaded',
+};
+
+const Element = {
+  CHANGE_ACTIONS: 'changeactions',
+  REPLY_DIALOG: 'replydialog',
+};
+
+/**
+ * @extends Polymer.Element
+ */
+class GrJsApiInterface extends mixinBehaviors( [
+  PatchSetBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get is() { return 'gr-js-api-interface'; }
+
+  constructor() {
+    super();
+    this.Element = Element;
+    this.EventType = EventType;
+  }
+
+  static get properties() {
+    return {
+      _elements: {
+        type: Object,
+        value: {}, // Shared across all instances.
+      },
+      _eventCallbacks: {
+        type: Object,
+        value: {}, // Shared across all instances.
+      },
+    };
+  }
+
+  handleEvent(type, detail) {
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      switch (type) {
+        case EventType.HISTORY:
+          this._handleHistory(detail);
+          break;
+        case EventType.SHOW_CHANGE:
+          this._handleShowChange(detail);
+          break;
+        case EventType.COMMENT:
+          this._handleComment(detail);
+          break;
+        case EventType.LABEL_CHANGE:
+          this._handleLabelChange(detail);
+          break;
+        case EventType.SHOW_REVISION_ACTIONS:
+          this._handleShowRevisionActions(detail);
+          break;
+        case EventType.HIGHLIGHTJS_LOADED:
+          this._handleHighlightjsLoaded(detail);
+          break;
+        default:
+          console.warn('handleEvent called with unsupported event type:',
+              type);
+          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);
+    const cancelSubmit = submitCallbacks.some(callback => {
+      try {
+        return callback(change, revision) === false;
+      } catch (err) {
+        console.error(err);
+      }
+      return false;
+    });
+
+    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)) {
+      try {
+        cb(detail.path);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleShowChange(detail) {
+    // Note (issue 8221) Shallow clone the change object and add a mergeable
+    // getter with deprecation warning. This makes the change detail appear as
+    // though SKIP_MERGEABLE was not set, so that plugins that expect it can
+    // still access.
+    //
+    // This clone and getter can be removed after plugins migrate to use
+    // info.mergeable.
+    //
+    // assign on getter with existing property will report error
+    // see Issue: 12286
+    const change = Object.assign({}, detail.change, {
+      get mergeable() {
+        console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
+            'deprecated! Use info.mergeable instead.');
+        return detail.info && detail.info.mergeable;
+      },
+    });
+    const patchNum = detail.patchNum;
+    const info = detail.info;
+
+    let revision;
+    for (const rev of Object.values(change.revisions || {})) {
+      if (this.patchNumEquals(rev._number, patchNum)) {
+        revision = rev;
+        break;
+      }
+    }
+
+    for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
+      try {
+        cb(change, revision, info);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  /**
+   * @param {!{change: !Object, revisionActions: !Object}} detail
+   */
+  _handleShowRevisionActions(detail) {
+    const registeredCallbacks = this._getEventCallbacks(
+        EventType.SHOW_REVISION_ACTIONS
+    );
+    for (const cb of registeredCallbacks) {
+      try {
+        cb(detail.revisionActions, detail.change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  handleCommitMessage(change, msg) {
+    for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
+      try {
+        cb(change, msg);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleComment(detail) {
+    for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
+      try {
+        cb(detail.node);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleLabelChange(detail) {
+    for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
+      try {
+        cb(detail.change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  _handleHighlightjsLoaded(detail) {
+    for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
+      try {
+        cb(detail.hljs);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
+  modifyRevertMsg(change, revertMsg, origMsg) {
+    for (const cb of this._getEventCallbacks(EventType.REVERT)) {
+      try {
+        revertMsg = cb(change, revertMsg, origMsg);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    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 = [];
+    for (const annotationApi of
+      this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+      try {
+        const layer = annotationApi.getLayer(path, changeNum, patchNum);
+        layers.push(layer);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    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 returned. If no plugin offers a coverage provider,
+   * will resolve to null.
+   *
+   * @return {!Promise<?GrAnnotationActionsInterface>}
+   */
+  getCoverageAnnotationApi() {
+    return pluginLoader.awaitPluginsLoaded()
+        .then(() => this._getEventCallbacks(EventType.ANNOTATE_DIFF)
+            .find(api => api.getCoverageProvider()));
+  }
+
+  getAdminMenuLinks() {
+    const links = [];
+    for (const adminApi of
+      this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
+      links.push(...adminApi.getMenuLinks());
+    }
+    return links;
+  }
+
+  getLabelValuesPostRevert(change) {
+    let labels = {};
+    for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
+      try {
+        labels = cb(change);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    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.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
deleted file mode 100644
index 72fc3d0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ /dev/null
@@ -1,54 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../../plugins/gr-admin-api/gr-admin-api.html">
-<link rel="import" href="../../plugins/gr-attribute-helper/gr-attribute-helper.html">
-<link rel="import" href="../../plugins/gr-change-metadata-api/gr-change-metadata-api.html">
-<link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
-<link rel="import" href="../../plugins/gr-event-helper/gr-event-helper.html">
-<link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
-<link rel="import" href="../../plugins/gr-repo-api/gr-repo-api.html">
-<link rel="import" href="../../plugins/gr-settings-api/gr-settings-api.html">
-<link rel="import" href="../../plugins/gr-styles-api/gr-styles-api.html">
-<link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-js-api-interface">
-  <!--
-    Note: the order matters as files depend on each other.
-    1. gr-api-utils will be used in multiple files below.
-    2. gr-gerrit depends on gr-plugin-loader, gr-public-js-api and
-      also gr-plugin-endpoints
-    3. gr-public-js-api depends on gr-plugin-rest-api
-  -->
-  <script src="gr-api-utils.js"></script>
-  <script src="../gr-event-interface/gr-event-interface.js"></script>
-  <script src="gr-annotation-actions-context.js"></script>
-  <script src="gr-annotation-actions-js-api.js"></script>
-  <script src="gr-change-actions-js-api.js"></script>
-  <script src="gr-change-reply-js-api.js"></script>
-  <script src="gr-js-api-interface.js"></script>
-  <script src="gr-plugin-endpoints.js"></script>
-  <script src="gr-plugin-action-context.js"></script>
-  <script src="gr-plugin-rest-api.js"></script>
-  <script src="gr-public-js-api.js"></script>
-  <script src="gr-plugin-loader.js"></script>
-  <script src="gr-gerrit.js"></script>
-</dom-module>
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 2c00c89..1cdb20f 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
@@ -14,279 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-js-api-interface-element.js';
+import './gr-public-js-api.js';
+import './gr-gerrit.js';
 
-  // Note: for new events, naming convention should be: `a-b`
-  const EventType = {
-    HISTORY: 'history',
-    LABEL_CHANGE: 'labelchange',
-    SHOW_CHANGE: 'showchange',
-    SUBMIT_CHANGE: 'submitchange',
-    SHOW_REVISION_ACTIONS: 'show-revision-actions',
-    COMMIT_MSG_EDIT: 'commitmsgedit',
-    COMMENT: 'comment',
-    REVERT: 'revert',
-    POST_REVERT: 'postrevert',
-    ANNOTATE_DIFF: 'annotatediff',
-    ADMIN_MENU_LINKS: 'admin-menu-links',
-    HIGHLIGHTJS_LOADED: 'highlightjs-loaded',
-  };
+/*
+  Note: the order matters as files depend on each other.
+  1. gr-api-utils will be used in multiple files below.
+  2. gr-gerrit depends on gr-plugin-loader, gr-public-js-api and
+    also gr-plugin-endpoints
+  3. gr-public-js-api depends on gr-plugin-rest-api
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
 
-  const Element = {
-    CHANGE_ACTIONS: 'changeactions',
-    REPLY_DIALOG: 'replydialog',
-  };
-
-  Polymer({
-    is: 'gr-js-api-interface',
-
-    properties: {
-      _elements: {
-        type: Object,
-        value: {}, // Shared across all instances.
-      },
-      _eventCallbacks: {
-        type: Object,
-        value: {}, // Shared across all instances.
-      },
-    },
-
-    behaviors: [Gerrit.PatchSetBehavior],
-
-    Element,
-    EventType,
-
-    handleEvent(type, detail) {
-      Gerrit.awaitPluginsLoaded().then(() => {
-        switch (type) {
-          case EventType.HISTORY:
-            this._handleHistory(detail);
-            break;
-          case EventType.SHOW_CHANGE:
-            this._handleShowChange(detail);
-            break;
-          case EventType.COMMENT:
-            this._handleComment(detail);
-            break;
-          case EventType.LABEL_CHANGE:
-            this._handleLabelChange(detail);
-            break;
-          case EventType.SHOW_REVISION_ACTIONS:
-            this._handleShowRevisionActions(detail);
-            break;
-          case EventType.HIGHLIGHTJS_LOADED:
-            this._handleHighlightjsLoaded(detail);
-            break;
-          default:
-            console.warn('handleEvent called with unsupported event type:',
-                type);
-            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);
-      const cancelSubmit = submitCallbacks.some(callback => {
-        try {
-          return callback(change, revision) === false;
-        } catch (err) {
-          console.error(err);
-        }
-        return false;
-      });
-
-      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)) {
-        try {
-          cb(detail.path);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    },
-
-    _handleShowChange(detail) {
-      // Note (issue 8221) Shallow clone the change object and add a mergeable
-      // getter with deprecation warning. This makes the change detail appear as
-      // though SKIP_MERGEABLE was not set, so that plugins that expect it can
-      // still access.
-      //
-      // This clone and getter can be removed after plugins migrate to use
-      // info.mergeable.
-      //
-      // assign on getter with existing property will report error
-      // see Issue: 12286
-      const change = Object.assign({}, detail.change, {
-        get mergeable() {
-          console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
-              'deprecated! Use info.mergeable instead.');
-          return detail.info && detail.info.mergeable;
-        },
-      });
-      const patchNum = detail.patchNum;
-      const info = detail.info;
-
-      let revision;
-      for (const rev of Object.values(change.revisions || {})) {
-        if (this.patchNumEquals(rev._number, patchNum)) {
-          revision = rev;
-          break;
-        }
-      }
-
-      for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
-        try {
-          cb(change, revision, info);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    },
-
-    /**
-     * @param {!{change: !Object, revisionActions: !Object}} detail
-     */
-    _handleShowRevisionActions(detail) {
-      const registeredCallbacks = this._getEventCallbacks(
-          EventType.SHOW_REVISION_ACTIONS
-      );
-      for (const cb of registeredCallbacks) {
-        try {
-          cb(detail.revisionActions, detail.change);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    },
-
-    handleCommitMessage(change, msg) {
-      for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
-        try {
-          cb(change, msg);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    },
-
-    _handleComment(detail) {
-      for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
-        try {
-          cb(detail.node);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    },
-
-    _handleLabelChange(detail) {
-      for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
-        try {
-          cb(detail.change);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    },
-
-    _handleHighlightjsLoaded(detail) {
-      for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
-        try {
-          cb(detail.hljs);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-    },
-
-    modifyRevertMsg(change, revertMsg, origMsg) {
-      for (const cb of this._getEventCallbacks(EventType.REVERT)) {
-        try {
-          revertMsg = cb(change, revertMsg, origMsg);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-      return revertMsg;
-    },
-
-    getDiffLayers(path, changeNum, patchNum) {
-      const layers = [];
-      for (const annotationApi of
-        this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-        try {
-          const layer = annotationApi.getLayer(path, changeNum, patchNum);
-          layers.push(layer);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-      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 returned. If no plugin offers a coverage provider,
-     * will resolve to null.
-     */
-    getCoverageAnnotationApi() {
-      return Gerrit.awaitPluginsLoaded()
-          .then(() => this._getEventCallbacks(EventType.ANNOTATE_DIFF)
-              .find(api => api.getCoverageProvider()));
-    },
-
-    getAdminMenuLinks() {
-      const links = [];
-      for (const adminApi of
-        this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
-        links.push(...adminApi.getMenuLinks());
-      }
-      return links;
-    },
-
-    getLabelValuesPostRevert(change) {
-      let labels = {};
-      for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
-        try {
-          labels = cb(change);
-        } catch (err) {
-          console.error(err);
-        }
-      }
-      return labels;
-    },
-
-    _getEventCallbacks(type) {
-      return this._eventCallbacks[type] || [];
-    },
-  });
-})();
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 14928e6..ea1ac91 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,513 +31,558 @@
   </template>
 </test-fixture>
 
-<script>
-  const {PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
-  suite('gr-js-api-interface tests', () => {
-    let element;
-    let plugin;
-    let errorStub;
-    let sandbox;
-    let getResponseObjectStub;
-    let sendStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
+import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
+import {GrPluginActionContext} from './gr-plugin-action-context.js';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
-    const throwErrFn = function() {
-      throw Error('Unfortunately, this handler has stopped');
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-js-api-interface tests', () => {
+  let element;
+  let plugin;
+  let errorStub;
+  let sandbox;
+  let getResponseObjectStub;
+  let sendStub;
+
+  const throwErrFn = function() {
+    throw Error('Unfortunately, this handler has stopped');
+  };
+
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+    sandbox = sinon.sandbox.create();
+    getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      getResponseObject: getResponseObjectStub,
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    element = fixture('basic');
+    errorStub = sandbox.stub(console, 'error');
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+  });
+
+  teardown(() => {
+    window.clock.restore();
+    sandbox.restore();
+    element._removeEventCallbacks();
+    plugin = null;
+  });
+
+  test('url', () => {
+    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
+    assert.equal(plugin.url('/static/test.js'),
+        'http://test.com/plugins/testplugin/static/test.js');
+  });
+
+  test('url for preloaded plugin without ASSETS_PATH', () => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'preloaded:testpluginB');
+    assert.equal(plugin.url(),
+        `${window.location.origin}/plugins/testpluginB/`);
+    assert.equal(plugin.url('/static/test.js'),
+        `${window.location.origin}/plugins/testpluginB/static/test.js`);
+  });
+
+  test('url for preloaded plugin without ASSETS_PATH', () => {
+    const oldAssetsPath = window.ASSETS_PATH;
+    window.ASSETS_PATH = 'http://test.com';
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'preloaded:testpluginC');
+    assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
+    assert.equal(plugin.url('/static/test.js'),
+        `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
+    window.ASSETS_PATH = oldAssetsPath;
+  });
+
+  test('_send on failure rejects with response text', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return plugin._send().catch(r => {
+      assert.equal(r.message, 'text');
+    });
+  });
+
+  test('_send on failure without text rejects with code', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve(null); }}));
+    return plugin._send().catch(r => {
+      assert.equal(r.message, '400');
+    });
+  });
+
+  test('get', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.get('/url', r => {
+      assert.isTrue(sendStub.calledWith(
+          'GET', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('get using Promise', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.get('/url', r => 'rubbish').then(r => {
+      assert.isTrue(sendStub.calledWith(
+          'GET', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('post', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.post('/url', payload, r => {
+      assert.isTrue(sendStub.calledWith(
+          'POST', 'http://test.com/plugins/testplugin/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('put', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.put('/url', payload, r => {
+      assert.isTrue(sendStub.calledWith(
+          'PUT', 'http://test.com/plugins/testplugin/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete works', () => {
+    const response = {status: 204};
+    sendStub.returns(Promise.resolve(response));
+    return plugin.delete('/url', r => {
+      assert.isTrue(sendStub.calledWithExactly(
+          'DELETE', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete fails', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return plugin.delete('/url', r => {
+      throw new Error('Should not resolve');
+    }).catch(err => {
+      assert.isTrue(sendStub.calledWith(
+          'DELETE', 'http://test.com/plugins/testplugin/url'));
+      assert.equal('text', err.message);
+    });
+  });
+
+  test('history event', done => {
+    plugin.on(element.EventType.HISTORY, throwErrFn);
+    plugin.on(element.EventType.HISTORY, path => {
+      assert.equal(path, '/path/to/awesomesauce');
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.HISTORY,
+        {path: '/path/to/awesomesauce'});
+  });
+
+  test('showchange event', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
     };
+    const expectedChange = Object.assign({mergeable: false}, testChange);
+    plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
+    plugin.on(element.EventType.SHOW_CHANGE, (change, revision, info) => {
+      assert.deepEqual(change, expectedChange);
+      assert.deepEqual(revision, testChange.revisions.abc);
+      assert.deepEqual(info, {mergeable: false});
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1, info: {mergeable: false}});
+  });
+
+  test('show-revision-actions event', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
+      assert.deepEqual(change, testChange);
+      assert.deepEqual(actions, {test: {}});
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
+        {change: testChange, revisionActions: {test: {}}});
+  });
+
+  test('handleEvent awaits plugins load', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    const spy = sandbox.spy();
+    pluginLoader.loadPlugins(['plugins/test.html']);
+    plugin.on(element.EventType.SHOW_CHANGE, spy);
+    element.handleEvent(element.EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1});
+    assert.isFalse(spy.called);
+
+    // Timeout on loading plugins
+    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    flush(() => {
+      assert.isTrue(spy.called);
+      done();
+    });
+  });
+
+  test('comment event', done => {
+    const testCommentNode = {foo: 'bar'};
+    plugin.on(element.EventType.COMMENT, throwErrFn);
+    plugin.on(element.EventType.COMMENT, commentNode => {
+      assert.deepEqual(commentNode, testCommentNode);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
+  });
+
+  test('revert event', () => {
+    function appendToRevertMsg(c, revertMsg, originalMsg) {
+      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
+    }
+
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(element.EventType.REVERT, throwErrFn);
+    plugin.on(element.EventType.REVERT, appendToRevertMsg);
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+        'test\n> origTest\ninfo');
+    assert.isTrue(errorStub.calledOnce);
+
+    plugin.on(element.EventType.REVERT, appendToRevertMsg);
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+        'test\n> origTest\ninfo\n> origTest\ninfo');
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('postrevert event', () => {
+    function getLabels(c) {
+      return {'Code-Review': 1};
+    }
+
+    assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(element.EventType.POST_REVERT, throwErrFn);
+    plugin.on(element.EventType.POST_REVERT, getLabels);
+    assert.deepEqual(
+        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('commitmsgedit event', done => {
+    const testMsg = 'Test CL commit message';
+    plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
+    plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
+      assert.deepEqual(msg, testMsg);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleCommitMessage(null, testMsg);
+  });
+
+  test('labelchange event', done => {
+    const testChange = {_number: 42};
+    plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
+    plugin.on(element.EventType.LABEL_CHANGE, change => {
+      assert.deepEqual(change, testChange);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
+  });
+
+  test('submitchange', () => {
+    plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+    assert.isTrue(element.canSubmitChange());
+    assert.isTrue(errorStub.calledOnce);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => false);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+    assert.isFalse(element.canSubmitChange());
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('highlightjs-loaded event', done => {
+    const testHljs = {_number: 42};
+    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
+    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
+      assert.deepEqual(hljs, testHljs);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
+  });
+
+  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', () => {
+    assert.isOk(plugin.attributeHelper());
+  });
+
+  test('deprecated.install', () => {
+    plugin.deprecated.install();
+    assert.strictEqual(plugin.popup, plugin.deprecated.popup);
+    assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
+    assert.notStrictEqual(plugin.install, plugin.deprecated.install);
+  });
+
+  test('getAdminMenuLinks', () => {
+    const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
+    const getCallbacksStub = sandbox.stub(element, '_getEventCallbacks')
+        .returns([
+          {getMenuLinks: () => [links[0]]},
+          {getMenuLinks: () => [links[1]]},
+        ]);
+    const result = element.getAdminMenuLinks();
+    assert.deepEqual(result, links);
+    assert.isTrue(getCallbacksStub.calledOnce);
+    assert.equal(getCallbacksStub.lastCall.args[0],
+        element.EventType.ADMIN_MENU_LINKS);
+  });
+
+  suite('test plugin with base url', () => {
+    let baseUrlPlugin;
 
     setup(() => {
-      this.clock = sinon.useFakeTimers();
-      sandbox = sinon.sandbox.create();
-      getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({name: 'Judy Hopps'});
-        },
-        getResponseObject: getResponseObjectStub,
-        send(...args) {
-          return sendStub(...args);
-        },
-      });
-      element = fixture('basic');
-      errorStub = sandbox.stub(console, 'error');
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      Gerrit._loadPlugins([]);
-    });
+      sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns('/r');
 
-    teardown(() => {
-      this.clock.restore();
-      sandbox.restore();
-      element._removeEventCallbacks();
-      plugin = null;
+      pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
+          'http://test.com/r/plugins/baseurlplugin/static/test.js');
     });
 
     test('url', () => {
-      assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
-      assert.equal(plugin.url('/static/test.js'),
-          'http://test.com/plugins/testplugin/static/test.js');
+      assert.notEqual(baseUrlPlugin.url(),
+          'http://test.com/plugins/baseurlplugin/');
+      assert.equal(baseUrlPlugin.url(),
+          'http://test.com/r/plugins/baseurlplugin/');
+      assert.equal(baseUrlPlugin.url('/static/test.js'),
+          'http://test.com/r/plugins/baseurlplugin/static/test.js');
+    });
+  });
+
+  suite('popup', () => {
+    test('popup(element) is deprecated', () => {
+      plugin.popup(document.createElement('div'));
+      assert.isTrue(console.error.calledOnce);
     });
 
-    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');
+    test('popup(moduleName) creates popup with component', () => {
+      const openStub = sandbox.stub(GrPopupInterface.prototype, 'open',
+          function() {
+            // Arrow function can't be used here, because we want to
+            // get properties from the instance of GrPopupInterface
+            // eslint-disable-next-line no-invalid-this
+            const grPopupInterface = this;
+            assert.equal(grPopupInterface.plugin, plugin);
+            assert.equal(grPopupInterface._moduleName, 'some-name');
+          });
+      plugin.popup('some-name');
+      assert.isTrue(openStub.calledOnce);
+    });
+
+    test('deprecated.popup(element) creates popup with element', () => {
+      const el = document.createElement('div');
+      el.textContent = 'some text here';
+      const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
+      openStub.returns(Promise.resolve({
+        _getElement() {
+          return document.createElement('div');
+        }}));
+      plugin.deprecated.popup(el);
+      assert.isTrue(openStub.calledOnce);
+    });
+  });
+
+  suite('onAction', () => {
+    let change;
+    let revision;
+    let actionDetails;
+
+    setup(() => {
+      change = {};
+      revision = {};
+      actionDetails = {__key: 'some'};
+      sandbox.stub(plugin, 'on').callsArgWith(1, change, revision);
+      sandbox.stub(plugin, 'changeActions').returns({
+        addTapListener: sandbox.stub().callsArg(1),
+        getActionDetails: () => actionDetails,
       });
     });
 
-    test('_send on failure without text rejects with code', () => {
-      sendStub.returns(Promise.resolve(
-          {status: 400, text() { return Promise.resolve(null); }}));
-      return plugin._send().catch(r => {
-        assert.equal(r, '400');
+    test('returns GrPluginActionContext', () => {
+      const stub = sandbox.stub();
+      plugin.deprecated.onAction('change', 'foo', ctx => {
+        assert.isTrue(ctx instanceof GrPluginActionContext);
+        assert.strictEqual(ctx.change, change);
+        assert.strictEqual(ctx.revision, revision);
+        assert.strictEqual(ctx.action, actionDetails);
+        assert.strictEqual(ctx.plugin, plugin);
+        stub();
       });
+      assert.isTrue(stub.called);
     });
 
-    test('get', () => {
-      const response = {foo: 'foo'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.get('/url', r => {
-        assert.isTrue(sendStub.calledWith(
-            'GET', 'http://test.com/plugins/testplugin/url'));
-        assert.strictEqual(r, response);
-      });
+    test('other actions', () => {
+      const stub = sandbox.stub();
+      plugin.deprecated.onAction('project', 'foo', stub);
+      plugin.deprecated.onAction('edit', 'foo', stub);
+      plugin.deprecated.onAction('branch', 'foo', stub);
+      assert.isFalse(stub.called);
+    });
+  });
+
+  suite('screen', () => {
+    test('screenUrl()', () => {
+      sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns('/base');
+      assert.equal(
+          plugin.screenUrl(),
+          `${location.origin}/base/x/testplugin`
+      );
+      assert.equal(
+          plugin.screenUrl('foo'),
+          `${location.origin}/base/x/testplugin/foo`
+      );
     });
 
-    test('get using Promise', () => {
-      const response = {foo: 'foo'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.get('/url', r => 'rubbish').then(r => {
-        assert.isTrue(sendStub.calledWith(
-            'GET', 'http://test.com/plugins/testplugin/url'));
-        assert.strictEqual(r, response);
-      });
+    test('deprecated works', () => {
+      const stub = sandbox.stub();
+      const hookStub = {onAttached: sandbox.stub()};
+      sandbox.stub(plugin, 'hook').returns(hookStub);
+      plugin.deprecated.screen('foo', stub);
+      assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
+      const fakeEl = {style: {display: ''}};
+      hookStub.onAttached.callArgWith(0, fakeEl);
+      assert.isTrue(stub.called);
+      assert.equal(fakeEl.style.display, 'none');
     });
 
-    test('post', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.post('/url', payload, r => {
-        assert.isTrue(sendStub.calledWith(
-            'POST', 'http://test.com/plugins/testplugin/url', payload));
-        assert.strictEqual(r, response);
-      });
+    test('works', () => {
+      sandbox.stub(plugin, 'registerCustomComponent');
+      plugin.screen('foo', 'some-module');
+      assert.isTrue(plugin.registerCustomComponent.calledWith(
+          'testplugin-screen-foo', 'some-module'));
+    });
+  });
+
+  suite('panel', () => {
+    let fakeEl;
+    let emulateAttached;
+
+    setup(()=> {
+      fakeEl = {change: {}, revision: {}};
+      const hookStub = {onAttached: sandbox.stub()};
+      sandbox.stub(plugin, 'hook').returns(hookStub);
+      emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
     });
 
-    test('put', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return plugin.put('/url', payload, r => {
-        assert.isTrue(sendStub.calledWith(
-            'PUT', 'http://test.com/plugins/testplugin/url', payload));
-        assert.strictEqual(r, response);
-      });
+    test('plugin.panel is deprecated', () => {
+      plugin.panel('rubbish');
+      assert.isTrue(console.error.called);
     });
 
-    test('delete works', () => {
-      const response = {status: 204};
-      sendStub.returns(Promise.resolve(response));
-      return plugin.delete('/url', r => {
-        assert.isTrue(sendStub.calledWithExactly(
-            'DELETE', 'http://test.com/plugins/testplugin/url'));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('delete fails', () => {
-      sendStub.returns(Promise.resolve(
-          {status: 400, text() { return Promise.resolve('text'); }}));
-      return plugin.delete('/url', r => {
-        throw new Error('Should not resolve');
-      }).catch(err => {
-        assert.isTrue(sendStub.calledWith(
-            'DELETE', 'http://test.com/plugins/testplugin/url'));
-        assert.equal('text', err);
-      });
-    });
-
-    test('history event', done => {
-      plugin.on(element.EventType.HISTORY, throwErrFn);
-      plugin.on(element.EventType.HISTORY, path => {
-        assert.equal(path, '/path/to/awesomesauce');
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.HISTORY,
-          {path: '/path/to/awesomesauce'});
-    });
-
-    test('showchange event', done => {
-      const testChange = {
-        _number: 42,
-        revisions: {def: {_number: 2}, abc: {_number: 1}},
-      };
-      const expectedChange = Object.assign({mergeable: false}, testChange);
-      plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SHOW_CHANGE, (change, revision, info) => {
-        assert.deepEqual(change, expectedChange);
-        assert.deepEqual(revision, testChange.revisions.abc);
-        assert.deepEqual(info, {mergeable: false});
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.SHOW_CHANGE,
-          {change: testChange, patchNum: 1, info: {mergeable: false}});
-    });
-
-    test('show-revision-actions event', done => {
-      const testChange = {
-        _number: 42,
-        revisions: {def: {_number: 2}, abc: {_number: 1}},
-      };
-      plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
-      plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
-        assert.deepEqual(change, testChange);
-        assert.deepEqual(actions, {test: {}});
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
-          {change: testChange, revisionActions: {test: {}}});
-    });
-
-    test('handleEvent awaits plugins load', done => {
-      const testChange = {
-        _number: 42,
-        revisions: {def: {_number: 2}, abc: {_number: 1}},
-      };
-      const spy = sandbox.spy();
-      Gerrit._loadPlugins(['plugins/test.html']);
-      plugin.on(element.EventType.SHOW_CHANGE, spy);
-      element.handleEvent(element.EventType.SHOW_CHANGE,
-          {change: testChange, patchNum: 1});
-      assert.isFalse(spy.called);
-
-      // Timeout on loading plugins
-      this.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
-    });
-
-    test('comment event', done => {
-      const testCommentNode = {foo: 'bar'};
-      plugin.on(element.EventType.COMMENT, throwErrFn);
-      plugin.on(element.EventType.COMMENT, commentNode => {
-        assert.deepEqual(commentNode, testCommentNode);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
-    });
-
-    test('revert event', () => {
-      function appendToRevertMsg(c, revertMsg, originalMsg) {
-        return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
-      }
-
-      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
-      assert.equal(errorStub.callCount, 0);
-
-      plugin.on(element.EventType.REVERT, throwErrFn);
-      plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-          'test\n> origTest\ninfo');
-      assert.isTrue(errorStub.calledOnce);
-
-      plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-          'test\n> origTest\ninfo\n> origTest\ninfo');
-      assert.isTrue(errorStub.calledTwice);
-    });
-
-    test('postrevert event', () => {
-      function getLabels(c) {
-        return {'Code-Review': 1};
-      }
-
-      assert.deepEqual(element.getLabelValuesPostRevert(null), {});
-      assert.equal(errorStub.callCount, 0);
-
-      plugin.on(element.EventType.POST_REVERT, throwErrFn);
-      plugin.on(element.EventType.POST_REVERT, getLabels);
-      assert.deepEqual(
-          element.getLabelValuesPostRevert(null), {'Code-Review': 1});
-      assert.isTrue(errorStub.calledOnce);
-    });
-
-    test('commitmsgedit event', done => {
-      const testMsg = 'Test CL commit message';
-      plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
-      plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
-        assert.deepEqual(msg, testMsg);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleCommitMessage(null, testMsg);
-    });
-
-    test('labelchange event', done => {
-      const testChange = {_number: 42};
-      plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
-      plugin.on(element.EventType.LABEL_CHANGE, change => {
-        assert.deepEqual(change, testChange);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
-    });
-
-    test('submitchange', () => {
-      plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return 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; });
-      assert.isFalse(element.canSubmitChange());
-      assert.isTrue(errorStub.calledTwice);
-    });
-
-    test('highlightjs-loaded event', done => {
-      const testHljs = {_number: 42};
-      plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
-      plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
-        assert.deepEqual(hljs, testHljs);
-        assert.isTrue(errorStub.calledOnce);
-        done();
-      });
-      element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
-    });
-
-    test('getAccount', done => {
-      plugin.restApi().getLoggedIn().then(loggedIn => {
-        assert.isTrue(loggedIn);
-        done();
-      });
-    });
-
-    test('attributeHelper', () => {
-      assert.isOk(plugin.attributeHelper());
-    });
-
-    test('deprecated.install', () => {
-      plugin.deprecated.install();
-      assert.strictEqual(plugin.popup, plugin.deprecated.popup);
-      assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
-      assert.notStrictEqual(plugin.install, plugin.deprecated.install);
-    });
-
-    test('getAdminMenuLinks', () => {
-      const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
-      const getCallbacksStub = sandbox.stub(element, '_getEventCallbacks')
-          .returns([
-            {getMenuLinks: () => [links[0]]},
-            {getMenuLinks: () => [links[1]]},
-          ]);
-      const result = element.getAdminMenuLinks();
-      assert.deepEqual(result, links);
-      assert.isTrue(getCallbacksStub.calledOnce);
-      assert.equal(getCallbacksStub.lastCall.args[0],
-          element.EventType.ADMIN_MENU_LINKS);
-    });
-
-    suite('test plugin with base url', () => {
-      let baseUrlPlugin;
-
-      setup(() => {
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
-
-        Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
-            'http://test.com/r/plugins/baseurlplugin/static/test.js');
-      });
-
-      test('url', () => {
-        assert.notEqual(baseUrlPlugin.url(),
-            'http://test.com/plugins/baseurlplugin/');
-        assert.equal(baseUrlPlugin.url(),
-            'http://test.com/r/plugins/baseurlplugin/');
-        assert.equal(baseUrlPlugin.url('/static/test.js'),
-            'http://test.com/r/plugins/baseurlplugin/static/test.js');
-      });
-    });
-
-    suite('popup', () => {
-      test('popup(element) is deprecated', () => {
-        plugin.popup(document.createElement('div'));
-        assert.isTrue(console.error.calledOnce);
-      });
-
-      test('popup(moduleName) creates popup with component', () => {
-        const openStub = sandbox.stub();
-        sandbox.stub(window, 'GrPopupInterface').returns({
-          open: openStub,
-        });
-        plugin.popup('some-name');
-        assert.isTrue(openStub.calledOnce);
-        assert.isTrue(GrPopupInterface.calledWith(plugin, 'some-name'));
-      });
-
-      test('deprecated.popup(element) creates popup with element', () => {
-        const el = document.createElement('div');
-        el.textContent = 'some text here';
-        const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
-        openStub.returns(Promise.resolve({
-          _getElement() {
-            return document.createElement('div');
-          }}));
-        plugin.deprecated.popup(el);
-        assert.isTrue(openStub.calledOnce);
-      });
-    });
-
-    suite('onAction', () => {
-      let change;
-      let revision;
-      let actionDetails;
-
-      setup(() => {
-        change = {};
-        revision = {};
-        actionDetails = {__key: 'some'};
-        sandbox.stub(plugin, 'on').callsArgWith(1, change, revision);
-        sandbox.stub(plugin, 'changeActions').returns({
-          addTapListener: sandbox.stub().callsArg(1),
-          getActionDetails: () => actionDetails,
-        });
-      });
-
-      test('returns GrPluginActionContext', () => {
-        const stub = sandbox.stub();
-        plugin.deprecated.onAction('change', 'foo', ctx => {
-          assert.isTrue(ctx instanceof GrPluginActionContext);
-          assert.strictEqual(ctx.change, change);
-          assert.strictEqual(ctx.revision, revision);
-          assert.strictEqual(ctx.action, actionDetails);
-          assert.strictEqual(ctx.plugin, plugin);
-          stub();
-        });
-        assert.isTrue(stub.called);
-      });
-
-      test('other actions', () => {
-        const stub = sandbox.stub();
-        plugin.deprecated.onAction('project', 'foo', stub);
-        plugin.deprecated.onAction('edit', 'foo', stub);
-        plugin.deprecated.onAction('branch', 'foo', stub);
-        assert.isFalse(stub.called);
-      });
-    });
-
-    suite('screen', () => {
-      test('screenUrl()', () => {
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/base');
-        assert.equal(plugin.screenUrl(), 'http://test.com/base/x/testplugin');
-        assert.equal(
-            plugin.screenUrl('foo'), 'http://test.com/base/x/testplugin/foo');
-      });
-
-      test('deprecated works', () => {
-        const stub = sandbox.stub();
-        const hookStub = {onAttached: sandbox.stub()};
-        sandbox.stub(plugin, 'hook').returns(hookStub);
-        plugin.deprecated.screen('foo', stub);
-        assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
-        const fakeEl = {style: {display: ''}};
-        hookStub.onAttached.callArgWith(0, fakeEl);
-        assert.isTrue(stub.called);
-        assert.equal(fakeEl.style.display, 'none');
-      });
-
-      test('works', () => {
-        sandbox.stub(plugin, 'registerCustomComponent');
-        plugin.screen('foo', 'some-module');
-        assert.isTrue(plugin.registerCustomComponent.calledWith(
-            'testplugin-screen-foo', 'some-module'));
-      });
-    });
-
-    suite('panel', () => {
-      let fakeEl;
-      let emulateAttached;
-
-      setup(()=> {
-        fakeEl = {change: {}, revision: {}};
-        const hookStub = {onAttached: sandbox.stub()};
-        sandbox.stub(plugin, 'hook').returns(hookStub);
-        emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-      });
-
-      test('plugin.panel is deprecated', () => {
-        plugin.panel('rubbish');
-        assert.isTrue(console.error.called);
-      });
-
-      [
-        ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
-        ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
-      ].forEach(([panelName, endpointName]) => {
-        test(`deprecated.panel works for ${panelName}`, () => {
-          const callback = sandbox.stub();
-          plugin.deprecated.panel(panelName, callback);
-          assert.isTrue(plugin.hook.calledWith(endpointName));
-          emulateAttached();
-          assert.isTrue(callback.called);
-          const args = callback.args[0][0];
-          assert.strictEqual(args.body, fakeEl);
-          assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
-          assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
-        });
-      });
-    });
-
-    suite('settingsScreen', () => {
-      test('plugin.settingsScreen is deprecated', () => {
-        plugin.settingsScreen('rubbish');
-        assert.isTrue(console.error.called);
-      });
-
-      test('plugin.settings() returns GrSettingsApi', () => {
-        assert.isOk(plugin.settings());
-        assert.isTrue(plugin.settings() instanceof GrSettingsApi);
-      });
-
-      test('plugin.deprecated.settingsScreen() works', () => {
-        const hookStub = {onAttached: sandbox.stub()};
-        sandbox.stub(plugin, 'hook').returns(hookStub);
-        const fakeSettings = {};
-        fakeSettings.title = sandbox.stub().returns(fakeSettings);
-        fakeSettings.token = sandbox.stub().returns(fakeSettings);
-        fakeSettings.module = sandbox.stub().returns(fakeSettings);
-        fakeSettings.build = sandbox.stub().returns(hookStub);
-        sandbox.stub(plugin, 'settings').returns(fakeSettings);
+    [
+      ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
+      ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
+    ].forEach(([panelName, endpointName]) => {
+      test(`deprecated.panel works for ${panelName}`, () => {
         const callback = sandbox.stub();
-
-        plugin.deprecated.settingsScreen('path', 'menu', callback);
-        assert.isTrue(fakeSettings.title.calledWith('menu'));
-        assert.isTrue(fakeSettings.token.calledWith('path'));
-        assert.isTrue(fakeSettings.module.calledWith('div'));
-        assert.equal(fakeSettings.build.callCount, 1);
-
-        const fakeBody = {};
-        const fakeEl = {
-          style: {
-            display: '',
-          },
-          querySelector: sandbox.stub().returns(fakeBody),
-        };
-        // Emulate settings screen attached
-        hookStub.onAttached.callArgWith(0, fakeEl);
+        plugin.deprecated.panel(panelName, callback);
+        assert.isTrue(plugin.hook.calledWith(endpointName));
+        emulateAttached();
         assert.isTrue(callback.called);
         const args = callback.args[0][0];
-        assert.strictEqual(args.body, fakeBody);
-        assert.equal(fakeEl.style.display, 'none');
+        assert.strictEqual(args.body, fakeEl);
+        assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
+        assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
       });
     });
   });
+
+  suite('settingsScreen', () => {
+    test('plugin.settingsScreen is deprecated', () => {
+      plugin.settingsScreen('rubbish');
+      assert.isTrue(console.error.called);
+    });
+
+    test('plugin.settings() returns GrSettingsApi', () => {
+      assert.isOk(plugin.settings());
+      assert.isTrue(plugin.settings() instanceof GrSettingsApi);
+    });
+
+    test('plugin.deprecated.settingsScreen() works', () => {
+      const hookStub = {onAttached: sandbox.stub()};
+      sandbox.stub(plugin, 'hook').returns(hookStub);
+      const fakeSettings = {};
+      fakeSettings.title = sandbox.stub().returns(fakeSettings);
+      fakeSettings.token = sandbox.stub().returns(fakeSettings);
+      fakeSettings.module = sandbox.stub().returns(fakeSettings);
+      fakeSettings.build = sandbox.stub().returns(hookStub);
+      sandbox.stub(plugin, 'settings').returns(fakeSettings);
+      const callback = sandbox.stub();
+
+      plugin.deprecated.settingsScreen('path', 'menu', callback);
+      assert.isTrue(fakeSettings.title.calledWith('menu'));
+      assert.isTrue(fakeSettings.token.calledWith('path'));
+      assert.isTrue(fakeSettings.module.calledWith('div'));
+      assert.equal(fakeSettings.build.callCount, 1);
+
+      const fakeBody = {};
+      const fakeEl = {
+        style: {
+          display: '',
+        },
+        querySelector: sandbox.stub().returns(fakeBody),
+      };
+      // Emulate settings screen attached
+      hookStub.onAttached.callArgWith(0, fakeEl);
+      assert.isTrue(callback.called);
+      const args = callback.args[0][0];
+      assert.strictEqual(args.body, fakeBody);
+      assert.equal(fakeEl.style.display, 'none');
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
index 5ac8773..e3256a1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
@@ -14,94 +14,89 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+export function GrPluginActionContext(plugin, action, change, revision) {
+  this.action = action;
+  this.plugin = plugin;
+  this.change = change;
+  this.revision = revision;
+  this._popups = [];
+}
 
-  function GrPluginActionContext(plugin, action, change, revision) {
-    this.action = action;
-    this.plugin = plugin;
-    this.change = change;
-    this.revision = revision;
-    this._popups = [];
+GrPluginActionContext.prototype.popup = function(element) {
+  this._popups.push(this.plugin.deprecated.popup(element));
+};
+
+GrPluginActionContext.prototype.hide = function() {
+  for (const popupApi of this._popups) {
+    popupApi.close();
   }
+  this._popups.splice(0);
+};
 
-  GrPluginActionContext.prototype.popup = function(element) {
-    this._popups.push(this.plugin.deprecated.popup(element));
-  };
+GrPluginActionContext.prototype.refresh = function() {
+  window.location.reload();
+};
 
-  GrPluginActionContext.prototype.hide = function() {
-    for (const popupApi of this._popups) {
-      popupApi.close();
-    }
-    this._popups.splice(0);
-  };
+GrPluginActionContext.prototype.textfield = function() {
+  return document.createElement('paper-input');
+};
 
-  GrPluginActionContext.prototype.refresh = function() {
-    window.location.reload();
-  };
+GrPluginActionContext.prototype.br = function() {
+  return document.createElement('br');
+};
 
-  GrPluginActionContext.prototype.textfield = function() {
-    return document.createElement('paper-input');
-  };
+GrPluginActionContext.prototype.msg = function(text) {
+  const label = document.createElement('gr-label');
+  Polymer.dom(label).appendChild(document.createTextNode(text));
+  return label;
+};
 
-  GrPluginActionContext.prototype.br = function() {
-    return document.createElement('br');
-  };
+GrPluginActionContext.prototype.div = function(...els) {
+  const div = document.createElement('div');
+  for (const el of els) {
+    Polymer.dom(div).appendChild(el);
+  }
+  return div;
+};
 
-  GrPluginActionContext.prototype.msg = function(text) {
-    const label = document.createElement('gr-label');
-    Polymer.dom(label).appendChild(document.createTextNode(text));
-    return label;
-  };
+GrPluginActionContext.prototype.button = function(label, callbacks) {
+  const onClick = callbacks && callbacks.onclick;
+  const button = document.createElement('gr-button');
+  Polymer.dom(button).appendChild(document.createTextNode(label));
+  if (onClick) {
+    this.plugin.eventHelper(button).onTap(onClick);
+  }
+  return button;
+};
 
-  GrPluginActionContext.prototype.div = function(...els) {
-    const div = document.createElement('div');
-    for (const el of els) {
-      Polymer.dom(div).appendChild(el);
-    }
-    return div;
-  };
+GrPluginActionContext.prototype.checkbox = function() {
+  const checkbox = document.createElement('input');
+  checkbox.type = 'checkbox';
+  return checkbox;
+};
 
-  GrPluginActionContext.prototype.button = function(label, callbacks) {
-    const onClick = callbacks && callbacks.onclick;
-    const button = document.createElement('gr-button');
-    Polymer.dom(button).appendChild(document.createTextNode(label));
-    if (onClick) {
-      this.plugin.eventHelper(button).onTap(onClick);
-    }
-    return button;
-  };
+GrPluginActionContext.prototype.label = function(checkbox, title) {
+  return this.div(checkbox, this.msg(title));
+};
 
-  GrPluginActionContext.prototype.checkbox = function() {
-    const checkbox = document.createElement('input');
-    checkbox.type = 'checkbox';
-    return checkbox;
-  };
+GrPluginActionContext.prototype.prependLabel = function(title, checkbox) {
+  return this.label(checkbox, title);
+};
 
-  GrPluginActionContext.prototype.label = function(checkbox, title) {
-    return this.div(checkbox, this.msg(title));
-  };
+GrPluginActionContext.prototype.call = function(payload, onSuccess) {
+  if (!this.action.__url) {
+    console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
+    return;
+  }
+  this.plugin.restApi()
+      .send(this.action.method, this.action.__url, payload)
+      .then(onSuccess)
+      .catch(error => {
+        document.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {
+            message: `Plugin network error: ${error}`,
+          },
+        }));
+      });
+};
 
-  GrPluginActionContext.prototype.prependLabel = function(title, checkbox) {
-    return this.label(checkbox, title);
-  };
-
-  GrPluginActionContext.prototype.call = function(payload, onSuccess) {
-    if (!this.action.__url) {
-      console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
-      return;
-    }
-    this.plugin.restApi()
-        .send(this.action.method, this.action.__url, payload)
-        .then(onSuccess)
-        .catch(error => {
-          document.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {
-              message: `Plugin network error: ${error}`,
-            },
-          }));
-        });
-  };
-
-  window.GrPluginActionContext = GrPluginActionContext;
-})(window);
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..08c784ab 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,121 +31,133 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-plugin-action-context tests', () => {
-    let instance;
-    let sandbox;
-    let plugin;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrPluginActionContext} from './gr-plugin-action-context.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrPluginActionContext(plugin);
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      sandbox.restore();
-    });
+suite('gr-plugin-action-context tests', () => {
+  let instance;
+  let sandbox;
+  let plugin;
 
-    test('popup() and hide()', () => {
-      const popupApiStub = {
-        close: sandbox.stub(),
-      };
-      sandbox.stub(plugin.deprecated, 'popup').returns(popupApiStub);
-      const el = {};
-      instance.popup(el);
-      assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrPluginActionContext(plugin);
+  });
 
-      instance.hide();
-      assert.isTrue(popupApiStub.close.called);
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('textfield', () => {
-      assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
-    });
+  test('popup() and hide()', () => {
+    const popupApiStub = {
+      close: sandbox.stub(),
+    };
+    sandbox.stub(plugin.deprecated, 'popup').returns(popupApiStub);
+    const el = {};
+    instance.popup(el);
+    assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
 
-    test('br', () => {
-      assert.equal(instance.br().tagName, 'BR');
-    });
+    instance.hide();
+    assert.isTrue(popupApiStub.close.called);
+  });
 
-    test('msg', () => {
-      const el = instance.msg('foobar');
-      assert.equal(el.tagName, 'GR-LABEL');
-      assert.equal(el.textContent, 'foobar');
-    });
+  test('textfield', () => {
+    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
+  });
 
-    test('div', () => {
-      const el1 = document.createElement('span');
-      el1.textContent = 'foo';
-      const el2 = document.createElement('div');
-      el2.textContent = 'bar';
-      const div = instance.div(el1, el2);
-      assert.equal(div.tagName, 'DIV');
-      assert.equal(div.textContent, 'foobar');
-    });
+  test('br', () => {
+    assert.equal(instance.br().tagName, 'BR');
+  });
 
-    test('button', () => {
-      const clickStub = sandbox.stub();
-      const button = instance.button('foo', {onclick: clickStub});
-      MockInteractions.tap(button);
-      flush(() => {
-        assert.isTrue(clickStub.called);
-        assert.equal(button.textContent, 'foo');
-      });
-    });
+  test('msg', () => {
+    const el = instance.msg('foobar');
+    assert.equal(el.tagName, 'GR-LABEL');
+    assert.equal(el.textContent, 'foobar');
+  });
 
-    test('checkbox', () => {
-      const el = instance.checkbox();
-      assert.equal(el.tagName, 'INPUT');
-      assert.equal(el.type, 'checkbox');
-    });
+  test('div', () => {
+    const el1 = document.createElement('span');
+    el1.textContent = 'foo';
+    const el2 = document.createElement('div');
+    el2.textContent = 'bar';
+    const div = instance.div(el1, el2);
+    assert.equal(div.tagName, 'DIV');
+    assert.equal(div.textContent, 'foobar');
+  });
 
-    test('label', () => {
-      const fakeMsg = {};
-      const fakeCheckbox = {};
-      sandbox.stub(instance, 'div');
-      sandbox.stub(instance, 'msg').returns(fakeMsg);
-      instance.label(fakeCheckbox, 'foo');
-      assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
-    });
-
-    test('call', () => {
-      instance.action = {
-        method: 'METHOD',
-        __key: 'key',
-        __url: '/changes/1/revisions/2/foo~bar',
-      };
-      const sendStub = sandbox.stub().returns(Promise.resolve());
-      sandbox.stub(plugin, 'restApi').returns({
-        send: sendStub,
-      });
-      const payload = {foo: 'foo'};
-      const successStub = sandbox.stub();
-      instance.call(payload, successStub);
-      assert.isTrue(sendStub.calledWith(
-          'METHOD', '/changes/1/revisions/2/foo~bar', payload));
-    });
-
-    test('call error', done => {
-      instance.action = {
-        method: 'METHOD',
-        __key: 'key',
-        __url: '/changes/1/revisions/2/foo~bar',
-      };
-      const sendStub = sandbox.stub().returns(Promise.reject(new Error('boom')));
-      sandbox.stub(plugin, 'restApi').returns({
-        send: sendStub,
-      });
-      const errorStub = sandbox.stub();
-      document.addEventListener('show-alert', errorStub);
-      instance.call();
-      flush(() => {
-        assert.isTrue(errorStub.calledOnce);
-        assert.equal(errorStub.args[0][0].detail.message,
-            'Plugin network error: Error: boom');
-        done();
-      });
+  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.
+    dom(document.body).appendChild(button);
+    MockInteractions.tap(button);
+    flush(() => {
+      assert.isTrue(clickStub.called);
+      assert.equal(button.textContent, 'foo');
+      done();
     });
   });
+
+  test('checkbox', () => {
+    const el = instance.checkbox();
+    assert.equal(el.tagName, 'INPUT');
+    assert.equal(el.type, 'checkbox');
+  });
+
+  test('label', () => {
+    const fakeMsg = {};
+    const fakeCheckbox = {};
+    sandbox.stub(instance, 'div');
+    sandbox.stub(instance, 'msg').returns(fakeMsg);
+    instance.label(fakeCheckbox, 'foo');
+    assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
+  });
+
+  test('call', () => {
+    instance.action = {
+      method: 'METHOD',
+      __key: 'key',
+      __url: '/changes/1/revisions/2/foo~bar',
+    };
+    const sendStub = sandbox.stub().returns(Promise.resolve());
+    sandbox.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const payload = {foo: 'foo'};
+    const successStub = sandbox.stub();
+    instance.call(payload, successStub);
+    assert.isTrue(sendStub.calledWith(
+        'METHOD', '/changes/1/revisions/2/foo~bar', payload));
+  });
+
+  test('call error', done => {
+    instance.action = {
+      method: 'METHOD',
+      __key: 'key',
+      __url: '/changes/1/revisions/2/foo~bar',
+    };
+    const sendStub = sandbox.stub().returns(Promise.reject(new Error('boom')));
+    sandbox.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const errorStub = sandbox.stub();
+    document.addEventListener('show-alert', errorStub);
+    instance.call();
+    flush(() => {
+      assert.isTrue(errorStub.calledOnce);
+      assert.equal(errorStub.args[0][0].detail.message,
+          'Plugin network error: Error: boom');
+      done();
+    });
+  });
+});
 </script>
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 f80e44a..0727397 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
@@ -14,148 +14,156 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  function GrPluginEndpoints() {
-    this._endpoints = {};
-    this._callbacks = {};
-    this._dynamicPlugins = {};
+import {pluginLoader} from './gr-plugin-loader.js';
+
+/** @constructor */
+export function GrPluginEndpoints() {
+  this._endpoints = {};
+  this._callbacks = {};
+  this._dynamicPlugins = {};
+}
+
+GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
+  if (!this._callbacks[endpoint]) {
+    this._callbacks[endpoint] = [];
   }
+  this._callbacks[endpoint].push(callback);
+};
 
-  GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
-    if (!this._callbacks[endpoint]) {
-      this._callbacks[endpoint] = [];
-    }
-    this._callbacks[endpoint].push(callback);
-  };
+GrPluginEndpoints.prototype.onDetachedEndpoint = function(endpoint,
+    callback) {
+  if (this._callbacks[endpoint]) {
+    this._callbacks[endpoint] = this._callbacks[endpoint]
+        .filter(cb => cb !== callback);
+  }
+};
 
-  GrPluginEndpoints.prototype.onDetachedEndpoint = function(endpoint,
-      callback) {
-    if (this._callbacks[endpoint]) {
-      this._callbacks[endpoint] = this._callbacks[endpoint]
-          .filter(cb => cb !== callback);
-    }
-  };
+GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin, opts) {
+  const {endpoint, slot, type, moduleName, domHook} = opts;
+  const existingModule = this._endpoints[endpoint].find(info =>
+    info.plugin === plugin &&
+      info.moduleName === moduleName &&
+      info.domHook === domHook &&
+      info.slot === slot
+  );
+  if (existingModule) {
+    return existingModule;
+  } else {
+    const newModule = {
+      moduleName,
+      plugin,
+      pluginUrl: plugin._url,
+      type,
+      domHook,
+      slot,
+    };
+    this._endpoints[endpoint].push(newModule);
+    return newModule;
+  }
+};
 
-  GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin,
-      endpoint, type, moduleName, domHook) {
-    const existingModule = this._endpoints[endpoint].find(info =>
-      info.plugin === plugin &&
-        info.moduleName === moduleName &&
-        info.domHook === domHook
-    );
-    if (existingModule) {
-      return existingModule;
-    } else {
-      const newModule = {
-        moduleName,
-        plugin,
-        pluginUrl: plugin._url,
-        type,
-        domHook,
-      };
-      this._endpoints[endpoint].push(newModule);
-      return newModule;
+/**
+ * Register a plugin to an endpoint.
+ *
+ * Dynamic plugins are registered to a specific prefix, such as
+ * 'change-list-header'. These plugins are then fetched by prefix to determine
+ * which endpoints to dynamically add to the page.
+ *
+ * @param {Object} plugin
+ * @param {Object} opts
+ */
+GrPluginEndpoints.prototype.registerModule = function(plugin, opts) {
+  const {endpoint, dynamicEndpoint} = opts;
+  if (dynamicEndpoint) {
+    if (!this._dynamicPlugins[dynamicEndpoint]) {
+      this._dynamicPlugins[dynamicEndpoint] = new Set();
     }
-  };
+    this._dynamicPlugins[dynamicEndpoint].add(endpoint);
+  }
+  if (!this._endpoints[endpoint]) {
+    this._endpoints[endpoint] = [];
+  }
+  const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
+  if (pluginLoader.arePluginsLoaded() && this._callbacks[endpoint]) {
+    this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
+  }
+};
 
-  /**
-   * Register a plugin to an endpoint.
-   *
-   * Dynamic plugins are registered to a specific prefix, such as
-   * 'change-list-header'. These plugins are then fetched by prefix to determine
-   * which endpoints to dynamically add to the page.
-   */
-  GrPluginEndpoints.prototype.registerModule = function(plugin, endpoint, type,
-      moduleName, domHook, dynamicEndpoint) {
-    if (dynamicEndpoint) {
-      if (!this._dynamicPlugins[dynamicEndpoint]) {
-        this._dynamicPlugins[dynamicEndpoint] = new Set();
-      }
-      this._dynamicPlugins[dynamicEndpoint].add(endpoint);
-    }
-    if (!this._endpoints[endpoint]) {
-      this._endpoints[endpoint] = [];
-    }
-    const moduleInfo = this._getOrCreateModuleInfo(plugin, endpoint, type,
-        moduleName, domHook);
-    if (Gerrit._arePluginsLoaded() && this._callbacks[endpoint]) {
-      this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
-    }
-  };
+GrPluginEndpoints.prototype.getDynamicEndpoints = function(dynamicEndpoint) {
+  const plugins = this._dynamicPlugins[dynamicEndpoint];
+  if (!plugins) return [];
+  return Array.from(plugins);
+};
 
-  GrPluginEndpoints.prototype.getDynamicEndpoints = function(dynamicEndpoint) {
-    const plugins = this._dynamicPlugins[dynamicEndpoint];
-    if (!plugins) return [];
-    return Array.from(plugins);
-  };
+/**
+ * Get detailed information about modules registered with an extension
+ * endpoint.
+ *
+ * @param {string} name Endpoint name.
+ * @param {?{
+ *   type: (string|undefined),
+ *   moduleName: (string|undefined)
+ * }} opt_options
+ * @return {!Array<{
+ *   moduleName: string,
+ *   plugin: Plugin,
+ *   pluginUrl: String,
+ *   type: EndpointType,
+ *   domHook: !Object
+ * }>}
+ */
+GrPluginEndpoints.prototype.getDetails = function(name, opt_options) {
+  const type = opt_options && opt_options.type;
+  const moduleName = opt_options && opt_options.moduleName;
+  if (!this._endpoints[name]) {
+    return [];
+  }
+  return this._endpoints[name]
+      .filter(item => (!type || item.type === type) &&
+                  (!moduleName || moduleName == item.moduleName));
+};
 
-  /**
-   * Get detailed information about modules registered with an extension
-   * endpoint.
-   *
-   * @param {string} name Endpoint name.
-   * @param {?{
-   *   type: (string|undefined),
-   *   moduleName: (string|undefined)
-   * }} opt_options
-   * @return {!Array<{
-   *   moduleName: string,
-   *   plugin: Plugin,
-   *   pluginUrl: String,
-   *   type: EndpointType,
-   *   domHook: !Object
-   * }>}
-   */
-  GrPluginEndpoints.prototype.getDetails = function(name, opt_options) {
-    const type = opt_options && opt_options.type;
-    const moduleName = opt_options && opt_options.moduleName;
-    if (!this._endpoints[name]) {
-      return [];
-    }
-    return this._endpoints[name]
-        .filter(item => (!type || item.type === type) &&
-                    (!moduleName || moduleName == item.moduleName));
-  };
+/**
+ * Get detailed module names for instantiating at the endpoint.
+ *
+ * @param {string} name Endpoint name.
+ * @param {?{
+ *   type: (string|undefined),
+ *   moduleName: (string|undefined)
+ * }} opt_options
+ * @return {!Array<string>}
+ */
+GrPluginEndpoints.prototype.getModules = function(name, opt_options) {
+  const modulesData = this.getDetails(name, opt_options);
+  if (!modulesData.length) {
+    return [];
+  }
+  return modulesData.map(m => m.moduleName);
+};
 
-  /**
-   * Get detailed module names for instantiating at the endpoint.
-   *
-   * @param {string} name Endpoint name.
-   * @param {?{
-   *   type: (string|undefined),
-   *   moduleName: (string|undefined)
-   * }} opt_options
-   * @return {!Array<string>}
-   */
-  GrPluginEndpoints.prototype.getModules = function(name, opt_options) {
-    const modulesData = this.getDetails(name, opt_options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return modulesData.map(m => m.moduleName);
-  };
+/**
+ * Get .html plugin URLs with element and module definitions.
+ *
+ * @param {string} name Endpoint name.
+ * @param {?{
+ *   type: (string|undefined),
+ *   moduleName: (string|undefined)
+ * }} opt_options
+ * @return {!Array<!URL>}
+ */
+GrPluginEndpoints.prototype.getPlugins = function(name, opt_options) {
+  const modulesData =
+        this.getDetails(name, opt_options).filter(
+            data => data.pluginUrl.pathname.includes('.html'));
+  if (!modulesData.length) {
+    return [];
+  }
+  return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
+};
 
-  /**
-   * Get .html plugin URLs with element and module definitions.
-   *
-   * @param {string} name Endpoint name.
-   * @param {?{
-   *   type: (string|undefined),
-   *   moduleName: (string|undefined)
-   * }} opt_options
-   * @return {!Array<!URL>}
-   */
-  GrPluginEndpoints.prototype.getPlugins = function(name, opt_options) {
-    const modulesData =
-          this.getDetails(name, opt_options).filter(
-              data => data.pluginUrl.pathname.includes('.html'));
-    if (!modulesData.length) {
-      return [];
-    }
-    return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
-  };
-
-  window.GrPluginEndpoints = GrPluginEndpoints;
-})(window);
+// TODO(dmfilippov): Convert to service and add to appContext
+export let pluginEndpoints = new GrPluginEndpoints();
+export function _testOnly_resetEndpoints() {
+  pluginEndpoints = new GrPluginEndpoints();
+}
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..3494e99 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
@@ -17,132 +17,164 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script>
-  suite('gr-plugin-endpoints tests', () => {
-    let sandbox;
-    let instance;
-    let pluginFoo;
-    let pluginBar;
-    let domHook;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {GrPluginEndpoints} from './gr-plugin-endpoints.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      domHook = {};
-      instance = new GrPluginEndpoints();
-      Gerrit.install(p => { pluginFoo = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/foo.html');
-      instance.registerModule(
-          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
-      Gerrit.install(p => { pluginBar = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/bar.html');
-      instance.registerModule(
-          pluginBar, 'a-place', 'style', 'bar-module', domHook);
-      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      sandbox.restore();
-    });
+suite('gr-plugin-endpoints tests', () => {
+  let sandbox;
+  let instance;
+  let pluginFoo;
+  let pluginBar;
+  let domHook;
 
-    test('getDetails all', () => {
-      assert.deepEqual(instance.getDetails('a-place'), [
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    domHook = {};
+    instance = new GrPluginEndpoints();
+    pluginApi.install(p => { pluginFoo = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/foo.html');
+    instance.registerModule(
+        pluginFoo,
         {
-          moduleName: 'foo-module',
-          plugin: pluginFoo,
-          pluginUrl: pluginFoo._url,
+          endpoint: 'a-place',
           type: 'decorate',
+          moduleName: 'foo-module',
           domHook,
-        },
+        }
+    );
+    pluginApi.install(p => { pluginBar = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/bar.html');
+    instance.registerModule(
+        pluginBar,
         {
-          moduleName: 'bar-module',
-          plugin: pluginBar,
-          pluginUrl: pluginBar._url,
+          endpoint: 'a-place',
           type: 'style',
-          domHook,
-        },
-      ]);
-    });
-
-    test('getDetails by type', () => {
-      assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
-        {
           moduleName: 'bar-module',
-          plugin: pluginBar,
-          pluginUrl: pluginBar._url,
-          type: 'style',
           domHook,
-        },
-      ]);
-    });
+        }
+    );
+    sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
+  });
 
-    test('getDetails by module', () => {
-      assert.deepEqual(
-          instance.getDetails('a-place', {moduleName: 'foo-module'}),
-          [
-            {
-              moduleName: 'foo-module',
-              plugin: pluginFoo,
-              pluginUrl: pluginFoo._url,
-              type: 'decorate',
-              domHook,
-            },
-          ]);
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('getModules', () => {
-      assert.deepEqual(
-          instance.getModules('a-place'), ['foo-module', 'bar-module']);
-    });
-
-    test('getPlugins', () => {
-      assert.deepEqual(
-          instance.getPlugins('a-place'), [pluginFoo._url]);
-    });
-
-    test('onNewEndpoint', () => {
-      const newModuleStub = sandbox.stub();
-      instance.onNewEndpoint('a-place', newModuleStub);
-      instance.registerModule(
-          pluginFoo, 'a-place', 'replace', 'zaz-module', domHook);
-      assert.deepEqual(newModuleStub.lastCall.args[0], {
-        moduleName: 'zaz-module',
+  test('getDetails all', () => {
+    assert.deepEqual(instance.getDetails('a-place'), [
+      {
+        moduleName: 'foo-module',
         plugin: pluginFoo,
         pluginUrl: pluginFoo._url,
-        type: 'replace',
+        type: 'decorate',
         domHook,
-      });
-    });
+        slot: undefined,
+      },
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
 
-    test('reuse dom hooks', () => {
-      instance.registerModule(
-          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
-      assert.deepEqual(instance.getDetails('a-place'), [
+  test('getDetails by type', () => {
+    assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
+
+  test('getDetails by module', () => {
+    assert.deepEqual(
+        instance.getDetails('a-place', {moduleName: 'foo-module'}),
+        [
+          {
+            moduleName: 'foo-module',
+            plugin: pluginFoo,
+            pluginUrl: pluginFoo._url,
+            type: 'decorate',
+            domHook,
+            slot: undefined,
+          },
+        ]);
+  });
+
+  test('getModules', () => {
+    assert.deepEqual(
+        instance.getModules('a-place'), ['foo-module', 'bar-module']);
+  });
+
+  test('getPlugins', () => {
+    assert.deepEqual(
+        instance.getPlugins('a-place'), [pluginFoo._url]);
+  });
+
+  test('onNewEndpoint', () => {
+    const newModuleStub = sandbox.stub();
+    instance.onNewEndpoint('a-place', newModuleStub);
+    instance.registerModule(
+        pluginFoo,
         {
-          moduleName: 'foo-module',
-          plugin: pluginFoo,
-          pluginUrl: pluginFoo._url,
-          type: 'decorate',
+          endpoint: 'a-place',
+          type: 'replace',
+          moduleName: 'zaz-module',
           domHook,
-        },
-        {
-          moduleName: 'bar-module',
-          plugin: pluginBar,
-          pluginUrl: pluginBar._url,
-          type: 'style',
-          domHook,
-        },
-      ]);
+        });
+    assert.deepEqual(newModuleStub.lastCall.args[0], {
+      moduleName: 'zaz-module',
+      plugin: pluginFoo,
+      pluginUrl: pluginFoo._url,
+      type: 'replace',
+      domHook,
+      slot: undefined,
     });
   });
+
+  test('reuse dom hooks', () => {
+    instance.registerModule(
+        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
+    assert.deepEqual(instance.getDetails('a-place'), [
+      {
+        moduleName: 'foo-module',
+        plugin: pluginFoo,
+        pluginUrl: pluginFoo._url,
+        type: 'decorate',
+        domHook,
+        slot: undefined,
+      },
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
+});
 </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..2f27304 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
@@ -14,384 +14,432 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import './gr-api-utils.js';
 
-(function(window) {
-  'use strict';
+import {
+  PLUGIN_LOADING_TIMEOUT_MS,
+  PRELOADED_PROTOCOL,
+  getPluginNameFromUrl,
+  getBaseUrl,
+} from './gr-api-utils.js';
 
-  // Import utils methods
-  const {
-    PLUGIN_LOADING_TIMEOUT_MS,
-    PRELOADED_PROTOCOL,
-    getPluginNameFromUrl,
-    getBaseUrl,
-  } = window._apiUtils;
-
+/**
+ * @enum {string}
+ */
+const PluginState = {
   /**
-   * @enum {string}
+   * State that indicates the plugin is pending to be loaded.
    */
-  const PluginState = {
-    /**
-     * State that indicates the plugin is pending to be loaded.
-     */
-    PENDING: 'PENDING',
-
-    /**
-     * State that indicates the plugin is already loaded.
-     */
-    LOADED: 'LOADED',
-
-    /**
-     * State that indicates the plugin is already loaded.
-     */
-    PRE_LOADED: 'PRE_LOADED',
-
-    /**
-     * State that indicates the plugin failed to load.
-     */
-    LOAD_FAILED: 'LOAD_FAILED',
-  };
-
-  // Prefix for any unrecognized plugin urls.
-  // Url should match following patterns:
-  // /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js)
-  // /plugins/PLUGINNAME.(js|html)
-  const UNKNOWN_PLUGIN_PREFIX = '__$$__';
-
-  // Current API version for Plugin,
-  // plugins with incompatible version will not be laoded.
-  const API_VERSION = '0.1';
+  PENDING: 'PENDING',
 
   /**
-   * PluginLoader, responsible for:
+   * State that indicates the plugin is already loaded.
+   */
+  LOADED: 'LOADED',
+
+  /**
+   * State that indicates the plugin is already loaded.
+   */
+  PRE_LOADED: 'PRE_LOADED',
+
+  /**
+   * State that indicates the plugin failed to load.
+   */
+  LOAD_FAILED: 'LOAD_FAILED',
+};
+
+// Prefix for any unrecognized plugin urls.
+// Url should match following patterns:
+// /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js)
+// /plugins/PLUGINNAME.(js|html)
+const UNKNOWN_PLUGIN_PREFIX = '__$$__';
+
+// Current API version for Plugin,
+// plugins with incompatible version will not be laoded.
+const API_VERSION = '0.1';
+
+/**
+ * PluginLoader, responsible for:
+ *
+ * Loading all plugins and handling errors etc.
+ * Recording plugin state.
+ * Reporting on plugin loading status.
+ * Retrieve plugin.
+ * Check plugin status and if all plugins loaded.
+ */
+export class PluginLoader {
+  constructor() {
+    this._pluginListLoaded = false;
+
+    /** @type {Map<string,PluginLoader.PluginObject>} */
+    this._plugins = new Map();
+
+    this._reporting = null;
+
+    // Promise that resolves when all plugins loaded
+    this._loadingPromise = null;
+
+    // Resolver to resolve _loadingPromise once all plugins loaded
+    this._loadingResolver = null;
+  }
+
+  _getReporting() {
+    if (!this._reporting) {
+      this._reporting = document.createElement('gr-reporting');
+    }
+    return this._reporting;
+  }
+
+  /**
+   * Use the plugin name or use the full url if not recognized.
    *
-   * Loading all plugins and handling errors etc.
-   * Recording plugin state.
-   * Reporting on plugin loading status.
-   * Retrieve plugin.
-   * Check plugin status and if all plugins loaded.
+   * @see gr-api-utils#getPluginNameFromUrl
+   * @param {string|URL} url
    */
-  class PluginLoader {
-    constructor() {
-      this._pluginListLoaded = false;
+  _getPluginKeyFromUrl(url) {
+    return getPluginNameFromUrl(url) ||
+      `${UNKNOWN_PLUGIN_PREFIX}${url}`;
+  }
 
-      /** @type {Map<string,PluginLoader.PluginObject>} */
-      this._plugins = new Map();
+  /**
+   * Load multiple plugins with certain options.
+   *
+   * @param {Array<string>} plugins
+   * @param {Object<string, PluginLoader.PluginOption>} opts
+   */
+  loadPlugins(plugins = [], opts = {}) {
+    this._pluginListLoaded = true;
 
-      this._reporting = null;
+    plugins.forEach(path => {
+      const url = this._urlFor(path, window.ASSETS_PATH);
+      // Skip if preloaded, for bundling.
+      if (this.isPluginPreloaded(url)) return;
 
-      // Promise that resolves when all plugins loaded
-      this._loadingPromise = null;
-
-      // Resolver to resolve _loadingPromise once all plugins loaded
-      this._loadingResolver = null;
-    }
-
-    _getReporting() {
-      if (!this._reporting) {
-        this._reporting = document.createElement('gr-reporting');
-      }
-      return this._reporting;
-    }
-
-    /**
-     * Use the plugin name or use the full url if not recognized.
-     *
-     * @see gr-api-utils#getPluginNameFromUrl
-     * @param {string|URL} url
-     */
-    _getPluginKeyFromUrl(url) {
-      return getPluginNameFromUrl(url) ||
-        `${UNKNOWN_PLUGIN_PREFIX}${url}`;
-    }
-
-    /**
-     * Load multiple plugins with certain options.
-     *
-     * @param {Array<string>} plugins
-     * @param {Object<string, PluginLoader.PluginOption>} opts
-     */
-    loadPlugins(plugins = [], opts = {}) {
-      this._pluginListLoaded = true;
-
-      plugins.forEach(path => {
-        const url = this._urlFor(path);
-        // Skip if preloaded, for bundling.
-        if (this.isPluginPreloaded(url)) return;
-
-        const pluginKey = this._getPluginKeyFromUrl(url);
-        // Skip if already installed.
-        if (this._plugins.has(pluginKey)) return;
-        this._plugins.set(pluginKey, {
-          name: pluginKey,
-          url,
-          state: PluginState.PENDING,
-          plugin: null,
-        });
-
-        if (this._isPathEndsWith(url, '.html')) {
-          this._importHtmlPlugin(url, opts && opts[path]);
-        } else if (this._isPathEndsWith(url, '.js')) {
-          this._loadJsPlugin(url);
-        } else {
-          this._failToLoad(`Unrecognized plugin url ${url}`, url);
-        }
+      const pluginKey = this._getPluginKeyFromUrl(url);
+      // Skip if already installed.
+      if (this._plugins.has(pluginKey)) return;
+      this._plugins.set(pluginKey, {
+        name: pluginKey,
+        url,
+        state: PluginState.PENDING,
+        plugin: null,
       });
 
-      this.awaitPluginsLoaded().then(() => {
-        console.info('Plugins loaded');
-        this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
-      });
-    }
-
-    _isPathEndsWith(url, suffix) {
-      if (!(url instanceof URL)) {
-        try {
-          url = new URL(url);
-        } catch (e) {
-          console.warn(e);
-          return false;
-        }
+      if (this._isPathEndsWith(url, '.html')) {
+        this._importHtmlPlugin(path, opts && opts[path]);
+      } else if (this._isPathEndsWith(url, '.js')) {
+        this._loadJsPlugin(path);
+      } else {
+        this._failToLoad(`Unrecognized plugin path ${path}`, path);
       }
+    });
 
-      return url.pathname && url.pathname.endsWith(suffix);
-    }
+    this.awaitPluginsLoaded().then(() => {
+      console.info('Plugins loaded');
+      this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
+    });
+  }
 
-    _getAllInstalledPluginNames() {
-      const installedPlugins = [];
-      for (const plugin of this._plugins.values()) {
-        if (plugin.state === PluginState.LOADED) {
-          installedPlugins.push(plugin.name);
-        }
-      }
-      return installedPlugins;
-    }
-
-    install(callback, opt_version, opt_src) {
-      // HTML import polyfill adds __importElement pointing to the import tag.
-      const script = document.currentScript &&
-          (document.currentScript.__importElement || document.currentScript);
-      let src = opt_src || (script && script.src);
-      if (!src || src.startsWith('data:')) {
-        src = script && script.baseURI;
-      }
-
-      if (opt_version && opt_version !== API_VERSION) {
-        this._failToLoad(`Plugin ${src} install error: only version ` +
-            API_VERSION + ' is supported in PolyGerrit. ' + opt_version +
-            ' was given.', src);
-        return;
-      }
-
-      const pluginObject = this.getPlugin(src);
-      let plugin = pluginObject && pluginObject.plugin;
-      if (!plugin) {
-        plugin = new Plugin(src);
-      }
+  _isPathEndsWith(url, suffix) {
+    if (!(url instanceof URL)) {
       try {
-        callback(plugin);
-        this._pluginInstalled(src, plugin);
+        url = new URL(url);
       } catch (e) {
-        this._failToLoad(`${e.name}: ${e.message}`, src);
-      }
-    }
-
-    get arePluginsLoaded() {
-      // As the size of plugins is relatively small,
-      // so the performance of this check should be reasonable
-      if (!this._pluginListLoaded) return false;
-      for (const plugin of this._plugins.values()) {
-        if (plugin.state === PluginState.PENDING) return false;
-      }
-      return true;
-    }
-
-    _checkIfCompleted() {
-      if (this.arePluginsLoaded && this._loadingResolver) {
-        this._loadingResolver();
-        this._loadingResolver = null;
-        this._loadingPromise = null;
-      }
-    }
-
-    _timeout() {
-      const pendingPlugins = [];
-      for (const plugin of this._plugins.values()) {
-        if (plugin.state === PluginState.PENDING) {
-          this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
-          this._checkIfCompleted();
-          pendingPlugins.push(plugin.url);
-        }
-      }
-      return `Timeout when loading plugins: ${pendingPlugins.join(',')}`;
-    }
-
-    _failToLoad(message, pluginUrl) {
-      // Show an alert with the error
-      document.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: `Plugin install error: ${message} from ${pluginUrl}`,
-        },
-      }));
-      this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
-      this._checkIfCompleted();
-    }
-
-    _updatePluginState(pluginUrl, state) {
-      const key = this._getPluginKeyFromUrl(pluginUrl);
-      if (this._plugins.has(key)) {
-        this._plugins.get(key).state = state;
-      } else {
-        // Plugin is not recorded for some reason.
-        console.warn(`Plugin loaded separately: ${pluginUrl}`);
-        this._plugins.set(key, {
-          name: key,
-          url: pluginUrl,
-          state,
-          plugin: null,
-        });
-      }
-      return this._plugins.get(key);
-    }
-
-    _pluginInstalled(url, plugin) {
-      const pluginObj = this._updatePluginState(url, PluginState.LOADED);
-      pluginObj.plugin = plugin;
-      this._getReporting().pluginLoaded(plugin.getPluginName() || url);
-      console.log(`Plugin ${plugin.getPluginName() || url} installed.`);
-      this._checkIfCompleted();
-    }
-
-    installPreloadedPlugins() {
-      if (!window.Gerrit || !window.Gerrit._preloadedPlugins) { return; }
-      const Gerrit = window.Gerrit;
-      for (const name in Gerrit._preloadedPlugins) {
-        if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
-        const callback = Gerrit._preloadedPlugins[name];
-        this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
-      }
-    }
-
-    isPluginPreloaded(pathOrUrl) {
-      const url = this._urlFor(pathOrUrl);
-      const name = getPluginNameFromUrl(url);
-      if (name && window.Gerrit._preloadedPlugins) {
-        return window.Gerrit._preloadedPlugins.hasOwnProperty(name);
-      } else {
+        console.warn(e);
         return false;
       }
     }
 
-    /**
-     * Checks if given plugin path/url is enabled or not.
-     *
-     * @param {string} pathOrUrl
-     */
-    isPluginEnabled(pathOrUrl) {
-      const url = this._urlFor(pathOrUrl);
-      if (this.isPluginPreloaded(url)) return true;
-      const key = this._getPluginKeyFromUrl(url);
-      return this._plugins.has(key);
-    }
+    return url.pathname && url.pathname.endsWith(suffix);
+  }
 
-    /**
-     * Returns the plugin object with a given url.
-     *
-     * @param {string} pathOrUrl
-     */
-    getPlugin(pathOrUrl) {
-      const key = this._getPluginKeyFromUrl(this._urlFor(pathOrUrl));
-      return this._plugins.get(key);
-    }
-
-    /**
-     * Checks if given plugin path/url is loaded or not.
-     *
-     * @param {string} pathOrUrl
-     */
-    isPluginLoaded(pathOrUrl) {
-      const url = this._urlFor(pathOrUrl);
-      const key = this._getPluginKeyFromUrl(url);
-      return this._plugins.has(key) ?
-        this._plugins.get(key).state === PluginState.LOADED :
-        false;
-    }
-
-    _importHtmlPlugin(pluginUrl, opts = {}) {
-      // onload (second param) needs to be a function. When null or undefined
-      // were passed, plugins were not loaded correctly.
-      (Polymer.importHref || Polymer.Base.importHref)(
-          this._urlFor(pluginUrl), () => {},
-          () => this._failToLoad(`${pluginUrl} import error`, pluginUrl),
-          !opts.sync);
-    }
-
-    _loadJsPlugin(pluginUrl) {
-      this._createScriptTag(this._urlFor(pluginUrl));
-    }
-
-    _createScriptTag(url) {
-      const el = document.createElement('script');
-      el.defer = true;
-      el.src = url;
-      el.onerror = () => this._failToLoad(`${url} load error`, url);
-      return document.body.appendChild(el);
-    }
-
-    _urlFor(pathOrUrl) {
-      if (!pathOrUrl) {
-        return pathOrUrl;
+  _getAllInstalledPluginNames() {
+    const installedPlugins = [];
+    for (const plugin of this._plugins.values()) {
+      if (plugin.state === PluginState.LOADED) {
+        installedPlugins.push(plugin.name);
       }
-      if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
-          pathOrUrl.startsWith('http')) {
-        // Plugins are loaded from another domain or preloaded.
-        return pathOrUrl;
-      }
-      if (!pathOrUrl.startsWith('/')) {
-        pathOrUrl = '/' + pathOrUrl;
-      }
-      return window.location.origin + getBaseUrl() + pathOrUrl;
+    }
+    return installedPlugins;
+  }
+
+  install(callback, opt_version, opt_src) {
+    // HTML import polyfill adds __importElement pointing to the import tag.
+    const script = document.currentScript &&
+        (document.currentScript.__importElement || document.currentScript);
+    let src = opt_src || (script && script.src);
+    if (!src || src.startsWith('data:')) {
+      src = script && script.baseURI;
     }
 
-    awaitPluginsLoaded() {
-      // Resolve if completed.
-      this._checkIfCompleted();
+    if (opt_version && opt_version !== API_VERSION) {
+      this._failToLoad(`Plugin ${src} install error: only version ` +
+          API_VERSION + ' is supported in PolyGerrit. ' + opt_version +
+          ' was given.', src);
+      return;
+    }
 
-      if (this.arePluginsLoaded) {
-        return Promise.resolve();
+    const url = this._urlFor(src);
+    const pluginObject = this.getPlugin(url);
+    let plugin = pluginObject && pluginObject.plugin;
+    if (!plugin) {
+      plugin = new Plugin(url);
+    }
+    try {
+      callback(plugin);
+      this._pluginInstalled(url, plugin);
+    } catch (e) {
+      this._failToLoad(`${e.name}: ${e.message}`, src);
+    }
+  }
+
+  // The polygerrit uses version of sinon where you can't stub getter,
+  // declare it as a function here
+  arePluginsLoaded() {
+    // As the size of plugins is relatively small,
+    // so the performance of this check should be reasonable
+    if (!this._pluginListLoaded) return false;
+    for (const plugin of this._plugins.values()) {
+      if (plugin.state === PluginState.PENDING) return false;
+    }
+    return true;
+  }
+
+  _checkIfCompleted() {
+    if (this.arePluginsLoaded() && this._loadingResolver) {
+      this._loadingResolver();
+      this._loadingResolver = null;
+      this._loadingPromise = null;
+    }
+  }
+
+  _timeout() {
+    const pendingPlugins = [];
+    for (const plugin of this._plugins.values()) {
+      if (plugin.state === PluginState.PENDING) {
+        this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
+        this._checkIfCompleted();
+        pendingPlugins.push(plugin.url);
       }
-      if (!this._loadingPromise) {
-        let timerId;
-        this._loadingPromise =
-          Promise.race([
-            new Promise(resolve => this._loadingResolver = resolve),
-            new Promise((_, reject) => timerId = setTimeout(
-                () => {
-                  reject(this._timeout());
-                }, PLUGIN_LOADING_TIMEOUT_MS)),
-          ]).then(() => {
-            if (timerId) clearTimeout(timerId);
-          });
-      }
-      return this._loadingPromise;
+    }
+    return `Timeout when loading plugins: ${pendingPlugins.join(',')}`;
+  }
+
+  _failToLoad(message, pluginUrl) {
+    // Show an alert with the error
+    document.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message: `Plugin install error: ${message} from ${pluginUrl}`,
+      },
+    }));
+    this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
+    this._checkIfCompleted();
+  }
+
+  _updatePluginState(pluginUrl, state) {
+    const key = this._getPluginKeyFromUrl(pluginUrl);
+    if (this._plugins.has(key)) {
+      this._plugins.get(key).state = state;
+    } else {
+      // Plugin is not recorded for some reason.
+      console.warn(`Plugin loaded separately: ${pluginUrl}`);
+      this._plugins.set(key, {
+        name: key,
+        url: pluginUrl,
+        state,
+        plugin: null,
+      });
+    }
+    return this._plugins.get(key);
+  }
+
+  _pluginInstalled(url, plugin) {
+    const pluginObj = this._updatePluginState(url, PluginState.LOADED);
+    pluginObj.plugin = plugin;
+    this._getReporting().pluginLoaded(plugin.getPluginName() || url);
+    console.log(`Plugin ${plugin.getPluginName() || url} installed.`);
+    this._checkIfCompleted();
+  }
+
+  installPreloadedPlugins() {
+    if (!window.Gerrit || !window.Gerrit._preloadedPlugins) { return; }
+    const Gerrit = window.Gerrit;
+    for (const name in Gerrit._preloadedPlugins) {
+      if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
+      const callback = Gerrit._preloadedPlugins[name];
+      this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
+    }
+  }
+
+  isPluginPreloaded(pathOrUrl) {
+    const url = this._urlFor(pathOrUrl);
+    const name = getPluginNameFromUrl(url);
+    if (name && window.Gerrit._preloadedPlugins) {
+      return window.Gerrit._preloadedPlugins.hasOwnProperty(name);
+    } else {
+      return false;
     }
   }
 
   /**
-   * @typedef {{
-   *            name:string,
-   *            url:string,
-   *            state:PluginState,
-   *            plugin:Object
-   *          }}
+   * Checks if given plugin path/url is enabled or not.
+   *
+   * @param {string} pathOrUrl
    */
-  PluginLoader.PluginObject;
+  isPluginEnabled(pathOrUrl) {
+    const url = this._urlFor(pathOrUrl);
+    if (this.isPluginPreloaded(url)) return true;
+    const key = this._getPluginKeyFromUrl(url);
+    return this._plugins.has(key);
+  }
 
   /**
-   * @typedef {{
-   *            sync:boolean,
-   *          }}
+   * Returns the plugin object with a given url.
+   *
+   * @param {string} pathOrUrl
    */
-  PluginLoader.PluginOption;
+  getPlugin(pathOrUrl) {
+    const key = this._getPluginKeyFromUrl(this._urlFor(pathOrUrl));
+    return this._plugins.get(key);
+  }
 
-  window.PluginLoader = PluginLoader;
-})(window);
\ No newline at end of file
+  /**
+   * Checks if given plugin path/url is loaded or not.
+   *
+   * @param {string} pathOrUrl
+   */
+  isPluginLoaded(pathOrUrl) {
+    const url = this._urlFor(pathOrUrl);
+    const key = this._getPluginKeyFromUrl(url);
+    return this._plugins.has(key) ?
+      this._plugins.get(key).state === PluginState.LOADED :
+      false;
+  }
+
+  _importHtmlPlugin(pluginUrl, opts = {}) {
+    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)(
+        url, () => {},
+        onerror,
+        !sync);
+  }
+
+  _loadJsPlugin(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, onerror) {
+    if (!onerror) {
+      onerror = () => this._failToLoad(`${url} load error`, url);
+    }
+
+    const el = document.createElement('script');
+    el.defer = true;
+    el.setAttribute('src', url);
+    // no credentials to send when fetch plugin js
+    // and this will help provide more meaningful error than
+    // 'Script error.'
+    el.setAttribute('crossorigin', 'anonymous');
+    el.onerror = onerror;
+    return document.body.appendChild(el);
+  }
+
+  _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;
+  }
+
+  awaitPluginsLoaded() {
+    // Resolve if completed.
+    this._checkIfCompleted();
+
+    if (this.arePluginsLoaded()) {
+      return Promise.resolve();
+    }
+    if (!this._loadingPromise) {
+      let timerId;
+      this._loadingPromise =
+        Promise.race([
+          new Promise(resolve => this._loadingResolver = resolve),
+          new Promise((_, reject) => timerId = setTimeout(
+              () => {
+                reject(new Error(this._timeout()));
+              }, PLUGIN_LOADING_TIMEOUT_MS)),
+        ]).then(() => {
+          if (timerId) clearTimeout(timerId);
+        });
+    }
+    return this._loadingPromise;
+  }
+}
+
+/**
+ * @typedef {{
+ *            name:string,
+ *            url:string,
+ *            state:PluginState,
+ *            plugin:Object
+ *          }}
+ */
+PluginLoader.PluginObject;
+
+/**
+ * @typedef {{
+ *            sync:boolean,
+ *          }}
+ */
+PluginLoader.PluginOption;
+
+// TODO(dmfilippov): Convert to service and add to appContext
+export let pluginLoader = new PluginLoader();
+export function _testOnly_resetPluginLoader() {
+  pluginLoader = new PluginLoader();
+}
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..c972f53 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,469 +31,540 @@
   </template>
 </test-fixture>
 
-<script>
-  const {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} = window._apiUtils;
-  suite('gr-plugin-loader tests', () => {
-    let plugin;
-    let sandbox;
-    let url;
-    let sendStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {_testOnly_flushPreinstalls} from './gr-gerrit.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-loader tests', () => {
+  let plugin;
+  let sandbox;
+  let url;
+  let sendStub;
+
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+    sandbox = sinon.sandbox.create();
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    sandbox.stub(document.body, 'appendChild');
+    fixture('basic');
+    url = window.location.origin;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+    window.clock.restore();
+    resetPlugins();
+  });
+
+  test('reuse plugin for install calls', () => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+
+    let otherPlugin;
+    pluginApi.install(p => { otherPlugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    assert.strictEqual(plugin, otherPlugin);
+  });
+
+  test('flushes preinstalls if provided', () => {
+    assert.doesNotThrow(() => {
+      _testOnly_flushPreinstalls();
+    });
+    window.Gerrit.flushPreinstalls = sandbox.stub();
+    _testOnly_flushPreinstalls();
+    assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+    delete window.Gerrit.flushPreinstalls;
+  });
+
+  test('versioning', () => {
+    const callback = sandbox.spy();
+    pluginApi.install(callback, '0.0pre-alpha');
+    assert(callback.notCalled);
+  });
+
+  test('report pluginsLoaded', done => {
+    stub('gr-reporting', {
+      pluginsLoaded() {
+        done();
+      },
+    });
+    pluginLoader.loadPlugins([]);
+  });
+
+  test('arePluginsLoaded', done => {
+    assert.isFalse(pluginLoader.arePluginsLoaded());
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    pluginLoader.loadPlugins(plugins);
+    assert.isFalse(pluginLoader.arePluginsLoaded());
+    // Timeout on loading plugins
+    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    flush(() => {
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      done();
+    });
+  });
+
+  test('plugins installed successfully', done => {
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      done();
+    });
+  });
+
+  test('isPluginEnabled and isPluginLoaded', done => {
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+      'bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+    assert.isTrue(
+        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+    );
+
+    flush(() => {
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(
+          plugins.every(plugin => pluginLoader.isPluginLoaded(plugin))
+      );
+
+      done();
+    });
+  });
+
+  test('plugins installed mixed result, 1 fail 1 succeed', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      done();
+    });
+  });
+
+  test('isPluginEnabled and isPluginLoaded for mixed results', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    pluginLoader.loadPlugins(plugins);
+    assert.isTrue(
+        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+    );
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      assert.isTrue(pluginLoader.isPluginLoaded(plugins[1]));
+      assert.isFalse(pluginLoader.isPluginLoaded(plugins[0]));
+      done();
+    });
+  });
+
+  test('plugins installed all failed', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => {
+        throw new Error('failed');
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledTwice);
+      done();
+    });
+  });
+
+  test('plugins installed failed becasue of wrong version', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sandbox.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => {
+      }, url === plugins[0] ? '' : 'alpha', url);
+    });
+
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      done();
+    });
+  });
+
+  test('multiple assets for same plugin installed successfully', done => {
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sandbox.stub();
+    stub('gr-reporting', {
+      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/foo/static/test2.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      done();
+    });
+  });
+
+  suite('plugin path and url', () => {
+    let importHtmlPluginStub;
+    let loadJsPluginStub;
     setup(() => {
-      this.clock = sinon.useFakeTimers();
-      sandbox = sinon.sandbox.create();
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({name: 'Judy Hopps'});
-        },
-        send(...args) {
-          return sendStub(...args);
-        },
+      importHtmlPluginStub = sandbox.stub();
+      sandbox.stub(pluginLoader, '_loadHtmlPlugin', url => {
+        importHtmlPluginStub(url);
       });
-      sandbox.stub(document.body, 'appendChild');
-      fixture('basic');
-      url = window.location.origin;
+      loadJsPluginStub = sandbox.stub();
+      sandbox.stub(pluginLoader, '_createScriptTag', url => {
+        loadJsPluginStub(url);
+      });
+    });
+
+    test('invalid plugin path', () => {
+      const failToLoadStub = sandbox.stub();
+      sandbox.stub(pluginLoader, '_failToLoad', (...args) => {
+        failToLoadStub(...args);
+      });
+
+      pluginLoader.loadPlugins([
+        'foo/bar',
+      ]);
+
+      assert.isTrue(failToLoadStub.calledOnce);
+      assert.isTrue(failToLoadStub.calledWithExactly(
+          'Unrecognized plugin path foo/bar',
+          'foo/bar'
+      ));
+    });
+
+    test('relative path for plugins', () => {
+      pluginLoader.loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
+      );
+    });
+
+    test('relative path should honor getBaseUrl', () => {
+      const testUrl = '/test';
+      sandbox.stub(BaseUrlBehavior, 'getBaseUrl', () => testUrl);
+
+      pluginLoader.loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(
+              `${url}${testUrl}/foo/bar.html`
+          )
+      );
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
+      );
+    });
+
+    test('absolute path for plugins', () => {
+      pluginLoader.loadPlugins([
+        'http://e.com/foo/bar.js',
+        'http://e.com/foo/bar.html',
+      ]);
+
+      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`)
+      );
+    });
+  });
+
+  suite('With ASSETS_PATH', () => {
+    let importHtmlPluginStub;
+    let loadJsPluginStub;
+    setup(() => {
+      window.ASSETS_PATH = 'https://cdn.com';
+      importHtmlPluginStub = sandbox.stub();
+      sandbox.stub(pluginLoader, '_loadHtmlPlugin', url => {
+        importHtmlPluginStub(url);
+      });
+      loadJsPluginStub = sandbox.stub();
+      sandbox.stub(pluginLoader, '_createScriptTag', url => {
+        loadJsPluginStub(url);
+      });
     });
 
     teardown(() => {
-      sandbox.restore();
-      this.clock.restore();
-      Gerrit._testOnly_resetPlugins();
+      window.ASSETS_PATH = '';
     });
 
-    test('reuse plugin for install calls', () => {
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-
-      let otherPlugin;
-      Gerrit.install(p => { otherPlugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      assert.strictEqual(plugin, otherPlugin);
-    });
-
-    test('flushes preinstalls if provided', () => {
-      assert.doesNotThrow(() => {
-        Gerrit._testOnly_flushPreinstalls();
-      });
-      window.Gerrit.flushPreinstalls = sandbox.stub();
-      Gerrit._testOnly_flushPreinstalls();
-      assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
-      delete window.Gerrit.flushPreinstalls;
-    });
-
-    test('versioning', () => {
-      const callback = sandbox.spy();
-      Gerrit.install(callback, '0.0pre-alpha');
-      assert(callback.notCalled);
-    });
-
-    test('report pluginsLoaded', done => {
-      stub('gr-reporting', {
-        pluginsLoaded() {
-          done();
-        },
-      });
-      Gerrit._loadPlugins([]);
-    });
-
-    test('arePluginsLoaded', done => {
-      assert.isFalse(Gerrit._arePluginsLoaded());
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      Gerrit._loadPlugins(plugins);
-      assert.isFalse(Gerrit._arePluginsLoaded());
-      // Timeout on loading plugins
-      this.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-      flush(() => {
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        done();
-      });
-    });
-
-    test('plugins installed successfully', done => {
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => void 0, undefined, url);
-      });
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        done();
-      });
-    });
-
-    test('isPluginEnabled and isPluginLoaded', done => {
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => void 0, undefined, url);
-      });
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-        'bar/static/test.js',
-      ];
-      Gerrit._loadPlugins(plugins);
-      assert.isTrue(
-          plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
-      );
-
-      flush(() => {
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(
-            plugins.every(plugin => Gerrit._pluginLoader.isPluginLoaded(plugin))
-        );
-
-        done();
-      });
-    });
-
-    test('plugins installed mixed result, 1 fail 1 succeed', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-          if (url === plugins[0]) {
-            throw new Error('failed');
-          }
-        }, undefined, url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledOnce);
-        done();
-      });
-    });
-
-    test('isPluginEnabled and isPluginLoaded for mixed results', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-          if (url === plugins[0]) {
-            throw new Error('failed');
-          }
-        }, undefined, url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-      assert.isTrue(
-          plugins.every(plugin => Gerrit._pluginLoader.isPluginEnabled(plugin))
-      );
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledOnce);
-        assert.isTrue(Gerrit._pluginLoader.isPluginLoaded(plugins[1]));
-        assert.isFalse(Gerrit._pluginLoader.isPluginLoaded(plugins[0]));
-        done();
-      });
-    });
-
-    test('plugins installed all failed', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-          throw new Error('failed');
-        }, undefined, url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledTwice);
-        done();
-      });
-    });
-
-    test('plugins installed failed becasue of wrong version', done => {
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-
-      const alertStub = sandbox.stub();
-      document.addEventListener('show-alert', alertStub);
-
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => {
-        }, url === plugins[0] ? '' : 'alpha', url);
-      });
-
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        assert.isTrue(alertStub.calledOnce);
-        done();
-      });
-    });
-
-    test('multiple assets for same plugin installed successfully', done => {
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => void 0, undefined, url);
-      });
-      const pluginsLoadedStub = sandbox.stub();
-      stub('gr-reporting', {
-        pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-      });
-
-      const plugins = [
-        'http://test.com/plugins/foo/static/test.js',
-        'http://test.com/plugins/foo/static/test2.js',
-        'http://test.com/plugins/bar/static/test.js',
-      ];
-      Gerrit._loadPlugins(plugins);
-
-      flush(() => {
-        assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-        assert.isTrue(Gerrit._arePluginsLoaded());
-        done();
-      });
-    });
-
-    suite('plugin path and url', () => {
-      let importHtmlPluginStub;
-      let loadJsPluginStub;
-      setup(() => {
-        importHtmlPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
-          importHtmlPluginStub(url);
-        });
-        loadJsPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-          loadJsPluginStub(url);
-        });
-      });
-
-      test('invalid plugin path', () => {
-        const failToLoadStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_failToLoad', (...args) => {
-          failToLoadStub(...args);
-        });
-
-        Gerrit._loadPlugins([
-          'foo/bar',
-        ]);
-
-        assert.isTrue(failToLoadStub.calledOnce);
-        assert.isTrue(failToLoadStub.calledWithExactly(
-            `Unrecognized plugin url ${url}/foo/bar`,
-            `${url}/foo/bar`
-        ));
-      });
-
-      test('relative path for plugins', () => {
-        Gerrit._loadPlugins([
-          'foo/bar.js',
-          'foo/bar.html',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
-        );
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
-        );
-      });
-
-
-      test('relative path should honor getBaseUrl', () => {
-        const testUrl = '/test';
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl', () => {
-          return testUrl;
-        });
-
-        Gerrit._loadPlugins([
-          'foo/bar.js',
-          'foo/bar.html',
-        ]);
-
-        assert.isTrue(importHtmlPluginStub.calledOnce);
-        assert.isTrue(loadJsPluginStub.calledOnce);
-        assert.isTrue(
-            importHtmlPluginStub.calledWithExactly(
-                `${url}${testUrl}/foo/bar.html`
-            )
-        );
-        assert.isTrue(
-            loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
-        );
-      });
-
-      test('absolute path for plugins', () => {
-        Gerrit._loadPlugins([
-          'http://e.com/foo/bar.js',
-          'http://e.com/foo/bar.html',
-        ]);
-
-        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('adds js plugins will call the body', () => {
-      Gerrit._loadPlugins([
-        'http://e.com/foo/bar.js',
-        'http://e.com/bar/foo.js',
+    test('Should try load plugins from assets path instead', () => {
+      pluginLoader.loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
       ]);
-      assert.isTrue(document.body.appendChild.calledTwice);
+
+      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('can call awaitPluginsLoaded multiple times', done => {
-      const plugins = [
+    test('Should honor original path if exists', () => {
+      pluginLoader.loadPlugins([
+        'http://e.com/foo/bar.html',
         'http://e.com/foo/bar.js',
-        'http://e.com/bar/foo.js',
-      ];
+      ]);
 
-      let installed = false;
-      function pluginCallback(url) {
-        if (url === plugins[1]) {
-          installed = true;
-        }
-      }
-      sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-        Gerrit.install(() => pluginCallback(url), undefined, url);
-      });
-
-      Gerrit._loadPlugins(plugins);
-
-      Gerrit.awaitPluginsLoaded().then(() => {
-        assert.isTrue(installed);
-
-        Gerrit.awaitPluginsLoaded().then(() => {
-          done();
-        });
-      });
+      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`));
     });
 
-    suite('preloaded plugins', () => {
-      test('skips preloaded plugins when load plugins', () => {
-        const importHtmlPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
-          importHtmlPluginStub(url);
-        });
-        const loadJsPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
-          loadJsPluginStub(url);
-        });
+    test('Should try replace current host with assetsPath', () => {
+      const host = window.location.origin;
+      pluginLoader.loadPlugins([
+        `${host}/foo/bar.html`,
+        `${host}/foo/bar.js`,
+      ]);
 
-        Gerrit._preloadedPlugins = {
-          foo: () => void 0,
-          bar: () => void 0,
-        };
+      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`));
+    });
+  });
 
-        Gerrit._loadPlugins([
-          'http://e.com/plugins/foo.js',
-          'plugins/bar.html',
-          'http://e.com/plugins/test/foo.js',
-        ]);
+  test('adds js plugins will call the body', () => {
+    pluginLoader.loadPlugins([
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ]);
+    assert.isTrue(document.body.appendChild.calledTwice);
+  });
 
-        assert.isTrue(importHtmlPluginStub.notCalled);
-        assert.isTrue(loadJsPluginStub.calledOnce);
-      });
+  test('can call awaitPluginsLoaded multiple times', done => {
+    const plugins = [
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ];
 
-      test('isPluginPreloaded', () => {
-        Gerrit._preloadedPlugins = {baz: ()=>{}};
-        assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('plugins/foo/bar'));
-        assert.isFalse(Gerrit._pluginLoader.isPluginPreloaded('http://a.com/42'));
-        assert.isTrue(
-            Gerrit._pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
-        );
-        Gerrit._preloadedPlugins = null;
-      });
+    let installed = false;
+    function pluginCallback(url) {
+      if (url === plugins[1]) {
+        installed = true;
+      }
+    }
+    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+      pluginApi.install(() => pluginCallback(url), undefined, url);
+    });
 
-      test('preloaded plugins are installed', () => {
-        const installStub = sandbox.stub();
-        Gerrit._preloadedPlugins = {foo: installStub};
-        Gerrit._pluginLoader.installPreloadedPlugins();
-        assert.isTrue(installStub.called);
-        const pluginApi = installStub.lastCall.args[0];
-        assert.strictEqual(pluginApi.getPluginName(), 'foo');
-      });
+    pluginLoader.loadPlugins(plugins);
 
-      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;
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      assert.isTrue(installed);
+
+      pluginLoader.awaitPluginsLoaded().then(() => {
+        done();
       });
     });
   });
+
+  suite('preloaded plugins', () => {
+    test('skips preloaded plugins when load plugins', () => {
+      const importHtmlPluginStub = sandbox.stub();
+      sandbox.stub(pluginLoader, '_importHtmlPlugin', url => {
+        importHtmlPluginStub(url);
+      });
+      const loadJsPluginStub = sandbox.stub();
+      sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
+        loadJsPluginStub(url);
+      });
+
+      window.Gerrit._preloadedPlugins = {
+        foo: () => void 0,
+        bar: () => void 0,
+      };
+
+      pluginLoader.loadPlugins([
+        'http://e.com/plugins/foo.js',
+        'plugins/bar.html',
+        'http://e.com/plugins/test/foo.js',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.notCalled);
+      assert.isTrue(loadJsPluginStub.calledOnce);
+    });
+
+    test('isPluginPreloaded', () => {
+      window.Gerrit._preloadedPlugins = {baz: ()=>{}};
+      assert.isFalse(pluginLoader.isPluginPreloaded('plugins/foo/bar'));
+      assert.isFalse(pluginLoader.isPluginPreloaded('http://a.com/42'));
+      assert.isTrue(
+          pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
+      );
+      window.Gerrit._preloadedPlugins = null;
+    });
+
+    test('preloaded plugins are installed', () => {
+      const installStub = sandbox.stub();
+      window.Gerrit._preloadedPlugins = {foo: installStub};
+      pluginLoader.installPreloadedPlugins();
+      assert.isTrue(installStub.called);
+      const pluginApi = installStub.lastCall.args[0];
+      assert.strictEqual(pluginApi.getPluginName(), 'foo');
+    });
+
+    test('installing preloaded plugin', () => {
+      let plugin;
+      pluginApi.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          `${window.location.origin}/plugins/foo/some/thing.html`);
+    });
+  });
+});
 </script>
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..d84cd834 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
@@ -14,139 +14,134 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  let restApi;
+let restApi;
 
-  function getRestApi() {
-    if (!restApi) {
-      restApi = document.createElement('gr-rest-api-interface');
+function getRestApi() {
+  if (!restApi) {
+    restApi = document.createElement('gr-rest-api-interface');
+  }
+  return restApi;
+}
+
+export function GrPluginRestApi(opt_prefix) {
+  this.opt_prefix = opt_prefix || '';
+}
+
+GrPluginRestApi.prototype.getLoggedIn = function() {
+  return getRestApi().getLoggedIn();
+};
+
+GrPluginRestApi.prototype.getVersion = function() {
+  return getRestApi().getVersion();
+};
+
+GrPluginRestApi.prototype.getConfig = function() {
+  return getRestApi().getConfig();
+};
+
+GrPluginRestApi.prototype.invalidateReposCache = function() {
+  getRestApi().invalidateReposCache();
+};
+
+GrPluginRestApi.prototype.getAccount = function() {
+  return getRestApi().getAccount();
+};
+
+GrPluginRestApi.prototype.getAccountCapabilities = function(capabilities) {
+  return getRestApi().getAccountCapabilities(capabilities);
+};
+
+GrPluginRestApi.prototype.getRepos =
+  function(filter, reposPerPage, opt_offset) {
+    return getRestApi().getRepos(filter, reposPerPage, opt_offset);
+  };
+
+/**
+ * Fetch and return native browser REST API Response.
+ *
+ * @param {string} method HTTP Method (GET, POST, etc)
+ * @param {string} url URL without base path or plugin prefix
+ * @param {Object=} payload Respected for POST and PUT only.
+ * @param {?function(?Response, string=)=} opt_errFn
+ *    passed as null sometimes.
+ * @return {!Promise}
+ */
+GrPluginRestApi.prototype.fetch = function(method, url, opt_payload,
+    opt_errFn, opt_contentType) {
+  return getRestApi().send(method, this.opt_prefix + url, opt_payload,
+      opt_errFn, opt_contentType);
+};
+
+/**
+ * Fetch and parse REST API response, if request succeeds.
+ *
+ * @param {string} method HTTP Method (GET, POST, etc)
+ * @param {string} url URL without base path or plugin prefix
+ * @param {Object=} payload Respected for POST and PUT only.
+ * @param {?function(?Response, string=)=} opt_errFn
+ *    passed as null sometimes.
+ * @return {!Promise} resolves on success, rejects on error.
+ */
+GrPluginRestApi.prototype.send = function(method, url, opt_payload,
+    opt_errFn, opt_contentType) {
+  return this.fetch(method, url, opt_payload, opt_errFn, opt_contentType)
+      .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 getRestApi().getResponseObject(response);
+        }
+      });
+};
+
+/**
+ * @param {string} url URL without base path or plugin prefix
+ * @return {!Promise} resolves on success, rejects on error.
+ */
+GrPluginRestApi.prototype.get = function(url) {
+  return this.send('GET', url);
+};
+
+/**
+ * @param {string} url URL without base path or plugin prefix
+ * @return {!Promise} resolves on success, rejects on error.
+ */
+GrPluginRestApi.prototype.post = function(url, opt_payload, opt_errFn,
+    opt_contentType) {
+  return this.send('POST', url, opt_payload, opt_errFn, opt_contentType);
+};
+
+/**
+ * @param {string} url URL without base path or plugin prefix
+ * @return {!Promise} resolves on success, rejects on error.
+ */
+GrPluginRestApi.prototype.put = function(url, opt_payload, opt_errFn,
+    opt_contentType) {
+  return this.send('PUT', url, opt_payload, opt_errFn, opt_contentType);
+};
+
+/**
+ * @param {string} url URL without base path or plugin prefix
+ * @return {!Promise} resolves on 204, rejects on error.
+ */
+GrPluginRestApi.prototype.delete = function(url) {
+  return this.fetch('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));
+        }
+      });
     }
-    return restApi;
-  }
-
-  function GrPluginRestApi(opt_prefix) {
-    this.opt_prefix = opt_prefix || '';
-  }
-
-  GrPluginRestApi.prototype.getLoggedIn = function() {
-    return getRestApi().getLoggedIn();
-  };
-
-  GrPluginRestApi.prototype.getVersion = function() {
-    return getRestApi().getVersion();
-  };
-
-  GrPluginRestApi.prototype.getConfig = function() {
-    return getRestApi().getConfig();
-  };
-
-  GrPluginRestApi.prototype.invalidateReposCache = function() {
-    getRestApi().invalidateReposCache();
-  };
-
-  GrPluginRestApi.prototype.getAccount = function() {
-    return getRestApi().getAccount();
-  };
-
-  GrPluginRestApi.prototype.getAccountCapabilities = function(capabilities) {
-    return getRestApi().getAccountCapabilities(capabilities);
-  };
-
-  GrPluginRestApi.prototype.getRepos =
-    function(filter, reposPerPage, opt_offset) {
-      return getRestApi().getRepos(filter, reposPerPage, opt_offset);
-    };
-
-  /**
-   * Fetch and return native browser REST API Response.
-   *
-   * @param {string} method HTTP Method (GET, POST, etc)
-   * @param {string} url URL without base path or plugin prefix
-   * @param {Object=} payload Respected for POST and PUT only.
-   * @param {?function(?Response, string=)=} opt_errFn
-   *    passed as null sometimes.
-   * @return {!Promise}
-   */
-  GrPluginRestApi.prototype.fetch = function(method, url, opt_payload,
-      opt_errFn, opt_contentType) {
-    return getRestApi().send(method, this.opt_prefix + url, opt_payload,
-        opt_errFn, opt_contentType);
-  };
-
-  /**
-   * Fetch and parse REST API response, if request succeeds.
-   *
-   * @param {string} method HTTP Method (GET, POST, etc)
-   * @param {string} url URL without base path or plugin prefix
-   * @param {Object=} payload Respected for POST and PUT only.
-   * @param {?function(?Response, string=)=} opt_errFn
-   *    passed as null sometimes.
-   * @return {!Promise} resolves on success, rejects on error.
-   */
-  GrPluginRestApi.prototype.send = function(method, url, opt_payload,
-      opt_errFn, opt_contentType) {
-    return this.fetch(method, url, opt_payload, opt_errFn, opt_contentType)
-        .then(response => {
-          if (response.status < 200 || response.status >= 300) {
-            return response.text().then(text => {
-              if (text) {
-                return Promise.reject(text);
-              } else {
-                return Promise.reject(response.status);
-              }
-            });
-          } else {
-            return getRestApi().getResponseObject(response);
-          }
-        });
-  };
-
-  /**
-   * @param {string} url URL without base path or plugin prefix
-   * @return {!Promise} resolves on success, rejects on error.
-   */
-  GrPluginRestApi.prototype.get = function(url) {
-    return this.send('GET', url);
-  };
-
-  /**
-   * @param {string} url URL without base path or plugin prefix
-   * @return {!Promise} resolves on success, rejects on error.
-   */
-  GrPluginRestApi.prototype.post = function(url, opt_payload, opt_errFn,
-      opt_contentType) {
-    return this.send('POST', url, opt_payload, opt_errFn, opt_contentType);
-  };
-
-  /**
-   * @param {string} url URL without base path or plugin prefix
-   * @return {!Promise} resolves on success, rejects on error.
-   */
-  GrPluginRestApi.prototype.put = function(url, opt_payload, opt_errFn,
-      opt_contentType) {
-    return this.send('PUT', url, opt_payload, opt_errFn, opt_contentType);
-  };
-
-  /**
-   * @param {string} url URL without base path or plugin prefix
-   * @return {!Promise} resolves on 204, rejects on error.
-   */
-  GrPluginRestApi.prototype.delete = function(url) {
-    return this.fetch('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 response;
-    });
-  };
-
-  window.GrPluginRestApi = GrPluginRestApi;
-})(window);
+    return response;
+  });
+};
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..fcc3b669 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
@@ -17,138 +17,144 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-js-api-interface.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-plugin-rest-api tests', () => {
-    let instance;
-    let sandbox;
-    let getResponseObjectStub;
-    let sendStub;
-    let restApiStub;
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-js-api-interface.js';
+import {GrPluginRestApi} from './gr-plugin-rest-api.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
-      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-      restApiStub = {
-        getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
-        getResponseObject: getResponseObjectStub,
-        send: sendStub,
-        getLoggedIn: sandbox.stub(),
-        getVersion: sandbox.stub(),
-        getConfig: sandbox.stub(),
-      };
-      stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
-        a[k] = (...args) => restApiStub[k](...args);
-        return a;
-      }, {}));
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrPluginRestApi();
-    });
+const pluginApi = _testOnly_initGerritPluginApi();
 
-    teardown(() => {
-      sandbox.restore();
-    });
+suite('gr-plugin-rest-api tests', () => {
+  let instance;
+  let sandbox;
+  let getResponseObjectStub;
+  let sendStub;
+  let restApiStub;
 
-    test('fetch', () => {
-      const payload = {foo: 'foo'};
-      return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-        assert.equal(r.status, 200);
-        assert.isFalse(getResponseObjectStub.called);
-      });
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
+    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+    restApiStub = {
+      getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
+      getResponseObject: getResponseObjectStub,
+      send: sendStub,
+      getLoggedIn: sandbox.stub(),
+      getVersion: sandbox.stub(),
+      getConfig: sandbox.stub(),
+    };
+    stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
+      a[k] = (...args) => restApiStub[k](...args);
+      return a;
+    }, {}));
+    pluginApi.install(p => {}, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrPluginRestApi();
+  });
 
-    test('send', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.send('HTTP_METHOD', '/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-        assert.strictEqual(r, response);
-      });
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('get', () => {
-      const response = {foo: 'foo'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.get('/url').then(r => {
-        assert.isTrue(sendStub.calledWith('GET', '/url'));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('post', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.post('/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('POST', '/url', payload));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('put', () => {
-      const payload = {foo: 'foo'};
-      const response = {bar: 'bar'};
-      getResponseObjectStub.returns(Promise.resolve(response));
-      return instance.put('/url', payload).then(r => {
-        assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('delete works', () => {
-      const response = {status: 204};
-      sendStub.returns(Promise.resolve(response));
-      return instance.delete('/url').then(r => {
-        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-        assert.strictEqual(r, response);
-      });
-    });
-
-    test('delete fails', () => {
-      sendStub.returns(Promise.resolve(
-          {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);
-      });
-    });
-
-    test('getLoggedIn', () => {
-      restApiStub.getLoggedIn.returns(Promise.resolve(true));
-      return instance.getLoggedIn().then(result => {
-        assert.isTrue(restApiStub.getLoggedIn.calledOnce);
-        assert.isTrue(result);
-      });
-    });
-
-    test('getVersion', () => {
-      restApiStub.getVersion.returns(Promise.resolve('foo bar'));
-      return instance.getVersion().then(result => {
-        assert.isTrue(restApiStub.getVersion.calledOnce);
-        assert.equal(result, 'foo bar');
-      });
-    });
-
-    test('getConfig', () => {
-      restApiStub.getConfig.returns(Promise.resolve('foo bar'));
-      return instance.getConfig().then(result => {
-        assert.isTrue(restApiStub.getConfig.calledOnce);
-        assert.equal(result, 'foo bar');
-      });
+  test('fetch', () => {
+    const payload = {foo: 'foo'};
+    return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+      assert.equal(r.status, 200);
+      assert.isFalse(getResponseObjectStub.called);
     });
   });
+
+  test('send', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.send('HTTP_METHOD', '/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('get', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.get('/url').then(r => {
+      assert.isTrue(sendStub.calledWith('GET', '/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('post', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.post('/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('POST', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('put', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.put('/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete works', () => {
+    const response = {status: 204};
+    sendStub.returns(Promise.resolve(response));
+    return instance.delete('/url').then(r => {
+      assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete fails', () => {
+    sendStub.returns(Promise.resolve(
+        {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.message);
+        });
+  });
+
+  test('getLoggedIn', () => {
+    restApiStub.getLoggedIn.returns(Promise.resolve(true));
+    return instance.getLoggedIn().then(result => {
+      assert.isTrue(restApiStub.getLoggedIn.calledOnce);
+      assert.isTrue(result);
+    });
+  });
+
+  test('getVersion', () => {
+    restApiStub.getVersion.returns(Promise.resolve('foo bar'));
+    return instance.getVersion().then(result => {
+      assert.isTrue(restApiStub.getVersion.calledOnce);
+      assert.equal(result, 'foo bar');
+    });
+  });
+
+  test('getConfig', () => {
+    restApiStub.getConfig.returns(Promise.resolve('foo bar'));
+    return instance.getConfig().then(result => {
+      assert.isTrue(restApiStub.getConfig.calledOnce);
+      assert.equal(result, 'foo bar');
+    });
+  });
+});
 </script>
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..9d79462 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
@@ -14,22 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper.js';
+import {GrChangeActionsInterface} from './gr-change-actions-js-api.js';
+import {GrChangeReplyInterface} from './gr-change-reply-js-api.js';
+import {GrDomHooksManager} from '../../plugins/gr-dom-hooks/gr-dom-hooks.js';
+import {GrThemeApi} from '../../plugins/gr-theme-api/gr-theme-api.js';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
+import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api.js';
+import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api.js';
+import {GrChangeMetadataApi} from '../../plugins/gr-change-metadata-api/gr-change-metadata-api.js';
+import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper.js';
+import {GrPluginRestApi} from './gr-plugin-rest-api.js';
+import {GrRepoApi} from '../../plugins/gr-repo-api/gr-repo-api.js';
+import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
+import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api.js';
+import {GrPluginActionContext} from './gr-plugin-action-context.js';
+import {pluginEndpoints} from './gr-plugin-endpoints.js';
+
+import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils.js';
+import {deprecatedDelete} from './gr-gerrit.js';
+
 (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',
   };
 
-  // Import utils methods
-  const {
-    getPluginNameFromUrl,
-    send,
-  } = window._apiUtils;
-
   /**
    * Plugin-provided custom components can affect content in extension
    * points using one of following methods:
@@ -46,13 +60,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 +84,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');
@@ -83,9 +94,9 @@
     return this._name;
   };
 
-  Plugin.prototype.registerStyleModule = function(endpointName, moduleName) {
-    Gerrit._endpoints.registerModule(
-        this, endpointName, EndpointType.STYLE, moduleName);
+  Plugin.prototype.registerStyleModule = function(endpoint, moduleName) {
+    pluginEndpoints.registerModule(
+        this, {endpoint, type: EndpointType.STYLE, moduleName});
   };
 
   /**
@@ -111,14 +122,15 @@
   };
 
   Plugin.prototype._registerCustomComponent = function(
-      endpointName, opt_moduleName, opt_options, dynamicEndpoint) {
+      endpoint, opt_moduleName, opt_options, dynamicEndpoint) {
     const type = opt_options && opt_options.replace ?
       EndpointType.REPLACE : EndpointType.DECORATE;
-    const hook = this._domHooks.getDomHook(endpointName, opt_moduleName);
-    const moduleName = opt_moduleName || hook.getModuleName();
-    Gerrit._endpoints.registerModule(
-        this, endpointName, type, moduleName, hook, dynamicEndpoint);
-    return hook.getPublicAPI();
+    const slot = opt_options && opt_options.slot || '';
+    const domHook = this._domHooks.getDomHook(endpoint, opt_moduleName);
+    const moduleName = opt_moduleName || domHook.getModuleName();
+    pluginEndpoints.registerModule(
+        this, {slot, endpoint, type, moduleName, domHook, dynamicEndpoint});
+    return domHook.getPublicAPI();
   };
 
   /**
@@ -139,9 +151,15 @@
 
   Plugin.prototype.url = function(opt_path) {
     const relPath = '/plugins/' + this._name + (opt_path || '/');
+    const sameOriginPath = window.location.origin +
+      `${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;
@@ -149,8 +167,8 @@
   };
 
   Plugin.prototype.screenUrl = function(opt_screenName) {
-    const origin = this._url.origin;
-    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
+    const origin = location.origin;
+    const base = BaseUrlBehavior.getBaseUrl();
     const tokenPart = opt_screenName ? '/' + opt_screenName : '';
     return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
   };
@@ -175,7 +193,7 @@
   };
 
   Plugin.prototype.delete = function(url, opt_callback) {
-    return Gerrit.delete(this.url(url), opt_callback);
+    return deprecatedDelete(this.url(url), opt_callback);
   };
 
   Plugin.prototype.annotationApi = function() {
@@ -189,13 +207,7 @@
   };
 
   Plugin.prototype.changeReply = function() {
-    return new GrChangeReplyInterface(this,
-        Plugin._sharedAPIElement.getElement(
-            Plugin._sharedAPIElement.Element.REPLY_DIALOG));
-  };
-
-  Plugin.prototype.changeView = function() {
-    return new GrChangeViewApi(this);
+    return new GrChangeReplyInterface(this);
   };
 
   Plugin.prototype.theme = function() {
@@ -228,7 +240,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
deleted file mode 100644
index 63a528e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
+++ /dev/null
@@ -1,130 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../styles/gr-voting-styles.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../gr-account-label/gr-account-label.html">
-<link rel="import" href="../gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-icons/gr-icons.html">
-<link rel="import" href="../gr-label/gr-label.html">
-<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-label-info">
-  <template strip-whitespace>
-    <style include="gr-voting-styles"></style>
-    <style include="shared-styles">
-      .placeholder {
-        color: var(--deemphasized-text-color);
-        padding-top: var(--spacing-xs);
-      }
-      .hidden {
-        display: none;
-      }
-      .voteChip {
-        display: flex;
-        justify-content: center;
-        margin-right: var(--spacing-s);
-        padding: 0;
-        @apply --vote-chip-styles;
-        border-width: 0;
-      }
-      .max {
-        background-color: var(--vote-color-approved);
-      }
-      .min {
-        background-color: var(--vote-color-rejected);
-      }
-      .positive {
-        background-color: var(--vote-color-recommended);
-      }
-      .negative {
-        background-color: var(--vote-color-disliked);
-      }
-      .hidden {
-        display: none;
-      }
-      td {
-        vertical-align: top;
-      }
-      tr {
-        min-height: var(--line-height-normal);
-      }
-      gr-button {
-        vertical-align: top;
-        --gr-button: {
-          height: var(--line-height-normal);
-          width: var(--line-height-normal);
-          padding: 0;
-        }
-      }
-      gr-button[disabled] iron-icon {
-        color: var(--border-color);
-      }
-      gr-account-chip {
-        margin-right: var(--spacing-xs);
-      }
-      iron-icon {
-        height: calc(var(--line-height-normal) - 2px);
-        width: calc(var(--line-height-normal) - 2px);
-      }
-      .labelValueContainer:not(:first-of-type) td {
-        padding-top: var(--spacing-s);
-      }
-    </style>
-    <table>
-      <p class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]">
-        No votes.
-      </p>
-      <template
-          is="dom-repeat"
-          items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
-          as="mappedLabel">
-        <tr class="labelValueContainer">
-          <td>
-            <gr-label
-                has-tooltip
-                title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-                class$="[[mappedLabel.className]] voteChip">
-              [[mappedLabel.value]]
-            </gr-label>
-          </td>
-          <td>
-            <gr-account-chip
-                account="[[mappedLabel.account]]"
-                transparent-background></gr-account-chip>
-          </td>
-          <td>
-            <gr-button
-                link
-                aria-label="Remove"
-                on-click="_onDeleteVote"
-                tooltip="Remove vote"
-                data-account-id$="[[mappedLabel.account._account_id]]"
-                class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]">
-              <iron-icon icon="gr-icons:delete"></iron-icon>
-            </gr-button>
-          </td>
-        </tr>
-      </template>
-    </table>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-label-info.js"></script>
-</dom-module>
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 ed2dfdd..22bdce1 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
@@ -14,153 +14,176 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-label-info',
+import '../../../styles/gr-voting-styles.js';
+import '../../../styles/shared-styles.js';
+import '../gr-account-label/gr-account-label.js';
+import '../gr-account-chip/gr-account-chip.js';
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-label/gr-label.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label-info_html.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrLabelInfo extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-label-info'; }
+
+  static get properties() {
+    return {
       labelInfo: Object,
       label: String,
       /** @type {?} */
       change: Object,
       account: Object,
       mutable: Boolean,
-    },
+    };
+  }
 
-    /**
-     * @param {!Object} labelInfo
-     * @param {!Object} account
-     * @param {Object} changeLabelsRecord not used, but added as a parameter in
-     *    order to trigger computation when a label is removed from the change.
-     */
-    _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
-      const result = [];
-      if (!labelInfo || !account) { return result; }
-      if (!labelInfo.values) {
-        if (labelInfo.rejected || labelInfo.approved) {
-          const ok = labelInfo.approved || !labelInfo.rejected;
-          return [{
-            value: ok ? '👍️' : '👎️',
-            className: ok ? 'positive' : 'negative',
-            account: ok ? labelInfo.approved : labelInfo.rejected,
-          }];
-        }
-        return result;
-      }
-      // Sort votes by positivity.
-      const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
-      const values = Object.keys(labelInfo.values);
-      for (const label of votes) {
-        if (label.value && label.value != labelInfo.default_value) {
-          let labelClassName;
-          let labelValPrefix = '';
-          if (label.value > 0) {
-            labelValPrefix = '+';
-            if (parseInt(label.value, 10) ===
-                parseInt(values[values.length - 1], 10)) {
-              labelClassName = 'max';
-            } else {
-              labelClassName = 'positive';
-            }
-          } else if (label.value < 0) {
-            if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
-              labelClassName = 'min';
-            } else {
-              labelClassName = 'negative';
-            }
-          }
-          const formattedLabel = {
-            value: labelValPrefix + label.value,
-            className: labelClassName,
-            account: label,
-          };
-          if (label._account_id === account._account_id) {
-            // Put self-votes at the top.
-            result.unshift(formattedLabel);
-          } else {
-            result.push(formattedLabel);
-          }
-        }
+  /**
+   * @param {!Object} labelInfo
+   * @param {!Object} account
+   * @param {Object} changeLabelsRecord not used, but added as a parameter in
+   *    order to trigger computation when a label is removed from the change.
+   */
+  _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
+    const result = [];
+    if (!labelInfo || !account) { return result; }
+    if (!labelInfo.values) {
+      if (labelInfo.rejected || labelInfo.approved) {
+        const ok = labelInfo.approved || !labelInfo.rejected;
+        return [{
+          value: ok ? '👍️' : '👎️',
+          className: ok ? 'positive' : 'negative',
+          account: ok ? labelInfo.approved : labelInfo.rejected,
+        }];
       }
       return result;
-    },
-
-    /**
-     * A user is able to delete a vote iff the mutable property is true and the
-     * reviewer that left the vote exists in the list of removable_reviewers
-     * received from the backend.
-     *
-     * @param {!Object} reviewer An object describing the reviewer that left the
-     *     vote.
-     * @param {Boolean} mutable
-     * @param {!Object} change
-     */
-    _computeDeleteClass(reviewer, mutable, change) {
-      if (!mutable || !change || !change.removable_reviewers) {
-        return 'hidden';
-      }
-      const removable = change.removable_reviewers;
-      if (removable.find(r => r._account_id === reviewer._account_id)) {
-        return '';
-      }
-      return 'hidden';
-    },
-
-    /**
-     * Closure annotation for Polymer.prototype.splice is off.
-     * For now, supressing annotations.
-     *
-     * @suppress {checkTypes} */
-    _onDeleteVote(e) {
-      e.preventDefault();
-      let target = Polymer.dom(e).rootTarget;
-      while (!target.classList.contains('deleteBtn')) {
-        if (!target.parentElement) { return; }
-        target = target.parentElement;
-      }
-
-      target.disabled = true;
-      const accountID = parseInt(target.getAttribute('data-account-id'), 10);
-      this._xhrPromise =
-          this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
-              .then(response => {
-                target.disabled = false;
-                if (!response.ok) { return; }
-                Gerrit.Nav.navigateToChange(this.change);
-              }).catch(err => {
-                target.disabled = false;
-                return;
-              });
-    },
-
-    _computeValueTooltip(labelInfo, score) {
-      if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
-        return '';
-      }
-      return labelInfo.values[score];
-    },
-
-    /**
-     * @param {!Object} labelInfo
-     * @param {Object} changeLabelsRecord not used, but added as a parameter in
-     *    order to trigger computation when a label is removed from the change.
-     */
-    _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
-      if (labelInfo &&
-          !labelInfo.values && (labelInfo.rejected || labelInfo.approved)) {
-        return 'hidden';
-      }
-
-      if (labelInfo && labelInfo.all) {
-        for (const label of labelInfo.all) {
-          if (label.value && label.value != labelInfo.default_value) {
-            return 'hidden';
+    }
+    // Sort votes by positivity.
+    const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
+    const values = Object.keys(labelInfo.values);
+    for (const label of votes) {
+      if (label.value && label.value != labelInfo.default_value) {
+        let labelClassName;
+        let labelValPrefix = '';
+        if (label.value > 0) {
+          labelValPrefix = '+';
+          if (parseInt(label.value, 10) ===
+              parseInt(values[values.length - 1], 10)) {
+            labelClassName = 'max';
+          } else {
+            labelClassName = 'positive';
+          }
+        } else if (label.value < 0) {
+          if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
+            labelClassName = 'min';
+          } else {
+            labelClassName = 'negative';
           }
         }
+        const formattedLabel = {
+          value: labelValPrefix + label.value,
+          className: labelClassName,
+          account: label,
+        };
+        if (label._account_id === account._account_id) {
+          // Put self-votes at the top.
+          result.unshift(formattedLabel);
+        } else {
+          result.push(formattedLabel);
+        }
       }
+    }
+    return result;
+  }
+
+  /**
+   * A user is able to delete a vote iff the mutable property is true and the
+   * reviewer that left the vote exists in the list of removable_reviewers
+   * received from the backend.
+   *
+   * @param {!Object} reviewer An object describing the reviewer that left the
+   *     vote.
+   * @param {boolean} mutable
+   * @param {!Object} change
+   */
+  _computeDeleteClass(reviewer, mutable, change) {
+    if (!mutable || !change || !change.removable_reviewers) {
+      return 'hidden';
+    }
+    const removable = change.removable_reviewers;
+    if (removable.find(r => r._account_id === reviewer._account_id)) {
       return '';
-    },
-  });
-})();
+    }
+    return 'hidden';
+  }
+
+  /**
+   * Closure annotation for Polymer.prototype.splice is off.
+   * For now, supressing annotations.
+   *
+   * @suppress {checkTypes} */
+  _onDeleteVote(e) {
+    e.preventDefault();
+    let target = dom(e).rootTarget;
+    while (!target.classList.contains('deleteBtn')) {
+      if (!target.parentElement) { return; }
+      target = target.parentElement;
+    }
+
+    target.disabled = true;
+    const accountID = parseInt(target.getAttribute('data-account-id'), 10);
+    this._xhrPromise =
+        this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
+            .then(response => {
+              target.disabled = false;
+              if (!response.ok) { return; }
+              GerritNav.navigateToChange(this.change);
+            })
+            .catch(err => {
+              target.disabled = false;
+              return;
+            });
+  }
+
+  _computeValueTooltip(labelInfo, score) {
+    if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
+      return '';
+    }
+    return labelInfo.values[score];
+  }
+
+  /**
+   * @param {!Object} labelInfo
+   * @param {Object} changeLabelsRecord not used, but added as a parameter in
+   *    order to trigger computation when a label is removed from the change.
+   */
+  _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
+    if (labelInfo &&
+        !labelInfo.values && (labelInfo.rejected || labelInfo.approved)) {
+      return 'hidden';
+    }
+
+    if (labelInfo && labelInfo.all) {
+      for (const label of labelInfo.all) {
+        if (label.value && label.value != labelInfo.default_value) {
+          return 'hidden';
+        }
+      }
+    }
+    return '';
+  }
+}
+
+customElements.define(GrLabelInfo.is, GrLabelInfo);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
new file mode 100644
index 0000000..2a86669
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
@@ -0,0 +1,125 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <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);
+      padding-top: var(--spacing-xs);
+    }
+    .hidden {
+      display: none;
+    }
+    .voteChip {
+      display: flex;
+      justify-content: center;
+      margin-right: var(--spacing-s);
+      padding: 0;
+      @apply --vote-chip-styles;
+      border-width: 0;
+    }
+    .max {
+      background-color: var(--vote-color-approved);
+    }
+    .min {
+      background-color: var(--vote-color-rejected);
+    }
+    .positive {
+      background-color: var(--vote-color-recommended);
+    }
+    .negative {
+      background-color: var(--vote-color-disliked);
+    }
+    .hidden {
+      display: none;
+    }
+    td {
+      vertical-align: top;
+    }
+    tr {
+      min-height: var(--line-height-normal);
+    }
+    gr-button {
+      vertical-align: top;
+      --gr-button: {
+        height: var(--line-height-normal);
+        width: var(--line-height-normal);
+        padding: 0;
+      }
+    }
+    gr-button[disabled] iron-icon {
+      color: var(--border-color);
+    }
+    gr-account-chip {
+      margin-right: var(--spacing-xs);
+    }
+    iron-icon {
+      height: calc(var(--line-height-normal) - 2px);
+      width: calc(var(--line-height-normal) - 2px);
+    }
+    .labelValueContainer:not(:first-of-type) td {
+      padding-top: var(--spacing-s);
+    }
+  </style>
+  <p
+    class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
+  >
+    No votes.
+  </p>
+  <table>
+    <template
+      is="dom-repeat"
+      items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
+      as="mappedLabel"
+    >
+      <tr class="labelValueContainer">
+        <td>
+          <gr-label
+            has-tooltip=""
+            title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
+            class$="[[mappedLabel.className]] voteChip"
+          >
+            [[mappedLabel.value]]
+          </gr-label>
+        </td>
+        <td>
+          <gr-account-chip
+            account="[[mappedLabel.account]]"
+            transparent-background=""
+          ></gr-account-chip>
+        </td>
+        <td>
+          <gr-button
+            link=""
+            aria-label="Remove"
+            on-click="_onDeleteVote"
+            tooltip="Remove vote"
+            data-account-id$="[[mappedLabel.account._account_id]]"
+            class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]"
+          >
+            <iron-icon icon="gr-icons:delete"></iron-icon>
+          </gr-button>
+        </td>
+      </tr>
+    </template>
+  </table>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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 658a9733..d7ccc45 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
@@ -16,16 +16,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-label-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,207 +30,220 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-account-link tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-label-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {isHidden} from '../../../test/test-utils.js';
+suite('gr-account-link tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    // Needed to trigger computed bindings.
+    element.account = {};
+    element.change = {labels: {}};
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('remove reviewer votes', () => {
     setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      // Needed to trigger computed bindings.
-      element.account = {};
-      element.change = {labels: {}};
+      sandbox.stub(element, '_computeValueTooltip').returns('');
+      element.account = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      const test = {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+      };
+      element.change = {
+        _number: 42,
+        change_id: 'the id',
+        actions: [],
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {test},
+        removable_reviewers: [],
+      };
+      element.labelInfo = test;
+      element.label = 'test';
+
+      flushAsynchronousOperations();
     });
 
-    teardown(() => {
-      sandbox.restore();
+    test('_computeCanDeleteVote', () => {
+      element.mutable = false;
+      const button = element.shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(isHidden(button));
+      element.change.removable_reviewers = [element.account];
+      element.mutable = true;
+      assert.isFalse(isHidden(button));
     });
 
-    suite('remove reviewer votes', () => {
-      setup(() => {
-        sandbox.stub(element, '_computeValueTooltip').returns('');
-        element.account = {
-          _account_id: 1,
-          name: 'bojack',
-        };
-        const test = {
-          all: [{_account_id: 1, name: 'bojack', value: 1}],
-          default_value: 0,
-          values: [],
-        };
-        element.change = {
-          _number: 42,
-          change_id: 'the id',
-          actions: [],
-          topic: 'the topic',
-          status: 'NEW',
-          submit_type: 'CHERRY_PICK',
-          labels: {test},
-          removable_reviewers: [],
-        };
-        element.labelInfo = test;
-        element.label = 'test';
+    test('deletes votes', () => {
+      const deleteResponse = Promise.resolve({ok: true});
+      const deleteStub = sandbox.stub(
+          element.$.restAPI, 'deleteVote').returns(deleteResponse);
 
-        flushAsynchronousOperations();
+      element.change.removable_reviewers = [element.account];
+      element.change.labels.test.recommended = {_account_id: 1};
+      element.mutable = true;
+      const button = element.shadowRoot
+          .querySelector('gr-button');
+      MockInteractions.tap(button);
+      assert.isTrue(button.disabled);
+      return deleteResponse.then(() => {
+        assert.isFalse(button.disabled);
+        assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
       });
-
-      test('_computeCanDeleteVote', () => {
-        element.mutable = false;
-        const button = element.$$('gr-button');
-        assert.isTrue(isHidden(button));
-        element.change.removable_reviewers = [element.account];
-        element.mutable = true;
-        assert.isFalse(isHidden(button));
-      });
-
-      test('deletes votes', () => {
-        const deleteResponse = Promise.resolve({ok: true});
-        const deleteStub = sandbox.stub(
-            element.$.restAPI, 'deleteVote').returns(deleteResponse);
-
-        element.change.removable_reviewers = [element.account];
-        element.change.labels.test.recommended = {_account_id: 1};
-        element.mutable = true;
-        const button = element.$$('gr-button');
-        MockInteractions.tap(button);
-        assert.isTrue(button.disabled);
-        return deleteResponse.then(() => {
-          assert.isFalse(button.disabled);
-          assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
-        });
-      });
-    });
-
-    suite('label color and order', () => {
-      test('valueless label rejected', () => {
-        element.labelInfo = {rejected: {name: 'someone'}};
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('negative'));
-      });
-
-      test('valueless label approved', () => {
-        element.labelInfo = {approved: {name: 'someone'}};
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('positive'));
-      });
-
-      test('-2 to +2', () => {
-        element.labelInfo = {
-          all: [
-            {value: 2, name: 'user 2'},
-            {value: 1, name: 'user 1'},
-            {value: -1, name: 'user 3'},
-            {value: -2, name: 'user 4'},
-          ],
-          values: {
-            '-2': 'Awful',
-            '-1': 'Don\'t submit as-is',
-            ' 0': 'No score',
-            '+1': 'Looks good to me',
-            '+2': 'Ready to submit',
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('positive'));
-        assert.isTrue(labels[2].classList.contains('negative'));
-        assert.isTrue(labels[3].classList.contains('min'));
-      });
-
-      test('-1 to +1', () => {
-        element.labelInfo = {
-          all: [
-            {value: 1, name: 'user 1'},
-            {value: -1, name: 'user 2'},
-          ],
-          values: {
-            '-1': 'Don\'t submit as-is',
-            ' 0': 'No score',
-            '+1': 'Looks good to me',
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('min'));
-      });
-
-      test('0 to +2', () => {
-        element.labelInfo = {
-          all: [
-            {value: 1, name: 'user 2'},
-            {value: 2, name: 'user '},
-          ],
-          values: {
-            ' 0': 'Don\'t submit as-is',
-            '+1': 'No score',
-            '+2': 'Looks good to me',
-          },
-        };
-        flushAsynchronousOperations();
-        const labels = Polymer.dom(element.root).querySelectorAll('gr-label');
-        assert.isTrue(labels[0].classList.contains('max'));
-        assert.isTrue(labels[1].classList.contains('positive'));
-      });
-
-      test('self votes at top', () => {
-        element.account = {
-          _account_id: 1,
-          name: 'bojack',
-        };
-        element.labelInfo = {
-          all: [
-            {value: 1, name: 'user 1', _account_id: 2},
-            {value: -1, name: 'bojack', _account_id: 1},
-          ],
-          values: {
-            '-1': 'Don\'t submit as-is',
-            ' 0': 'No score',
-            '+1': 'Looks good to me',
-          },
-        };
-        flushAsynchronousOperations();
-        const chips =
-            Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-        assert.equal(chips[0].account._account_id, element.account._account_id);
-      });
-    });
-
-    test('_computeValueTooltip', () => {
-      // Existing label.
-      let labelInfo = {values: {0: 'Baz'}};
-      let score = '0';
-      assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
-
-      // Non-exsistent score.
-      score = '2';
-      assert.equal(element._computeValueTooltip(labelInfo, score), '');
-
-      // No values on label.
-      labelInfo = {values: {}};
-      score = '0';
-      assert.equal(element._computeValueTooltip(labelInfo, score), '');
-    });
-
-    test('placeholder', () => {
-      element.labelInfo = {};
-      assert.isFalse(isHidden(element.$$('.placeholder')));
-      element.labelInfo = {all: []};
-      assert.isFalse(isHidden(element.$$('.placeholder')));
-      element.labelInfo = {all: [{value: 1}]};
-      assert.isTrue(isHidden(element.$$('.placeholder')));
-      element.labelInfo = {rejected: []};
-      assert.isTrue(isHidden(element.$$('.placeholder')));
-      element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
-      assert.isTrue(isHidden(element.$$('.placeholder')));
-      element.labelInfo = {approved: []};
-      assert.isTrue(isHidden(element.$$('.placeholder')));
-      element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
-      assert.isTrue(isHidden(element.$$('.placeholder')));
     });
   });
+
+  suite('label color and order', () => {
+    test('valueless label rejected', () => {
+      element.labelInfo = {rejected: {name: 'someone'}};
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('negative'));
+    });
+
+    test('valueless label approved', () => {
+      element.labelInfo = {approved: {name: 'someone'}};
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('positive'));
+    });
+
+    test('-2 to +2', () => {
+      element.labelInfo = {
+        all: [
+          {value: 2, name: 'user 2'},
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 3'},
+          {value: -2, name: 'user 4'},
+        ],
+        values: {
+          '-2': 'Awful',
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+          '+2': 'Ready to submit',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+      assert.isTrue(labels[2].classList.contains('negative'));
+      assert.isTrue(labels[3].classList.contains('min'));
+    });
+
+    test('-1 to +1', () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 2'},
+        ],
+        values: {
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('min'));
+    });
+
+    test('0 to +2', () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 2'},
+          {value: 2, name: 'user '},
+        ],
+        values: {
+          ' 0': 'Don\'t submit as-is',
+          '+1': 'No score',
+          '+2': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+    });
+
+    test('self votes at top', () => {
+      element.account = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1', _account_id: 2},
+          {value: -1, name: 'bojack', _account_id: 1},
+        ],
+        values: {
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const chips =
+          dom(element.root).querySelectorAll('gr-account-chip');
+      assert.equal(chips[0].account._account_id, element.account._account_id);
+    });
+  });
+
+  test('_computeValueTooltip', () => {
+    // Existing label.
+    let labelInfo = {values: {0: 'Baz'}};
+    let score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
+
+    // Non-exsistent score.
+    score = '2';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+
+    // No values on label.
+    labelInfo = {values: {}};
+    score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+  });
+
+  test('placeholder', () => {
+    element.labelInfo = {};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: []};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {rejected: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {approved: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html b/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
deleted file mode 100644
index 55ecc98..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.html
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<dom-module id="gr-label">
-  <template strip-whitespace>
-    <slot></slot>
-  </template>
-  <script src="gr-label.js"></script>
-</dom-module>
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..014e85e 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
@@ -14,14 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-label',
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-label_html.js';
+import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 
-    behaviors: [
-      Gerrit.TooltipBehavior,
-    ],
-  });
-})();
+/**
+ * @extends Polymer.Element
+ */
+class GrLabel extends mixinBehaviors( [
+  TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-label'; }
+}
+
+customElements.define(GrLabel.is, GrLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
new file mode 100644
index 0000000..c4310fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html` <slot></slot> `;
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
deleted file mode 100644
index da0b93f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!--
-@license
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-labeled-autocomplete">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        width: 12em;
-      }
-      #container {
-        background: var(--chip-background-color);
-        border-radius: 1em;
-        padding: var(--spacing-m);
-      }
-      #header {
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-bold);
-        font-size: var(--font-size-small);
-      }
-      #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);
-      }
-      #trigger:hover {
-        color: var(--primary-text-color);
-      }
-    </style>
-    <div id="container">
-      <div id="header">[[label]]</div>
-      <div id="body">
-        <gr-autocomplete
-            id="autocomplete"
-            threshold="[[_autocompleteThreshold]]"
-            query="[[query]]"
-            disabled="[[disabled]]"
-            placeholder="[[placeholder]]"
-            borderless></gr-autocomplete>
-        <div id="trigger" on-click="_handleTriggerClick">▼</div>
-      </div>
-    </div>
-  </template>
-  <script src="gr-labeled-autocomplete.js"></script>
-</dom-module>
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..f585347 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
@@ -14,19 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-labeled-autocomplete',
+import '../gr-autocomplete/gr-autocomplete.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-labeled-autocomplete_html.js';
 
-    /**
-     * Fired when a value is chosen.
-     *
-     * @event commit
-     */
+/** @extends Polymer.Element */
+class GrLabeledAutocomplete extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-labeled-autocomplete'; }
+  /**
+   * Fired when a value is chosen.
+   *
+   * @event commit
+   */
+
+  static get properties() {
+    return {
 
       /**
        * Used just like the query property of gr-autocomplete.
@@ -56,21 +67,23 @@
         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();
-    },
+  _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);
-    },
+  setText(text) {
+    this.$.autocomplete.setText(text);
+  }
 
-    clear() {
-      this.setText('');
-    },
-  });
-})();
+  clear() {
+    this.setText('');
+  }
+}
+
+customElements.define(GrLabeledAutocomplete.is, GrLabeledAutocomplete);
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
new file mode 100644
index 0000000..615a525
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 12em;
+    }
+    #container {
+      background: var(--chip-background-color);
+      border-radius: 1em;
+      padding: var(--spacing-m);
+    }
+    #header {
+      color: var(--deemphasized-text-color);
+      font-weight: var(--font-weight-bold);
+      font-size: var(--font-size-small);
+    }
+    #body {
+      display: flex;
+    }
+    #trigger {
+      color: var(--deemphasized-text-color);
+      cursor: pointer;
+      padding-left: var(--spacing-s);
+    }
+    #trigger:hover {
+      color: var(--primary-text-color);
+    }
+  </style>
+  <div id="container">
+    <div id="header">[[label]]</div>
+    <div id="body">
+      <gr-autocomplete
+        id="autocomplete"
+        threshold="[[_autocompleteThreshold]]"
+        query="[[query]]"
+        disabled="[[disabled]]"
+        placeholder="[[placeholder]]"
+        borderless=""
+      ></gr-autocomplete>
+      <div id="trigger" on-click="_handleTriggerClick">▼</div>
+    </div>
+  </div>
+`;
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..99a038e 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
@@ -16,16 +16,13 @@
 limitations under the License.
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-labeled-autocomplete.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,31 +30,33 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-labeled-autocomplete tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-labeled-autocomplete.js';
+suite('gr-labeled-autocomplete tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('tapping trigger focuses autocomplete', () => {
-      const e = {stopPropagation: () => undefined};
-      sandbox.stub(e, 'stopPropagation');
-      sandbox.stub(element.$.autocomplete, 'focus');
-      element._handleTriggerClick(e);
-      assert.isTrue(e.stopPropagation.calledOnce);
-      assert.isTrue(element.$.autocomplete.focus.calledOnce);
-    });
-
-    test('setText', () => {
-      sandbox.stub(element.$.autocomplete, 'setText');
-      element.setText('foo-bar');
-      assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('tapping trigger focuses autocomplete', () => {
+    const e = {stopPropagation: () => undefined};
+    sandbox.stub(e, 'stopPropagation');
+    sandbox.stub(element.$.autocomplete, 'focus');
+    element._handleTriggerClick(e);
+    assert.isTrue(e.stopPropagation.calledOnce);
+    assert.isTrue(element.$.autocomplete.focus.calledOnce);
+  });
+
+  test('setText', () => {
+    sandbox.stub(element.$.autocomplete, 'setText');
+    element.setText('foo-bar');
+    assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
deleted file mode 100644
index fb55c67..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
-
-<dom-module id="gr-lib-loader">
-  <template>
-    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  </template>
-  <script src="gr-lib-loader.js"></script>
-</dom-module>
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..9bd8a11 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
@@ -14,16 +14,29 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-  const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
+import '../gr-js-api-interface/gr-js-api-interface.js';
+import {importHref} from '../../../scripts/import-href.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-lib-loader_html.js';
 
-  Polymer({
-    is: 'gr-lib-loader',
+const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrLibLoader extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-lib-loader'; }
+
+  static get properties() {
+    return {
       _hljsState: {
         type: Object,
 
@@ -34,123 +47,130 @@
           callbacks: [],
         },
       },
-    },
+    };
+  }
 
-    /**
-     * Get the HLJS library. Returns a promise that resolves with a reference to
-     * the library after it's been loaded. The promise resolves immediately if
-     * it's already been loaded.
-     *
-     * @return {!Promise<Object>}
-     */
-    getHLJS() {
-      return new Promise((resolve, reject) => {
-        // If the lib is totally loaded, resolve immediately.
-        if (this._getHighlightLib()) {
-          resolve(this._getHighlightLib());
-          return;
-        }
-
-        // If the library is not currently being loaded, then start loading it.
-        if (!this._hljsState.loading) {
-          this._hljsState.loading = true;
-          this._loadScript(this._getHLJSUrl())
-              .then(this._onHLJSLibLoaded.bind(this)).catch(reject);
-        }
-
-        this._hljsState.callbacks.push(resolve);
-      });
-    },
-
-    /**
-     * Loads the dark theme document. Returns a promise that resolves with a
-     * custom-style DOM element.
-     *
-     * @return {!Promise<Element>}
-     * @suppress {checkTypes}
-     */
-    getDarkTheme() {
-      return new Promise((resolve, reject) => {
-        (this.importHref || Polymer.importHref)(
-            this._getLibRoot() + DARK_THEME_PATH, () => {
-              const module = document.createElement('style', 'custom-style');
-              module.setAttribute('include', 'dark-theme');
-              resolve(module);
-            });
-      });
-    },
-
-    /**
-     * Execute callbacks awaiting the HLJS lib load.
-     */
-    _onHLJSLibLoaded() {
-      const lib = this._getHighlightLib();
-      this._hljsState.loading = false;
-      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.HIGHLIGHTJS_LOADED, {
-        hljs: lib,
-      });
-      for (const cb of this._hljsState.callbacks) {
-        cb(lib);
+  /**
+   * Get the HLJS library. Returns a promise that resolves with a reference to
+   * the library after it's been loaded. The promise resolves immediately if
+   * it's already been loaded.
+   *
+   * @return {!Promise<Object>}
+   */
+  getHLJS() {
+    return new Promise((resolve, reject) => {
+      // If the lib is totally loaded, resolve immediately.
+      if (this._getHighlightLib()) {
+        resolve(this._getHighlightLib());
+        return;
       }
-      this._hljsState.callbacks = [];
-    },
 
-    /**
-     * Get the HLJS library, assuming it has been loaded. Configure the library
-     * if it hasn't already been configured.
-     *
-     * @return {!Object}
-     */
-    _getHighlightLib() {
-      const lib = window.hljs;
-      if (lib && !this._hljsState.configured) {
-        this._hljsState.configured = true;
-
-        lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+      // If the library is not currently being loaded, then start loading it.
+      if (!this._hljsState.loading) {
+        this._hljsState.loading = true;
+        this._loadScript(this._getHLJSUrl())
+            .then(this._onHLJSLibLoaded.bind(this))
+            .catch(reject);
       }
-      return lib;
-    },
 
-    /**
-     * Get the resource path used to load the application. If the application
-     * was loaded through a CDN, then this will be the path to CDN resources.
-     *
-     * @return {string}
-     */
-    _getLibRoot() {
-      if (window.STATIC_RESOURCE_PATH) {
-        return window.STATIC_RESOURCE_PATH + '/';
+      this._hljsState.callbacks.push(resolve);
+    });
+  }
+
+  /**
+   * Loads the dark theme document. Returns a promise that resolves with a
+   * custom-style DOM element.
+   *
+   * @return {!Promise<Element>}
+   * @suppress {checkTypes}
+   */
+  getDarkTheme() {
+    return new Promise((resolve, reject) => {
+      importHref(
+          this._getLibRoot() + DARK_THEME_PATH, () => {
+            const module = document.createElement('style');
+            module.setAttribute('include', 'dark-theme');
+            const cs = document.createElement('custom-style');
+            cs.appendChild(module);
+
+            resolve(cs);
+          },
+          reject);
+    });
+  }
+
+  /**
+   * Execute callbacks awaiting the HLJS lib load.
+   */
+  _onHLJSLibLoaded() {
+    const lib = this._getHighlightLib();
+    this._hljsState.loading = false;
+    this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.HIGHLIGHTJS_LOADED, {
+      hljs: lib,
+    });
+    for (const cb of this._hljsState.callbacks) {
+      cb(lib);
+    }
+    this._hljsState.callbacks = [];
+  }
+
+  /**
+   * Get the HLJS library, assuming it has been loaded. Configure the library
+   * if it hasn't already been configured.
+   *
+   * @return {!Object}
+   */
+  _getHighlightLib() {
+    const lib = window.hljs;
+    if (lib && !this._hljsState.configured) {
+      this._hljsState.configured = true;
+
+      lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+    }
+    return lib;
+  }
+
+  /**
+   * Get the resource path used to load the application. If the application
+   * was loaded through a CDN, then this will be the path to CDN resources.
+   *
+   * @return {string}
+   */
+  _getLibRoot() {
+    if (window.STATIC_RESOURCE_PATH) {
+      return window.STATIC_RESOURCE_PATH + '/';
+    }
+    return '/';
+  }
+
+  /**
+   * Load and execute a JS file from the lib root.
+   *
+   * @param {string} src The path to the JS file without the lib root.
+   * @return {Promise} a promise that resolves when the script's onload
+   *     executes.
+   */
+  _loadScript(src) {
+    return new Promise((resolve, reject) => {
+      const script = document.createElement('script');
+
+      if (!src) {
+        reject(new Error('Unable to load blank script url.'));
+        return;
       }
-      return '/';
-    },
 
-    /**
-     * Load and execute a JS file from the lib root.
-     *
-     * @param {string} src The path to the JS file without the lib root.
-     * @return {Promise} a promise that resolves when the script's onload
-     *     executes.
-     */
-    _loadScript(src) {
-      return new Promise((resolve, reject) => {
-        const script = document.createElement('script');
+      script.setAttribute('src', src);
+      script.onload = resolve;
+      script.onerror = reject;
+      dom(document.head).appendChild(script);
+    });
+  }
 
-        if (!src) {
-          reject(new Error('Unable to load blank script url.'));
-          return;
-        }
+  _getHLJSUrl() {
+    const root = this._getLibRoot();
+    if (!root) { return null; }
+    return root + HLJS_PATH;
+  }
+}
 
-        script.setAttribute('src', src);
-        script.onload = resolve;
-        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_html.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
new file mode 100644
index 0000000..204aa87
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
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..f2e5e3d 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-lib-loader.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,116 +31,118 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-lib-loader tests', () => {
-    let sandbox;
-    let element;
-    let resolveLoad;
-    let loadStub;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-lib-loader.js';
+suite('gr-lib-loader tests', () => {
+  let sandbox;
+  let element;
+  let resolveLoad;
+  let loadStub;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+
+    loadStub = sandbox.stub(element, '_loadScript', () =>
+      new Promise(resolve => resolveLoad = resolve)
+    );
+
+    // Assert preconditions:
+    assert.isFalse(element._hljsState.loading);
+  });
+
+  teardown(() => {
+    if (window.hljs) {
+      delete window.hljs;
+    }
+    sandbox.restore();
+
+    // Because the element state is a singleton, clean it up.
+    element._hljsState.configured = false;
+    element._hljsState.loading = false;
+    element._hljsState.callbacks = [];
+  });
+
+  test('only load once', done => {
+    sandbox.stub(element, '_getHLJSUrl').returns('');
+    const firstCallHandler = sinon.stub();
+    element.getHLJS().then(firstCallHandler);
+
+    // It should now be in the loading state.
+    assert.isTrue(loadStub.called);
+    assert.isTrue(element._hljsState.loading);
+    assert.isFalse(firstCallHandler.called);
+
+    const secondCallHandler = sinon.stub();
+    element.getHLJS().then(secondCallHandler);
+
+    // No change in state.
+    assert.isTrue(element._hljsState.loading);
+    assert.isFalse(firstCallHandler.called);
+    assert.isFalse(secondCallHandler.called);
+
+    // Now load the library.
+    resolveLoad();
+    flush(() => {
+      // The state should be loaded and both handlers called.
+      assert.isFalse(element._hljsState.loading);
+      assert.isTrue(firstCallHandler.called);
+      assert.isTrue(secondCallHandler.called);
+      done();
+    });
+  });
+
+  suite('preloaded', () => {
+    let hljsStub;
 
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-
-      loadStub = sandbox.stub(element, '_loadScript', () =>
-        new Promise(resolve => resolveLoad = resolve)
-      );
-
-      // Assert preconditions:
-      assert.isFalse(element._hljsState.loading);
+      hljsStub = {
+        configure: sinon.stub(),
+      };
+      window.hljs = hljsStub;
     });
 
     teardown(() => {
-      if (window.hljs) {
-        delete window.hljs;
-      }
-      sandbox.restore();
-
-      // Because the element state is a singleton, clean it up.
-      element._hljsState.configured = false;
-      element._hljsState.loading = false;
-      element._hljsState.callbacks = [];
+      delete window.hljs;
     });
 
-    test('only load once', done => {
-      sandbox.stub(element, '_getHLJSUrl').returns('');
+    test('returns hljs', done => {
       const firstCallHandler = sinon.stub();
       element.getHLJS().then(firstCallHandler);
-
-      // It should now be in the loading state.
-      assert.isTrue(loadStub.called);
-      assert.isTrue(element._hljsState.loading);
-      assert.isFalse(firstCallHandler.called);
-
-      const secondCallHandler = sinon.stub();
-      element.getHLJS().then(secondCallHandler);
-
-      // No change in state.
-      assert.isTrue(element._hljsState.loading);
-      assert.isFalse(firstCallHandler.called);
-      assert.isFalse(secondCallHandler.called);
-
-      // Now load the library.
-      resolveLoad();
       flush(() => {
-        // The state should be loaded and both handlers called.
-        assert.isFalse(element._hljsState.loading);
         assert.isTrue(firstCallHandler.called);
-        assert.isTrue(secondCallHandler.called);
+        assert.isTrue(firstCallHandler.calledWith(hljsStub));
         done();
       });
     });
 
-    suite('preloaded', () => {
-      let hljsStub;
-
-      setup(() => {
-        hljsStub = {
-          configure: sinon.stub(),
-        };
-        window.hljs = hljsStub;
-      });
-
-      teardown(() => {
-        delete window.hljs;
-      });
-
-      test('returns hljs', done => {
-        const firstCallHandler = sinon.stub();
-        element.getHLJS().then(firstCallHandler);
-        flush(() => {
-          assert.isTrue(firstCallHandler.called);
-          assert.isTrue(firstCallHandler.calledWith(hljsStub));
-          done();
-        });
-      });
-
-      test('configures hljs', done => {
-        element.getHLJS().then(() => {
-          assert.isTrue(window.hljs.configure.calledOnce);
-          done();
-        });
-      });
-    });
-
-    suite('_getHLJSUrl', () => {
-      suite('checking _getLibRoot', () => {
-        let root;
-
-        setup(() => {
-          sandbox.stub(element, '_getLibRoot', () => root);
-        });
-
-        test('with no root', () => {
-          assert.isNull(element._getHLJSUrl());
-        });
-
-        test('with root', () => {
-          root = 'test-root.com/';
-          assert.equal(element._getHLJSUrl(),
-              'test-root.com/bower_components/highlightjs/highlight.min.js');
-        });
+    test('configures hljs', done => {
+      element.getHLJS().then(() => {
+        assert.isTrue(window.hljs.configure.calledOnce);
+        done();
       });
     });
   });
+
+  suite('_getHLJSUrl', () => {
+    suite('checking _getLibRoot', () => {
+      let root;
+
+      setup(() => {
+        sandbox.stub(element, '_getLibRoot', () => root);
+      });
+
+      test('with no root', () => {
+        assert.isNull(element._getHLJSUrl());
+      });
+
+      test('with root', () => {
+        root = 'test-root.com/';
+        assert.equal(element._getHLJSUrl(),
+            'test-root.com/bower_components/highlightjs/highlight.min.js');
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
deleted file mode 100644
index d00416b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
+++ /dev/null
@@ -1,24 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-
-<dom-module id="gr-limited-text">
-  <template>[[_computeDisplayText(text, limit)]]</template>
-  <script src="gr-limited-text.js"></script>
-</dom-module>
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..b7bfbf3 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
@@ -14,21 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  /*
-   * 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.
-   */
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-limited-text_html.js';
+import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 
-  Polymer({
-    is: 'gr-limited-text',
+/**
+ * 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.
+ *
+ * @extends Polymer.Element
+ */
+class GrLimitedText extends mixinBehaviors( [
+  TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
-      /** The un-truncated text to display. */
+  static get is() { return 'gr-limited-text'; }
+
+  static get properties() {
+    return {
+    /** The un-truncated text to display. */
       text: String,
 
       /** The maximum length for the text to display before truncating. */
@@ -37,7 +51,7 @@
         value: null,
       },
 
-      /** Boolean property used by Gerrit.TooltipBehavior. */
+      /** Boolean property used by TooltipBehavior. */
       hasTooltip: {
         type: Boolean,
         value: false,
@@ -59,39 +73,39 @@
         type: Number,
         value: 1024,
       },
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_updateTitle(text, limit, tooltipLimit)',
-    ],
+    ];
+  }
 
-    behaviors: [
-      Gerrit.TooltipBehavior,
-    ],
+  /**
+   * The text or limit have changed. Recompute whether a tooltip needs to be
+   * enabled.
+   */
+  _updateTitle(text, limit, tooltipLimit) {
+    // Polymer 2: check for undefined
+    if ([text, limit, tooltipLimit].some(arg => arg === undefined)) {
+      return;
+    }
 
-    /**
-     * The text or limit have changed. Recompute whether a tooltip needs to be
-     * enabled.
-     */
-    _updateTitle(text, limit, tooltipLimit) {
-      // Polymer 2: check for undefined
-      if ([text, limit, tooltipLimit].some(arg => arg === undefined)) {
-        return;
-      }
+    this.hasTooltip = !!limit && !!text && text.length > limit;
+    if (this.hasTooltip && !this.disableTooltip) {
+      this.setAttribute('title', text.substr(0, tooltipLimit));
+    } else {
+      this.removeAttribute('title');
+    }
+  }
 
-      this.hasTooltip = !!limit && !!text && text.length > limit;
-      if (this.hasTooltip && !this.disableTooltip) {
-        this.setAttribute('title', text.substr(0, tooltipLimit));
-      } else {
-        this.removeAttribute('title');
-      }
-    },
+  _computeDisplayText(text, limit) {
+    if (!!limit && !!text && text.length > limit) {
+      return text.substr(0, limit - 1) + '…';
+    }
+    return text;
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
new file mode 100644
index 0000000..6bcce8c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html` [[_computeDisplayText(text, limit)]] `;
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..889b786 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-limited-text.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,71 +31,73 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-limited-text tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-limited-text.js';
+suite('gr-limited-text tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_updateTitle', () => {
-      const updateSpy = sandbox.spy(element, '_updateTitle');
-      element.text = 'abc 123';
-      flushAsynchronousOperations();
-      assert.isTrue(updateSpy.calledOnce);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-
-      element.limit = 10;
-      flushAsynchronousOperations();
-      assert.isTrue(updateSpy.calledTwice);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-
-      element.limit = 3;
-      flushAsynchronousOperations();
-      assert.isTrue(updateSpy.calledThrice);
-      assert.equal(element.getAttribute('title'), 'abc 123');
-      assert.isTrue(element.hasTooltip);
-
-      element.tooltipLimit = 3;
-      flushAsynchronousOperations();
-      assert.equal(element.getAttribute('title'), 'abc');
-
-      element.tooltipLimit = 1024;
-      element.limit = 100;
-      flushAsynchronousOperations();
-      assert.equal(updateSpy.callCount, 6);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-
-      element.limit = null;
-      flushAsynchronousOperations();
-      assert.equal(updateSpy.callCount, 7);
-      assert.isNotOk(element.getAttribute('title'));
-      assert.isFalse(element.hasTooltip);
-    });
-
-    test('_computeDisplayText', () => {
-      assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
-      assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
-      assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
-    });
-
-    test('when disable tooltip', () => {
-      sandbox.spy(element, '_updateTitle');
-      element.text = 'abcdefghijklmn';
-      element.disableTooltip = true;
-      element.limit = 10;
-      flushAsynchronousOperations();
-      assert.equal(element.getAttribute('title'), null);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_updateTitle', () => {
+    const updateSpy = sandbox.spy(element, '_updateTitle');
+    element.text = 'abc 123';
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledOnce);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 10;
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledTwice);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 3;
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledThrice);
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.isTrue(element.hasTooltip);
+
+    element.tooltipLimit = 3;
+    flushAsynchronousOperations();
+    assert.equal(element.getAttribute('title'), 'abc');
+
+    element.tooltipLimit = 1024;
+    element.limit = 100;
+    flushAsynchronousOperations();
+    assert.equal(updateSpy.callCount, 6);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = null;
+    flushAsynchronousOperations();
+    assert.equal(updateSpy.callCount, 7);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+  });
+
+  test('_computeDisplayText', () => {
+    assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
+    assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
+    assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
+  });
+
+  test('when disable tooltip', () => {
+    sandbox.spy(element, '_updateTitle');
+    element.text = 'abcdefghijklmn';
+    element.disableTooltip = true;
+    element.limit = 10;
+    flushAsynchronousOperations();
+    assert.equal(element.getAttribute('title'), null);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
deleted file mode 100644
index f3e0906..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
+++ /dev/null
@@ -1,99 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../gr-button/gr-button.html">
-<link rel="import" href="../gr-icons/gr-icons.html">
-<link rel="import" href="../gr-limited-text/gr-limited-text.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-linked-chip">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        overflow: hidden;
-      }
-      .container {
-        align-items: center;
-        background: var(--chip-background-color);
-        border-radius: .75em;
-        display: inline-flex;
-        padding: 0 var(--spacing-m);
-      }
-      gr-button.remove {
-        --gr-remove-button-style: {
-          border: 0;
-          color: var(--deemphasized-text-color);
-          font-weight: normal;
-          height: .6em;
-          line-height: 10px;
-          margin-left: var(--spacing-xs);
-          padding: 0;
-          text-decoration: none;
-        }
-      }
-
-      gr-button.remove:hover,
-      gr-button.remove:focus {
-        --gr-button: {
-          @apply --gr-remove-button-style;
-          color: #333;
-        }
-      }
-      gr-button.remove {
-        --gr-button: {
-          @apply --gr-remove-button-style;
-        }
-      }
-      .transparentBackground,
-      gr-button.transparentBackground {
-        background-color: transparent;
-      }
-      :host([disabled]) {
-        opacity: .6;
-        pointer-events: none;
-      }
-      a {
-       color: var(--linked-chip-text-color);
-      }
-      iron-icon {
-        height: 1.2rem;
-        width: 1.2rem;
-      }
-    </style>
-    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-      <a href$="[[href]]">
-        <gr-limited-text
-            limit="[[limit]]"
-            text="[[text]]"></gr-limited-text>
-      </a>
-      <gr-button
-          id="remove"
-          link
-          hidden$="[[!removable]]"
-          hidden
-          class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-click="_handleRemoveTap">
-        <iron-icon icon="gr-icons:close"></iron-icon>
-      </gr-button>
-    </div>
-  </template>
-  <script src="gr-linked-chip.js"></script>
-</dom-module>
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..077ca74 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
@@ -14,13 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-linked-chip',
+import '../gr-button/gr-button.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-limited-text/gr-limited-text.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-linked-chip_html.js';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrLinkedChip extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-linked-chip'; }
+
+  static get properties() {
+    return {
       href: String,
       disabled: {
         type: Boolean,
@@ -39,19 +54,19 @@
 
       /**  If provided, sets the maximum length of the content. */
       limit: Number,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  _getBackgroundClass(transparent) {
+    return transparent ? 'transparentBackground' : '';
+  }
 
-    _getBackgroundClass(transparent) {
-      return transparent ? 'transparentBackground' : '';
-    },
+  _handleRemoveTap(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('remove', {
+      composed: true, bubbles: true,
+    }));
+  }
+}
 
-    _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_html.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
new file mode 100644
index 0000000..f1f5f46
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      overflow: hidden;
+    }
+    .container {
+      align-items: center;
+      background: var(--chip-background-color);
+      border-radius: 0.75em;
+      display: inline-flex;
+      padding: 0 var(--spacing-m);
+    }
+    gr-button.remove {
+      --gr-remove-button-style: {
+        border: 0;
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-normal);
+        height: 0.6em;
+        line-height: 10px;
+        margin-left: var(--spacing-xs);
+        padding: 0;
+        text-decoration: none;
+      }
+    }
+
+    gr-button.remove:hover,
+    gr-button.remove:focus {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+        color: #333;
+      }
+    }
+    gr-button.remove {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+      }
+    }
+    .transparentBackground,
+    gr-button.transparentBackground {
+      background-color: transparent;
+    }
+    :host([disabled]) {
+      opacity: 0.6;
+      pointer-events: none;
+    }
+    a {
+      color: var(--linked-chip-text-color);
+    }
+    iron-icon {
+      height: 1.2rem;
+      width: 1.2rem;
+    }
+  </style>
+  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
+    <a href$="[[href]]">
+      <gr-limited-text limit="[[limit]]" text="[[text]]"></gr-limited-text>
+    </a>
+    <gr-button
+      id="remove"
+      link=""
+      hidden$="[[!removable]]"
+      hidden=""
+      class$="remove [[_getBackgroundClass(transparentBackground)]]"
+      on-click="_handleRemoveTap"
+    >
+      <iron-icon icon="gr-icons:close"></iron-icon>
+    </gr-button>
+  </div>
+`;
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..c8de3df 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
@@ -17,18 +17,20 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="/bower_components/iron-test-helpers/mock-interactions.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-linked-chip.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<!-- Can't use absolute path below for mock-interaction.js.
+Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
+actually /node_modules directory). Also, wct patches some files to load modules from /components.
+With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
+/node_modules/...) though this is actually the same file. This leads to a run-time error.
+-->
+<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,26 +38,28 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-linked-chip tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-linked-chip.js';
+suite('gr-linked-chip tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('remove fired', () => {
-      const spy = sandbox.spy();
-      element.addEventListener('remove', spy);
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.$.remove);
-      assert.isTrue(spy.called);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('remove fired', () => {
+    const spy = sandbox.spy();
+    element.addEventListener('remove', spy);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.$.remove);
+    assert.isTrue(spy.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
deleted file mode 100644
index 61facc0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
-
-<script src="/bower_components/ba-linkify/ba-linkify.js"></script>
-<script src="link-text-parser.js"></script>
-<dom-module id="gr-linked-text">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      :host([pre]) span {
-        white-space: var(--linked-text-white-space, pre-wrap);
-        word-wrap: var(--linked-text-word-wrap, break-word);
-      }
-      :host([disabled]) a {
-        color: inherit;
-        text-decoration: none;
-        pointer-events: none;
-      }
-    </style>
-    <span id="output"></span>
-  </template>
-  <script src="gr-linked-text.js"></script>
-</dom-module>
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 229fa19..6ea4b78 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2015 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,13 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-linked-text',
+import '../../../styles/shared-styles.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import 'ba-linkify/ba-linkify.js';
+import {htmlTemplate} from './gr-linked-text_html.js';
+import {GrLinkTextParser} from './link-text-parser.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrLinkedText extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-linked-text'; }
+
+  static get properties() {
+    return {
       removeZeroWidthSpace: Boolean,
       content: {
         type: String,
@@ -37,78 +52,82 @@
         reflectToAttribute: true,
       },
       config: Object,
-    },
+    };
+  }
 
-    observers: [
+  static get observers() {
+    return [
       '_contentOrConfigChanged(content, config)',
-    ],
+    ];
+  }
 
-    _contentChanged(content) {
-      // In the case where the config may not be set (perhaps due to the
-      // request for it still being in flight), set the content anyway to
-      // prevent waiting on the config to display the text.
-      if (this.config != null) { return; }
-      this.$.output.textContent = content;
-    },
+  _contentChanged(content) {
+    // In the case where the config may not be set (perhaps due to the
+    // request for it still being in flight), set the content anyway to
+    // 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,
-     * the content should be re-parsed.
-     *
-     * @param {string|null|undefined} content The raw, un-linkified source
-     *     string to parse.
-     * @param {Object|null|undefined} config The server config specifying
-     *     commentLink patterns
-     */
-    _contentOrConfigChanged(content, config) {
-      if (!Gerrit.Nav || !Gerrit.Nav.mapCommentlinks) return;
-      config = Gerrit.Nav.mapCommentlinks(config);
-      const output = Polymer.dom(this.$.output);
-      output.textContent = '';
-      const parser = new GrLinkTextParser(config,
-          this._handleParseResult.bind(this), this.removeZeroWidthSpace);
-      parser.parse(content);
+  /**
+   * Because either the source text or the linkification config has changed,
+   * the content should be re-parsed.
+   *
+   * @param {string|null|undefined} content The raw, un-linkified source
+   *     string to parse.
+   * @param {Object|null|undefined} config The server config specifying
+   *     commentLink patterns
+   */
+  _contentOrConfigChanged(content, config) {
+    if (!GerritNav.mapCommentlinks) return;
+    config = GerritNav.mapCommentlinks(config);
+    const output = dom(this.$.output);
+    output.textContent = '';
+    const parser = new GrLinkTextParser(config,
+        this._handleParseResult.bind(this), this.removeZeroWidthSpace);
+    parser.parse(content);
 
-      // Ensure that external links originating from HTML commentlink configs
-      // open in a new tab. @see Issue 5567
-      // Ensure links to the same host originating from commentlink configs
-      // open in the same tab. When target is not set - default is _self
-      // @see Issue 4616
-      output.querySelectorAll('a').forEach(anchor => {
-        if (anchor.hostname === window.location.hostname) {
-          anchor.removeAttribute('target');
-        } else {
-          anchor.setAttribute('target', '_blank');
-        }
-        anchor.setAttribute('rel', 'noopener');
-      });
-    },
-
-    /**
-     * This method is called when the GrLikTextParser emits a partial result
-     * (used as the "callback" parameter). It will be called in either of two
-     * ways:
-     * - To create a link: when called with `text` and `href` arguments, a link
-     *   element should be created and attached to the resulting DOM.
-     * - To attach an arbitrary fragment: when called with only the `fragment`
-     *   argument, the fragment should be attached to the resulting DOM as is.
-     *
-     * @param {string|null} text
-     * @param {string|null} href
-     * @param  {DocumentFragment|undefined} fragment
-     */
-    _handleParseResult(text, href, fragment) {
-      const output = Polymer.dom(this.$.output);
-      if (href) {
-        const a = document.createElement('a');
-        a.href = href;
-        a.textContent = text;
-        a.target = '_blank';
-        a.rel = 'noopener';
-        output.appendChild(a);
-      } else if (fragment) {
-        output.appendChild(fragment);
+    // Ensure that external links originating from HTML commentlink configs
+    // open in a new tab. @see Issue 5567
+    // Ensure links to the same host originating from commentlink configs
+    // open in the same tab. When target is not set - default is _self
+    // @see Issue 4616
+    output.querySelectorAll('a').forEach(anchor => {
+      if (anchor.hostname === window.location.hostname) {
+        anchor.removeAttribute('target');
+      } else {
+        anchor.setAttribute('target', '_blank');
       }
-    },
-  });
-})();
+      anchor.setAttribute('rel', 'noopener');
+    });
+  }
+
+  /**
+   * This method is called when the GrLikTextParser emits a partial result
+   * (used as the "callback" parameter). It will be called in either of two
+   * ways:
+   * - To create a link: when called with `text` and `href` arguments, a link
+   *   element should be created and attached to the resulting DOM.
+   * - To attach an arbitrary fragment: when called with only the `fragment`
+   *   argument, the fragment should be attached to the resulting DOM as is.
+   *
+   * @param {string|null} text
+   * @param {string|null} href
+   * @param  {DocumentFragment|undefined} fragment
+   */
+  _handleParseResult(text, href, fragment) {
+    const output = dom(this.$.output);
+    if (href) {
+      const a = document.createElement('a');
+      a.href = href;
+      a.textContent = text;
+      a.target = '_blank';
+      a.rel = 'noopener';
+      output.appendChild(a);
+    } 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_html.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
new file mode 100644
index 0000000..59bed1e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([pre]) span {
+      white-space: var(--linked-text-white-space, pre-wrap);
+      word-wrap: var(--linked-text-word-wrap, break-word);
+    }
+    :host([disabled]) a {
+      color: inherit;
+      text-decoration: none;
+      pointer-events: none;
+    }
+  </style>
+  <span id="output"></span>
+`;
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 0deff05..4fa4390 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-linked-text.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -38,338 +33,344 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-linked-text tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-linked-text.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(Gerrit.Nav, 'mapCommentlinks', x => x);
-      element.config = {
-        ph: {
-          match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-          link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-        },
-        prefixsameinlinkandpattern: {
-          match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
-          link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-        },
-        changeid: {
-          match: '(I[0-9a-f]{8,40})',
-          link: '#/q/$1',
-        },
-        changeid2: {
-          match: 'Change-Id: +(I[0-9a-f]{8,40})',
-          link: '#/q/$1',
-        },
-        googlesearch: {
-          match: 'google:(.+)',
-          link: 'https://bing.com/search?q=$1', // html should supercede link.
-          html: '<a href="https://google.com/search?q=$1">$1</a>',
-        },
-        hashedhtml: {
-          match: 'hash:(.+)',
-          html: '<a href="#/awesomesauce">$1</a>',
-        },
-        baseurl: {
-          match: 'test (.+)',
-          html: '<a href="/r/awesomesauce">$1</a>',
-        },
-        anotatstartwithbaseurl: {
-          match: 'a test (.+)',
-          html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
-        },
-        disabledconfig: {
-          match: 'foo:(.+)',
-          link: 'https://google.com/search?q=$1',
-          enabled: false,
-        },
-      };
-    });
+suite('gr-linked-text tests', () => {
+  let element;
+  let sandbox;
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('URL pattern was parsed and linked.', () => {
-      // Regular inline link.
-      const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-      element.content = url;
-      const linkEl = element.$.output.childNodes[0];
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.rel, 'noopener');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, url);
-    });
-
-    test('Bug pattern was parsed and linked', () => {
-      // "Issue/Bug" pattern.
-      element.content = 'Issue 3650';
-
-      let linkEl = element.$.output.childNodes[0];
-      const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, 'Issue 3650');
-
-      element.content = 'Bug 3650';
-      linkEl = element.$.output.childNodes[0];
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.rel, 'noopener');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, 'Bug 3650');
-    });
-
-    test('Pattern with same prefix as link was correctly parsed', () => {
-      // Pattern starts with the same prefix (`http`) as the url.
-      element.content = 'httpexample 3650';
-
-      assert.equal(element.$.output.childNodes.length, 1);
-      const linkEl = element.$.output.childNodes[0];
-      const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-      assert.equal(linkEl.target, '_blank');
-      assert.equal(linkEl.href, url);
-      assert.equal(linkEl.textContent, 'httpexample 3650');
-    });
-
-    test('Change-Id pattern was parsed and linked', () => {
-      // "Change-Id:" pattern.
-      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      const prefix = 'Change-Id: ';
-      element.content = prefix + changeID;
-
-      const textNode = element.$.output.childNodes[0];
-      const linkEl = element.$.output.childNodes[1];
-      assert.equal(textNode.textContent, prefix);
-      const url = '/q/' + changeID;
-      assert.isFalse(linkEl.hasAttribute('target'));
-      // Since url is a path, the host is added automatically.
-      assert.isTrue(linkEl.href.endsWith(url));
-      assert.equal(linkEl.textContent, changeID);
-    });
-
-    test('Change-Id pattern was parsed and linked with base url', () => {
-      window.CANONICAL_PATH = '/r';
-
-      // "Change-Id:" pattern.
-      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      const prefix = 'Change-Id: ';
-      element.content = prefix + changeID;
-
-      const textNode = element.$.output.childNodes[0];
-      const linkEl = element.$.output.childNodes[1];
-      assert.equal(textNode.textContent, prefix);
-      const url = '/r/q/' + changeID;
-      assert.isFalse(linkEl.hasAttribute('target'));
-      // Since url is a path, the host is added automatically.
-      assert.isTrue(linkEl.href.endsWith(url));
-      assert.equal(linkEl.textContent, changeID);
-    });
-
-    test('Multiple matches', () => {
-      element.content = 'Issue 3650\nIssue 3450';
-      const linkEl1 = element.$.output.childNodes[0];
-      const linkEl2 = element.$.output.childNodes[2];
-
-      assert.equal(linkEl1.target, '_blank');
-      assert.equal(linkEl1.href,
-          'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
-      assert.equal(linkEl1.textContent, 'Issue 3650');
-
-      assert.equal(linkEl2.target, '_blank');
-      assert.equal(linkEl2.href,
-          'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
-      assert.equal(linkEl2.textContent, 'Issue 3450');
-    });
-
-    test('Change-Id pattern parsed before bug pattern', () => {
-      // "Change-Id:" pattern.
-      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      const prefix = 'Change-Id: ';
-
-      // "Issue/Bug" pattern.
-      const bug = 'Issue 3650';
-
-      const changeUrl = '/q/' + changeID;
-      const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-
-      element.content = prefix + changeID + bug;
-
-      const textNode = element.$.output.childNodes[0];
-      const changeLinkEl = element.$.output.childNodes[1];
-      const bugLinkEl = element.$.output.childNodes[2];
-
-      assert.equal(textNode.textContent, prefix);
-
-      assert.isFalse(changeLinkEl.hasAttribute('target'));
-      assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
-      assert.equal(changeLinkEl.textContent, changeID);
-
-      assert.equal(bugLinkEl.target, '_blank');
-      assert.equal(bugLinkEl.href, bugUrl);
-      assert.equal(bugLinkEl.textContent, 'Issue 3650');
-    });
-
-    test('html field in link config', () => {
-      element.content = 'google:do a barrel roll';
-      const linkEl = element.$.output.childNodes[0];
-      assert.equal(linkEl.getAttribute('href'),
-          'https://google.com/search?q=do a barrel roll');
-      assert.equal(linkEl.textContent, 'do a barrel roll');
-    });
-
-    test('removing hash from links', () => {
-      element.content = 'hash:foo';
-      const linkEl = element.$.output.childNodes[0];
-      assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('html with base url', () => {
-      window.CANONICAL_PATH = '/r';
-
-      element.content = 'test foo';
-      const linkEl = element.$.output.childNodes[0];
-      assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('a is not at start', () => {
-      window.CANONICAL_PATH = '/r';
-
-      element.content = 'a test foo';
-      const linkEl = element.$.output.childNodes[1];
-      assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('hash html with base url', () => {
-      window.CANONICAL_PATH = '/r';
-
-      element.content = 'hash:foo';
-      const linkEl = element.$.output.childNodes[0];
-      assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-      assert.equal(linkEl.textContent, 'foo');
-    });
-
-    test('disabled config', () => {
-      element.content = 'foo:baz';
-      assert.equal(element.$.output.innerHTML, 'foo:baz');
-    });
-
-    test('R=email labels link correctly', () => {
-      element.removeZeroWidthSpace = true;
-      element.content = 'R=\u200Btest@google.com';
-      assert.equal(element.$.output.textContent, 'R=test@google.com');
-      assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
-    });
-
-    test('CC=email labels link correctly', () => {
-      element.removeZeroWidthSpace = true;
-      element.content = 'CC=\u200Btest@google.com';
-      assert.equal(element.$.output.textContent, 'CC=test@google.com');
-      assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
-    });
-
-    test('only {http,https,mailto} protocols are linkified', () => {
-      element.content = 'xx mailto:test@google.com yy';
-      let links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-      element.content = 'xx http://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'http://google.com');
-      assert.equal(links[0].innerHTML, 'http://google.com');
-
-      element.content = 'xx https://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'https://google.com');
-      assert.equal(links[0].innerHTML, 'https://google.com');
-
-      element.content = 'xx ssh://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-
-      element.content = 'xx ftp://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-    });
-
-    test('links without leading whitespace are linkified', () => {
-      element.content = 'xx abcmailto:test@google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
-      let links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-      element.content = 'xx defhttp://google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'http://google.com');
-      assert.equal(links[0].innerHTML, 'http://google.com');
-
-      element.content = 'xx qwehttps://google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'https://google.com');
-      assert.equal(links[0].innerHTML, 'https://google.com');
-
-      // Non-latin character
-      element.content = 'xx абвhttps://google.com yy';
-      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 1);
-      assert.equal(links[0].getAttribute('href'), 'https://google.com');
-      assert.equal(links[0].innerHTML, 'https://google.com');
-
-      element.content = 'xx ssh://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-
-      element.content = 'xx ftp://google.com yy';
-      links = element.$.output.querySelectorAll('a');
-      assert.equal(links.length, 0);
-    });
-
-    test('overlapping links', () => {
-      element.config = {
-        b1: {
-          match: '(B:\\s*)(\\d+)',
-          html: '$1<a href="ftp://foo/$2">$2</a>',
-        },
-        b2: {
-          match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
-          html: '$1<a href="ftp://foo/$2">$2</a>',
-        },
-      };
-      element.content = '- B: 123, 45';
-      const links = Polymer.dom(element.root).querySelectorAll('a');
-
-      assert.equal(links.length, 2);
-      assert.equal(element.$$('span').textContent, '- B: 123, 45');
-
-      assert.equal(links[0].href, 'ftp://foo/123');
-      assert.equal(links[0].textContent, '123');
-
-      assert.equal(links[1].href, 'ftp://foo/45');
-      assert.equal(links[1].textContent, '45');
-    });
-
-    test('_contentOrConfigChanged called with config', () => {
-      const contentStub = sandbox.stub(element, '_contentChanged');
-      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
-      element.content = 'some text';
-      assert.isTrue(contentStub.called);
-      assert.isTrue(contentConfigStub.called);
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    sandbox.stub(GerritNav, 'mapCommentlinks', x => x);
+    element.config = {
+      ph: {
+        match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      prefixsameinlinkandpattern: {
+        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      changeid: {
+        match: '(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      changeid2: {
+        match: 'Change-Id: +(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      googlesearch: {
+        match: 'google:(.+)',
+        link: 'https://bing.com/search?q=$1', // html should supercede link.
+        html: '<a href="https://google.com/search?q=$1">$1</a>',
+      },
+      hashedhtml: {
+        match: 'hash:(.+)',
+        html: '<a href="#/awesomesauce">$1</a>',
+      },
+      baseurl: {
+        match: 'test (.+)',
+        html: '<a href="/r/awesomesauce">$1</a>',
+      },
+      anotatstartwithbaseurl: {
+        match: 'a test (.+)',
+        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
+      },
+      disabledconfig: {
+        match: 'foo:(.+)',
+        link: 'https://google.com/search?q=$1',
+        enabled: false,
+      },
+    };
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('URL pattern was parsed and linked.', () => {
+    // Regular inline link.
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    element.content = url;
+    const linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, url);
+  });
+
+  test('Bug pattern was parsed and linked', () => {
+    // "Issue/Bug" pattern.
+    element.content = 'Issue 3650';
+
+    let linkEl = element.$.output.childNodes[0];
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Issue 3650');
+
+    element.content = 'Bug 3650';
+    linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Bug 3650');
+  });
+
+  test('Pattern with same prefix as link was correctly parsed', () => {
+    // Pattern starts with the same prefix (`http`) as the url.
+    element.content = 'httpexample 3650';
+
+    assert.equal(element.$.output.childNodes.length, 1);
+    const linkEl = element.$.output.childNodes[0];
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'httpexample 3650');
+  });
+
+  test('Change-Id pattern was parsed and linked', () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+
+    const textNode = element.$.output.childNodes[0];
+    const linkEl = element.$.output.childNodes[1];
+    assert.equal(textNode.textContent, prefix);
+    const url = '/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Change-Id pattern was parsed and linked with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+
+    const textNode = element.$.output.childNodes[0];
+    const linkEl = element.$.output.childNodes[1];
+    assert.equal(textNode.textContent, prefix);
+    const url = '/r/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Multiple matches', () => {
+    element.content = 'Issue 3650\nIssue 3450';
+    const linkEl1 = element.$.output.childNodes[0];
+    const linkEl2 = element.$.output.childNodes[2];
+
+    assert.equal(linkEl1.target, '_blank');
+    assert.equal(linkEl1.href,
+        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
+    assert.equal(linkEl1.textContent, 'Issue 3650');
+
+    assert.equal(linkEl2.target, '_blank');
+    assert.equal(linkEl2.href,
+        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
+    assert.equal(linkEl2.textContent, 'Issue 3450');
+  });
+
+  test('Change-Id pattern parsed before bug pattern', () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+
+    // "Issue/Bug" pattern.
+    const bug = 'Issue 3650';
+
+    const changeUrl = '/q/' + changeID;
+    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+
+    element.content = prefix + changeID + bug;
+
+    const textNode = element.$.output.childNodes[0];
+    const changeLinkEl = element.$.output.childNodes[1];
+    const bugLinkEl = element.$.output.childNodes[2];
+
+    assert.equal(textNode.textContent, prefix);
+
+    assert.isFalse(changeLinkEl.hasAttribute('target'));
+    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
+    assert.equal(changeLinkEl.textContent, changeID);
+
+    assert.equal(bugLinkEl.target, '_blank');
+    assert.equal(bugLinkEl.href, bugUrl);
+    assert.equal(bugLinkEl.textContent, 'Issue 3650');
+  });
+
+  test('html field in link config', () => {
+    element.content = 'google:do a barrel roll';
+    const linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.getAttribute('href'),
+        'https://google.com/search?q=do a barrel roll');
+    assert.equal(linkEl.textContent, 'do a barrel roll');
+  });
+
+  test('removing hash from links', () => {
+    element.content = 'hash:foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('html with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'test foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('a is not at start', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'a test foo';
+    const linkEl = element.$.output.childNodes[1];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('hash html with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'hash:foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('disabled config', () => {
+    element.content = 'foo:baz';
+    assert.equal(element.$.output.innerHTML, 'foo:baz');
+  });
+
+  test('R=email labels link correctly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'R=\u200Btest@google.com';
+    assert.equal(element.$.output.textContent, 'R=test@google.com');
+    assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
+  });
+
+  test('CC=email labels link correctly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'CC=\u200Btest@google.com';
+    assert.equal(element.$.output.textContent, 'CC=test@google.com');
+    assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
+  });
+
+  test('only {http,https,mailto} protocols are linkified', () => {
+    element.content = 'xx mailto:test@google.com yy';
+    let links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx http://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx https://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('links without leading whitespace are linkified', () => {
+    element.content = 'xx abcmailto:test@google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
+    let links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx defhttp://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx qwehttps://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    // Non-latin character
+    element.content = 'xx абвhttps://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('overlapping links', () => {
+    element.config = {
+      b1: {
+        match: '(B:\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+      b2: {
+        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+    };
+    element.content = '- B: 123, 45';
+    const links = dom(element.root).querySelectorAll('a');
+
+    assert.equal(links.length, 2);
+    assert.equal(element.shadowRoot
+        .querySelector('span').textContent, '- B: 123, 45');
+
+    assert.equal(links[0].href, 'ftp://foo/123');
+    assert.equal(links[0].textContent, '123');
+
+    assert.equal(links[1].href, 'ftp://foo/45');
+    assert.equal(links[1].textContent, '45');
+  });
+
+  test('_contentOrConfigChanged called with config', () => {
+    const contentStub = sandbox.stub(element, '_contentChanged');
+    const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
 </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 fa38a66..6f8a88a 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
@@ -14,344 +14,343 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
 
-  /**
-   * Pattern describing URLs with supported protocols.
-   *
-   * @type {RegExp}
-   */
-  const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 
-  /**
-   * Construct a parser for linkifying text. Will linkify plain URLs that appear
-   * in the text as well as custom links if any are specified in the linkConfig
-   * parameter.
-   *
-   * @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
-   *     parse result is emitted. The callback is passed text and href strings
-   *     if a link is to be created, or a document fragment otherwise.
-   * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
-   *     spaces will be removed from R=<email> and CC=<email> expressions.
-   */
-  function GrLinkTextParser(linkConfig, callback, opt_removeZeroWidthSpace) {
-    this.linkConfig = linkConfig;
-    this.callback = callback;
-    this.removeZeroWidthSpace = opt_removeZeroWidthSpace;
-    this.baseUrl = Gerrit.BaseUrlBehavior.getBaseUrl();
-    Object.preventExtensions(this);
+/**
+ * Pattern describing URLs with supported protocols.
+ *
+ * @type {RegExp}
+ */
+const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
+
+/**
+ * Construct a parser for linkifying text. Will linkify plain URLs that appear
+ * 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
+ *     parse result is emitted. The callback is passed text and href strings
+ *     if a link is to be created, or a document fragment otherwise.
+ * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
+ *     spaces will be removed from R=<email> and CC=<email> expressions.
+ */
+export function GrLinkTextParser(linkConfig, callback,
+    opt_removeZeroWidthSpace) {
+  this.linkConfig = linkConfig;
+  this.callback = callback;
+  this.removeZeroWidthSpace = opt_removeZeroWidthSpace;
+  this.baseUrl = BaseUrlBehavior.getBaseUrl();
+  Object.preventExtensions(this);
+}
+
+/**
+ * Emit a callback to create a link element.
+ *
+ * @param {string} text The text of the link.
+ * @param {string} href The URL to use as the href of the link.
+ */
+GrLinkTextParser.prototype.addText = function(text, href) {
+  if (!text) { return; }
+  this.callback(text, href);
+};
+
+/**
+ * Given the source text and a list of CommentLinkItem objects that were
+ * generated by the commentlinks config, emit parsing callbacks.
+ *
+ * @param {string} text The chuml of source text over which the outputArray
+ *     items range.
+ * @param {!Array<Gerrit.CommentLinkItem>} outputArray The list of items to add
+ *     resulting from commentlink matches.
+ */
+GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
+  this.sortArrayReverse(outputArray);
+  const fragment = document.createDocumentFragment();
+  let cursor = text.length;
+
+  // Start inserting linkified URLs from the end of the String. That way, the
+  // string positions of the items don't change as we iterate through.
+  outputArray.forEach(item => {
+    // Add any text between the current linkified item and the item added
+    // before if it exists.
+    if (item.position + item.length !== cursor) {
+      fragment.insertBefore(
+          document.createTextNode(
+              text.slice(item.position + item.length, cursor)),
+          fragment.firstChild);
+    }
+    fragment.insertBefore(item.html, fragment.firstChild);
+    cursor = item.position;
+  });
+
+  // Add the beginning portion at the end.
+  if (cursor !== 0) {
+    fragment.insertBefore(
+        document.createTextNode(text.slice(0, cursor)), fragment.firstChild);
   }
 
-  /**
-   * Emit a callback to create a link element.
-   *
-   * @param {string} text The text of the link.
-   * @param {string} href The URL to use as the href of the link.
-   */
-  GrLinkTextParser.prototype.addText = function(text, href) {
-    if (!text) { return; }
-    this.callback(text, href);
-  };
+  this.callback(null, null, fragment);
+};
 
-  /**
-   * Given the source text and a list of CommentLinkItem objects that were
-   * generated by the commentlinks config, emit parsing callbacks.
-   *
-   * @param {string} text The chuml of source text over which the outputArray
-   *     items range.
-   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The list of items to add
-   *     resulting from commentlink matches.
-   */
-  GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
-    this.sortArrayReverse(outputArray);
-    const fragment = document.createDocumentFragment();
-    let cursor = text.length;
+/**
+ * Sort the given array of CommentLinkItems such that the positions are in
+ * reverse order.
+ *
+ * @param {!Array<Gerrit.CommentLinkItem>} outputArray
+ */
+GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
+  outputArray.sort((a, b) => b.position - a.position);
+};
 
-    // Start inserting linkified URLs from the end of the String. That way, the
-    // string positions of the items don't change as we iterate through.
-    outputArray.forEach(item => {
-      // Add any text between the current linkified item and the item added
-      // before if it exists.
-      if (item.position + item.length !== cursor) {
-        fragment.insertBefore(
-            document.createTextNode(
-                text.slice(item.position + item.length, cursor)),
-            fragment.firstChild);
+/**
+ * Create a CommentLinkItem and append it to the given output array. This
+ * method can be called in either of two ways:
+ * - With `text` and `href` parameters provided, and the `html` parameter
+ *   passed as `null`. In this case, the new CommentLinkItem will be a link
+ *   element with the given text and href value.
+ * - With the `html` paremeter provided, and the `text` and `href` parameters
+ *   passed as `null`. In this case, the string of HTML will be parsed and the
+ *   first resulting node will be used as the resulting content.
+ *
+ * @param {string|null} text The text to use if creating a link.
+ * @param {string|null} href The href to use as the URL if creating a link.
+ * @param {string|null} html The html to parse and use as the result.
+ * @param {number} position The position inside the source text where the item
+ *     starts.
+ * @param {number} length The number of characters in the source text
+ *     represented by the item.
+ * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
+ *     new item is to be appended.
+ */
+GrLinkTextParser.prototype.addItem =
+    function(text, href, html, position, length, outputArray) {
+      let htmlOutput = '';
+
+      if (href) {
+        const a = document.createElement('a');
+        a.href = href;
+        a.textContent = text;
+        a.target = '_blank';
+        a.rel = 'noopener';
+        htmlOutput = a;
+      } else if (html) {
+        const fragment = document.createDocumentFragment();
+        // Create temporary div to hold the nodes in.
+        const div = document.createElement('div');
+        div.innerHTML = html;
+        while (div.firstChild) {
+          fragment.appendChild(div.firstChild);
+        }
+        htmlOutput = fragment;
       }
-      fragment.insertBefore(item.html, fragment.firstChild);
-      cursor = item.position;
-    });
 
-    // Add the beginning portion at the end.
-    if (cursor !== 0) {
-      fragment.insertBefore(
-          document.createTextNode(text.slice(0, cursor)), fragment.firstChild);
-    }
-
-    this.callback(null, null, fragment);
-  };
-
-  /**
-   * Sort the given array of CommentLinkItems such that the positions are in
-   * reverse order.
-   *
-   * @param {!Array<Gerrit.CommentLinkItem>} outputArray
-   */
-  GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
-    outputArray.sort((a, b) => b.position - a.position);
-  };
-
-  /**
-   * Create a CommentLinkItem and append it to the given output array. This
-   * method can be called in either of two ways:
-   * - With `text` and `href` parameters provided, and the `html` parameter
-   *   passed as `null`. In this case, the new CommentLinkItem will be a link
-   *   element with the given text and href value.
-   * - With the `html` paremeter provided, and the `text` and `href` parameters
-   *   passed as `null`. In this case, the string of HTML will be parsed and the
-   *   first resulting node will be used as the resulting content.
-   *
-   * @param {string|null} text The text to use if creating a link.
-   * @param {string|null} href The href to use as the URL if creating a link.
-   * @param {string|null} html The html to parse and use as the result.
-   * @param {number} position The position inside the source text where the item
-   *     starts.
-   * @param {number} length The number of characters in the source text
-   *     represented by the item.
-   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
-   *     new item is to be appended.
-   */
-  GrLinkTextParser.prototype.addItem =
-      function(text, href, html, position, length, outputArray) {
-        let htmlOutput = '';
-
-        if (href) {
-          const a = document.createElement('a');
-          a.href = href;
-          a.textContent = text;
-          a.target = '_blank';
-          a.rel = 'noopener';
-          htmlOutput = a;
-        } else if (html) {
-          const fragment = document.createDocumentFragment();
-          // Create temporary div to hold the nodes in.
-          const div = document.createElement('div');
-          div.innerHTML = html;
-          while (div.firstChild) {
-            fragment.appendChild(div.firstChild);
-          }
-          htmlOutput = fragment;
-        }
-
-        outputArray.push({
-          html: htmlOutput,
-          position,
-          length,
-        });
-      };
-
-  /**
-   * Create a CommentLinkItem for a link and append it to the given output
-   * array.
-   *
-   * @param {string|null} text The text for the link.
-   * @param {string|null} href The href to use as the URL of the link.
-   * @param {number} position The position inside the source text where the link
-   *     starts.
-   * @param {number} length The number of characters in the source text
-   *     represented by the link.
-   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
-   *     new item is to be appended.
-   */
-  GrLinkTextParser.prototype.addLink =
-      function(text, href, position, length, outputArray) {
-        if (!text || this.hasOverlap(position, length, outputArray)) { return; }
-        if (!!this.baseUrl && href.startsWith('/') &&
-             !href.startsWith(this.baseUrl)) {
-          href = this.baseUrl + href;
-        }
-        this.addItem(text, href, null, position, length, outputArray);
-      };
-
-  /**
-   * Create a CommentLinkItem specified by an HTMl string and append it to the
-   * given output array.
-   *
-   * @param {string|null} html The html to parse and use as the result.
-   * @param {number} position The position inside the source text where the item
-   *     starts.
-   * @param {number} length The number of characters in the source text
-   *     represented by the item.
-   * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
-   *     new item is to be appended.
-   */
-  GrLinkTextParser.prototype.addHTML =
-      function(html, position, length, outputArray) {
-        if (this.hasOverlap(position, length, outputArray)) { return; }
-        if (!!this.baseUrl && html.match(/<a href=\"\//g) &&
-             !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)) {
-          html = html.replace(/<a href=\"\//g, `<a href=\"${this.baseUrl}\/`);
-        }
-        this.addItem(null, null, html, position, length, outputArray);
-      };
-
-  /**
-   * Does the given range overlap with anything already in the item list.
-   *
-   * @param {number} position
-   * @param {number} length
-   * @param {!Array<Gerrit.CommentLinkItem>} outputArray
-   */
-  GrLinkTextParser.prototype.hasOverlap =
-      function(position, length, outputArray) {
-        const endPosition = position + length;
-        for (let i = 0; i < outputArray.length; i++) {
-          const arrayItemStart = outputArray[i].position;
-          const arrayItemEnd = outputArray[i].position + outputArray[i].length;
-          if ((position >= arrayItemStart && position < arrayItemEnd) ||
-        (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
-        (position === arrayItemStart && position === arrayItemEnd)) {
-            return true;
-          }
-        }
-        return false;
-      };
-
-  /**
-   * Parse the given source text and emit callbacks for the items that are
-   * parsed.
-   *
-   * @param {string} text
-   */
-  GrLinkTextParser.prototype.parse = function(text) {
-    if (text) {
-      linkify(text, {
-        callback: this.parseChunk.bind(this),
+      outputArray.push({
+        html: htmlOutput,
+        position,
+        length,
       });
-    }
-  };
+    };
 
-  /**
-   * Callback that is pased into the linkify function. ba-linkify will call this
-   * method in either of two ways:
-   * - With both a `text` and `href` parameter provided: this indicates that
-   *   ba-linkify has found a plain URL and wants it linkified.
-   * - With only a `text` parameter provided: this represents the non-link
-   *   content that lies between the links the library has found.
-   *
-   * @param {string} text
-   * @param {string|null|undefined} href
-   */
-  GrLinkTextParser.prototype.parseChunk = function(text, href) {
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    if (this.removeZeroWidthSpace) {
-      // Remove the zero-width space added in gr-change-view.
-      text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
-    }
+/**
+ * Create a CommentLinkItem for a link and append it to the given output
+ * array.
+ *
+ * @param {string|null} text The text for the link.
+ * @param {string|null} href The href to use as the URL of the link.
+ * @param {number} position The position inside the source text where the link
+ *     starts.
+ * @param {number} length The number of characters in the source text
+ *     represented by the link.
+ * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
+ *     new item is to be appended.
+ */
+GrLinkTextParser.prototype.addLink =
+    function(text, href, position, length, outputArray) {
+      if (!text || this.hasOverlap(position, length, outputArray)) { return; }
+      if (!!this.baseUrl && href.startsWith('/') &&
+           !href.startsWith(this.baseUrl)) {
+        href = this.baseUrl + href;
+      }
+      this.addItem(text, href, null, position, length, outputArray);
+    };
 
-    // If the href is provided then ba-linkify has recognized it as a URL. If
-    // the source text does not include a protocol, the protocol will be added
-    // by ba-linkify. Create the link if the href is provided and its protocol
-    // matches the expected pattern.
-    if (href) {
-      const result = URL_PROTOCOL_PATTERN.exec(href);
-      if (result) {
-        const prefixText = result[1];
-        if (prefixText.length > 0) {
-          // Fix for simple cases from
-          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
-          // When leading whitespace is missed before link,
-          // linkify add this text before link as a schema name to href.
-          // We suppose, that prefixText just a single word
-          // before link and add this word as is, without processing
-          // any patterns in it.
-          this.parseLinks(prefixText, []);
-          text = text.substring(prefixText.length);
-          href = href.substring(prefixText.length);
+/**
+ * Create a CommentLinkItem specified by an HTMl string and append it to the
+ * given output array.
+ *
+ * @param {string|null} html The html to parse and use as the result.
+ * @param {number} position The position inside the source text where the item
+ *     starts.
+ * @param {number} length The number of characters in the source text
+ *     represented by the item.
+ * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
+ *     new item is to be appended.
+ */
+GrLinkTextParser.prototype.addHTML =
+    function(html, position, length, outputArray) {
+      if (this.hasOverlap(position, length, outputArray)) { return; }
+      if (!!this.baseUrl && html.match(/<a href=\"\//g) &&
+           !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)) {
+        html = html.replace(/<a href=\"\//g, `<a href=\"${this.baseUrl}\/`);
+      }
+      this.addItem(null, null, html, position, length, outputArray);
+    };
+
+/**
+ * Does the given range overlap with anything already in the item list.
+ *
+ * @param {number} position
+ * @param {number} length
+ * @param {!Array<Gerrit.CommentLinkItem>} outputArray
+ */
+GrLinkTextParser.prototype.hasOverlap =
+    function(position, length, outputArray) {
+      const endPosition = position + length;
+      for (let i = 0; i < outputArray.length; i++) {
+        const arrayItemStart = outputArray[i].position;
+        const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+        if ((position >= arrayItemStart && position < arrayItemEnd) ||
+      (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
+      (position === arrayItemStart && position === arrayItemEnd)) {
+          return true;
         }
-        this.addText(text, href);
-        return;
+      }
+      return false;
+    };
+
+/**
+ * Parse the given source text and emit callbacks for the items that are
+ * parsed.
+ *
+ * @param {string} text
+ */
+GrLinkTextParser.prototype.parse = function(text) {
+  if (text) {
+    linkify(text, {
+      callback: this.parseChunk.bind(this),
+    });
+  }
+};
+
+/**
+ * Callback that is pased into the linkify function. ba-linkify will call this
+ * method in either of two ways:
+ * - With both a `text` and `href` parameter provided: this indicates that
+ *   ba-linkify has found a plain URL and wants it linkified.
+ * - With only a `text` parameter provided: this represents the non-link
+ *   content that lies between the links the library has found.
+ *
+ * @param {string} text
+ * @param {string|null|undefined} href
+ */
+GrLinkTextParser.prototype.parseChunk = function(text, href) {
+  // TODO(wyatta) switch linkify sequence, see issue 5526.
+  if (this.removeZeroWidthSpace) {
+    // Remove the zero-width space added in gr-change-view.
+    text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
+  }
+
+  // If the href is provided then ba-linkify has recognized it as a URL. If
+  // the source text does not include a protocol, the protocol will be added
+  // by ba-linkify. Create the link if the href is provided and its protocol
+  // matches the expected pattern.
+  if (href) {
+    const result = URL_PROTOCOL_PATTERN.exec(href);
+    if (result) {
+      const prefixText = result[1];
+      if (prefixText.length > 0) {
+        // Fix for simple cases from
+        // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+        // When leading whitespace is missed before link,
+        // linkify add this text before link as a schema name to href.
+        // We suppose, that prefixText just a single word
+        // before link and add this word as is, without processing
+        // any patterns in it.
+        this.parseLinks(prefixText, []);
+        text = text.substring(prefixText.length);
+        href = href.substring(prefixText.length);
+      }
+      this.addText(text, href);
+      return;
+    }
+  }
+  // For the sections of text that lie between the links found by
+  // ba-linkify, we search for the project-config-specified link patterns.
+  this.parseLinks(text, this.linkConfig);
+};
+
+/**
+ * Walk over the given source text to find matches for comemntlink patterns
+ * and emit parse result callbacks.
+ *
+ * @param {string} text The raw source text.
+ * @param {Object|null|undefined} patterns A comment links specification
+ *   object.
+ */
+GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
+  // The outputArray is used to store all of the matches found for all
+  // patterns.
+  const outputArray = [];
+  for (const p in patterns) {
+    if (patterns[p].enabled != null && patterns[p].enabled == false) {
+      continue;
+    }
+    // PolyGerrit doesn't use hash-based navigation like the GWT UI.
+    // Account for this.
+    if (patterns[p].html) {
+      patterns[p].html =
+          patterns[p].html.replace(/<a href=\"#\//g, '<a href="/');
+    } else if (patterns[p].link) {
+      if (patterns[p].link[0] == '#') {
+        patterns[p].link = patterns[p].link.substr(1);
       }
     }
-    // For the sections of text that lie between the links found by
-    // ba-linkify, we search for the project-config-specified link patterns.
-    this.parseLinks(text, this.linkConfig);
-  };
 
-  /**
-   * Walk over the given source text to find matches for comemntlink patterns
-   * and emit parse result callbacks.
-   *
-   * @param {string} text The raw source text.
-   * @param {Object|null|undefined} patterns A comment links specification
-   *   object.
-   */
-  GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
-    // The outputArray is used to store all of the matches found for all
-    // patterns.
-    const outputArray = [];
-    for (const p in patterns) {
-      if (patterns[p].enabled != null && patterns[p].enabled == false) {
-        continue;
-      }
-      // PolyGerrit doesn't use hash-based navigation like the GWT UI.
-      // Account for this.
+    const pattern = new RegExp(patterns[p].match, 'g');
+
+    let match;
+    let textToCheck = text;
+    let susbtrIndex = 0;
+
+    while ((match = pattern.exec(textToCheck)) != null) {
+      textToCheck = textToCheck.substr(match.index + match[0].length);
+      let result = match[0].replace(pattern,
+          patterns[p].html || patterns[p].link);
+
       if (patterns[p].html) {
-        patterns[p].html =
-            patterns[p].html.replace(/<a href=\"#\//g, '<a href="/');
+        let i;
+        // Skip portion of replacement string that is equal to original to
+        // allow overlapping patterns.
+        for (i = 0; i < result.length; i++) {
+          if (result[i] !== match[0][i]) { break; }
+        }
+        result = result.slice(i);
+
+        this.addHTML(
+            result,
+            susbtrIndex + match.index + i,
+            match[0].length - i,
+            outputArray);
       } else if (patterns[p].link) {
-        if (patterns[p].link[0] == '#') {
-          patterns[p].link = patterns[p].link.substr(1);
-        }
+        this.addLink(
+            match[0],
+            result,
+            susbtrIndex + match.index,
+            match[0].length,
+            outputArray);
+      } else {
+        throw Error('linkconfig entry ' + p +
+            ' doesn’t contain a link or html attribute.');
       }
 
-      const pattern = new RegExp(patterns[p].match, 'g');
-
-      let match;
-      let textToCheck = text;
-      let susbtrIndex = 0;
-
-      while ((match = pattern.exec(textToCheck)) != null) {
-        textToCheck = textToCheck.substr(match.index + match[0].length);
-        let result = match[0].replace(pattern,
-            patterns[p].html || patterns[p].link);
-
-        if (patterns[p].html) {
-          let i;
-          // Skip portion of replacement string that is equal to original to
-          // allow overlapping patterns.
-          for (i = 0; i < result.length; i++) {
-            if (result[i] !== match[0][i]) { break; }
-          }
-          result = result.slice(i);
-
-          this.addHTML(
-              result,
-              susbtrIndex + match.index + i,
-              match[0].length - i,
-              outputArray);
-        } else if (patterns[p].link) {
-          this.addLink(
-              match[0],
-              result,
-              susbtrIndex + match.index,
-              match[0].length,
-              outputArray);
-        } else {
-          throw Error('linkconfig entry ' + p +
-              ' doesn’t contain a link or html attribute.');
-        }
-
-        // Update the substring location so we know where we are in relation to
-        // the initial full text string.
-        susbtrIndex = susbtrIndex + match.index + match[0].length;
-      }
+      // Update the substring location so we know where we are in relation to
+      // the initial full text string.
+      susbtrIndex = susbtrIndex + match.index + match[0].length;
     }
-    this.processLinks(text, outputArray);
-  };
-
-  window.GrLinkTextParser = GrLinkTextParser;
-})();
+  }
+  this.processLinks(text, outputArray);
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
deleted file mode 100644
index 3d41a7c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
+++ /dev/null
@@ -1,106 +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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-input/iron-input.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-
-<dom-module id="gr-list-view">
-  <template>
-    <style include="shared-styles">
-      #filter {
-        max-width: 25em;
-      }
-      #filter:focus {
-        outline: none;
-      }
-      #topContainer {
-        align-items: center;
-        display: flex;
-        height: 3rem;
-        justify-content: space-between;
-        margin: 0 var(--spacing-l);
-      }
-      #createNewContainer:not(.show) {
-        display: none;
-      }
-      a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      a:hover {
-        text-decoration: underline;
-      }
-      nav {
-        align-items: center;
-        display: flex;
-        height: 3rem;
-        justify-content: flex-end;
-        margin-right: 20px;
-      }
-      nav,
-      iron-icon {
-        color: var(--deemphasized-text-color);
-      }
-      iron-icon {
-        height: 1.85rem;
-        margin-left: 16px;
-        width: 1.85rem;
-      }
-    </style>
-    <div id="topContainer">
-      <div class="filterContainer">
-        <label>Filter:</label>
-        <iron-input
-            type="text"
-            bind-value="{{filter}}">
-          <input
-              is="iron-input"
-              type="text"
-              id="filter"
-              bind-value="{{filter}}">
-        </iron-input>
-      </div>
-      <div id="createNewContainer"
-          class$="[[_computeCreateClass(createNew)]]">
-        <gr-button primary link id="createNew" on-click="_createNewItem">
-          Create New
-        </gr-button>
-      </div>
-    </div>
-    <slot></slot>
-    <nav>
-      Page [[_computePage(offset, itemsPerPage)]]
-      <a id="prevArrow"
-          href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
-          hidden$="[[_hidePrevArrow(loading, offset)]]" hidden>
-        <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-      </a>
-      <a id="nextArrow"
-          href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
-          hidden$="[[_hideNextArrow(loading, items)]]" hidden>
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-      </a>
-    </nav>
-  </template>
-  <script src="gr-list-view.js"></script>
-</dom-module>
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..7a44c67 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
@@ -14,15 +14,38 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
+import '@polymer/iron-input/iron-input.js';
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../gr-button/gr-button.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-list-view_html.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import page from 'page/page.mjs';
 
-  Polymer({
-    is: 'gr-list-view',
+const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrListView extends mixinBehaviors( [
+  BaseUrlBehavior,
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-list-view'; }
+
+  static get properties() {
+    return {
       createNew: Boolean,
       items: Array,
       itemsPerPage: Number,
@@ -33,75 +56,75 @@
       offset: Number,
       loading: Boolean,
       path: String,
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('reload');
+  }
 
-    detached() {
-      this.cancelDebouncer('reload');
-    },
+  _filterChanged(newFilter, oldFilter) {
+    if (!newFilter && !oldFilter) {
+      return;
+    }
 
-    _filterChanged(newFilter, oldFilter) {
-      if (!newFilter && !oldFilter) {
-        return;
-      }
+    this._debounceReload(newFilter);
+  }
 
-      this._debounceReload(newFilter);
-    },
-
-    _debounceReload(filter) {
-      this.debounce('reload', () => {
-        if (filter) {
-          return page.show(`${this.path}/q/filter:` +
-              this.encodeURL(filter, false));
-        }
-        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.
-      offset = +(offset || 0);
-      const newOffset = Math.max(0, offset + (itemsPerPage * direction));
-      let href = this.getBaseUrl() + path;
+  _debounceReload(filter) {
+    this.debounce('reload', () => {
       if (filter) {
-        href += '/q/filter:' + this.encodeURL(filter, false);
+        return page.show(`${this.path}/q/filter:` +
+            this.encodeURL(filter, false));
       }
-      if (newOffset > 0) {
-        href += ',' + newOffset;
-      }
-      return href;
-    },
+      page.show(this.path);
+    }, REQUEST_DEBOUNCE_INTERVAL_MS);
+  }
 
-    _computeCreateClass(createNew) {
-      return createNew ? 'show' : '';
-    },
+  _createNewItem() {
+    this.dispatchEvent(new CustomEvent('create-clicked', {
+      composed: true, bubbles: true,
+    }));
+  }
 
-    _hidePrevArrow(loading, offset) {
-      return loading || offset === 0;
-    },
+  _computeNavLink(offset, direction, itemsPerPage, filter, path) {
+    // Offset could be a string when passed from the router.
+    offset = +(offset || 0);
+    const newOffset = Math.max(0, offset + (itemsPerPage * direction));
+    let href = this.getBaseUrl() + path;
+    if (filter) {
+      href += '/q/filter:' + this.encodeURL(filter, false);
+    }
+    if (newOffset > 0) {
+      href += ',' + newOffset;
+    }
+    return href;
+  }
 
-    _hideNextArrow(loading, items) {
-      if (loading || !items || !items.length) {
-        return true;
-      }
-      const lastPage = items.length < this.itemsPerPage + 1;
-      return lastPage;
-    },
+  _computeCreateClass(createNew) {
+    return createNew ? 'show' : '';
+  }
 
-    // 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;
-    },
-  });
-})();
+  _hidePrevArrow(loading, offset) {
+    return loading || offset === 0;
+  }
+
+  _hideNextArrow(loading, items) {
+    if (loading || !items || !items.length) {
+      return true;
+    }
+    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_html.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
new file mode 100644
index 0000000..ff73d4d8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #filter {
+      max-width: 25em;
+    }
+    #filter:focus {
+      outline: none;
+    }
+    #topContainer {
+      align-items: center;
+      display: flex;
+      height: 3rem;
+      justify-content: space-between;
+      margin: 0 var(--spacing-l);
+    }
+    #createNewContainer:not(.show) {
+      display: none;
+    }
+    a {
+      color: var(--primary-text-color);
+      text-decoration: none;
+    }
+    a:hover {
+      text-decoration: underline;
+    }
+    nav {
+      align-items: center;
+      display: flex;
+      height: 3rem;
+      justify-content: flex-end;
+      margin-right: 20px;
+    }
+    nav,
+    iron-icon {
+      color: var(--deemphasized-text-color);
+    }
+    iron-icon {
+      height: 1.85rem;
+      margin-left: 16px;
+      width: 1.85rem;
+    }
+  </style>
+  <div id="topContainer">
+    <div class="filterContainer">
+      <label>Filter:</label>
+      <iron-input type="text" bind-value="{{filter}}">
+        <input
+          is="iron-input"
+          type="text"
+          id="filter"
+          bind-value="{{filter}}"
+        />
+      </iron-input>
+    </div>
+    <div id="createNewContainer" class$="[[_computeCreateClass(createNew)]]">
+      <gr-button primary="" link="" id="createNew" on-click="_createNewItem">
+        Create New
+      </gr-button>
+    </div>
+  </div>
+  <slot></slot>
+  <nav>
+    Page [[_computePage(offset, itemsPerPage)]]
+    <a
+      id="prevArrow"
+      href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
+      hidden$="[[_hidePrevArrow(loading, offset)]]"
+      hidden=""
+    >
+      <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+    </a>
+    <a
+      id="nextArrow"
+      href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
+      hidden$="[[_hideNextArrow(loading, items)]]"
+      hidden=""
+    >
+      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+    </a>
+  </nav>
+`;
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..55aab82 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
@@ -17,17 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
 
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-list-view.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -35,130 +31,136 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-list-view tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-list-view.js';
+import page from 'page/page.mjs';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+suite('gr-list-view tests', () => {
+  let element;
+  let sandbox;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('_computeNavLink', () => {
+    const offset = 25;
+    const projectsPerPage = 25;
+    let filter = 'test';
+    const path = '/admin/projects';
+
+    sandbox.stub(element, 'getBaseUrl', () => '');
+
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:test,50');
+
+    assert.equal(
+        element._computeNavLink(offset, -1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:test');
+
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, null, path),
+        '/admin/projects,50');
+
+    assert.equal(
+        element._computeNavLink(offset, -1, projectsPerPage, null, path),
+        '/admin/projects');
+
+    filter = 'plugins/';
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:plugins%252F,50');
+  });
+
+  test('_onValueChange', done => {
+    element.path = '/admin/projects';
+    sandbox.stub(page, 'show', url => {
+      assert.equal(url, '/admin/projects/q/filter:test');
+      done();
     });
+    element.filter = 'test';
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  test('_filterChanged not reload when swap between falsy values', () => {
+    sandbox.stub(element, '_debounceReload');
+    element.filter = null;
+    element.filter = undefined;
+    element.filter = '';
+    assert.isFalse(element._debounceReload.called);
+  });
 
-    test('_computeNavLink', () => {
-      const offset = 25;
-      const projectsPerPage = 25;
-      let filter = 'test';
-      const path = '/admin/projects';
+  test('next button', done => {
+    element.itemsPerPage = 25;
+    let projects = new Array(26);
 
-      sandbox.stub(element, 'getBaseUrl', () => '');
-
-      assert.equal(
-          element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-          '/admin/projects/q/filter:test,50');
-
-      assert.equal(
-          element._computeNavLink(offset, -1, projectsPerPage, filter, path),
-          '/admin/projects/q/filter:test');
-
-      assert.equal(
-          element._computeNavLink(offset, 1, projectsPerPage, null, path),
-          '/admin/projects,50');
-
-      assert.equal(
-          element._computeNavLink(offset, -1, projectsPerPage, null, path),
-          '/admin/projects');
-
-      filter = 'plugins/';
-      assert.equal(
-          element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-          '/admin/projects/q/filter:plugins%252F,50');
-    });
-
-    test('_onValueChange', done => {
-      element.path = '/admin/projects';
-      sandbox.stub(page, 'show', url => {
-        assert.equal(url, '/admin/projects/q/filter:test');
-        done();
-      });
-      element.filter = 'test';
-    });
-
-    test('_filterChanged not reload when swap between falsy values', () => {
-      sandbox.stub(element, '_debounceReload');
-      element.filter = null;
-      element.filter = undefined;
-      element.filter = '';
-      assert.isFalse(element._debounceReload.called);
-    });
-
-    test('next button', done => {
-      element.itemsPerPage = 25;
-      projects = new Array(26);
-
-      flush(() => {
-        let loading;
-        assert.isFalse(element._hideNextArrow(loading, projects));
-        loading = true;
-        assert.isTrue(element._hideNextArrow(loading, projects));
-        loading = false;
-        assert.isFalse(element._hideNextArrow(loading, projects));
-        element._projects = [];
-        assert.isTrue(element._hideNextArrow(loading, element._projects));
-        projects = new Array(4);
-        assert.isTrue(element._hideNextArrow(loading, projects));
-        done();
-      });
-    });
-
-    test('prev button', () => {
-      assert.isTrue(element._hidePrevArrow(true, 0));
-      flush(() => {
-        let offset = 0;
-        assert.isTrue(element._hidePrevArrow(false, offset));
-        offset = 5;
-        assert.isFalse(element._hidePrevArrow(false, offset));
-      });
-    });
-
-    test('createNew link appears correctly', () => {
-      assert.isFalse(element.$$('#createNewContainer').classList
-          .contains('show'));
-      element.createNew = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$$('#createNewContainer').classList
-          .contains('show'));
-    });
-
-    test('fires create clicked event when button tapped', () => {
-      const clickHandler = sandbox.stub();
-      element.addEventListener('create-clicked', clickHandler);
-      element.createNew = true;
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.$$('#createNew'));
-      assert.isTrue(clickHandler.called);
-    });
-
-    test('next/prev links change when path changes', () => {
-      const BRANCHES_PATH = '/path/to/branches';
-      const TAGS_PATH = '/path/to/tags';
-      sandbox.stub(element, '_computeNavLink');
-      element.offset = 0;
-      element.itemsPerPage = 25;
-      element.filter = '';
-      element.path = BRANCHES_PATH;
-      assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
-      element.path = TAGS_PATH;
-      assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
-    });
-
-    test('_computePage', () => {
-      assert.equal(element._computePage(0, 25), 1);
-      assert.equal(element._computePage(50, 25), 3);
+    flush(() => {
+      let loading;
+      assert.isFalse(element._hideNextArrow(loading, projects));
+      loading = true;
+      assert.isTrue(element._hideNextArrow(loading, projects));
+      loading = false;
+      assert.isFalse(element._hideNextArrow(loading, projects));
+      element._projects = [];
+      assert.isTrue(element._hideNextArrow(loading, element._projects));
+      projects = new Array(4);
+      assert.isTrue(element._hideNextArrow(loading, projects));
+      done();
     });
   });
+
+  test('prev button', () => {
+    assert.isTrue(element._hidePrevArrow(true, 0));
+    flush(() => {
+      let offset = 0;
+      assert.isTrue(element._hidePrevArrow(false, offset));
+      offset = 5;
+      assert.isFalse(element._hidePrevArrow(false, offset));
+    });
+  });
+
+  test('createNew link appears correctly', () => {
+    assert.isFalse(element.shadowRoot
+        .querySelector('#createNewContainer').classList
+        .contains('show'));
+    element.createNew = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.shadowRoot
+        .querySelector('#createNewContainer').classList
+        .contains('show'));
+  });
+
+  test('fires create clicked event when button tapped', () => {
+    const clickHandler = sandbox.stub();
+    element.addEventListener('create-clicked', clickHandler);
+    element.createNew = true;
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
+    assert.isTrue(clickHandler.called);
+  });
+
+  test('next/prev links change when path changes', () => {
+    const BRANCHES_PATH = '/path/to/branches';
+    const TAGS_PATH = '/path/to/tags';
+    sandbox.stub(element, '_computeNavLink');
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    element.filter = '';
+    element.path = BRANCHES_PATH;
+    assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
+    element.path = TAGS_PATH;
+    assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
+  });
+
+  test('_computePage', () => {
+    assert.equal(element._computePage(0, 25), 1);
+    assert.equal(element._computePage(50, 25), 3);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
deleted file mode 100644
index 2b4b982..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-overlay">
-  <template>
-    <style include="shared-styles">
-      :host {
-        background: var(--dialog-background-color);
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
-      }
-
-      @media screen and (max-width: 50em) {
-        :host {
-          height: 100%;
-          left: 0;
-          position: fixed;
-          right: 0;
-          top: 0;
-        }
-      }
-    </style>
-    <slot></slot>
-  </template>
-  <script src="gr-overlay.js"></script>
-</dom-module>
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..a5a3fb4 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -14,95 +14,118 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const AWAIT_MAX_ITERS = 10;
-  const AWAIT_STEP = 5;
-  const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
+import {IronOverlayBehaviorImpl, IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
+import '../../../styles/shared-styles.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-overlay_html.js';
 
-  Polymer({
-    is: 'gr-overlay',
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
 
-    /**
-     * Fired when a fullscreen overlay is closed
-     *
-     * @event fullscreen-overlay-closed
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrOverlay extends mixinBehaviors( [
+  IronOverlayBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    /**
-     * Fired when an overlay is opened in full screen mode
-     *
-     * @event fullscreen-overlay-opened
-     */
+  static get is() { return 'gr-overlay'; }
+  /**
+   * Fired when a fullscreen overlay is closed
+   *
+   * @event fullscreen-overlay-closed
+   */
 
-    properties: {
+  /**
+   * Fired when an overlay is opened in full screen mode
+   *
+   * @event fullscreen-overlay-opened
+   */
+
+  static get properties() {
+    return {
       _fullScreenOpen: {
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Polymer.IronOverlayBehavior,
-    ],
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('iron-overlay-closed',
+        () => this._close());
+    this.addEventListener('iron-overlay-cancelled',
+        () => this._close());
+  }
 
-    listeners: {
-      'iron-overlay-closed': '_close',
-      'iron-overlay-cancelled': '_close',
-    },
-
-    open(...args) {
-      return new Promise(resolve => {
-        Polymer.IronOverlayBehaviorImpl.open.apply(this, args);
-        if (this._isMobile()) {
-          this.fire('fullscreen-overlay-opened');
-          this._fullScreenOpen = true;
-        }
-        this._awaitOpen(resolve);
-      });
-    },
-
-    _isMobile() {
-      return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-    },
-
-    _close() {
-      if (this._fullScreenOpen) {
-        this.fire('fullscreen-overlay-closed');
-        this._fullScreenOpen = false;
+  open(...args) {
+    return new Promise((resolve, reject) => {
+      IronOverlayBehaviorImpl.open.apply(this, args);
+      if (this._isMobile()) {
+        this.dispatchEvent(new CustomEvent('fullscreen-overlay-opened', {
+          composed: true, bubbles: true,
+        }));
+        this._fullScreenOpen = true;
       }
-    },
+      this._awaitOpen(resolve, reject);
+    });
+  }
 
-    /**
-     * Override the focus stops that iron-overlay-behavior tries to find.
-     */
-    setFocusStops(stops) {
-      this.__firstFocusableNode = stops.start;
-      this.__lastFocusableNode = stops.end;
-    },
+  _isMobile() {
+    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+  }
 
-    /**
-     * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-     * opening. Eventually replace with a direct way to listen to the overlay.
-     */
-    _awaitOpen(fn) {
-      let iters = 0;
-      const step = () => {
-        this.async(() => {
-          if (this.style.display !== 'none') {
-            fn.call(this);
-          } else if (iters++ < AWAIT_MAX_ITERS) {
-            step.call(this);
-          }
-        }, AWAIT_STEP);
-      };
-      step.call(this);
-    },
+  _close() {
+    if (this._fullScreenOpen) {
+      this.dispatchEvent(new CustomEvent('fullscreen-overlay-closed', {
+        composed: true, bubbles: true,
+      }));
+      this._fullScreenOpen = false;
+    }
+  }
 
-    _id() {
-      return this.getAttribute('id') || 'global';
-    },
-  });
-})();
+  /**
+   * Override the focus stops that iron-overlay-behavior tries to find.
+   */
+  setFocusStops(stops) {
+    this.__firstFocusableNode = stops.start;
+    this.__lastFocusableNode = stops.end;
+  }
+
+  /**
+   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+   * opening. Eventually replace with a direct way to listen to the overlay.
+   */
+  _awaitOpen(fn, reject) {
+    let iters = 0;
+    const step = () => {
+      this.async(() => {
+        if (this.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        } else {
+          reject(new Error('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_html.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
new file mode 100644
index 0000000..7123adb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background: var(--dialog-background-color);
+      border-radius: var(--border-radius);
+      box-shadow: var(--elevation-level-5);
+    }
+
+    @media screen and (max-width: 50em) {
+      :host {
+        height: 100%;
+        left: 0;
+        position: fixed;
+        right: 0;
+        top: 0;
+        border-radius: 0;
+        box-shadow: none;
+      }
+    }
+  </style>
+  <slot></slot>
+`;
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..d43c739 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
@@ -17,19 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../../test/common-test-setup.html"/>
-
-<link rel="import" href="gr-overlay.html">
-
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -39,56 +34,58 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-overlay tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-overlay.js';
+suite('gr-overlay tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('events are fired on fullscreen view', done => {
-      sandbox.stub(element, '_isMobile').returns(true);
-      const openHandler = sandbox.stub();
-      const closeHandler = sandbox.stub();
-      element.addEventListener('fullscreen-overlay-opened', openHandler);
-      element.addEventListener('fullscreen-overlay-closed', closeHandler);
+  test('events are fired on fullscreen view', done => {
+    sandbox.stub(element, '_isMobile').returns(true);
+    const openHandler = sandbox.stub();
+    const closeHandler = sandbox.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
 
-      element.open().then(() => {
-        assert.isTrue(element._isMobile.called);
-        assert.isTrue(element._fullScreenOpen);
-        assert.isTrue(openHandler.called);
+    element.open().then(() => {
+      assert.isTrue(element._isMobile.called);
+      assert.isTrue(element._fullScreenOpen);
+      assert.isTrue(openHandler.called);
 
-        element._close();
-        assert.isFalse(element._fullScreenOpen);
-        assert.isTrue(closeHandler.called);
-        done();
-      });
-    });
-
-    test('events are not fired on desktop view', done => {
-      sandbox.stub(element, '_isMobile').returns(false);
-      const openHandler = sandbox.stub();
-      const closeHandler = sandbox.stub();
-      element.addEventListener('fullscreen-overlay-opened', openHandler);
-      element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-      element.open().then(() => {
-        assert.isTrue(element._isMobile.called);
-        assert.isFalse(element._fullScreenOpen);
-        assert.isFalse(openHandler.called);
-
-        element._close();
-        assert.isFalse(element._fullScreenOpen);
-        assert.isFalse(closeHandler.called);
-        done();
-      });
+      element._close();
+      assert.isFalse(element._fullScreenOpen);
+      assert.isTrue(closeHandler.called);
+      done();
     });
   });
+
+  test('events are not fired on desktop view', done => {
+    sandbox.stub(element, '_isMobile').returns(false);
+    const openHandler = sandbox.stub();
+    const closeHandler = sandbox.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+    element.open().then(() => {
+      assert.isTrue(element._isMobile.called);
+      assert.isFalse(element._fullScreenOpen);
+      assert.isFalse(openHandler.called);
+
+      element._close();
+      assert.isFalse(element._fullScreenOpen);
+      assert.isFalse(closeHandler.called);
+      done();
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
deleted file mode 100644
index f1c3a6f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
+++ /dev/null
@@ -1,47 +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.
--->
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-page-nav">
-  <template>
-    <style include="shared-styles">
-      #nav {
-        background-color: var(--table-header-background-color);
-        border: 1px solid var(--border-color);
-        border-top: none;
-        height: 100%;
-        position: absolute;
-        top: 0;
-        width: 14em;
-      }
-      #nav.pinned {
-        position: fixed;
-      }
-      @media only screen and (max-width: 53em) {
-        #nav {
-          display: none;
-        }
-      }
-    </style>
-    <nav id="nav">
-      <slot></slot>
-    </nav>
-  </template>
-  <script src="gr-page-nav.js"></script>
-</dom-module>
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..8366463 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
@@ -14,51 +14,67 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-page-nav_html.js';
 
-  Polymer({
-    is: 'gr-page-nav',
+/** @extends Polymer.Element */
+class GrPageNav extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-page-nav'; }
+
+  static get properties() {
+    return {
       _headerHeight: Number,
-    },
+    };
+  }
 
-    attached() {
-      this.listen(window, 'scroll', '_handleBodyScroll');
-    },
+  /** @override */
+  attached() {
+    super.attached();
+    this.listen(window, 'scroll', '_handleBodyScroll');
+  }
 
-    detached() {
-      this.unlisten(window, 'scroll', '_handleBodyScroll');
-    },
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleBodyScroll');
+  }
 
-    _handleBodyScroll() {
-      if (this._headerHeight === undefined) {
-        let top = this._getOffsetTop(this);
-        for (let offsetParent = this.offsetParent;
-          offsetParent;
-          offsetParent = this._getOffsetParent(offsetParent)) {
-          top += this._getOffsetTop(offsetParent);
-        }
-        this._headerHeight = top;
+  _handleBodyScroll() {
+    if (this._headerHeight === undefined) {
+      let top = this._getOffsetTop(this);
+      for (let offsetParent = this.offsetParent;
+        offsetParent;
+        offsetParent = this._getOffsetParent(offsetParent)) {
+        top += this._getOffsetTop(offsetParent);
       }
+      this._headerHeight = top;
+    }
 
-      this.$.nav.classList.toggle('pinned',
-          this._getScrollY() >= this._headerHeight);
-    },
+    this.$.nav.classList.toggle('pinned',
+        this._getScrollY() >= this._headerHeight);
+  }
 
-    /* Functions used for test purposes */
-    _getOffsetParent(element) {
-      if (!element || !element.offsetParent) { return ''; }
-      return element.offsetParent;
-    },
+  /* Functions used for test purposes */
+  _getOffsetParent(element) {
+    if (!element || !element.offsetParent) { return ''; }
+    return element.offsetParent;
+  }
 
-    _getOffsetTop(element) {
-      return element.offsetTop;
-    },
+  _getOffsetTop(element) {
+    return element.offsetTop;
+  }
 
-    _getScrollY() {
-      return window.scrollY;
-    },
-  });
-})();
+  _getScrollY() {
+    return window.scrollY;
+  }
+}
+
+customElements.define(GrPageNav.is, GrPageNav);
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
new file mode 100644
index 0000000..c5e9142
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #nav {
+      background-color: var(--table-header-background-color);
+      border: 1px solid var(--border-color);
+      border-top: none;
+      height: 100%;
+      position: absolute;
+      top: 0;
+      width: 14em;
+    }
+    #nav.pinned {
+      position: fixed;
+    }
+    @media only screen and (max-width: 53em) {
+      #nav {
+        display: none;
+      }
+    }
+  </style>
+  <nav id="nav">
+    <slot></slot>
+  </nav>
+`;
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..a54d7a3 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
@@ -17,19 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="../../../test/common-test-setup.html"/>
-
-<link rel="import" href="gr-page-nav.html">
-
-<script>void(0);</script>
+<script src="/node_modules/page/page.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -41,52 +36,54 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-page-nav tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-page-nav.js';
+suite('gr-page-nav tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('header is not pinned just below top', () => {
-      sandbox.stub(element, '_getOffsetParent', () => 0);
-      sandbox.stub(element, '_getOffsetTop', () => 10);
-      sandbox.stub(element, '_getScrollY', () => 5);
-      element._handleBodyScroll();
-      assert.isFalse(element.$.nav.classList.contains('pinned'));
-    });
-
-    test('header is pinned when scroll down the page', () => {
-      sandbox.stub(element, '_getOffsetParent', () => 0);
-      sandbox.stub(element, '_getOffsetTop', () => 10);
-      sandbox.stub(element, '_getScrollY', () => 25);
-      window.scrollY = 100;
-      element._handleBodyScroll();
-      assert.isTrue(element.$.nav.classList.contains('pinned'));
-    });
-
-    test('header is not pinned just below top with header set', () => {
-      element._headerHeight = 20;
-      sandbox.stub(element, '_getScrollY', () => 15);
-      window.scrollY = 100;
-      element._handleBodyScroll();
-      assert.isFalse(element.$.nav.classList.contains('pinned'));
-    });
-
-    test('header is pinned when scroll down the page with header set', () => {
-      element._headerHeight = 20;
-      sandbox.stub(element, '_getScrollY', () => 25);
-      window.scrollY = 100;
-      element._handleBodyScroll();
-      assert.isTrue(element.$.nav.classList.contains('pinned'));
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    flushAsynchronousOperations();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('header is not pinned just below top', () => {
+    sandbox.stub(element, '_getOffsetParent', () => 0);
+    sandbox.stub(element, '_getOffsetTop', () => 10);
+    sandbox.stub(element, '_getScrollY', () => 5);
+    element._handleBodyScroll();
+    assert.isFalse(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page', () => {
+    sandbox.stub(element, '_getOffsetParent', () => 0);
+    sandbox.stub(element, '_getOffsetTop', () => 10);
+    sandbox.stub(element, '_getScrollY', () => 25);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isTrue(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is not pinned just below top with header set', () => {
+    element._headerHeight = 20;
+    sandbox.stub(element, '_getScrollY', () => 15);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isFalse(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page with header set', () => {
+    element._headerHeight = 20;
+    sandbox.stub(element, '_getScrollY', () => 25);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isTrue(element.$.nav.classList.contains('pinned'));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
deleted file mode 100644
index ce596f8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.html
+++ /dev/null
@@ -1,60 +0,0 @@
-<!--
-@license
-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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html">
-<link rel="import" href="../../shared/gr-icons/gr-icons.html">
-<link rel="import" href="../../shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<dom-module id="gr-repo-branch-picker">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-      }
-      gr-labeled-autocomplete,
-      iron-icon {
-        display: inline-block;
-      }
-      iron-icon {
-        margin-bottom: var(--spacing-l);
-      }
-    </style>
-    <div>
-      <gr-labeled-autocomplete
-          id="repoInput"
-          label="Repository"
-          placeholder="Select repo"
-          on-commit="_repoCommitted"
-          query="[[_repoQuery]]">
-      </gr-labeled-autocomplete>
-      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-      <gr-labeled-autocomplete
-          id="branchInput"
-          label="Branch"
-          placeholder="Select branch"
-          disabled="[[_branchDisabled]]"
-          on-commit="_branchCommitted"
-          query="[[_query]]">
-      </gr-labeled-autocomplete>
-    </div>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-  <script src="gr-repo-branch-picker.js"></script>
-</dom-module>
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..c499f56 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
@@ -14,16 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const SUGGESTIONS_LIMIT = 15;
-  const REF_PREFIX = 'refs/heads/';
+import '@polymer/iron-icon/iron-icon.js';
+import '../../../styles/shared-styles.js';
+import '../gr-icons/gr-icons.js';
+import '../gr-labeled-autocomplete/gr-labeled-autocomplete.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-repo-branch-picker_html.js';
+import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 
-  Polymer({
-    is: 'gr-repo-branch-picker',
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
 
-    properties: {
+/**
+ * @extends Polymer.Element
+ */
+class GrRepoBranchPicker extends mixinBehaviors( [
+  URLEncodingBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-repo-branch-picker'; }
+
+  static get properties() {
+    return {
       repo: {
         type: String,
         notify: true,
@@ -46,64 +67,68 @@
           return this._getRepoSuggestions.bind(this);
         },
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.URLEncodingBehavior,
-    ],
+  /** @override */
+  attached() {
+    super.attached();
+    if (this.repo) {
+      this.$.repoInput.setText(this.repo);
+    }
+  }
 
-    attached() {
-      if (this.repo) {
-        this.$.repoInput.setText(this.repo);
-      }
-    },
+  /** @override */
+  ready() {
+    super.ready();
+    this._branchDisabled = !this.repo;
+  }
 
-    ready() {
-      this._branchDisabled = !this.repo;
-    },
+  _getRepoBranchesSuggestions(input) {
+    if (!this.repo) { return Promise.resolve([]); }
+    if (input.startsWith(REF_PREFIX)) {
+      input = input.substring(REF_PREFIX.length);
+    }
+    return this.$.restAPI.getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+        .then(this._branchResponseToSuggestions.bind(this));
+  }
 
-    _getRepoBranchesSuggestions(input) {
-      if (!this.repo) { return Promise.resolve([]); }
-      if (input.startsWith(REF_PREFIX)) {
-        input = input.substring(REF_PREFIX.length);
-      }
-      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));
+  }
 
-    _getRepoSuggestions(input) {
-      return this.$.restAPI.getRepos(input, SUGGESTIONS_LIMIT)
-          .then(this._repoResponseToSuggestions.bind(this));
-    },
-
-    _repoResponseToSuggestions(res) {
-      return res.map(repo => ({
+  _repoResponseToSuggestions(res) {
+    return res.map(repo => {
+      return {
         name: repo.name,
         value: this.singleDecodeURL(repo.id),
-      }));
-    },
+      };
+    });
+  }
 
-    _branchResponseToSuggestions(res) {
-      return Object.keys(res).map(key => {
-        let branch = res[key].ref;
-        if (branch.startsWith(REF_PREFIX)) {
-          branch = branch.substring(REF_PREFIX.length);
-        }
-        return {name: branch, value: branch};
-      });
-    },
+  _branchResponseToSuggestions(res) {
+    return Object.keys(res).map(key => {
+      let branch = res[key].ref;
+      if (branch.startsWith(REF_PREFIX)) {
+        branch = branch.substring(REF_PREFIX.length);
+      }
+      return {name: branch, value: branch};
+    });
+  }
 
-    _repoCommitted(e) {
-      this.repo = e.detail.value;
-    },
+  _repoCommitted(e) {
+    this.repo = e.detail.value;
+  }
 
-    _branchCommitted(e) {
-      this.branch = e.detail.value;
-    },
+  _branchCommitted(e) {
+    this.branch = e.detail.value;
+  }
 
-    _repoChanged() {
-      this.$.branchInput.clear();
-      this._branchDisabled = !this.repo;
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
new file mode 100644
index 0000000..0ce885a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    gr-labeled-autocomplete,
+    iron-icon {
+      display: inline-block;
+    }
+    iron-icon {
+      margin-bottom: var(--spacing-l);
+    }
+  </style>
+  <div>
+    <gr-labeled-autocomplete
+      id="repoInput"
+      label="Repository"
+      placeholder="Select repo"
+      on-commit="_repoCommitted"
+      query="[[_repoQuery]]"
+    >
+    </gr-labeled-autocomplete>
+    <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+    <gr-labeled-autocomplete
+      id="branchInput"
+      label="Branch"
+      placeholder="Select branch"
+      disabled="[[_branchDisabled]]"
+      on-commit="_branchCommitted"
+      query="[[_query]]"
+    >
+    </gr-labeled-autocomplete>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
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..67b82f9 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
@@ -16,16 +16,13 @@
 limitations under the License.
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-repo-branch-picker.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,110 +30,114 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-repo-branch-picker tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-repo-branch-picker.js';
+suite('gr-repo-branch-picker tests', () => {
+  let element;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  suite('_getRepoSuggestions', () => {
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      sandbox.stub(element.$.restAPI, 'getRepos')
+          .returns(Promise.resolve([
+            {
+              id: 'plugins%2Favatars-external',
+              name: 'plugins/avatars-external',
+            }, {
+              id: 'plugins%2Favatars-gravatar',
+              name: 'plugins/avatars-gravatar',
+            }, {
+              id: 'plugins%2Favatars%2Fexternal',
+              name: 'plugins/avatars/external',
+            }, {
+              id: 'plugins%2Favatars%2Fgravatar',
+              name: 'plugins/avatars/gravatar',
+            },
+          ]));
     });
 
-    teardown(() => { sandbox.restore(); });
-
-    suite('_getRepoSuggestions', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepos')
-            .returns(Promise.resolve([
-              {
-                id: 'plugins%2Favatars-external',
-                name: 'plugins/avatars-external',
-              }, {
-                id: 'plugins%2Favatars-gravatar',
-                name: 'plugins/avatars-gravatar',
-              }, {
-                id: 'plugins%2Favatars%2Fexternal',
-                name: 'plugins/avatars/external',
-              }, {
-                id: 'plugins%2Favatars%2Fgravatar',
-                name: 'plugins/avatars/gravatar',
-              },
-            ]));
-      });
-
-      test('converts to suggestion objects', () => {
-        const input = 'plugins/avatars';
-        return element._getRepoSuggestions(input).then(suggestions => {
-          assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
-          const unencodedNames = [
-            'plugins/avatars-external',
-            'plugins/avatars-gravatar',
-            'plugins/avatars/external',
-            'plugins/avatars/gravatar',
-          ];
-          assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
-          assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
-        });
-      });
-    });
-
-    suite('_getRepoBranchesSuggestions', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoBranches')
-            .returns(Promise.resolve([
-              {ref: 'refs/heads/stable-2.10'},
-              {ref: 'refs/heads/stable-2.11'},
-              {ref: 'refs/heads/stable-2.12'},
-              {ref: 'refs/heads/stable-2.13'},
-              {ref: 'refs/heads/stable-2.14'},
-              {ref: 'refs/heads/stable-2.15'},
-            ]));
-      });
-
-      test('converts to suggestion objects', () => {
-        const repo = 'gerrit';
-        const branchInput = 'stable-2.1';
-        element.repo = repo;
-        return element._getRepoBranchesSuggestions(branchInput)
-            .then(suggestions => {
-              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                  branchInput, repo, 15));
-              const refNames = [
-                'stable-2.10',
-                'stable-2.11',
-                'stable-2.12',
-                'stable-2.13',
-                'stable-2.14',
-                'stable-2.15',
-              ];
-              assert.deepEqual(suggestions.map(s => s.name), refNames);
-              assert.deepEqual(suggestions.map(s => s.value), refNames);
-            });
-      });
-
-      test('filters out ref prefix', () => {
-        const repo = 'gerrit';
-        const branchInput = 'refs/heads/stable-2.1';
-        element.repo = repo;
-        return element._getRepoBranchesSuggestions(branchInput)
-            .then(suggestions => {
-              assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                  'stable-2.1', repo, 15));
-            });
-      });
-
-      test('does not query when repo is unset', () => {
-        return element._getRepoBranchesSuggestions('')
-            .then(() => {
-              assert.isFalse(element.$.restAPI.getRepoBranches.called);
-              element.repo = 'gerrit';
-              return element._getRepoBranchesSuggestions('');
-            })
-            .then(() => {
-              assert.isTrue(element.$.restAPI.getRepoBranches.called);
-            });
+    test('converts to suggestion objects', () => {
+      const input = 'plugins/avatars';
+      return element._getRepoSuggestions(input).then(suggestions => {
+        assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
+        const unencodedNames = [
+          'plugins/avatars-external',
+          'plugins/avatars-gravatar',
+          'plugins/avatars/external',
+          'plugins/avatars/gravatar',
+        ];
+        assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
+        assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
       });
     });
   });
+
+  suite('_getRepoBranchesSuggestions', () => {
+    setup(() => {
+      sandbox.stub(element.$.restAPI, 'getRepoBranches')
+          .returns(Promise.resolve([
+            {ref: 'refs/heads/stable-2.10'},
+            {ref: 'refs/heads/stable-2.11'},
+            {ref: 'refs/heads/stable-2.12'},
+            {ref: 'refs/heads/stable-2.13'},
+            {ref: 'refs/heads/stable-2.14'},
+            {ref: 'refs/heads/stable-2.15'},
+          ]));
+    });
+
+    test('converts to suggestion objects', () => {
+      const repo = 'gerrit';
+      const branchInput = 'stable-2.1';
+      element.repo = repo;
+      return element._getRepoBranchesSuggestions(branchInput)
+          .then(suggestions => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                branchInput, repo, 15));
+            const refNames = [
+              'stable-2.10',
+              'stable-2.11',
+              'stable-2.12',
+              'stable-2.13',
+              'stable-2.14',
+              'stable-2.15',
+            ];
+            assert.deepEqual(suggestions.map(s => s.name), refNames);
+            assert.deepEqual(suggestions.map(s => s.value), refNames);
+          });
+    });
+
+    test('filters out ref prefix', () => {
+      const repo = 'gerrit';
+      const branchInput = 'refs/heads/stable-2.1';
+      element.repo = repo;
+      return element._getRepoBranchesSuggestions(branchInput)
+          .then(suggestions => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                'stable-2.1', repo, 15));
+          });
+    });
+
+    test('does not query when repo is unset', done => {
+      element
+          ._getRepoBranchesSuggestions('')
+          .then(() => {
+            assert.isFalse(element.$.restAPI.getRepoBranches.called);
+            element.repo = 'gerrit';
+            return element._getRepoBranchesSuggestions('');
+          })
+          .then(() => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.called);
+            done();
+          });
+    });
+  });
+});
 </script>
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..6663f07 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
@@ -14,173 +14,260 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
 
-  // Prevent redefinition.
-  if (window.Gerrit.Auth) { return; }
+const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
+const MAX_GET_TOKEN_RETRIES = 2;
 
-  const MAX_GET_TOKEN_RETRIES = 2;
+/**
+ * Auth class.
+ */
+export class Auth {
+  // TODO(taoalpha): this whole thing should be moved to a service
 
-  Gerrit.Auth = {
-    TYPE: {
-      XSRF_TOKEN: 'xsrf_token',
-      ACCESS_TOKEN: 'access_token',
-    },
+  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 BaseUrlBehavior.getBaseUrl();
+  }
 
-    _getToken() {
-      return Promise.resolve(this._cachedTokenPromise);
-    },
+  /**
+   * 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();
+    }
 
-    /**
-     * Enable cross-domain authentication using OAuth access token.
-     *
-     * @param {
-     *   function(): !Promise<{
-     *     access_token: string,
-     *     expires_at: number
-     *   }>
-     * } getToken
-     * @param {?{credentials:string}} defaultOptions
-     */
-    setup(getToken, defaultOptions) {
-      this._retriesLeft = MAX_GET_TOKEN_RETRIES;
-      if (getToken) {
-        this._type = Gerrit.Auth.TYPE.ACCESS_TOKEN;
-        this._cachedTokenPromise = null;
-        this._getToken = getToken;
-      }
-      this._defaultOptions = {};
-      if (defaultOptions) {
-        for (const p of ['credentials']) {
-          this._defaultOptions[p] = defaultOptions[p];
-        }
-      }
-    },
-
-    /**
-     * Perform network fetch with authentication.
-     *
-     * @param {string} url
-     * @param {Object=} opt_options
-     * @return {!Promise<!Response>}
-     */
-    fetch(url, opt_options) {
-      const options = Object.assign({
-        headers: new Headers(),
-      }, this._defaultOptions, opt_options);
-      if (this._type === Gerrit.Auth.TYPE.ACCESS_TOKEN) {
-        return this._getAccessToken().then(
-            accessToken => this._fetchWithAccessToken(url, options, accessToken)
-        );
+    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 {
-        return this._fetchWithXsrfToken(url, options);
+        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;
+    });
+  }
 
-    _getCookie(name) {
-      const key = name + '=';
-      let result = '';
-      document.cookie.split(';').some(c => {
-        c = c.trim();
-        if (c.startsWith(key)) {
-          result = c.substring(key.length);
-          return true;
-        }
+  clearCache() {
+    this._authCheckPromise = null;
+  }
+
+  /**
+   * @param {Auth.STATUS} status
+   */
+  _setStatus(status) {
+    if (this._status === status) return;
+
+    if (this._status === Auth.STATUS.AUTHED) {
+      gerritEventEmitter.emit('auth-error', {
+        message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
       });
-      return result;
-    },
+    }
+    this._status = status;
+  }
 
-    _isTokenValid(token) {
-      if (!token) { return false; }
-      if (!token.access_token || !token.expires_at) { return false; }
+  get status() {
+    return this._status;
+  }
 
-      const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
-      if (Date.now() >= expiration.getTime()) { return false; }
+  get isAuthed() {
+    return this._status === Auth.STATUS.AUTHED;
+  }
 
-      return true;
-    },
+  _getToken() {
+    return Promise.resolve(this._cachedTokenPromise);
+  }
 
-    _fetchWithXsrfToken(url, options) {
-      if (options.method && options.method !== 'GET') {
-        const token = this._getCookie('XSRF_TOKEN');
-        if (token) {
-          options.headers.append('X-Gerrit-Auth', token);
-        }
+  /**
+   * Enable cross-domain authentication using OAuth access token.
+   *
+   * @param {
+   *   function(): !Promise<{
+   *     access_token: string,
+   *     expires_at: number
+   *   }>
+   * } getToken
+   * @param {?{credentials:string}} defaultOptions
+   */
+  setup(getToken, defaultOptions) {
+    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+    if (getToken) {
+      this._type = Auth.TYPE.ACCESS_TOKEN;
+      this._cachedTokenPromise = null;
+      this._getToken = getToken;
+    }
+    this._defaultOptions = {};
+    if (defaultOptions) {
+      for (const p of ['credentials']) {
+        this._defaultOptions[p] = defaultOptions[p];
       }
-      options.credentials = 'same-origin';
-      return fetch(url, options);
-    },
+    }
+  }
 
-    /**
-     * @return {!Promise<string>}
-     */
-    _getAccessToken() {
-      if (!this._cachedTokenPromise) {
-        this._cachedTokenPromise = this._getToken();
+  /**
+   * Perform network fetch with authentication.
+   *
+   * @param {string} url
+   * @param {Object=} opt_options
+   * @return {!Promise<!Response>}
+   */
+  fetch(url, opt_options) {
+    const options = Object.assign({
+      headers: new Headers(),
+    }, this._defaultOptions, opt_options);
+    if (this._type === Auth.TYPE.ACCESS_TOKEN) {
+      return this._getAccessToken().then(
+          accessToken =>
+            this._fetchWithAccessToken(url, options, accessToken)
+      );
+    } else {
+      return this._fetchWithXsrfToken(url, options);
+    }
+  }
+
+  _getCookie(name) {
+    const key = name + '=';
+    let result = '';
+    document.cookie.split(';').some(c => {
+      c = c.trim();
+      if (c.startsWith(key)) {
+        result = c.substring(key.length);
+        return true;
       }
-      return this._cachedTokenPromise.then(token => {
-        if (this._isTokenValid(token)) {
-          this._retriesLeft = MAX_GET_TOKEN_RETRIES;
-          return token.access_token;
-        }
-        if (this._retriesLeft > 0) {
-          this._retriesLeft--;
-          this._cachedTokenPromise = null;
-          return this._getAccessToken();
-        }
-        // Fall back to anonymous access.
-        return null;
-      });
-    },
+    });
+    return result;
+  }
 
-    _fetchWithAccessToken(url, options, accessToken) {
-      const params = [];
+  _isTokenValid(token) {
+    if (!token) { return false; }
+    if (!token.access_token || !token.expires_at) { return false; }
 
-      if (accessToken) {
-        params.push(`access_token=${accessToken}`);
-        const baseUrl = Gerrit.BaseUrlBehavior.getBaseUrl();
-        const pathname = baseUrl ?
-          url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
-        if (!pathname.startsWith('/a/')) {
-          url = url.replace(pathname, '/a' + pathname);
-        }
+    const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
+    if (Date.now() >= expiration.getTime()) { return false; }
+
+    return true;
+  }
+
+  _fetchWithXsrfToken(url, options) {
+    if (options.method && options.method !== 'GET') {
+      const token = this._getCookie('XSRF_TOKEN');
+      if (token) {
+        options.headers.append('X-Gerrit-Auth', token);
       }
+    }
+    options.credentials = 'same-origin';
+    return fetch(url, options);
+  }
 
-      const method = options.method || 'GET';
-      let contentType = options.headers.get('Content-Type');
-
-      // For all requests with body, ensure json content type.
-      if (!contentType && options.body) {
-        contentType = 'application/json';
+  /**
+   * @return {!Promise<string>}
+   */
+  _getAccessToken() {
+    if (!this._cachedTokenPromise) {
+      this._cachedTokenPromise = this._getToken();
+    }
+    return this._cachedTokenPromise.then(token => {
+      if (this._isTokenValid(token)) {
+        this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+        return token.access_token;
       }
-
-      if (method !== 'GET') {
-        options.method = 'POST';
-        params.push(`$m=${method}`);
-        // If a request is not GET, and does not have a body, ensure text/plain
-        // content type.
-        if (!contentType) {
-          contentType = 'text/plain';
-        }
+      if (this._retriesLeft > 0) {
+        this._retriesLeft--;
+        this._cachedTokenPromise = null;
+        return this._getAccessToken();
       }
+      // Fall back to anonymous access.
+      return null;
+    });
+  }
 
-      if (contentType) {
-        options.headers.set('Content-Type', 'text/plain');
-        params.push(`$ct=${encodeURIComponent(contentType)}`);
+  _fetchWithAccessToken(url, options, accessToken) {
+    const params = [];
+
+    if (accessToken) {
+      params.push(`access_token=${accessToken}`);
+      const baseUrl = this.baseUrl;
+      const pathname = baseUrl ?
+        url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
+      if (!pathname.startsWith('/a/')) {
+        url = url.replace(pathname, '/a' + pathname);
       }
+    }
 
-      if (params.length) {
-        url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
+    const method = options.method || 'GET';
+    let contentType = options.headers.get('Content-Type');
+
+    // For all requests with body, ensure json content type.
+    if (!contentType && options.body) {
+      contentType = 'application/json';
+    }
+
+    if (method !== 'GET') {
+      options.method = 'POST';
+      params.push(`$m=${method}`);
+      // If a request is not GET, and does not have a body, ensure text/plain
+      // content type.
+      if (!contentType) {
+        contentType = 'text/plain';
       }
-      return fetch(url, options);
-    },
-  };
+    }
 
-  window.Gerrit.Auth = Gerrit.Auth;
-})(window);
+    if (contentType) {
+      options.headers.set('Content-Type', 'text/plain');
+      params.push(`$ct=${encodeURIComponent(contentType)}`);
+    }
+
+    if (params.length) {
+      url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
+    }
+    return fetch(url, options);
+  }
+}
+
+Auth.TYPE = {
+  XSRF_TOKEN: 'xsrf_token',
+  ACCESS_TOKEN: 'access_token',
+};
+
+/** @enum {number} */
+Auth.STATUS = {
+  UNDETERMINED: 0,
+  AUTHED: 1,
+  NOT_AUTHED: 2,
+  ERROR: 3,
+};
+
+Auth.CREDS_EXPIRED_MSG = 'Credentials expired.';
+// TODO(dmfilippov) move to appContext
+export const authService = new Auth();
+
+// TODO(dmfilippov) Remove the following lines with assignments
+// Plugins can use global Auth because it was accessible with
+// the global Gerrit... variable. To avoid breaking changes in plugins
+// temporary assign global variables.
+window.Gerrit = window.Gerrit || {};
+window.Gerrit.Auth = authService;
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..5fa476f 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
@@ -17,173 +17,378 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="gr-auth.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {Auth, authService} from './gr-auth.js';
+import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
 
-<script>
-  suite('gr-auth', () => {
-    let auth;
-    let sandbox;
+suite('gr-auth', () => {
+  let auth;
+  let sandbox;
 
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    auth = authService;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('Auth class methods', () => {
+    let fakeFetch;
     setup(() => {
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
-      auth = Gerrit.Auth;
+      auth = new Auth();
+      fakeFetch = sandbox.stub(window, 'fetch');
     });
 
-    teardown(() => {
-      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');
-        });
+    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', () => {
-        sandbox.stub(auth, '_getCookie')
-            .withArgs('XSRF_TOKEN')
-            .returns('foobar');
-        return 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');
+    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();
         });
       });
     });
 
-    suite('cors (access token)', () => {
-      let getToken;
-
-      const makeToken = opt_accessToken => {
-        return {
-          access_token: opt_accessToken || 'zbaz',
-          expires_at: new Date(Date.now() + 10e8).getTime(),
-        };
-      };
-
-      setup(() => {
-        getToken = sandbox.stub();
-        getToken.returns(Promise.resolve(makeToken()));
-        auth.setup(getToken);
-      });
-
-      test('base url support', () => {
-        const baseUrl = 'http://foo';
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
-        return auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
-          const [url] = fetch.lastCall.args;
-          assert.equal(url, 'http://foo/a/url?access_token=zbaz');
+    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('fetch not signed in', () => {
-        getToken.returns(Promise.resolve());
-        return 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);
+    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('fetch signed in', () => {
-        return 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');
+    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('getToken calls are cached', () => {
-        return Promise.all([
-          auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
-          assert.equal(getToken.callCount, 1);
+    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();
+        gerritEventEmitter.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          assert.isTrue(emitStub.called);
+          done();
         });
       });
+    });
 
-      test('getToken refreshes token', () => {
-        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');
+    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();
+        gerritEventEmitter.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.isTrue(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.ERROR);
+          done();
         });
       });
+    });
 
-      test('signed in token error falls back to anonymous', () => {
-        getToken.returns(Promise.resolve('rubbish'));
-        return auth.fetch('/url', {bar: 'bar'}).then(() => {
-          const [url, options] = fetch.lastCall.args;
-          assert.equal(url, '/url');
-          assert.equal(options.bar, 'bar');
+    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();
+        gerritEventEmitter.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.isFalse(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
         });
       });
+    });
 
-      test('_isTokenValid', () => {
-        assert.isFalse(auth._isTokenValid());
-        assert.isFalse(auth._isTokenValid({}));
-        assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
-        assert.isFalse(auth._isTokenValid({
-          access_token: 'foo',
-          expires_at: Date.now()/1000 - 1,
-        }));
-        assert.isTrue(auth._isTokenValid({
-          access_token: 'foo',
-          expires_at: Date.now()/1000 + 1,
-        }));
-      });
-
-      test('HTTP PUT with content type', () => {
-        const originalOptions = {
-          method: 'PUT',
-          headers: new Headers({'Content-Type': 'mail/pigeon'}),
-        };
-        return auth.fetch('/url', originalOptions).then(() => {
-          assert.isTrue(getToken.called);
-          const [url, options] = fetch.lastCall.args;
-          assert.include(url, '$ct=mail%2Fpigeon');
-          assert.include(url, '$m=PUT');
-          assert.include(url, 'access_token=zbaz');
-          assert.equal(options.method, 'POST');
-          assert.equal(options.headers.get('Content-Type'), 'text/plain');
-        });
-      });
-
-      test('HTTP PUT without content type', () => {
-        const originalOptions = {
-          method: 'PUT',
-        };
-        return auth.fetch('/url', originalOptions).then(() => {
-          assert.isTrue(getToken.called);
-          const [url, options] = fetch.lastCall.args;
-          assert.include(url, '$ct=text%2Fplain');
-          assert.include(url, '$m=PUT');
-          assert.include(url, 'access_token=zbaz');
-          assert.equal(options.method, 'POST');
-          assert.equal(options.headers.get('Content-Type'), 'text/plain');
+    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();
+        gerritEventEmitter.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');
+      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 => {
+      return {
+        access_token: opt_accessToken || 'zbaz',
+        expires_at: new Date(Date.now() + 10e8).getTime(),
+      };
+    };
+
+    setup(() => {
+      getToken = sandbox.stub();
+      getToken.returns(Promise.resolve(makeToken()));
+      auth.setup(getToken);
+    });
+
+    test('base url support', done => {
+      const baseUrl = 'http://foo';
+      sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
+      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', done => {
+      getToken.returns(Promise.resolve());
+      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', 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', done => {
+      Promise.all([
+        auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
+        assert.equal(getToken.callCount, 1);
+        done();
+      });
+    });
+
+    test('getToken refreshes token', done => {
+      sandbox.stub(auth, '_isTokenValid');
+      auth._isTokenValid
+          .onFirstCall().returns(true)
+          .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', done => {
+      getToken.returns(Promise.resolve('rubbish'));
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.bar, 'bar');
+        done();
+      });
+    });
+
+    test('_isTokenValid', () => {
+      assert.isFalse(auth._isTokenValid());
+      assert.isFalse(auth._isTokenValid({}));
+      assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
+      assert.isFalse(auth._isTokenValid({
+        access_token: 'foo',
+        expires_at: Date.now()/1000 - 1,
+      }));
+      assert.isTrue(auth._isTokenValid({
+        access_token: 'foo',
+        expires_at: Date.now()/1000 + 1,
+      }));
+    });
+
+    test('HTTP PUT with content type', done => {
+      const originalOptions = {
+        method: 'PUT',
+        headers: new Headers({'Content-Type': 'mail/pigeon'}),
+      };
+      auth.fetch('/url', originalOptions).then(() => {
+        assert.isTrue(getToken.called);
+        const [url, options] = fetch.lastCall.args;
+        assert.include(url, '$ct=mail%2Fpigeon');
+        assert.include(url, '$m=PUT');
+        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', done => {
+      const originalOptions = {
+        method: 'PUT',
+      };
+      auth.fetch('/url', originalOptions).then(() => {
+        assert.isTrue(getToken.called);
+        const [url, options] = fetch.lastCall.args;
+        assert.include(url, '$ct=text%2Fplain');
+        assert.include(url, '$m=PUT');
+        assert.include(url, 'access_token=zbaz');
+        assert.equal(options.method, 'POST');
+        assert.equal(options.headers.get('Content-Type'), 'text/plain');
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
deleted file mode 100644
index d3500d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<dom-module id="gr-etag-decorator">
-  <script src="gr-etag-decorator.js"></script>
-</dom-module>
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..23b8de7 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
@@ -14,91 +14,93 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Prevent redefinition.
-  if (window.GrEtagDecorator) { return; }
+const $_documentContainer = document.createElement('template');
 
-  // Limit cache size because /change/detail responses may be large.
-  const MAX_CACHE_SIZE = 30;
+$_documentContainer.innerHTML = `<dom-module id="gr-etag-decorator">
+  
+</dom-module>`;
 
-  function GrEtagDecorator() {
-    this._etags = new Map();
-    this._payloadCache = new Map();
+document.head.appendChild($_documentContainer.content);
+
+// Limit cache size because /change/detail responses may be large.
+const MAX_CACHE_SIZE = 30;
+
+/** @constructor */
+export function GrEtagDecorator() {
+  this._etags = new Map();
+  this._payloadCache = new Map();
+}
+
+/**
+ * Get or upgrade fetch options to include an ETag in a request.
+ *
+ * @param {string} url The URL being fetched.
+ * @param {!Object=} opt_options Optional options object in which to include
+ *     the ETag request header. If omitted, the result will be a fresh option
+ *     set.
+ * @return {!Object}
+ */
+GrEtagDecorator.prototype.getOptions = function(url, opt_options) {
+  const etag = this._etags.get(url);
+  if (!etag) {
+    return opt_options;
   }
+  const options = Object.assign({}, opt_options);
+  options.headers = options.headers || new Headers();
+  options.headers.set('If-None-Match', this._etags.get(url));
+  return options;
+};
 
-  /**
-   * Get or upgrade fetch options to include an ETag in a request.
-   *
-   * @param {string} url The URL being fetched.
-   * @param {!Object=} opt_options Optional options object in which to include
-   *     the ETag request header. If omitted, the result will be a fresh option
-   *     set.
-   * @return {!Object}
-   */
-  GrEtagDecorator.prototype.getOptions = function(url, opt_options) {
-    const etag = this._etags.get(url);
-    if (!etag) {
-      return opt_options;
+/**
+ * Handle a response to a request with ETag headers, potentially incorporating
+ * its result in the payload cache.
+ *
+ * @param {string} url The URL of the request.
+ * @param {!Response} response The response object.
+ * @param {string} payload The raw, unparsed JSON contained in the response
+ *     body. Note: because response.text() cannot be read twice, this must be
+ *     provided separately.
+ */
+GrEtagDecorator.prototype.collect = function(url, response, payload) {
+  if (!response ||
+      !response.ok ||
+      response.status !== 200 ||
+      response.status === 304) {
+    // 304 Not Modified means etag is still valid.
+    return;
+  }
+  this._payloadCache.set(url, payload);
+  const etag = response.headers && response.headers.get('etag');
+  if (!etag) {
+    this._etags.delete(url);
+  } else {
+    this._etags.set(url, etag);
+    this._truncateCache();
+  }
+};
+
+/**
+ * Get the cached payload for a given URL.
+ *
+ * @param {string} url
+ * @return {string|undefined} Returns the unparsed JSON payload from the
+ *     cache.
+ */
+GrEtagDecorator.prototype.getCachedPayload = function(url) {
+  return this._payloadCache.get(url);
+};
+
+/**
+ * Limit the cache size to MAX_CACHE_SIZE.
+ */
+GrEtagDecorator.prototype._truncateCache = function() {
+  for (const url of this._etags.keys()) {
+    if (this._etags.size <= MAX_CACHE_SIZE) {
+      break;
     }
-    const options = Object.assign({}, opt_options);
-    options.headers = options.headers || new Headers();
-    options.headers.set('If-None-Match', this._etags.get(url));
-    return options;
-  };
-
-  /**
-   * Handle a response to a request with ETag headers, potentially incorporating
-   * its result in the payload cache.
-   *
-   * @param {string} url The URL of the request.
-   * @param {!Response} response The response object.
-   * @param {string} payload The raw, unparsed JSON contained in the response
-   *     body. Note: because response.text() cannot be read twice, this must be
-   *     provided separately.
-   */
-  GrEtagDecorator.prototype.collect = function(url, response, payload) {
-    if (!response ||
-        !response.ok ||
-        response.status !== 200 ||
-        response.status === 304) {
-      // 304 Not Modified means etag is still valid.
-      return;
-    }
-    this._payloadCache.set(url, payload);
-    const etag = response.headers && response.headers.get('etag');
-    if (!etag) {
-      this._etags.delete(url);
-    } else {
-      this._etags.set(url, etag);
-      this._truncateCache();
-    }
-  };
-
-  /**
-   * Get the cached payload for a given URL.
-   *
-   * @param {string} url
-   * @return {string|undefined} Returns the unparsed JSON payload from the
-   *     cache.
-   */
-  GrEtagDecorator.prototype.getCachedPayload = function(url) {
-    return this._payloadCache.get(url);
-  };
-
-  /**
-   * Limit the cache size to MAX_CACHE_SIZE.
-   */
-  GrEtagDecorator.prototype._truncateCache = function() {
-    for (const url of this._etags.keys()) {
-      if (this._etags.size <= MAX_CACHE_SIZE) {
-        break;
-      }
-      this._etags.delete(url);
-      this._payloadCache.delete(url);
-    }
-  };
-
-  window.GrEtagDecorator = GrEtagDecorator;
-})(window);
+    this._etags.delete(url);
+    this._payloadCache.delete(url);
+  }
+};
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..cfa164f 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
@@ -17,83 +17,83 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script src="gr-etag-decorator.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {GrEtagDecorator} from './gr-etag-decorator.js';
 
-<script>
-  suite('gr-etag-decorator', () => {
-    let etag;
-    let sandbox;
+suite('gr-etag-decorator', () => {
+  let etag;
+  let sandbox;
 
-    const fakeRequest = (opt_etag, opt_status) => {
-      const headers = new Headers();
-      if (opt_etag) {
-        headers.set('etag', opt_etag);
-      }
-      const status = opt_status || 200;
-      return {ok: true, status, headers};
-    };
+  const fakeRequest = (opt_etag, opt_status) => {
+    const headers = new Headers();
+    if (opt_etag) {
+      headers.set('etag', opt_etag);
+    }
+    const status = opt_status || 200;
+    return {ok: true, status, headers};
+  };
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      etag = new GrEtagDecorator();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('exists', () => {
-      assert.isOk(etag);
-    });
-
-    test('works', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      const options = etag.getOptions('/foo');
-      assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-    });
-
-    test('updates etags', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      etag.collect('/foo', fakeRequest('baz'));
-      const options = etag.getOptions('/foo');
-      assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
-    });
-
-    test('discards empty etags', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      etag.collect('/foo', fakeRequest());
-      const options = etag.getOptions('/foo', {headers: new Headers()});
-      assert.isNull(options.headers.get('If-None-Match'));
-    });
-
-    test('discards etags in order used', () => {
-      etag.collect('/foo', fakeRequest('bar'));
-      _.times(29, i => {
-        etag.collect('/qaz/' + i, fakeRequest('qaz'));
-      });
-      let options = etag.getOptions('/foo');
-      assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-      etag.collect('/zaq', fakeRequest('zaq'));
-      options = etag.getOptions('/foo', {headers: new Headers()});
-      assert.isNull(options.headers.get('If-None-Match'));
-    });
-
-    test('getCachedPayload', () => {
-      const payload = 'payload';
-      etag.collect('/foo', fakeRequest('bar'), payload);
-      assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-      etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
-      assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-      etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
-      assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    etag = new GrEtagDecorator();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('exists', () => {
+    assert.isOk(etag);
+  });
+
+  test('works', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+  });
+
+  test('updates etags', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    etag.collect('/foo', fakeRequest('baz'));
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
+  });
+
+  test('discards empty etags', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    etag.collect('/foo', fakeRequest());
+    const options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options.headers.get('If-None-Match'));
+  });
+
+  test('discards etags in order used', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    _.times(29, i => {
+      etag.collect('/qaz/' + i, fakeRequest('qaz'));
+    });
+    let options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+    etag.collect('/zaq', fakeRequest('zaq'));
+    options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options.headers.get('If-None-Match'));
+  });
+
+  test('getCachedPayload', () => {
+    const payload = 'payload';
+    etag.collect('/foo', fakeRequest('bar'), payload);
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
+    assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
deleted file mode 100644
index 7461ac4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ /dev/null
@@ -1,36 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
-<link rel="import" href="gr-etag-decorator.html">
-
-<!-- NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 -->
-<script src="/bower_components/es6-promise/dist/es6-promise.min.js"></script>
-<script src="/bower_components/fetch/fetch.js"></script>
-
-<dom-module id="gr-rest-api-interface">
-  <!-- NB: Order is important, because of namespaced classes. -->
-  <script src="gr-rest-apis/gr-rest-api-helper.js"></script>
-  <script src="gr-auth.js"></script>
-  <script src="gr-reviewer-updates-parser.js"></script>
-  <script src="gr-rest-api-interface.js"></script>
-</dom-module>
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 8c34e33..b675df7 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
@@ -14,74 +14,92 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+/* NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 */
+/* NB: Order is important, because of namespaced classes. */
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+import '../../../scripts/bundled-polymer.js';
 
-  const DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
-  const JSON_PREFIX = ')]}\'';
-  const MAX_PROJECT_RESULTS = 25;
-  // This value is somewhat arbitrary and not based on research or calculations.
-  const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
-  const PARENT_PATCH_NUM = 'PARENT';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import 'es6-promise/lib/es6-promise.js';
+import 'whatwg-fetch/fetch.js';
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
+import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {GrEtagDecorator} from './gr-etag-decorator.js';
+import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-apis/gr-rest-api-helper.js';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
+import {util} from '../../../scripts/util.js';
+import {authService} from './gr-auth.js';
 
-  const Requests = {
-    SEND_DIFF_DRAFT: 'sendDiffDraft',
-  };
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+const JSON_PREFIX = ')]}\'';
+const MAX_PROJECT_RESULTS = 25;
+// This value is somewhat arbitrary and not based on research or calculations.
+const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
+const PARENT_PATCH_NUM = 'PARENT';
 
-  const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
-      'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
-  const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
+const Requests = {
+  SEND_DIFF_DRAFT: 'sendDiffDraft',
+};
 
-  const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
-  const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
-      '/revisions/*';
+const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
+    'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
+const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
 
-  Polymer({
-    is: 'gr-rest-api-interface',
+const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
+    '/revisions/*';
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+/**
+ * @extends Polymer.Element
+ */
+class GrRestApiInterface extends mixinBehaviors( [
+  PathListBehavior,
+  PatchSetBehavior,
+  RESTClientBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get is() { return 'gr-rest-api-interface'; }
+  /**
+   * Fired when an server error occurs.
+   *
+   * @event server-error
+   */
 
-    /**
-     * Fired when an server error occurs.
-     *
-     * @event server-error
-     */
+  /**
+   * Fired when a network error occurs.
+   *
+   * @event network-error
+   */
 
-    /**
-     * Fired when a network error occurs.
-     *
-     * @event network-error
-     */
+  /**
+   * Fired after an RPC completes.
+   *
+   * @event rpc-log
+   */
 
-    /**
-     * Fired when credentials were rejected by server (e.g. expired).
-     *
-     * @event auth-error
-     */
+  constructor() {
+    super();
+    this.JSON_PREFIX = JSON_PREFIX;
+  }
 
-    /**
-     * Fired after an RPC completes.
-     *
-     * @event rpc-log
-     */
-
-    properties: {
+  static get properties() {
+    return {
       _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.
@@ -101,2493 +119,2551 @@
         type: Object,
         value: {}, // Intentional to share the object across instances.
       },
-      _auth: {
-        type: Object,
-        value: Gerrit.Auth, // Share across instances.
-      },
-    },
+    };
+  }
 
-    JSON_PREFIX,
+  /** @override */
+  created() {
+    super.created();
+    this._auth = authService;
+    this._initRestApiHelper();
+  }
 
-    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.
-      */
-      //
+  _initRestApiHelper() {
+    if (this._restApiHelper) {
+      return;
+    }
+    if (this._cache && this._auth && this._sharedFetchPromises) {
+      this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
+          this._sharedFetchPromises, this);
+    }
+  }
 
-      this._initRestApiHelper();
-    },
+  _fetchSharedCacheURL(req) {
+    // Cache is shared across instances
+    return this._restApiHelper.fetchCacheURL(req);
+  }
 
-    ready() {
-      // See comments in created()
-      this._initRestApiHelper();
-    },
+  /**
+   * @param {!Object} response
+   * @return {?}
+   */
+  getResponseObject(response) {
+    return this._restApiHelper.getResponseObject(response);
+  }
 
-    _initRestApiHelper() {
-      if (this._restApiHelper) {
-        return;
-      }
-      if (this._cache && this._auth && this._sharedFetchPromises
-          && this._credentialCheck) {
-        this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
-            this._sharedFetchPromises, this._credentialCheck, this);
-      }
-    },
-
-    _fetchSharedCacheURL(req) {
-      // Cache is shared across instances
-      return this._restApiHelper.fetchCacheURL(req);
-    },
-
-    /**
-     * @param {!Object} response
-     * @return {?}
-     */
-    getResponseObject(response) {
-      return this._restApiHelper.getResponseObject(response);
-    },
-
-    getConfig(noCache) {
-      if (!noCache) {
-        return this._fetchSharedCacheURL({
-          url: '/config/server/info',
-          reportUrlAsIs: true,
-        });
-      }
-
-      return this._restApiHelper.fetchJSON({
+  getConfig(noCache) {
+    if (!noCache) {
+      return this._fetchSharedCacheURL({
         url: '/config/server/info',
         reportUrlAsIs: true,
       });
-    },
+    }
 
-    getRepo(repo, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: '/projects/' + encodeURIComponent(repo),
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*',
-      });
-    },
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/info',
+      reportUrlAsIs: true,
+    });
+  }
 
-    getProjectConfig(repo, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: '/projects/' + encodeURIComponent(repo) + '/config',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/config',
-      });
-    },
+  getRepo(repo, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo),
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*',
+    });
+  }
 
-    getRepoAccess(repo) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: '/access/?project=' + encodeURIComponent(repo),
-        anonymizedUrl: '/access/?project=*',
-      });
-    },
+  getProjectConfig(repo, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo) + '/config',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/config',
+    });
+  }
 
-    getRepoDashboards(repo, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/dashboards?inherited',
-      });
-    },
+  getRepoAccess(repo) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/access/?project=' + encodeURIComponent(repo),
+      anonymizedUrl: '/access/?project=*',
+    });
+  }
 
-    saveRepoConfig(repo, config, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const url = `/projects/${encodeURIComponent(repo)}/config`;
-      this._cache.delete(url);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url,
-        body: config,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/config',
-      });
-    },
+  getRepoDashboards(repo, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/dashboards?inherited',
+    });
+  }
 
-    runRepoGC(repo, opt_errFn) {
-      if (!repo) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(repo);
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: `/projects/${encodeName}/gc`,
-        body: '',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/gc',
-      });
-    },
+  saveRepoConfig(repo, config, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const url = `/projects/${encodeURIComponent(repo)}/config`;
+    this._cache.delete(url);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url,
+      body: config,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/config',
+    });
+  }
 
-    /**
-     * @param {?Object} config
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createRepo(config, opt_errFn) {
-      if (!config.name) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(config.name);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeName}`,
-        body: config,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*',
-      });
-    },
+  runRepoGC(repo, opt_errFn) {
+    if (!repo) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: `/projects/${encodeName}/gc`,
+      body: '',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/gc',
+    });
+  }
 
-    /**
-     * @param {?Object} config
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createGroup(config, opt_errFn) {
-      if (!config.name) { return ''; }
-      const encodeName = encodeURIComponent(config.name);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeName}`,
-        body: config,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*',
-      });
-    },
+  /**
+   * @param {?Object} config
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createRepo(config, opt_errFn) {
+    if (!config.name) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(config.name);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeName}`,
+      body: config,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*',
+    });
+  }
 
-    getGroupConfig(group, opt_errFn) {
-      return this._restApiHelper.fetchJSON({
-        url: `/groups/${encodeURIComponent(group)}/detail`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/detail',
-      });
-    },
+  /**
+   * @param {?Object} config
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createGroup(config, opt_errFn) {
+    if (!config.name) { return ''; }
+    const encodeName = encodeURIComponent(config.name);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeName}`,
+      body: config,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*',
+    });
+  }
 
-    /**
-     * @param {string} repo
-     * @param {string} ref
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteRepoBranches(repo, ref, opt_errFn) {
-      if (!repo || !ref) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(repo);
-      const encodeRef = encodeURIComponent(ref);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/projects/${encodeName}/branches/${encodeRef}`,
-        body: '',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/branches/*',
-      });
-    },
+  getGroupConfig(group, opt_errFn) {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(group)}/detail`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/detail',
+    });
+  }
 
-    /**
-     * @param {string} repo
-     * @param {string} ref
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteRepoTags(repo, ref, opt_errFn) {
-      if (!repo || !ref) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(repo);
-      const encodeRef = encodeURIComponent(ref);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/projects/${encodeName}/tags/${encodeRef}`,
-        body: '',
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/tags/*',
-      });
-    },
+  /**
+   * @param {string} repo
+   * @param {string} ref
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteRepoBranches(repo, ref, opt_errFn) {
+    if (!repo || !ref) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    const encodeRef = encodeURIComponent(ref);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/projects/${encodeName}/branches/${encodeRef}`,
+      body: '',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
 
-    /**
-     * @param {string} name
-     * @param {string} branch
-     * @param {string} revision
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createRepoBranch(name, branch, revision, opt_errFn) {
-      if (!name || !branch || !revision) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(name);
-      const encodeBranch = encodeURIComponent(branch);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeName}/branches/${encodeBranch}`,
-        body: revision,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/branches/*',
-      });
-    },
+  /**
+   * @param {string} repo
+   * @param {string} ref
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteRepoTags(repo, ref, opt_errFn) {
+    if (!repo || !ref) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    const encodeRef = encodeURIComponent(ref);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/projects/${encodeName}/tags/${encodeRef}`,
+      body: '',
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
 
-    /**
-     * @param {string} name
-     * @param {string} tag
-     * @param {string} revision
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    createRepoTag(name, tag, revision, opt_errFn) {
-      if (!name || !tag || !revision) { return ''; }
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      const encodeName = encodeURIComponent(name);
-      const encodeTag = encodeURIComponent(tag);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeName}/tags/${encodeTag}`,
-        body: revision,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/tags/*',
-      });
-    },
+  /**
+   * @param {string} name
+   * @param {string} branch
+   * @param {string} revision
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createRepoBranch(name, branch, revision, opt_errFn) {
+    if (!name || !branch || !revision) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(name);
+    const encodeBranch = encodeURIComponent(branch);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeName}/branches/${encodeBranch}`,
+      body: revision,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
 
-    /**
-     * @param {!string} groupName
-     * @returns {!Promise<boolean>}
-     */
-    getIsGroupOwner(groupName) {
-      const encodeName = encodeURIComponent(groupName);
-      const req = {
-        url: `/groups/?owned&g=${encodeName}`,
-        anonymizedUrl: '/groups/owned&g=*',
-      };
-      return this._fetchSharedCacheURL(req)
-          .then(configs => configs.hasOwnProperty(groupName));
-    },
+  /**
+   * @param {string} name
+   * @param {string} tag
+   * @param {string} revision
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  createRepoTag(name, tag, revision, opt_errFn) {
+    if (!name || !tag || !revision) { return ''; }
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(name);
+    const encodeTag = encodeURIComponent(tag);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeName}/tags/${encodeTag}`,
+      body: revision,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
 
-    getGroupMembers(groupName, opt_errFn) {
-      const encodeName = encodeURIComponent(groupName);
-      return this._restApiHelper.fetchJSON({
-        url: `/groups/${encodeName}/members/`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/members',
-      });
-    },
+  /**
+   * @param {!string} groupName
+   * @returns {!Promise<boolean>}
+   */
+  getIsGroupOwner(groupName) {
+    const encodeName = encodeURIComponent(groupName);
+    const req = {
+      url: `/groups/?owned&g=${encodeName}`,
+      anonymizedUrl: '/groups/owned&g=*',
+    };
+    return this._fetchSharedCacheURL(req)
+        .then(configs => configs.hasOwnProperty(groupName));
+  }
 
-    getIncludedGroup(groupName) {
-      return this._restApiHelper.fetchJSON({
-        url: `/groups/${encodeURIComponent(groupName)}/groups/`,
-        anonymizedUrl: '/groups/*/groups',
-      });
-    },
+  getGroupMembers(groupName, opt_errFn) {
+    const encodeName = encodeURIComponent(groupName);
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeName}/members/`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/members',
+    });
+  }
 
-    saveGroupName(groupId, name) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/name`,
-        body: {name},
-        anonymizedUrl: '/groups/*/name',
-      });
-    },
+  getIncludedGroup(groupName) {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+      anonymizedUrl: '/groups/*/groups',
+    });
+  }
 
-    saveGroupOwner(groupId, ownerId) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/owner`,
-        body: {owner: ownerId},
-        anonymizedUrl: '/groups/*/owner',
-      });
-    },
+  saveGroupName(groupId, name) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/name`,
+      body: {name},
+      anonymizedUrl: '/groups/*/name',
+    });
+  }
 
-    saveGroupDescription(groupId, description) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/description`,
-        body: {description},
-        anonymizedUrl: '/groups/*/description',
-      });
-    },
+  saveGroupOwner(groupId, ownerId) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/owner`,
+      body: {owner: ownerId},
+      anonymizedUrl: '/groups/*/owner',
+    });
+  }
 
-    saveGroupOptions(groupId, options) {
-      const encodeId = encodeURIComponent(groupId);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeId}/options`,
-        body: options,
-        anonymizedUrl: '/groups/*/options',
-      });
-    },
+  saveGroupDescription(groupId, description) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/description`,
+      body: {description},
+      anonymizedUrl: '/groups/*/description',
+    });
+  }
 
-    getGroupAuditLog(group, opt_errFn) {
-      return this._fetchSharedCacheURL({
-        url: '/groups/' + group + '/log.audit',
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/log.audit',
-      });
-    },
+  saveGroupOptions(groupId, options) {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeId}/options`,
+      body: options,
+      anonymizedUrl: '/groups/*/options',
+    });
+  }
 
-    saveGroupMembers(groupName, groupMembers) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeMember = encodeURIComponent(groupMembers);
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/groups/${encodeName}/members/${encodeMember}`,
-        parseResponse: true,
-        anonymizedUrl: '/groups/*/members/*',
-      });
-    },
+  getGroupAuditLog(group, opt_errFn) {
+    return this._fetchSharedCacheURL({
+      url: '/groups/' + group + '/log.audit',
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/log.audit',
+    });
+  }
 
-    saveIncludedGroup(groupName, includedGroup, opt_errFn) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeIncludedGroup = encodeURIComponent(includedGroup);
-      const req = {
-        method: 'PUT',
-        url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/groups/*/groups/*',
-      };
-      return this._restApiHelper.send(req).then(response => {
-        if (response.ok) {
-          return this.getResponseObject(response);
-        }
-      });
-    },
+  saveGroupMembers(groupName, groupMembers) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(groupMembers);
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      parseResponse: true,
+      anonymizedUrl: '/groups/*/members/*',
+    });
+  }
 
-    deleteGroupMembers(groupName, groupMembers) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeMember = encodeURIComponent(groupMembers);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/groups/${encodeName}/members/${encodeMember}`,
-        anonymizedUrl: '/groups/*/members/*',
-      });
-    },
-
-    deleteIncludedGroup(groupName, includedGroup) {
-      const encodeName = encodeURIComponent(groupName);
-      const encodeIncludedGroup = encodeURIComponent(includedGroup);
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
-        anonymizedUrl: '/groups/*/groups/*',
-      });
-    },
-
-    getVersion() {
-      return this._fetchSharedCacheURL({
-        url: '/config/server/version',
-        reportUrlAsIs: true,
-      });
-    },
-
-    getDiffPreferences() {
-      return this.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          return this._fetchSharedCacheURL({
-            url: '/accounts/self/preferences.diff',
-            reportUrlAsIs: true,
-          });
-        }
-        // These defaults should match the defaults in
-        // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
-        // NOTE: There are some settings that don't apply to PolyGerrit
-        // (Render mode being at least one of them).
-        return Promise.resolve({
-          auto_hide_diff_table_header: true,
-          context: 10,
-          cursor_blink_rate: 0,
-          font_size: 12,
-          ignore_whitespace: 'IGNORE_NONE',
-          intraline_difference: true,
-          line_length: 100,
-          line_wrapping: false,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        });
-      });
-    },
-
-    getEditPreferences() {
-      return this.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          return this._fetchSharedCacheURL({
-            url: '/accounts/self/preferences.edit',
-            reportUrlAsIs: true,
-          });
-        }
-        // These defaults should match the defaults in
-        // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
-        return Promise.resolve({
-          auto_close_brackets: false,
-          cursor_blink_rate: 0,
-          hide_line_numbers: false,
-          hide_top_menu: false,
-          indent_unit: 2,
-          indent_with_tabs: false,
-          key_map_type: 'DEFAULT',
-          line_length: 100,
-          line_wrapping: false,
-          match_brackets: true,
-          show_base: false,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        });
-      });
-    },
-
-    /**
-     * @param {?Object} prefs
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    savePreferences(prefs, opt_errFn) {
-      // Note (Issue 5142): normalize the download scheme with lower case before
-      // saving.
-      if (prefs.download_scheme) {
-        prefs.download_scheme = prefs.download_scheme.toLowerCase();
+  saveIncludedGroup(groupName, includedGroup, opt_errFn) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    const req = {
+      method: 'PUT',
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/groups/*/groups/*',
+    };
+    return this._restApiHelper.send(req).then(response => {
+      if (response.ok) {
+        return this.getResponseObject(response);
       }
+    });
+  }
 
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/preferences',
-        body: prefs,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
+  deleteGroupMembers(groupName, groupMembers) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(groupMembers);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      anonymizedUrl: '/groups/*/members/*',
+    });
+  }
+
+  deleteIncludedGroup(groupName, includedGroup) {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      anonymizedUrl: '/groups/*/groups/*',
+    });
+  }
+
+  getVersion() {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/version',
+      reportUrlAsIs: true,
+    });
+  }
+
+  getDiffPreferences() {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.diff',
+          reportUrlAsIs: true,
+        });
+      }
+      // These defaults should match the defaults in
+      // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+      // NOTE: There are some settings that don't apply to PolyGerrit
+      // (Render mode being at least one of them).
+      return Promise.resolve({
+        auto_hide_diff_table_header: true,
+        context: 10,
+        cursor_blink_rate: 0,
+        font_size: 12,
+        ignore_whitespace: 'IGNORE_NONE',
+        intraline_difference: true,
+        line_length: 100,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        tab_size: 8,
+        theme: 'DEFAULT',
       });
-    },
+    });
+  }
 
-    /**
-     * @param {?Object} prefs
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveDiffPreferences(prefs, opt_errFn) {
-      // Invalidate the cache.
-      this._cache.delete('/accounts/self/preferences.diff');
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/preferences.diff',
-        body: prefs,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
+  getEditPreferences() {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.edit',
+          reportUrlAsIs: true,
+        });
+      }
+      // These defaults should match the defaults in
+      // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+      return Promise.resolve({
+        auto_close_brackets: false,
+        cursor_blink_rate: 0,
+        hide_line_numbers: false,
+        hide_top_menu: false,
+        indent_unit: 2,
+        indent_with_tabs: false,
+        key_map_type: 'DEFAULT',
+        line_length: 100,
+        line_wrapping: false,
+        match_brackets: true,
+        show_base: false,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        tab_size: 8,
+        theme: 'DEFAULT',
       });
-    },
+    });
+  }
 
-    /**
-     * @param {?Object} prefs
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveEditPreferences(prefs, opt_errFn) {
-      // Invalidate the cache.
-      this._cache.delete('/accounts/self/preferences.edit');
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/preferences.edit',
-        body: prefs,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    },
+  /**
+   * @param {?Object} prefs
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  savePreferences(prefs, opt_errFn) {
+    // Note (Issue 5142): normalize the download scheme with lower case before
+    // saving.
+    if (prefs.download_scheme) {
+      prefs.download_scheme = prefs.download_scheme.toLowerCase();
+    }
 
-    getAccount() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/detail',
-        reportUrlAsIs: true,
-        errFn: resp => {
-          if (!resp || resp.status === 403) {
-            this._cache.delete('/accounts/self/detail');
-          }
-        },
-      });
-    },
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/preferences',
+      body: prefs,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    getAvatarChangeUrl() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/avatar.change.url',
-        reportUrlAsIs: true,
-        errFn: resp => {
-          if (!resp || resp.status === 403) {
-            this._cache.delete('/accounts/self/avatar.change.url');
-          }
-        },
-      });
-    },
+  /**
+   * @param {?Object} prefs
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveDiffPreferences(prefs, opt_errFn) {
+    // Invalidate the cache.
+    this._cache.delete('/accounts/self/preferences.diff');
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/preferences.diff',
+      body: prefs,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    getExternalIds() {
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/self/external.ids',
-        reportUrlAsIs: true,
-      });
-    },
+  /**
+   * @param {?Object} prefs
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveEditPreferences(prefs, opt_errFn) {
+    // Invalidate the cache.
+    this._cache.delete('/accounts/self/preferences.edit');
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/preferences.edit',
+      body: prefs,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    deleteAccountIdentity(id) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/external.ids:delete',
-        body: id,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    },
-
-    /**
-     * @param {string} userId the ID of the user usch as an email address.
-     * @return {!Promise<!Object>}
-     */
-    getAccountDetails(userId) {
-      return this._restApiHelper.fetchJSON({
-        url: `/accounts/${encodeURIComponent(userId)}/detail`,
-        anonymizedUrl: '/accounts/*/detail',
-      });
-    },
-
-    getAccountEmails() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/emails',
-        reportUrlAsIs: true,
-      });
-    },
-
-    /**
-     * @param {string} email
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    addAccountEmail(email, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/emails/' + encodeURIComponent(email),
-        errFn: opt_errFn,
-        anonymizedUrl: '/account/self/emails/*',
-      });
-    },
-
-    /**
-     * @param {string} email
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteAccountEmail(email, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/emails/' + encodeURIComponent(email),
-        errFn: opt_errFn,
-        anonymizedUrl: '/accounts/self/email/*',
-      });
-    },
-
-    /**
-     * @param {string} email
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setPreferredAccountEmail(email, opt_errFn) {
-      const encodedEmail = encodeURIComponent(email);
-      const req = {
-        method: 'PUT',
-        url: `/accounts/self/emails/${encodedEmail}/preferred`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/accounts/self/emails/*/preferred',
-      };
-      return this._restApiHelper.send(req).then(() => {
-        // If result of getAccountEmails is in cache, update it in the cache
-        // so we don't have to invalidate it.
-        const cachedEmails = this._cache.get('/accounts/self/emails');
-        if (cachedEmails) {
-          const emails = cachedEmails.map(entry => {
-            if (entry.email === email) {
-              return {email, preferred: true};
-            } else {
-              return {email};
-            }
-          });
-          this._cache.set('/accounts/self/emails', emails);
+  getAccount() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/detail',
+      reportUrlAsIs: true,
+      errFn: resp => {
+        if (!resp || resp.status === 403) {
+          this._cache.delete('/accounts/self/detail');
         }
-      });
-    },
+      },
+    });
+  }
 
-    /**
-     * @param {?Object} obj
-     */
-    _updateCachedAccount(obj) {
-      // If result of getAccount is in cache, update it in the cache
+  getAvatarChangeUrl() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/avatar.change.url',
+      reportUrlAsIs: true,
+      errFn: resp => {
+        if (!resp || resp.status === 403) {
+          this._cache.delete('/accounts/self/avatar.change.url');
+        }
+      },
+    });
+  }
+
+  getExternalIds() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/external.ids',
+      reportUrlAsIs: true,
+    });
+  }
+
+  deleteAccountIdentity(id) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/external.ids:delete',
+      body: id,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} userId the ID of the user usch as an email address.
+   * @return {!Promise<!Object>}
+   */
+  getAccountDetails(userId) {
+    return this._restApiHelper.fetchJSON({
+      url: `/accounts/${encodeURIComponent(userId)}/detail`,
+      anonymizedUrl: '/accounts/*/detail',
+    });
+  }
+
+  getAccountEmails() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/emails',
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} email
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  addAccountEmail(email, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      errFn: opt_errFn,
+      anonymizedUrl: '/account/self/emails/*',
+    });
+  }
+
+  /**
+   * @param {string} email
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteAccountEmail(email, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      errFn: opt_errFn,
+      anonymizedUrl: '/accounts/self/email/*',
+    });
+  }
+
+  /**
+   * @param {string} email
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setPreferredAccountEmail(email, opt_errFn) {
+    const encodedEmail = encodeURIComponent(email);
+    const req = {
+      method: 'PUT',
+      url: `/accounts/self/emails/${encodedEmail}/preferred`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/accounts/self/emails/*/preferred',
+    };
+    return this._restApiHelper.send(req).then(() => {
+      // If result of getAccountEmails is in cache, update it in the cache
       // so we don't have to invalidate it.
-      const cachedAccount = this._cache.get('/accounts/self/detail');
-      if (cachedAccount) {
-        // Replace object in cache with new object to force UI updates.
-        this._cache.set('/accounts/self/detail',
-            Object.assign({}, cachedAccount, obj));
-      }
-    },
-
-    /**
-     * @param {string} name
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setAccountName(name, opt_errFn) {
-      const req = {
-        method: 'PUT',
-        url: '/accounts/self/name',
-        body: {name},
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(newName => this._updateCachedAccount({name: newName}));
-    },
-
-    /**
-     * @param {string} username
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setAccountUsername(username, opt_errFn) {
-      const req = {
-        method: 'PUT',
-        url: '/accounts/self/username',
-        body: {username},
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(newName => this._updateCachedAccount({username: newName}));
-    },
-
-    /**
-     * @param {string} status
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    setAccountStatus(status, opt_errFn) {
-      const req = {
-        method: 'PUT',
-        url: '/accounts/self/status',
-        body: {status},
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      };
-      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({
-        method: 'PUT',
-        url: '/accounts/self/agreements',
-        body: name,
-        reportUrlAsIs: true,
-      });
-    },
-
-    /**
-     * @param {string=} opt_params
-     */
-    getAccountCapabilities(opt_params) {
-      let queryString = '';
-      if (opt_params) {
-        queryString = '?q=' + opt_params
-            .map(param => { return 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;
-      });
-    },
-
-    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();
-    },
-
-    getDefaultPreferences() {
-      return this._fetchSharedCacheURL({
-        url: '/config/server/preferences',
-        reportUrlAsIs: true,
-      });
-    },
-
-    getPreferences() {
-      return this.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
-          return this._fetchSharedCacheURL(req).then(res => {
-            if (this._isNarrowScreen()) {
-              // Note that this can be problematic, because the diff will stay
-              // unified even after increasing the window width.
-              res.default_diff_view = DiffViewMode.UNIFIED;
-            } else {
-              res.default_diff_view = res.diff_view;
-            }
-            return Promise.resolve(res);
-          });
-        }
-
-        return Promise.resolve({
-          changes_per_page: 25,
-          default_diff_view: this._isNarrowScreen() ?
-            DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
-          diff_view: 'SIDE_BY_SIDE',
-          size_bar_in_change_table: true,
+      const cachedEmails = this._cache.get('/accounts/self/emails');
+      if (cachedEmails) {
+        const emails = cachedEmails.map(entry => {
+          if (entry.email === email) {
+            return {email, preferred: true};
+          } else {
+            return {email};
+          }
         });
-      });
-    },
-
-    getWatchedProjects() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/watched.projects',
-        reportUrlAsIs: true,
-      });
-    },
-
-    /**
-     * @param {string} projects
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveWatchedProjects(projects, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/watched.projects',
-        body: projects,
-        errFn: opt_errFn,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    },
-
-    /**
-     * @param {string} projects
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    deleteWatchedProjects(projects, opt_errFn) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/watched.projects:delete',
-        body: projects,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    },
-
-    _isNarrowScreen() {
-      return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
-    },
-
-    /**
-     * @param {number=} opt_changesPerPage
-     * @param {string|!Array<string>=} opt_query A query or an array of queries.
-     * @param {number|string=} opt_offset
-     * @param {!Object=} opt_options
-     * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
-     *     array, _fetchJSON will return an array of arrays of changeInfos. If it
-     *     is unspecified or a string, _fetchJSON will return an array of
-     *     changeInfos.
-     */
-    getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
-      const options = opt_options || this.listChangesOptionsToHex(
-          this.ListChangesOption.LABELS,
-          this.ListChangesOption.DETAILED_ACCOUNTS
-      );
-      // Issue 4524: respect legacy token with max sortkey.
-      if (opt_offset === 'n,z') {
-        opt_offset = 0;
+        this._cache.set('/accounts/self/emails', emails);
       }
-      const params = {
-        O: options,
-        S: opt_offset || 0,
-      };
-      if (opt_changesPerPage) { params.n = opt_changesPerPage; }
-      if (opt_query && opt_query.length > 0) {
-        params.q = opt_query;
-      }
-      const iterateOverChanges = arr => {
-        for (const change of (arr || [])) {
-          this._maybeInsertInLookup(change);
-        }
-      };
-      const req = {
-        url: '/changes/',
-        params,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.fetchJSON(req).then(response => {
-        // Response may be an array of changes OR an array of arrays of
-        // changes.
-        if (opt_query instanceof Array) {
-          // Normalize the response to look like a multi-query response
-          // when there is only one query.
-          if (opt_query.length === 1) {
-            response = [response];
+    });
+  }
+
+  /**
+   * @param {?Object} obj
+   */
+  _updateCachedAccount(obj) {
+    // If result of getAccount is in cache, update it in the cache
+    // so we don't have to invalidate it.
+    const cachedAccount = this._cache.get('/accounts/self/detail');
+    if (cachedAccount) {
+      // Replace object in cache with new object to force UI updates.
+      this._cache.set('/accounts/self/detail',
+          Object.assign({}, cachedAccount, obj));
+    }
+  }
+
+  /**
+   * @param {string} name
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountName(name, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/name',
+      body: {name},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(newName => this._updateCachedAccount({name: newName}));
+  }
+
+  /**
+   * @param {string} username
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountUsername(username, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/username',
+      body: {username},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(newName => this._updateCachedAccount({username: newName}));
+  }
+
+  /**
+   * @param {string} displayName
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountDisplayName(displayName, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/displayname',
+      body: {display_name: displayName},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(newName => this._updateCachedAccount({displayName: newName}));
+  }
+
+  /**
+   * @param {string} status
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  setAccountStatus(status, opt_errFn) {
+    const req = {
+      method: 'PUT',
+      url: '/accounts/self/status',
+      body: {status},
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    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({
+      method: 'PUT',
+      url: '/accounts/self/agreements',
+      body: name,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string=} opt_params
+   */
+  getAccountCapabilities(opt_params) {
+    let queryString = '';
+    if (opt_params) {
+      queryString = '?q=' + opt_params
+          .map(param => encodeURIComponent(param))
+          .join('&q=');
+    }
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/capabilities' + queryString,
+      anonymizedUrl: '/accounts/self/capabilities?q=*',
+    });
+  }
+
+  getLoggedIn() {
+    return this._auth.authCheck();
+  }
+
+  getIsAdmin() {
+    return this.getLoggedIn()
+        .then(isLoggedIn => {
+          if (isLoggedIn) {
+            return this.getAccountCapabilities();
+          } else {
+            return Promise.resolve();
           }
-          for (const arr of response) {
-            iterateOverChanges(arr);
+        })
+        .then(
+            capabilities => capabilities && capabilities.administrateServer
+        );
+  }
+
+  getDefaultPreferences() {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/preferences',
+      reportUrlAsIs: true,
+    });
+  }
+
+  getPreferences() {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+        return this._fetchSharedCacheURL(req).then(res => {
+          if (this._isNarrowScreen()) {
+            // Note that this can be problematic, because the diff will stay
+            // unified even after increasing the window width.
+            res.default_diff_view = DiffViewMode.UNIFIED;
+          } else {
+            res.default_diff_view = res.diff_view;
           }
-        } else {
-          iterateOverChanges(response);
-        }
-        return response;
-      });
-    },
-
-    /**
-     * Inserts a change into _projectLookup iff it has a valid structure.
-     *
-     * @param {?{ _number: (number|string) }} change
-     */
-    _maybeInsertInLookup(change) {
-      if (change && change.project && change._number) {
-        this.setInProjectLookup(change._number, change.project);
+          return Promise.resolve(res);
+        });
       }
-    },
 
-    /**
-     * TODO (beckysiegel) this needs to be rewritten with the optional param
-     * at the end.
-     *
-     * @param {number|string} changeNum
-     * @param {?number|string=} opt_patchNum passed as null sometimes.
-     * @param {?=} endpoint
-     * @return {!Promise<string>}
-     */
-    getChangeActionURL(changeNum, opt_patchNum, endpoint) {
-      return this._changeBaseURL(changeNum, opt_patchNum)
-          .then(url => url + endpoint);
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
-     */
-    getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      // This list MUST be kept in sync with
-      // ChangeIT#changeDetailsDoesNotRequireIndex
-      const options = [
-        this.ListChangesOption.ALL_COMMITS,
-        this.ListChangesOption.ALL_REVISIONS,
-        this.ListChangesOption.CHANGE_ACTIONS,
-        this.ListChangesOption.DETAILED_LABELS,
-        this.ListChangesOption.DOWNLOAD_COMMANDS,
-        this.ListChangesOption.MESSAGES,
-        this.ListChangesOption.SUBMITTABLE,
-        this.ListChangesOption.WEB_LINKS,
-        this.ListChangesOption.SKIP_MERGEABLE,
-        this.ListChangesOption.SKIP_DIFFSTAT,
-      ];
-      return this.getConfig(false).then(config => {
-        if (config.receive && config.receive.enable_signed_push) {
-          options.push(this.ListChangesOption.PUSH_CERTIFICATES);
-        }
-        const optionsHex = this.listChangesOptionsToHex(...options);
-        return this._getChangeDetail(
-            changeNum, optionsHex, opt_errFn, opt_cancelCondition)
-            .then(GrReviewerUpdatesParser.parse);
+      return Promise.resolve({
+        changes_per_page: 25,
+        default_diff_view: this._isNarrowScreen() ?
+          DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
+        diff_view: 'SIDE_BY_SIDE',
+        size_bar_in_change_table: true,
       });
-    },
+    });
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
-     */
-    getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      const optionsHex = this.listChangesOptionsToHex(
+  getWatchedProjects() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/watched.projects',
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} projects
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveWatchedProjects(projects, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/watched.projects',
+      body: projects,
+      errFn: opt_errFn,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} projects
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  deleteWatchedProjects(projects, opt_errFn) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/watched.projects:delete',
+      body: projects,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
+
+  _isNarrowScreen() {
+    return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
+  }
+
+  /**
+   * @param {number=} opt_changesPerPage
+   * @param {string|!Array<string>=} opt_query A query or an array of queries.
+   * @param {number|string=} opt_offset
+   * @param {!Object=} opt_options
+   * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
+   *     array, _fetchJSON will return an array of arrays of changeInfos. If it
+   *     is unspecified or a string, _fetchJSON will return an array of
+   *     changeInfos.
+   */
+  getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
+    return this.getConfig(false)
+        .then(config => {
+          const options = opt_options || this._getChangesOptionsHex(config);
+          // Issue 4524: respect legacy token with max sortkey.
+          if (opt_offset === 'n,z') {
+            opt_offset = 0;
+          }
+          const params = {
+            O: options,
+            S: opt_offset || 0,
+          };
+          if (opt_changesPerPage) { params.n = opt_changesPerPage; }
+          if (opt_query && opt_query.length > 0) {
+            params.q = opt_query;
+          }
+          return {
+            url: '/changes/',
+            params,
+            reportUrlAsIs: true,
+          };
+        })
+        .then(req => this._restApiHelper.fetchJSON(req))
+        .then(response => {
+          const iterateOverChanges = arr => {
+            for (const change of (arr || [])) {
+              this._maybeInsertInLookup(change);
+            }
+          };
+          // Response may be an array of changes OR an array of arrays of
+          // changes.
+          if (opt_query instanceof Array) {
+            // Normalize the response to look like a multi-query response
+            // when there is only one query.
+            if (opt_query.length === 1) {
+              response = [response];
+            }
+            for (const arr of response) {
+              iterateOverChanges(arr);
+            }
+          } else {
+            iterateOverChanges(response);
+          }
+          return response;
+        });
+  }
+
+  /**
+   * Inserts a change into _projectLookup iff it has a valid structure.
+   *
+   * @param {?{ _number: (number|string) }} change
+   */
+  _maybeInsertInLookup(change) {
+    if (change && change.project && change._number) {
+      this.setInProjectLookup(change._number, change.project);
+    }
+  }
+
+  /**
+   * TODO (beckysiegel) this needs to be rewritten with the optional param
+   * at the end.
+   *
+   * @param {number|string} changeNum
+   * @param {?number|string=} opt_patchNum passed as null sometimes.
+   * @param {?=} endpoint
+   * @return {!Promise<string>}
+   */
+  getChangeActionURL(changeNum, opt_patchNum, endpoint) {
+    return this._changeBaseURL(changeNum, opt_patchNum)
+        .then(url => url + endpoint);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {function(?Response, string=)=} opt_errFn
+   * @param {function()=} opt_cancelCondition
+   */
+  getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+    return this.getConfig(false).then(config => {
+      const optionsHex = this._getChangeOptionsHex(config);
+      return this._getChangeDetail(
+          changeNum, optionsHex, opt_errFn, opt_cancelCondition)
+          .then(GrReviewerUpdatesParser.parse);
+    });
+  }
+
+  _getChangesOptionsHex(config) {
+    const options = [
+      this.ListChangesOption.LABELS,
+      this.ListChangesOption.DETAILED_ACCOUNTS,
+    ];
+    if (config && config.change && config.change.enable_attention_set) {
+      options.push(this.ListChangesOption.DETAILED_LABELS);
+    } else {
+      options.push(this.ListChangesOption.REVIEWED);
+    }
+
+    return this.listChangesOptionsToHex(...options);
+  }
+
+  _getChangeOptionsHex(config) {
+    if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage
+        && !(config.receive && config.receive.enable_signed_push)) {
+      return window.DEFAULT_DETAIL_HEXES.changePage;
+    }
+
+    // This list MUST be kept in sync with
+    // ChangeIT#changeDetailsDoesNotRequireIndex
+    const options = [
+      this.ListChangesOption.ALL_COMMITS,
+      this.ListChangesOption.ALL_REVISIONS,
+      this.ListChangesOption.CHANGE_ACTIONS,
+      this.ListChangesOption.DETAILED_LABELS,
+      this.ListChangesOption.DOWNLOAD_COMMANDS,
+      this.ListChangesOption.MESSAGES,
+      this.ListChangesOption.SUBMITTABLE,
+      this.ListChangesOption.WEB_LINKS,
+      this.ListChangesOption.SKIP_DIFFSTAT,
+    ];
+    if (config.receive && config.receive.enable_signed_push) {
+      options.push(this.ListChangesOption.PUSH_CERTIFICATES);
+    }
+    return this.listChangesOptionsToHex(...options);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {function(?Response, string=)=} opt_errFn
+   * @param {function()=} opt_cancelCondition
+   */
+  getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+    let optionsHex = '';
+    if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.diffPage) {
+      optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
+    } else {
+      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);
-    },
+    }
+    return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
+        opt_cancelCondition);
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {string|undefined} optionsHex list changes options in hex
-     * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
-     */
-    _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
-      return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
-        const urlWithParams = this._restApiHelper
-            .urlWithParams(url, optionsHex);
-        const params = {O: optionsHex};
-        let req = {
-          url,
-          errFn: opt_errFn,
-          cancelCondition: opt_cancelCondition,
-          params,
-          fetchOptions: this._etags.getOptions(urlWithParams),
-          anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
-        };
-        req = this._restApiHelper.addAcceptJsonHeader(req);
-        return this._restApiHelper.fetchRawJSON(req).then(response => {
-          if (response && response.status === 304) {
-            return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
-                this._etags.getCachedPayload(urlWithParams)));
-          }
-
-          if (response && !response.ok) {
-            if (opt_errFn) {
-              opt_errFn.call(null, response);
-            } else {
-              this.fire('server-error', {request: req, response});
-            }
-            return;
-          }
-
-          const payloadPromise = response ?
-            this._restApiHelper.readResponsePayload(response) :
-            Promise.resolve(null);
-
-          return payloadPromise.then(payload => {
-            if (!payload) { return null; }
-            this._etags.collect(urlWithParams, response, payload.raw);
-            this._maybeInsertInLookup(payload.parsed);
-
-            return payload.parsed;
-          });
-        });
-      });
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     */
-    getChangeCommitInfo(changeNum, patchNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/commit?links',
-        patchNum,
-        reportEndpointAsIs: true,
-      });
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {Gerrit.PatchRange} patchRange
-     * @param {number=} opt_parentIndex
-     */
-    getChangeFiles(changeNum, patchRange, opt_parentIndex) {
-      let params = undefined;
-      if (this.isMergeParent(patchRange.basePatchNum)) {
-        params = {parent: this.getParentIndex(patchRange.basePatchNum)};
-      } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
-        params = {base: patchRange.basePatchNum};
-      }
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/files',
-        patchNum: patchRange.patchNum,
-        params,
-        reportEndpointAsIs: true,
-      });
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {Gerrit.PatchRange} patchRange
-     */
-    getChangeEditFiles(changeNum, patchRange) {
-      let endpoint = '/edit?list';
-      let anonymizedEndpoint = endpoint;
-      if (patchRange.basePatchNum !== 'PARENT') {
-        endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
-        anonymizedEndpoint += '&base=*';
-      }
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint,
-        anonymizedEndpoint,
-      });
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     * @param {string} query
-     * @return {!Promise<!Object>}
-     */
-    queryChangeFiles(changeNum, patchNum, query) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: `/files?q=${encodeURIComponent(query)}`,
-        patchNum,
-        anonymizedEndpoint: '/files?q=*',
-      });
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {Gerrit.PatchRange} patchRange
-     * @return {!Promise<!Array<!Object>>}
-     */
-    getChangeOrEditFiles(changeNum, patchRange) {
-      if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) {
-        return this.getChangeEditFiles(changeNum, patchRange).then(res =>
-          res.files);
-      }
-      return this.getChangeFiles(changeNum, patchRange);
-    },
-
-    getChangeRevisionActions(changeNum, patchNum) {
+  /**
+   * @param {number|string} changeNum
+   * @param {string|undefined} optionsHex list changes options in hex
+   * @param {function(?Response, string=)=} opt_errFn
+   * @param {function()=} opt_cancelCondition
+   */
+  _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
+    return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
+      const urlWithParams = this._restApiHelper
+          .urlWithParams(url, optionsHex);
+      const params = {O: optionsHex};
       const req = {
-        changeNum,
-        endpoint: '/actions',
-        patchNum,
-        reportEndpointAsIs: true,
-      };
-      return this._getChangeURLAndFetch(req);
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} inputVal
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
-      return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal,
-          opt_errFn);
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} inputVal
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    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,
-      // see issue 10793.
-      const params = {'n': 6, 'reviewer-state': reviewerState};
-      if (inputVal) { params.q = inputVal; }
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/suggest_reviewers',
+        url,
         errFn: opt_errFn,
+        cancelCondition: opt_cancelCondition,
         params,
-        reportEndpointAsIs: true,
-      });
-    },
+        fetchOptions: this._etags.getOptions(urlWithParams),
+        anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
+      };
+      return this._restApiHelper.fetchRawJSON(req).then(response => {
+        if (response && response.status === 304) {
+          return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
+              this._etags.getCachedPayload(urlWithParams)));
+        }
 
-    /**
-     * @param {number|string} changeNum
-     */
-    getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/in',
-        reportEndpointAsIs: true,
-      });
-    },
-
-    _computeFilter(filter) {
-      if (filter && filter.startsWith('^')) {
-        filter = '&r=' + encodeURIComponent(filter);
-      } else if (filter) {
-        filter = '&m=' + encodeURIComponent(filter);
-      } else {
-        filter = '';
-      }
-      return filter;
-    },
-
-    /**
-     * @param {string} filter
-     * @param {number} groupsPerPage
-     * @param {number=} opt_offset
-     */
-    _getGroupsUrl(filter, groupsPerPage, opt_offset) {
-      const offset = opt_offset || 0;
-
-      return `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
-        this._computeFilter(filter);
-    },
-
-    /**
-     * @param {string} filter
-     * @param {number} reposPerPage
-     * @param {number=} opt_offset
-     */
-    _getReposUrl(filter, reposPerPage, opt_offset) {
-      const defaultFilter = 'state:active OR state:read-only';
-      const namePartDelimiters = /[@.\-\s\/_]/g;
-      const offset = opt_offset || 0;
-
-      if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
-        // The query language specifies hyphens as operators. Split the string
-        // by hyphens and 'AND' the parts together as 'inname:' queries.
-        // If the filter includes a semicolon, the user is using a more complex
-        // query so we trust them and don't do any magic under the hood.
-        const originalFilter = filter;
-        filter = '';
-        originalFilter.split(namePartDelimiters).forEach(part => {
-          if (part) {
-            filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+        if (response && !response.ok) {
+          if (opt_errFn) {
+            opt_errFn.call(null, response);
+          } else {
+            this.dispatchEvent(new CustomEvent('server-error', {
+              detail: {request: req, response},
+              composed: true, bubbles: true,
+            }));
           }
+          return;
+        }
+
+        const payloadPromise = response ?
+          this._restApiHelper.readResponsePayload(response) :
+          Promise.resolve(null);
+
+        return payloadPromise.then(payload => {
+          if (!payload) { return null; }
+          this._etags.collect(urlWithParams, response, payload.raw);
+          this._maybeInsertInLookup(payload.parsed);
+
+          return payload.parsed;
         });
-      }
-      // Check if filter is now empty which could be either because the user did
-      // not provide it or because the user provided only a split character.
-      if (!filter) {
-        filter = defaultFilter;
-      }
-
-      filter = filter.trim();
-      const encodedFilter = encodeURIComponent(filter);
-
-      return `/projects/?n=${reposPerPage + 1}&S=${offset}` +
-        `&query=${encodedFilter}`;
-    },
-
-    invalidateGroupsCache() {
-      this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
-    },
-
-    invalidateReposCache() {
-      this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
-    },
-
-    /**
-     * @param {string} filter
-     * @param {number} groupsPerPage
-     * @param {number=} opt_offset
-     * @return {!Promise<?Object>}
-     */
-    getGroups(filter, groupsPerPage, opt_offset) {
-      const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset);
-
-      return this._fetchSharedCacheURL({
-        url,
-        anonymizedUrl: '/groups/?*',
       });
-    },
+    });
+  }
 
-    /**
-     * @param {string} filter
-     * @param {number} reposPerPage
-     * @param {number=} opt_offset
-     * @return {!Promise<?Object>}
-     */
-    getRepos(filter, reposPerPage, opt_offset) {
-      const url = this._getReposUrl(filter, reposPerPage, opt_offset);
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   */
+  getChangeCommitInfo(changeNum, patchNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/commit?links',
+      patchNum,
+      reportEndpointAsIs: true,
+    });
+  }
 
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url,
-        anonymizedUrl: '/projects/?*',
+  /**
+   * @param {number|string} changeNum
+   * @param {Gerrit.PatchRange} patchRange
+   * @param {number=} opt_parentIndex
+   */
+  getChangeFiles(changeNum, patchRange, opt_parentIndex) {
+    let params = undefined;
+    if (this.isMergeParent(patchRange.basePatchNum)) {
+      params = {parent: this.getParentIndex(patchRange.basePatchNum)};
+    } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
+      params = {base: patchRange.basePatchNum};
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files',
+      patchNum: patchRange.patchNum,
+      params,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {Gerrit.PatchRange} patchRange
+   */
+  getChangeEditFiles(changeNum, patchRange) {
+    let endpoint = '/edit?list';
+    let anonymizedEndpoint = endpoint;
+    if (patchRange.basePatchNum !== 'PARENT') {
+      endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
+      anonymizedEndpoint += '&base=*';
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint,
+      anonymizedEndpoint,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   * @param {string} query
+   * @return {!Promise<!Object>}
+   */
+  queryChangeFiles(changeNum, patchNum, query) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files?q=${encodeURIComponent(query)}`,
+      patchNum,
+      anonymizedEndpoint: '/files?q=*',
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {Gerrit.PatchRange} patchRange
+   * @return {!Promise<!Array<!Object>>}
+   */
+  getChangeOrEditFiles(changeNum, patchRange) {
+    if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) {
+      return this.getChangeEditFiles(changeNum, patchRange).then(res =>
+        res.files);
+    }
+    return this.getChangeFiles(changeNum, patchRange);
+  }
+
+  getChangeRevisionActions(changeNum, patchNum) {
+    const req = {
+      changeNum,
+      endpoint: '/actions',
+      patchNum,
+      reportEndpointAsIs: true,
+    };
+    return this._getChangeURLAndFetch(req);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} inputVal
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
+    return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal,
+        opt_errFn);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} inputVal
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  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,
+    // see issue 10793.
+    const params = {'n': 6, 'reviewer-state': reviewerState};
+    if (inputVal) { params.q = inputVal; }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/suggest_reviewers',
+      errFn: opt_errFn,
+      params,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   */
+  getChangeIncludedIn(changeNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/in',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  _computeFilter(filter) {
+    if (filter && filter.startsWith('^')) {
+      filter = '&r=' + encodeURIComponent(filter);
+    } else if (filter) {
+      filter = '&m=' + encodeURIComponent(filter);
+    } else {
+      filter = '';
+    }
+    return filter;
+  }
+
+  /**
+   * @param {string} filter
+   * @param {number} groupsPerPage
+   * @param {number=} opt_offset
+   */
+  _getGroupsUrl(filter, groupsPerPage, opt_offset) {
+    const offset = opt_offset || 0;
+
+    return `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+      this._computeFilter(filter);
+  }
+
+  /**
+   * @param {string} filter
+   * @param {number} reposPerPage
+   * @param {number=} opt_offset
+   */
+  _getReposUrl(filter, reposPerPage, opt_offset) {
+    const defaultFilter = 'state:active OR state:read-only';
+    const namePartDelimiters = /[@.\-\s\/_]/g;
+    const offset = opt_offset || 0;
+
+    if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
+      // The query language specifies hyphens as operators. Split the string
+      // by hyphens and 'AND' the parts together as 'inname:' queries.
+      // If the filter includes a semicolon, the user is using a more complex
+      // query so we trust them and don't do any magic under the hood.
+      const originalFilter = filter;
+      filter = '';
+      originalFilter.split(namePartDelimiters).forEach(part => {
+        if (part) {
+          filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+        }
       });
-    },
+    }
+    // Check if filter is now empty which could be either because the user did
+    // not provide it or because the user provided only a split character.
+    if (!filter) {
+      filter = defaultFilter;
+    }
 
-    setRepoHead(repo, ref) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeURIComponent(repo)}/HEAD`,
-        body: {ref},
-        anonymizedUrl: '/projects/*/HEAD',
-      });
-    },
+    filter = filter.trim();
+    const encodedFilter = encodeURIComponent(filter);
 
-    /**
-     * @param {string} filter
-     * @param {string} repo
-     * @param {number} reposBranchesPerPage
-     * @param {number=} opt_offset
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>}
-     */
-    getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) {
-      const offset = opt_offset || 0;
-      const count = reposBranchesPerPage + 1;
-      filter = this._computeFilter(filter);
-      repo = encodeURIComponent(repo);
-      const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.fetchJSON({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/branches?*',
-      });
-    },
+    return `/projects/?n=${reposPerPage + 1}&S=${offset}` +
+      `&query=${encodedFilter}`;
+  }
 
-    /**
-     * @param {string} filter
-     * @param {string} repo
-     * @param {number} reposTagsPerPage
-     * @param {number=} opt_offset
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>}
-     */
-    getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) {
-      const offset = opt_offset || 0;
-      const encodedRepo = encodeURIComponent(repo);
-      const n = reposTagsPerPage + 1;
-      const encodedFilter = this._computeFilter(filter);
-      const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` +
-          encodedFilter;
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.fetchJSON({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/tags',
-      });
-    },
+  invalidateGroupsCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
+  }
 
-    /**
-     * @param {string} filter
-     * @param {number} pluginsPerPage
-     * @param {number=} opt_offset
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>}
-     */
-    getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) {
-      const offset = opt_offset || 0;
-      const encodedFilter = this._computeFilter(filter);
-      const n = pluginsPerPage + 1;
-      const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-      return this._restApiHelper.fetchJSON({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/plugins/?all',
-      });
-    },
+  invalidateReposCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
+  }
 
-    getRepoAccessRights(repoName, opt_errFn) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.fetchJSON({
-        url: `/projects/${encodeURIComponent(repoName)}/access`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/access',
-      });
-    },
+  invalidateAccountsCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
+  }
 
-    setRepoAccessRights(repoName, repoInfo) {
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: `/projects/${encodeURIComponent(repoName)}/access`,
-        body: repoInfo,
-        anonymizedUrl: '/projects/*/access',
-      });
-    },
+  /**
+   * @param {string} filter
+   * @param {number} groupsPerPage
+   * @param {number=} opt_offset
+   * @return {!Promise<?Object>}
+   */
+  getGroups(filter, groupsPerPage, opt_offset) {
+    const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset);
 
-    setRepoAccessRightsForReview(projectName, projectInfo) {
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: `/projects/${encodeURIComponent(projectName)}/access:review`,
-        body: projectInfo,
-        parseResponse: true,
-        anonymizedUrl: '/projects/*/access:review',
-      });
-    },
+    return this._fetchSharedCacheURL({
+      url,
+      anonymizedUrl: '/groups/?*',
+    });
+  }
 
-    /**
-     * @param {string} inputVal
-     * @param {number} opt_n
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getSuggestedGroups(inputVal, opt_n, opt_errFn) {
-      const params = {s: inputVal};
-      if (opt_n) { params.n = opt_n; }
-      return this._restApiHelper.fetchJSON({
-        url: '/groups/',
-        errFn: opt_errFn,
-        params,
-        reportUrlAsIs: true,
-      });
-    },
+  /**
+   * @param {string} filter
+   * @param {number} reposPerPage
+   * @param {number=} opt_offset
+   * @return {!Promise<?Object>}
+   */
+  getRepos(filter, reposPerPage, opt_offset) {
+    const url = this._getReposUrl(filter, reposPerPage, opt_offset);
 
-    /**
-     * @param {string} inputVal
-     * @param {number} opt_n
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getSuggestedProjects(inputVal, opt_n, opt_errFn) {
-      const params = {
-        m: inputVal,
-        n: MAX_PROJECT_RESULTS,
-        type: 'ALL',
-      };
-      if (opt_n) { params.n = opt_n; }
-      return this._restApiHelper.fetchJSON({
-        url: '/projects/',
-        errFn: opt_errFn,
-        params,
-        reportUrlAsIs: true,
-      });
-    },
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url,
+      anonymizedUrl: '/projects/?*',
+    });
+  }
 
-    /**
-     * @param {string} inputVal
-     * @param {number} opt_n
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getSuggestedAccounts(inputVal, opt_n, opt_errFn) {
-      if (!inputVal) {
-        return Promise.resolve([]);
-      }
-      const params = {suggest: null, q: inputVal};
-      if (opt_n) { params.n = opt_n; }
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/',
-        errFn: opt_errFn,
-        params,
-        anonymizedUrl: '/accounts/?n=*',
-      });
-    },
+  setRepoHead(repo, ref) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeURIComponent(repo)}/HEAD`,
+      body: {ref},
+      anonymizedUrl: '/projects/*/HEAD',
+    });
+  }
 
-    addChangeReviewer(changeNum, reviewerID) {
-      return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
-    },
+  /**
+   * @param {string} filter
+   * @param {string} repo
+   * @param {number} reposBranchesPerPage
+   * @param {number=} opt_offset
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>}
+   */
+  getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) {
+    const offset = opt_offset || 0;
+    const count = reposBranchesPerPage + 1;
+    filter = this._computeFilter(filter);
+    repo = encodeURIComponent(repo);
+    const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/branches?*',
+    });
+  }
 
-    removeChangeReviewer(changeNum, reviewerID) {
-      return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
-    },
+  /**
+   * @param {string} filter
+   * @param {string} repo
+   * @param {number} reposTagsPerPage
+   * @param {number=} opt_offset
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>}
+   */
+  getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) {
+    const offset = opt_offset || 0;
+    const encodedRepo = encodeURIComponent(repo);
+    const n = reposTagsPerPage + 1;
+    const encodedFilter = this._computeFilter(filter);
+    const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` +
+        encodedFilter;
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/tags',
+    });
+  }
 
-    _sendChangeReviewerRequest(method, changeNum, reviewerID) {
-      return this.getChangeActionURL(changeNum, null, '/reviewers')
-          .then(url => {
-            let body;
-            switch (method) {
-              case 'POST':
-                body = {reviewer: reviewerID};
-                break;
-              case 'DELETE':
-                url += '/' + encodeURIComponent(reviewerID);
-                break;
-              default:
-                throw Error('Unsupported HTTP method: ' + method);
-            }
+  /**
+   * @param {string} filter
+   * @param {number} pluginsPerPage
+   * @param {number=} opt_offset
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>}
+   */
+  getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) {
+    const offset = opt_offset || 0;
+    const encodedFilter = this._computeFilter(filter);
+    const n = pluginsPerPage + 1;
+    const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/plugins/?all',
+    });
+  }
 
-            return this._restApiHelper.send({method, url, body});
-          });
-    },
+  getRepoAccessRights(repoName, opt_errFn) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/access',
+    });
+  }
 
-    getRelatedChanges(changeNum, patchNum) {
+  setRepoAccessRights(repoName, repoInfo) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      body: repoInfo,
+      anonymizedUrl: '/projects/*/access',
+    });
+  }
+
+  setRepoAccessRightsForReview(projectName, projectInfo) {
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: `/projects/${encodeURIComponent(projectName)}/access:review`,
+      body: projectInfo,
+      parseResponse: true,
+      anonymizedUrl: '/projects/*/access:review',
+    });
+  }
+
+  /**
+   * @param {string} inputVal
+   * @param {number} opt_n
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getSuggestedGroups(inputVal, opt_n, opt_errFn) {
+    const params = {s: inputVal};
+    if (opt_n) { params.n = opt_n; }
+    return this._restApiHelper.fetchJSON({
+      url: '/groups/',
+      errFn: opt_errFn,
+      params,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} inputVal
+   * @param {number} opt_n
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getSuggestedProjects(inputVal, opt_n, opt_errFn) {
+    const params = {
+      m: inputVal,
+      n: MAX_PROJECT_RESULTS,
+      type: 'ALL',
+    };
+    if (opt_n) { params.n = opt_n; }
+    return this._restApiHelper.fetchJSON({
+      url: '/projects/',
+      errFn: opt_errFn,
+      params,
+      reportUrlAsIs: true,
+    });
+  }
+
+  /**
+   * @param {string} inputVal
+   * @param {number} opt_n
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getSuggestedAccounts(inputVal, opt_n, opt_errFn) {
+    if (!inputVal) {
+      return Promise.resolve([]);
+    }
+    const params = {suggest: null, q: inputVal};
+    if (opt_n) { params.n = opt_n; }
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/',
+      errFn: opt_errFn,
+      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')
+        .then(url => {
+          let body;
+          switch (method) {
+            case 'POST':
+              body = {reviewer: reviewerID};
+              break;
+            case 'DELETE':
+              url += '/' + encodeURIComponent(reviewerID);
+              break;
+            default:
+              throw Error('Unsupported HTTP method: ' + method);
+          }
+
+          return this._restApiHelper.send({method, url, body});
+        });
+  }
+
+  getRelatedChanges(changeNum, patchNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/related',
+      patchNum,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  getChangesSubmittedTogether(changeNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  getChangeConflicts(changeNum) {
+    const options = this.listChangesOptionsToHex(
+        this.ListChangesOption.CURRENT_REVISION,
+        this.ListChangesOption.CURRENT_COMMIT
+    );
+    const params = {
+      O: options,
+      q: 'status:open conflicts:' + changeNum,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/conflicts:*',
+    });
+  }
+
+  getChangeCherryPicks(project, changeID, changeNum) {
+    const options = this.listChangesOptionsToHex(
+        this.ListChangesOption.CURRENT_REVISION,
+        this.ListChangesOption.CURRENT_COMMIT
+    );
+    const query = [
+      'project:' + project,
+      'change:' + changeID,
+      '-change:' + changeNum,
+      '-is:abandoned',
+    ].join(' ');
+    const params = {
+      O: options,
+      q: query,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/change:*',
+    });
+  }
+
+  getChangesWithSameTopic(topic, changeNum) {
+    const options = this.listChangesOptionsToHex(
+        this.ListChangesOption.LABELS,
+        this.ListChangesOption.CURRENT_REVISION,
+        this.ListChangesOption.CURRENT_COMMIT,
+        this.ListChangesOption.DETAILED_LABELS
+    );
+    const query = [
+      'status:open',
+      '-change:' + changeNum,
+      `topic:"${topic}"`,
+    ].join(' ');
+    const params = {
+      O: options,
+      q: query,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/topic:*',
+    });
+  }
+
+  getReviewedFiles(changeNum, patchNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files?reviewed',
+      patchNum,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   * @param {string} path
+   * @param {boolean} reviewed
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: reviewed ? 'PUT' : 'DELETE',
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
+      errFn: opt_errFn,
+      anonymizedEndpoint: '/files/*/reviewed',
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} patchNum
+   * @param {!Object} review
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  saveChangeReview(changeNum, patchNum, review, opt_errFn) {
+    const promises = [
+      this.awaitPendingDiffDrafts(),
+      this.getChangeActionURL(changeNum, patchNum, '/review'),
+    ];
+    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;
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) { return false; }
       return this._getChangeURLAndFetch({
         changeNum,
-        endpoint: '/related',
-        patchNum,
-        reportEndpointAsIs: true,
-      });
-    },
-
-    getChangesSubmittedTogether(changeNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
-        reportEndpointAsIs: true,
-      });
-    },
-
-    getChangeConflicts(changeNum) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.CURRENT_REVISION,
-          this.ListChangesOption.CURRENT_COMMIT
-      );
-      const params = {
-        O: options,
-        q: 'status:open is:mergeable conflicts:' + changeNum,
-      };
-      return this._restApiHelper.fetchJSON({
-        url: '/changes/',
+        endpoint: '/edit/',
         params,
-        anonymizedUrl: '/changes/conflicts:*',
-      });
-    },
-
-    getChangeCherryPicks(project, changeID, changeNum) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.CURRENT_REVISION,
-          this.ListChangesOption.CURRENT_COMMIT
-      );
-      const query = [
-        'project:' + project,
-        'change:' + changeID,
-        '-change:' + changeNum,
-        '-is:abandoned',
-      ].join(' ');
-      const params = {
-        O: options,
-        q: query,
-      };
-      return this._restApiHelper.fetchJSON({
-        url: '/changes/',
-        params,
-        anonymizedUrl: '/changes/change:*',
-      });
-    },
-
-    getChangesWithSameTopic(topic, changeNum) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.LABELS,
-          this.ListChangesOption.CURRENT_REVISION,
-          this.ListChangesOption.CURRENT_COMMIT,
-          this.ListChangesOption.DETAILED_LABELS
-      );
-      const query = [
-        'status:open',
-        '-change:' + changeNum,
-        `topic:"${topic}"`,
-      ].join(' ');
-      const params = {
-        O: options,
-        q: query,
-      };
-      return this._restApiHelper.fetchJSON({
-        url: '/changes/',
-        params,
-        anonymizedUrl: '/changes/topic:*',
-      });
-    },
-
-    getReviewedFiles(changeNum, patchNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/files?reviewed',
-        patchNum,
         reportEndpointAsIs: true,
-      });
-    },
+      }, true);
+    });
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     * @param {string} path
-     * @param {boolean} reviewed
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: reviewed ? 'PUT' : 'DELETE',
-        patchNum,
-        endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
-        errFn: opt_errFn,
-        anonymizedEndpoint: '/files/*/reviewed',
-      });
-    },
+  /**
+   * @param {string} project
+   * @param {string} branch
+   * @param {string} subject
+   * @param {string=} opt_topic
+   * @param {boolean=} opt_isPrivate
+   * @param {boolean=} opt_workInProgress
+   * @param {string=} opt_baseChange
+   * @param {string=} opt_baseCommit
+   */
+  createChange(project, branch, subject, opt_topic, opt_isPrivate,
+      opt_workInProgress, opt_baseChange, opt_baseCommit) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/changes/',
+      body: {
+        project,
+        branch,
+        subject,
+        topic: opt_topic,
+        is_private: opt_isPrivate,
+        work_in_progress: opt_workInProgress,
+        base_change: opt_baseChange,
+        base_commit: opt_baseCommit,
+      },
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} patchNum
-     * @param {!Object} review
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    saveChangeReview(changeNum, patchNum, review, opt_errFn) {
-      const promises = [
-        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,
-        });
-      });
-    },
+  /**
+   * @param {number|string} changeNum
+   * @param {string} path
+   * @param {number|string} patchNum
+   */
+  getFileContent(changeNum, path, patchNum) {
+    // 404s indicate the file does not exist yet in the revision, so suppress
+    // them.
+    const suppress404s = res => {
+      if (res && res.status !== 404) {
+        this.dispatchEvent(new CustomEvent('server-error', {
+          detail: {res},
+          composed: true, bubbles: true,
+        }));
+      }
+      return res;
+    };
+    const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ?
+      this._getFileInChangeEdit(changeNum, path) :
+      this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
-    getChangeEdit(changeNum, opt_download_commands) {
-      const params = opt_download_commands ? {'download-commands': true} : null;
-      return this.getLoggedIn().then(loggedIn => {
-        if (!loggedIn) { return false; }
-        return this._getChangeURLAndFetch({
-          changeNum,
-          endpoint: '/edit/',
-          params,
-          reportEndpointAsIs: true,
-        });
-      });
-    },
+    return promise.then(res => {
+      if (!res.ok) { return res; }
 
-    /**
-     * @param {string} project
-     * @param {string} branch
-     * @param {string} subject
-     * @param {string=} opt_topic
-     * @param {boolean=} opt_isPrivate
-     * @param {boolean=} opt_workInProgress
-     * @param {string=} opt_baseChange
-     * @param {string=} opt_baseCommit
-     */
-    createChange(project, branch, subject, opt_topic, opt_isPrivate,
-        opt_workInProgress, opt_baseChange, opt_baseCommit) {
+      // The file type (used for syntax highlighting) is identified in the
+      // X-FYI-Content-Type header of the response.
+      const type = res.headers.get('X-FYI-Content-Type');
+      return this.getResponseObject(res).then(content => {
+        return {content, type, ok: true};
+      });
+    });
+  }
+
+  /**
+   * Gets a file in a specific change and revision.
+   *
+   * @param {number|string} changeNum
+   * @param {string} path
+   * @param {number|string} patchNum
+   * @param {?function(?Response, string=)=} opt_errFn
+   */
+  _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'GET',
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/content`,
+      errFn: opt_errFn,
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/files/*/content',
+    });
+  }
+
+  /**
+   * Gets a file in a change edit.
+   *
+   * @param {number|string} changeNum
+   * @param {string} path
+   */
+  _getFileInChangeEdit(changeNum, path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'GET',
+      endpoint: '/edit/' + encodeURIComponent(path),
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  rebaseChangeEdit(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit:rebase',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeEdit(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/edit',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  restoreFileInChangeEdit(changeNum, restore_path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit',
+      body: {restore_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  renameFileInChangeEdit(changeNum, old_path, new_path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit',
+      body: {old_path, new_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteFileInChangeEdit(changeNum, path) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/edit/' + encodeURIComponent(path),
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  saveChangeEdit(changeNum, path, contents) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/edit/' + encodeURIComponent(path),
+      body: contents,
+      contentType: 'text/plain',
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  saveFileUploadChangeEdit(changeNum, path, content) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/edit/' + encodeURIComponent(path),
+      body: {binary_content: content},
+      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) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/edit:message',
+      body: {message},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  publishChangeEdit(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/edit:publish',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  putChangeCommitMessage(changeNum, message) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/message',
+      body: {message},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeCommitMessage(changeNum, messageId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/messages/' + messageId,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  saveChangeStarred(changeNum, starred) {
+    // Some servers may require the project name to be provided
+    // alongside the change number, so resolve the project name
+    // first.
+    return this.getFromProjectLookup(changeNum).then(project => {
+      const url = '/accounts/self/starred.changes/' +
+          (project ? encodeURIComponent(project) + '~' : '') + changeNum;
       return this._restApiHelper.send({
-        method: 'POST',
-        url: '/changes/',
-        body: {
-          project,
-          branch,
-          subject,
-          topic: opt_topic,
-          is_private: opt_isPrivate,
-          work_in_progress: opt_workInProgress,
-          base_change: opt_baseChange,
-          base_commit: opt_baseCommit,
-        },
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {string} path
-     * @param {number|string} patchNum
-     */
-    getFileContent(changeNum, path, patchNum) {
-      // 404s indicate the file does not exist yet in the revision, so suppress
-      // them.
-      const suppress404s = res => {
-        if (res && res.status !== 404) { this.fire('server-error', {res}); }
-        return res;
-      };
-      const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ?
-        this._getFileInChangeEdit(changeNum, path) :
-        this._getFileInRevision(changeNum, path, patchNum, suppress404s);
-
-      return promise.then(res => {
-        if (!res.ok) { return res; }
-
-        // The file type (used for syntax highlighting) is identified in the
-        // X-FYI-Content-Type header of the response.
-        const type = res.headers.get('X-FYI-Content-Type');
-        return this.getResponseObject(res).then(content => {
-          return {content, type, ok: true};
-        });
-      });
-    },
-
-    /**
-     * Gets a file in a specific change and revision.
-     *
-     * @param {number|string} changeNum
-     * @param {string} path
-     * @param {number|string} patchNum
-     * @param {?function(?Response, string=)=} opt_errFn
-     */
-    _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'GET',
-        patchNum,
-        endpoint: `/files/${encodeURIComponent(path)}/content`,
-        errFn: opt_errFn,
-        headers: {Accept: 'application/json'},
-        anonymizedEndpoint: '/files/*/content',
-      });
-    },
-
-    /**
-     * Gets a file in a change edit.
-     *
-     * @param {number|string} changeNum
-     * @param {string} path
-     */
-    _getFileInChangeEdit(changeNum, path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'GET',
-        endpoint: '/edit/' + encodeURIComponent(path),
-        headers: {Accept: 'application/json'},
-        anonymizedEndpoint: '/edit/*',
-      });
-    },
-
-    rebaseChangeEdit(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit:rebase',
-        reportEndpointAsIs: true,
-      });
-    },
-
-    deleteChangeEdit(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: '/edit',
-        reportEndpointAsIs: true,
-      });
-    },
-
-    restoreFileInChangeEdit(changeNum, restore_path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit',
-        body: {restore_path},
-        reportEndpointAsIs: true,
-      });
-    },
-
-    renameFileInChangeEdit(changeNum, old_path, new_path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit',
-        body: {old_path, new_path},
-        reportEndpointAsIs: true,
-      });
-    },
-
-    deleteFileInChangeEdit(changeNum, path) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: '/edit/' + encodeURIComponent(path),
-        anonymizedEndpoint: '/edit/*',
-      });
-    },
-
-    saveChangeEdit(changeNum, path, contents) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/edit/' + encodeURIComponent(path),
-        body: contents,
-        contentType: 'text/plain',
-        anonymizedEndpoint: '/edit/*',
-      });
-    },
-
-    // Deprecated, prefer to use putChangeCommitMessage instead.
-    saveChangeCommitMessageEdit(changeNum, message) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/edit:message',
-        body: {message},
-        reportEndpointAsIs: true,
-      });
-    },
-
-    publishChangeEdit(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/edit:publish',
-        reportEndpointAsIs: true,
-      });
-    },
-
-    putChangeCommitMessage(changeNum, message) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/message',
-        body: {message},
-        reportEndpointAsIs: true,
-      });
-    },
-
-    saveChangeStarred(changeNum, starred) {
-      // Some servers may require the project name to be provided
-      // alongside the change number, so resolve the project name
-      // first.
-      return this.getFromProjectLookup(changeNum).then(project => {
-        const url = '/accounts/self/starred.changes/' +
-            (project ? encodeURIComponent(project) + '~' : '') + changeNum;
-        return this._restApiHelper.send({
-          method: starred ? 'PUT' : 'DELETE',
-          url,
-          anonymizedUrl: '/accounts/self/starred.changes/*',
-        });
-      });
-    },
-
-    saveChangeReviewed(changeNum, reviewed) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: reviewed ? '/reviewed' : '/unreviewed',
-      });
-    },
-
-    /**
-     * Public version of the _restApiHelper.send method preserved for plugins.
-     *
-     * @param {string} method
-     * @param {string} url
-     * @param {?string|number|Object=} opt_body passed as null sometimes
-     *    and also apparently a number. TODO (beckysiegel) remove need for
-     *    number at least.
-     * @param {?function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @param {?string=} opt_contentType
-     * @param {Object=} opt_headers
-     */
-    send(method, url, opt_body, opt_errFn, opt_contentType,
-        opt_headers) {
-      return this._restApiHelper.send({
-        method,
+        method: starred ? 'PUT' : 'DELETE',
         url,
-        body: opt_body,
-        errFn: opt_errFn,
-        contentType: opt_contentType,
-        headers: opt_headers,
+        anonymizedUrl: '/accounts/self/starred.changes/*',
       });
-    },
+    });
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string} basePatchNum Negative values specify merge parent
-     *     index.
-     * @param {number|string} patchNum
-     * @param {string} path
-     * @param {string=} opt_whitespace the ignore-whitespace level for the diff
-     *     algorithm.
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace,
-        opt_errFn) {
-      const params = {
-        context: 'ALL',
-        intraline: null,
-        whitespace: opt_whitespace || 'IGNORE_NONE',
-      };
-      if (this.isMergeParent(basePatchNum)) {
-        params.parent = this.getParentIndex(basePatchNum);
-      } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) {
-        params.base = basePatchNum;
-      }
-      const endpoint = `/files/${encodeURIComponent(path)}/diff`;
-      const req = {
-        changeNum,
-        endpoint,
-        patchNum,
-        errFn: opt_errFn,
-        params,
-        anonymizedEndpoint: '/files/*/diff',
-      };
+  saveChangeReviewed(changeNum, reviewed) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: reviewed ? '/reviewed' : '/unreviewed',
+    });
+  }
 
-      // Invalidate the cache if its edit patch to make sure we always get latest.
-      if (patchNum === this.EDIT_NAME) {
-        if (!req.fetchOptions) req.fetchOptions = {};
-        if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
-        req.fetchOptions.headers.append('Cache-Control', 'no-cache');
-      }
+  /**
+   * Public version of the _restApiHelper.send method preserved for plugins.
+   *
+   * @param {string} method
+   * @param {string} url
+   * @param {?string|number|Object=} opt_body passed as null sometimes
+   *    and also apparently a number. TODO (beckysiegel) remove need for
+   *    number at least.
+   * @param {?function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
+   * @param {?string=} opt_contentType
+   * @param {Object=} opt_headers
+   */
+  send(method, url, opt_body, opt_errFn, opt_contentType,
+      opt_headers) {
+    return this._restApiHelper.send({
+      method,
+      url,
+      body: opt_body,
+      errFn: opt_errFn,
+      contentType: opt_contentType,
+      headers: opt_headers,
+    });
+  }
 
-      return this._getChangeURLAndFetch(req);
-    },
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string} basePatchNum Negative values specify merge parent
+   *     index.
+   * @param {number|string} patchNum
+   * @param {string} path
+   * @param {string=} opt_whitespace the ignore-whitespace level for the diff
+   *     algorithm.
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace,
+      opt_errFn) {
+    const params = {
+      context: 'ALL',
+      intraline: null,
+      whitespace: opt_whitespace || 'IGNORE_NONE',
+    };
+    if (this.isMergeParent(basePatchNum)) {
+      params.parent = this.getParentIndex(basePatchNum);
+    } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) {
+      params.base = basePatchNum;
+    }
+    const endpoint = `/files/${encodeURIComponent(path)}/diff`;
+    const req = {
+      changeNum,
+      endpoint,
+      patchNum,
+      errFn: opt_errFn,
+      params,
+      anonymizedEndpoint: '/files/*/diff',
+    };
 
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
-     */
-    getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
-      return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
+    // Invalidate the cache if its edit patch to make sure we always get latest.
+    if (patchNum === this.EDIT_NAME) {
+      if (!req.fetchOptions) req.fetchOptions = {};
+      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+      req.fetchOptions.headers.append('Cache-Control', 'no-cache');
+    }
+
+    return this._getChangeURLAndFetch(req);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
+    return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
+        opt_patchNum, opt_path);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  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
+   * is no logged in user, the request is not made and the promise yields an
+   * empty object.
+   *
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) { return Promise.resolve({}); }
+      return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
           opt_patchNum, opt_path);
-    },
+    });
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
-     */
-    getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
-      return this._getDiffComments(changeNum, '/robotcomments',
-          opt_basePatchNum, opt_patchNum, opt_path);
-    },
+  _setRange(comments, comment) {
+    if (comment.in_reply_to && !comment.range) {
+      for (let i = 0; i < comments.length; i++) {
+        if (comments[i].id === comment.in_reply_to) {
+          comment.range = comments[i].range;
+          break;
+        }
+      }
+    }
+    return comment;
+  }
 
+  _setRanges(comments) {
+    comments = comments || [];
+    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
+   * @param {string} endpoint
+   * @param {number|string=} opt_basePatchNum
+   * @param {number|string=} opt_patchNum
+   * @param {string=} opt_path
+   * @return {!Promise<!Object>}
+   */
+  _getDiffComments(changeNum, endpoint, opt_basePatchNum,
+      opt_patchNum, opt_path) {
     /**
-     * If the user is logged in, fetch the user's draft diff comments. If there
-     * is no logged in user, the request is not made and the promise yields an
-     * empty object.
+     * Fetches the comments for a given patchNum.
+     * Helper function to make promises more legible.
      *
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
+     * @param {string|number=} opt_patchNum
+     * @return {!Promise<!Object>} Diff comments response.
      */
-    getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
-      return this.getLoggedIn().then(loggedIn => {
-        if (!loggedIn) { return Promise.resolve({}); }
-        return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
-            opt_patchNum, opt_path);
-      });
-    },
+    // We don't want to add accept header, since preloading of comments is
+    // working only without accept header.
+    const noAcceptHeader = true;
+    const fetchComments = opt_patchNum => this._getChangeURLAndFetch({
+      changeNum,
+      endpoint,
+      patchNum: opt_patchNum,
+      reportEndpointAsIs: true,
+    }, noAcceptHeader);
 
-    _setRange(comments, comment) {
-      if (comment.in_reply_to && !comment.range) {
-        for (let i = 0; i < comments.length; i++) {
-          if (comments[i].id === comment.in_reply_to) {
-            comment.range = comments[i].range;
-            break;
-          }
-        }
+    if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
+      return fetchComments();
+    }
+    function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
+    function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
+    function setPath(c) { c.path = opt_path; }
+
+    const promises = [];
+    let comments;
+    let baseComments;
+    let fetchPromise;
+    fetchPromise = fetchComments(opt_patchNum).then(response => {
+      comments = response[opt_path] || [];
+      // TODO(kaspern): Implement this on in the backend so this can
+      // be removed.
+      // Sort comments by date so that parent ranges can be propagated
+      // in a single pass.
+      comments = this._setRanges(comments);
+
+      if (opt_basePatchNum == PARENT_PATCH_NUM) {
+        baseComments = comments.filter(onlyParent);
+        baseComments.forEach(setPath);
       }
-      return comment;
-    },
+      comments = comments.filter(withoutParent);
 
-    _setRanges(comments) {
-      comments = comments || [];
-      comments.sort((a, b) => {
-        return util.parseDate(a.updated) - util.parseDate(b.updated);
-      });
-      for (const comment of comments) {
-        this._setRange(comments, comment);
-      }
-      return comments;
-    },
+      comments.forEach(setPath);
+    });
+    promises.push(fetchPromise);
 
-    /**
-     * @param {number|string} changeNum
-     * @param {string} endpoint
-     * @param {number|string=} opt_basePatchNum
-     * @param {number|string=} opt_patchNum
-     * @param {string=} opt_path
-     * @return {!Promise<!Object>}
-     */
-    _getDiffComments(changeNum, endpoint, opt_basePatchNum,
-        opt_patchNum, opt_path) {
-      /**
-       * Fetches the comments for a given patchNum.
-       * Helper function to make promises more legible.
-       *
-       * @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,
-        });
-      };
-
-      if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
-        return fetchComments();
-      }
-      function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
-      function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
-      function setPath(c) { c.path = opt_path; }
-
-      const promises = [];
-      let comments;
-      let baseComments;
-      let fetchPromise;
-      fetchPromise = fetchComments(opt_patchNum).then(response => {
-        comments = response[opt_path] || [];
-        // TODO(kaspern): Implement this on in the backend so this can
-        // be removed.
-        // Sort comments by date so that parent ranges can be propagated
-        // in a single pass.
-        comments = this._setRanges(comments);
-
-        if (opt_basePatchNum == PARENT_PATCH_NUM) {
-          baseComments = comments.filter(onlyParent);
-          baseComments.forEach(setPath);
-        }
-        comments = comments.filter(withoutParent);
-
-        comments.forEach(setPath);
+    if (opt_basePatchNum != PARENT_PATCH_NUM) {
+      fetchPromise = fetchComments(opt_basePatchNum).then(response => {
+        baseComments = (response[opt_path] || [])
+            .filter(withoutParent);
+        baseComments = this._setRanges(baseComments);
+        baseComments.forEach(setPath);
       });
       promises.push(fetchPromise);
+    }
 
-      if (opt_basePatchNum != PARENT_PATCH_NUM) {
-        fetchPromise = fetchComments(opt_basePatchNum).then(response => {
-          baseComments = (response[opt_path] || [])
-              .filter(withoutParent);
-          baseComments = this._setRanges(baseComments);
-          baseComments.forEach(setPath);
+    return Promise.all(promises).then(() => Promise.resolve({
+      baseComments,
+      comments,
+    }));
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {string} endpoint
+   * @param {number|string=} opt_patchNum
+   */
+  _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.
+   */
+  hasPendingDiffDrafts() {
+    const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+    return promises && promises.length;
+  }
+
+  /**
+   * @returns {!Promise<undefined>} A promise that resolves when all pending
+   *    diff draft sends have resolved.
+   */
+  awaitPendingDiffDrafts() {
+    return Promise.all(this._pendingRequests[Requests.SEND_DIFF_DRAFT] || [])
+        .then(() => {
+          this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
         });
-        promises.push(fetchPromise);
-      }
+  }
 
-      return Promise.all(promises).then(() => {
-        return Promise.resolve({
-          baseComments,
-          comments,
+  _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
+    const isCreate = !draft.id && method === 'PUT';
+    let endpoint = '/drafts';
+    let anonymizedEndpoint = endpoint;
+    if (draft.id) {
+      endpoint += '/' + draft.id;
+      anonymizedEndpoint += '/*';
+    }
+    let body;
+    if (method === 'PUT') {
+      body = draft;
+    }
+
+    if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+    }
+
+    const req = {
+      changeNum,
+      method,
+      patchNum,
+      endpoint,
+      body,
+      anonymizedEndpoint,
+    };
+
+    const promise = this._getChangeURLAndSend(req);
+    this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
+
+    if (isCreate) {
+      return this._failForCreate200(promise);
+    }
+
+    return promise;
+  }
+
+  getCommitInfo(project, commit) {
+    return this._restApiHelper.fetchJSON({
+      url: '/projects/' + encodeURIComponent(project) +
+          '/commits/' + encodeURIComponent(commit),
+      anonymizedUrl: '/projects/*/comments/*',
+    });
+  }
+
+  _fetchB64File(url) {
+    return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
+        .then(response => {
+          if (!response.ok) {
+            return Promise.reject(new Error(response.statusText));
+          }
+          const type = response.headers.get('X-FYI-Content-Type');
+          return response.text()
+              .then(text => {
+                return {body: text, type};
+              });
         });
-      });
-    },
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {string} endpoint
-     * @param {number|string=} opt_patchNum
-     */
-    _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) {
-      return this._changeBaseURL(changeNum, opt_patchNum)
-          .then(url => url + endpoint);
-    },
+  /**
+   * @param {string} changeId
+   * @param {string|number} patchNum
+   * @param {string} path
+   * @param {number=} opt_parentIndex
+   */
+  getB64FileContents(changeId, patchNum, path, opt_parentIndex) {
+    const parent = typeof opt_parentIndex === 'number' ?
+      '?parent=' + opt_parentIndex : '';
+    return this._changeBaseURL(changeId, patchNum).then(url => {
+      url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
+      return this._fetchB64File(url);
+    });
+  }
 
-    saveDiffDraft(changeNum, patchNum, draft) {
-      return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
-    },
+  getImagesForDiff(changeNum, diff, patchRange) {
+    let promiseA;
+    let promiseB;
 
-    deleteDiffDraft(changeNum, patchNum, draft) {
-      return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
-    },
-
-    /**
-     * @returns {boolean} Whether there are pending diff draft sends.
-     */
-    hasPendingDiffDrafts() {
-      const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
-      return promises && promises.length;
-    },
-
-    /**
-     * @returns {!Promise<undefined>} A promise that resolves when all pending
-     *    diff draft sends have resolved.
-     */
-    awaitPendingDiffDrafts() {
-      return Promise.all(this._pendingRequests[Requests.SEND_DIFF_DRAFT] || [])
-          .then(() => {
-            this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
-          });
-    },
-
-    _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
-      const isCreate = !draft.id && method === 'PUT';
-      let endpoint = '/drafts';
-      let anonymizedEndpoint = endpoint;
-      if (draft.id) {
-        endpoint += '/' + draft.id;
-        anonymizedEndpoint += '/*';
-      }
-      let body;
-      if (method === 'PUT') {
-        body = draft;
-      }
-
-      if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
-        this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
-      }
-
-      const req = {
-        changeNum,
-        method,
-        patchNum,
-        endpoint,
-        body,
-        anonymizedEndpoint,
-      };
-
-      const promise = this._getChangeURLAndSend(req);
-      this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
-
-      if (isCreate) {
-        return this._failForCreate200(promise);
-      }
-
-      return promise;
-    },
-
-    getCommitInfo(project, commit) {
-      return this._restApiHelper.fetchJSON({
-        url: '/projects/' + encodeURIComponent(project) +
-            '/commits/' + encodeURIComponent(commit),
-        anonymizedUrl: '/projects/*/comments/*',
-      });
-    },
-
-    _fetchB64File(url) {
-      return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
-          .then(response => {
-            if (!response.ok) {
-              return Promise.reject(new Error(response.statusText));
-            }
-            const type = response.headers.get('X-FYI-Content-Type');
-            return response.text()
-                .then(text => {
-                  return {body: text, type};
-                });
-          });
-    },
-
-    /**
-     * @param {string} changeId
-     * @param {string|number} patchNum
-     * @param {string} path
-     * @param {number=} opt_parentIndex
-     */
-    getB64FileContents(changeId, patchNum, path, opt_parentIndex) {
-      const parent = typeof opt_parentIndex === 'number' ?
-        '?parent=' + opt_parentIndex : '';
-      return this._changeBaseURL(changeId, patchNum).then(url => {
-        url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
-        return this._fetchB64File(url);
-      });
-    },
-
-    getImagesForDiff(changeNum, diff, patchRange) {
-      let promiseA;
-      let promiseB;
-
-      if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) {
-        if (patchRange.basePatchNum === 'PARENT') {
-          // Note: we only attempt to get the image from the first parent.
-          promiseA = this.getB64FileContents(changeNum, patchRange.patchNum,
-              diff.meta_a.name, 1);
-        } else {
-          promiseA = this.getB64FileContents(changeNum,
-              patchRange.basePatchNum, diff.meta_a.name);
-        }
+    if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) {
+      if (patchRange.basePatchNum === 'PARENT') {
+        // Note: we only attempt to get the image from the first parent.
+        promiseA = this.getB64FileContents(changeNum, patchRange.patchNum,
+            diff.meta_a.name, 1);
       } else {
-        promiseA = Promise.resolve(null);
+        promiseA = this.getB64FileContents(changeNum,
+            patchRange.basePatchNum, diff.meta_a.name);
+      }
+    } else {
+      promiseA = Promise.resolve(null);
+    }
+
+    if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) {
+      promiseB = this.getB64FileContents(changeNum, patchRange.patchNum,
+          diff.meta_b.name);
+    } else {
+      promiseB = Promise.resolve(null);
+    }
+
+    return Promise.all([promiseA, promiseB]).then(results => {
+      const baseImage = results[0];
+      const revisionImage = results[1];
+
+      // Sometimes the server doesn't send back the content type.
+      if (baseImage) {
+        baseImage._expectedType = diff.meta_a.content_type;
+        baseImage._name = diff.meta_a.name;
+      }
+      if (revisionImage) {
+        revisionImage._expectedType = diff.meta_b.content_type;
+        revisionImage._name = diff.meta_b.name;
       }
 
-      if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) {
-        promiseB = this.getB64FileContents(changeNum, patchRange.patchNum,
-            diff.meta_b.name);
-      } else {
-        promiseB = Promise.resolve(null);
+      return {baseImage, revisionImage};
+    });
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {?number|string=} opt_patchNum passed as null sometimes.
+   * @param {string=} opt_project
+   * @return {!Promise<string>}
+   */
+  _changeBaseURL(changeNum, opt_patchNum, opt_project) {
+    // TODO(kaspern): For full slicer migration, app should warn with a call
+    // stack every time _changeBaseURL is called without a project.
+    const projectPromise = opt_project ?
+      Promise.resolve(opt_project) :
+      this.getFromProjectLookup(changeNum);
+    return projectPromise.then(project => {
+      let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
+      if (opt_patchNum) {
+        url += `/revisions/${opt_patchNum}`;
       }
+      return url;
+    });
+  }
 
-      return Promise.all([promiseA, promiseB]).then(results => {
-        const baseImage = results[0];
-        const revisionImage = results[1];
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  setChangeTopic(changeNum, topic) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/topic',
+      body: {topic},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
 
-        // Sometimes the server doesn't send back the content type.
-        if (baseImage) {
-          baseImage._expectedType = diff.meta_a.content_type;
-          baseImage._name = diff.meta_a.name;
-        }
-        if (revisionImage) {
-          revisionImage._expectedType = diff.meta_b.content_type;
-          revisionImage._name = diff.meta_b.name;
-        }
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  setChangeHashtag(changeNum, hashtag) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/hashtags',
+      body: hashtag,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
 
-        return {baseImage, revisionImage};
-      });
-    },
+  deleteAccountHttpPassword() {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/password.http',
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {?number|string=} opt_patchNum passed as null sometimes.
-     * @param {string=} opt_project
-     * @return {!Promise<string>}
-     */
-    _changeBaseURL(changeNum, opt_patchNum, opt_project) {
-      // TODO(kaspern): For full slicer migration, app should warn with a call
-      // stack every time _changeBaseURL is called without a project.
-      const projectPromise = opt_project ?
-        Promise.resolve(opt_project) :
-        this.getFromProjectLookup(changeNum);
-      return projectPromise.then(project => {
-        let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
-        if (opt_patchNum) {
-          url += `/revisions/${opt_patchNum}`;
-        }
-        return url;
-      });
-    },
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  generateAccountHttpPassword() {
+    return this._restApiHelper.send({
+      method: 'PUT',
+      url: '/accounts/self/password.http',
+      body: {generate: true},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    setChangeTopic(changeNum, topic) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/topic',
-        body: {topic},
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    },
+  getAccountSSHKeys() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/sshkeys',
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    setChangeHashtag(changeNum, hashtag) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/hashtags',
-        body: hashtag,
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    },
+  addAccountSSHKey(key) {
+    const req = {
+      method: 'POST',
+      url: '/accounts/self/sshkeys',
+      body: key,
+      contentType: 'text/plain',
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(response => {
+          if (response.status < 200 && response.status >= 300) {
+            return Promise.reject(new Error('error'));
+          }
+          return this.getResponseObject(response);
+        })
+        .then(obj => {
+          if (!obj.valid) { return Promise.reject(new Error('error')); }
+          return obj;
+        });
+  }
 
-    deleteAccountHttpPassword() {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/password.http',
-        reportUrlAsIs: true,
-      });
-    },
+  deleteAccountSSHKey(id) {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/sshkeys/' + id,
+      anonymizedUrl: '/accounts/self/sshkeys/*',
+    });
+  }
 
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    generateAccountHttpPassword() {
-      return this._restApiHelper.send({
-        method: 'PUT',
-        url: '/accounts/self/password.http',
-        body: {generate: true},
-        parseResponse: true,
-        reportUrlAsIs: true,
-      });
-    },
+  getAccountGPGKeys() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/gpgkeys',
+      reportUrlAsIs: true,
+    });
+  }
 
-    getAccountSSHKeys() {
-      return this._fetchSharedCacheURL({
-        url: '/accounts/self/sshkeys',
-        reportUrlAsIs: true,
-      });
-    },
+  addAccountGPGKey(key) {
+    const req = {
+      method: 'POST',
+      url: '/accounts/self/gpgkeys',
+      body: key,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req)
+        .then(response => {
+          if (response.status < 200 && response.status >= 300) {
+            return Promise.reject(new Error('error'));
+          }
+          return this.getResponseObject(response);
+        })
+        .then(obj => {
+          if (!obj) { return Promise.reject(new Error('error')); }
+          return obj;
+        });
+  }
 
-    addAccountSSHKey(key) {
-      const req = {
-        method: 'POST',
-        url: '/accounts/self/sshkeys',
-        body: key,
-        contentType: 'text/plain',
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(response => {
-            if (response.status < 200 && response.status >= 300) {
-              return Promise.reject(new Error('error'));
-            }
-            return this.getResponseObject(response);
-          })
-          .then(obj => {
-            if (!obj.valid) { return Promise.reject(new Error('error')); }
-            return obj;
-          });
-    },
+  deleteAccountGPGKey(id) {
+    return this._restApiHelper.send({
+      method: 'DELETE',
+      url: '/accounts/self/gpgkeys/' + id,
+      anonymizedUrl: '/accounts/self/gpgkeys/*',
+    });
+  }
 
-    deleteAccountSSHKey(id) {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/sshkeys/' + id,
-        anonymizedUrl: '/accounts/self/sshkeys/*',
-      });
-    },
+  deleteVote(changeNum, account, label) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+      anonymizedEndpoint: '/reviewers/*/votes/*',
+    });
+  }
 
-    getAccountGPGKeys() {
-      return this._restApiHelper.fetchJSON({
-        url: '/accounts/self/gpgkeys',
-        reportUrlAsIs: true,
-      });
-    },
+  setDescription(changeNum, patchNum, desc) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT', patchNum,
+      endpoint: '/description',
+      body: {description: desc},
+      reportUrlAsIs: true,
+    });
+  }
 
-    addAccountGPGKey(key) {
-      const req = {
-        method: 'POST',
-        url: '/accounts/self/gpgkeys',
-        body: key,
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req)
-          .then(response => {
-            if (response.status < 200 && response.status >= 300) {
-              return Promise.reject(new Error('error'));
-            }
-            return this.getResponseObject(response);
-          })
-          .then(obj => {
-            if (!obj) { return Promise.reject(new Error('error')); }
-            return obj;
-          });
-    },
-
-    deleteAccountGPGKey(id) {
-      return this._restApiHelper.send({
-        method: 'DELETE',
-        url: '/accounts/self/gpgkeys/' + id,
-        anonymizedUrl: '/accounts/self/gpgkeys/*',
-      });
-    },
-
-    deleteVote(changeNum, account, label) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
-        anonymizedEndpoint: '/reviewers/*/votes/*',
-      });
-    },
-
-    setDescription(changeNum, patchNum, desc) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT', patchNum,
-        endpoint: '/description',
-        body: {description: desc},
-        reportUrlAsIs: true,
-      });
-    },
-
-    confirmEmail(token) {
-      const req = {
-        method: 'PUT',
-        url: '/config/server/email.confirm',
-        body: {token},
-        reportUrlAsIs: true,
-      };
-      return this._restApiHelper.send(req).then(response => {
-        if (response.status === 204) {
-          return 'Email confirmed successfully.';
-        }
-        return null;
-      });
-    },
-
-    getCapabilities(opt_errFn) {
-      return this._restApiHelper.fetchJSON({
-        url: '/config/server/capabilities',
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    },
-
-    getTopMenus(opt_errFn) {
-      return this._fetchSharedCacheURL({
-        url: '/config/server/top-menus',
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    },
-
-    setAssignee(changeNum, assignee) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'PUT',
-        endpoint: '/assignee',
-        body: {assignee},
-        reportUrlAsIs: true,
-      });
-    },
-
-    deleteAssignee(changeNum) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'DELETE',
-        endpoint: '/assignee',
-        reportUrlAsIs: true,
-      });
-    },
-
-    probePath(path) {
-      return fetch(new Request(path, {method: 'HEAD'}))
-          .then(response => {
-            return response.ok;
-          });
-    },
-
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_message
-     */
-    startWorkInProgress(changeNum, opt_message) {
-      const body = {};
-      if (opt_message) {
-        body.message = opt_message;
+  confirmEmail(token) {
+    const req = {
+      method: 'PUT',
+      url: '/config/server/email.confirm',
+      body: {token},
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req).then(response => {
+      if (response.status === 204) {
+        return 'Email confirmed successfully.';
       }
-      const req = {
-        changeNum,
-        method: 'POST',
-        endpoint: '/wip',
-        body,
-        reportUrlAsIs: true,
-      };
-      return this._getChangeURLAndSend(req).then(response => {
-        if (response.status === 204) {
-          return 'Change marked as Work In Progress.';
-        }
-      });
-    },
+      return null;
+    });
+  }
 
-    /**
-     * @param {number|string} changeNum
-     * @param {number|string=} opt_body
-     * @param {function(?Response, string=)=} opt_errFn
-     */
-    startReview(changeNum, opt_body, opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        endpoint: '/ready',
-        body: opt_body,
-        errFn: opt_errFn,
-        reportUrlAsIs: true,
-      });
-    },
+  getCapabilities(opt_errFn) {
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/capabilities',
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * @suppress {checkTypes}
-     * Resulted in error: Promise.prototype.then does not match formal
-     * parameter.
-     */
-    deleteComment(changeNum, patchNum, commentID, reason) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: 'POST',
-        patchNum,
-        endpoint: `/comments/${commentID}/delete`,
-        body: {reason},
-        parseResponse: true,
-        anonymizedEndpoint: '/comments/*/delete',
-      });
-    },
+  getTopMenus(opt_errFn) {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/top-menus',
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * Given a changeNum, gets the change.
-     *
-     * @param {number|string} changeNum
-     * @param {function(?Response, string=)=} opt_errFn
-     * @return {!Promise<?Object>} The change
-     */
-    getChange(changeNum, opt_errFn) {
-      // Cannot use _changeBaseURL, as this function is used by _projectLookup.
-      return this._restApiHelper.fetchJSON({
-        url: `/changes/?q=change:${changeNum}`,
-        errFn: opt_errFn,
-        anonymizedUrl: '/changes/?q=change:*',
-      }).then(res => {
-        if (!res || !res.length) { return null; }
-        return res[0];
-      });
-    },
+  setAssignee(changeNum, assignee) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'PUT',
+      endpoint: '/assignee',
+      body: {assignee},
+      reportUrlAsIs: true,
+    });
+  }
 
-    /**
-     * @param {string|number} changeNum
-     * @param {string=} project
-     */
-    setInProjectLookup(changeNum, project) {
-      if (this._projectLookup[changeNum] &&
-          this._projectLookup[changeNum] !== project) {
-        console.warn('Change set with multiple project nums.' +
-            'One of them must be invalid.');
+  deleteAssignee(changeNum) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: '/assignee',
+      reportUrlAsIs: true,
+    });
+  }
+
+  probePath(path) {
+    return fetch(new Request(path, {method: 'HEAD'}))
+        .then(response => response.ok);
+  }
+
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_message
+   */
+  startWorkInProgress(changeNum, opt_message) {
+    const body = {};
+    if (opt_message) {
+      body.message = opt_message;
+    }
+    const req = {
+      changeNum,
+      method: 'POST',
+      endpoint: '/wip',
+      body,
+      reportUrlAsIs: true,
+    };
+    return this._getChangeURLAndSend(req).then(response => {
+      if (response.status === 204) {
+        return 'Change marked as Work In Progress.';
       }
-      this._projectLookup[changeNum] = project;
-    },
+    });
+  }
 
-    /**
-     * Checks in _projectLookup for the changeNum. If it exists, returns the
-     * project. If not, calls the restAPI to get the change, populates
-     * _projectLookup with the project for that change, and returns the project.
-     *
-     * @param {string|number} changeNum
-     * @return {!Promise<string|undefined>}
-     */
-    getFromProjectLookup(changeNum) {
-      const project = this._projectLookup[changeNum];
-      if (project) { return Promise.resolve(project); }
+  /**
+   * @param {number|string} changeNum
+   * @param {number|string=} opt_body
+   * @param {function(?Response, string=)=} opt_errFn
+   */
+  startReview(changeNum, opt_body, opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/ready',
+      body: opt_body,
+      errFn: opt_errFn,
+      reportUrlAsIs: true,
+    });
+  }
 
-      const onError = response => {
-        // Fire a page error so that the visual 404 is displayed.
-        this.fire('page-error', {response});
-      };
+  /**
+   * @suppress {checkTypes}
+   * Resulted in error: Promise.prototype.then does not match formal
+   * parameter.
+   */
+  deleteComment(changeNum, patchNum, commentID, reason) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      patchNum,
+      endpoint: `/comments/${commentID}/delete`,
+      body: {reason},
+      parseResponse: true,
+      anonymizedEndpoint: '/comments/*/delete',
+    });
+  }
 
-      return this.getChange(changeNum, onError).then(change => {
-        if (!change || !change.project) { return; }
-        this.setInProjectLookup(changeNum, change.project);
-        return change.project;
-      });
-    },
+  /**
+   * Given a changeNum, gets the change.
+   *
+   * @param {number|string} changeNum
+   * @param {function(?Response, string=)=} opt_errFn
+   * @return {!Promise<?Object>} The change
+   */
+  getChange(changeNum, opt_errFn) {
+    // Cannot use _changeBaseURL, as this function is used by _projectLookup.
+    return this._restApiHelper.fetchJSON({
+      url: `/changes/?q=change:${changeNum}`,
+      errFn: opt_errFn,
+      anonymizedUrl: '/changes/?q=change:*',
+    }).then(res => {
+      if (!res || !res.length) { return null; }
+      return res[0];
+    });
+  }
 
-    /**
-     * Alias for _changeBaseURL.then(send).
-     *
-     * @todo(beckysiegel) clean up comments
-     * @param {Gerrit.ChangeSendRequest} req
-     * @return {!Promise<!Object>}
-     */
-    _getChangeURLAndSend(req) {
-      const anonymizedBaseUrl = req.patchNum ?
-        ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
-      const anonymizedEndpoint = req.reportEndpointAsIs ?
-        req.endpoint : req.anonymizedEndpoint;
+  /**
+   * @param {string|number} changeNum
+   * @param {string=} project
+   */
+  setInProjectLookup(changeNum, project) {
+    if (this._projectLookup[changeNum] &&
+        this._projectLookup[changeNum] !== project) {
+      console.warn('Change set with multiple project nums.' +
+          'One of them must be invalid.');
+    }
+    this._projectLookup[changeNum] = project;
+  }
 
-      return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
-        return this._restApiHelper.send({
+  /**
+   * Checks in _projectLookup for the changeNum. If it exists, returns the
+   * project. If not, calls the restAPI to get the change, populates
+   * _projectLookup with the project for that change, and returns the project.
+   *
+   * @param {string|number} changeNum
+   * @return {!Promise<string|undefined>}
+   */
+  getFromProjectLookup(changeNum) {
+    const project = this._projectLookup[changeNum];
+    if (project) { return Promise.resolve(project); }
+
+    const onError = response => {
+      // Fire a page error so that the visual 404 is displayed.
+      this.dispatchEvent(new CustomEvent('page-error', {
+        detail: {response},
+        composed: true, bubbles: true,
+      }));
+    };
+
+    return this.getChange(changeNum, onError).then(change => {
+      if (!change || !change.project) { return; }
+      this.setInProjectLookup(changeNum, change.project);
+      return change.project;
+    });
+  }
+
+  /**
+   * Alias for _changeBaseURL.then(send).
+   *
+   * @todo(beckysiegel) clean up comments
+   * @param {Gerrit.ChangeSendRequest} req
+   * @return {!Promise<!Object>}
+   */
+  _getChangeURLAndSend(req) {
+    const anonymizedBaseUrl = req.patchNum ?
+      ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+    const anonymizedEndpoint = req.reportEndpointAsIs ?
+      req.endpoint : req.anonymizedEndpoint;
+
+    return this._changeBaseURL(req.changeNum, req.patchNum)
+        .then(url => this._restApiHelper.send({
           method: req.method,
           url: url + req.endpoint,
           body: req.body,
@@ -2597,158 +2673,157 @@
           parseResponse: req.parseResponse,
           anonymizedUrl: anonymizedEndpoint ?
             (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-        });
-      });
-    },
+        }));
+  }
 
-    /**
-     * Alias for _changeBaseURL.then(_fetchJSON).
-     *
-     * @param {Gerrit.ChangeFetchRequest} req
-     * @return {!Promise<!Object>}
-     */
-    _getChangeURLAndFetch(req) {
-      const anonymizedEndpoint = req.reportEndpointAsIs ?
-        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({
+  /**
+   * Alias for _changeBaseURL.then(_fetchJSON).
+   *
+   * @param {Gerrit.ChangeFetchRequest} req
+   * @return {!Promise<!Object>}
+   */
+  _getChangeURLAndFetch(req, noAcceptHeader) {
+    const anonymizedEndpoint = req.reportEndpointAsIs ?
+      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 => this._restApiHelper.fetchJSON({
           url: url + req.endpoint,
           errFn: req.errFn,
           params: req.params,
           fetchOptions: req.fetchOptions,
           anonymizedUrl: anonymizedEndpoint ?
             (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-        });
-      });
-    },
+        }, noAcceptHeader));
+  }
 
-    /**
-     * Execute a change action or revision action on a change.
-     *
-     * @param {number} changeNum
-     * @param {string} method
-     * @param {string} endpoint
-     * @param {string|number|undefined} opt_patchNum
-     * @param {Object=} opt_payload
-     * @param {?function(?Response, string=)=} opt_errFn
-     * @return {Promise}
-     */
-    executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload,
-        opt_errFn) {
-      return this._getChangeURLAndSend({
-        changeNum,
-        method,
-        patchNum: opt_patchNum,
-        endpoint,
-        body: opt_payload,
-        errFn: opt_errFn,
-      });
-    },
+  /**
+   * Execute a change action or revision action on a change.
+   *
+   * @param {number} changeNum
+   * @param {string} method
+   * @param {string} endpoint
+   * @param {string|number|undefined} opt_patchNum
+   * @param {Object=} opt_payload
+   * @param {?function(?Response, string=)=} opt_errFn
+   * @return {Promise}
+   */
+  executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload,
+      opt_errFn) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method,
+      patchNum: opt_patchNum,
+      endpoint,
+      body: opt_payload,
+      errFn: opt_errFn,
+    });
+  }
 
-    /**
-     * Get blame information for the given diff.
-     *
-     * @param {string|number} changeNum
-     * @param {string|number} patchNum
-     * @param {string} path
-     * @param {boolean=} opt_base If true, requests blame for the base of the
-     *     diff, rather than the revision.
-     * @return {!Promise<!Object>}
-     */
-    getBlame(changeNum, patchNum, path, opt_base) {
-      const encodedPath = encodeURIComponent(path);
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: `/files/${encodedPath}/blame`,
-        patchNum,
-        params: opt_base ? {base: 't'} : undefined,
-        anonymizedEndpoint: '/files/*/blame',
-      });
-    },
+  /**
+   * Get blame information for the given diff.
+   *
+   * @param {string|number} changeNum
+   * @param {string|number} patchNum
+   * @param {string} path
+   * @param {boolean=} opt_base If true, requests blame for the base of the
+   *     diff, rather than the revision.
+   * @return {!Promise<!Object>}
+   */
+  getBlame(changeNum, patchNum, path, opt_base) {
+    const encodedPath = encodeURIComponent(path);
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files/${encodedPath}/blame`,
+      patchNum,
+      params: opt_base ? {base: 't'} : undefined,
+      anonymizedEndpoint: '/files/*/blame',
+    });
+  }
 
-    /**
-     * Modify the given create draft request promise so that it fails and throws
-     * an error if the response bears HTTP status 200 instead of HTTP 201.
-     *
-     * @see Issue 7763
-     * @param {Promise} promise The original promise.
-     * @return {Promise} The modified promise.
-     */
-    _failForCreate200(promise) {
-      return promise.then(result => {
-        if (result.status === 200) {
-          // Read the response headers into an object representation.
-          const headers = Array.from(result.headers.entries())
-              .reduce((obj, [key, val]) => {
-                if (!HEADER_REPORTING_BLACKLIST.test(key)) {
-                  obj[key] = val;
-                }
-                return obj;
-              }, {});
-          const err = new Error([
-            CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
-            JSON.stringify(headers),
-          ].join('\n'));
-          // Throw the error so that it is caught by gr-reporting.
-          throw err;
-        }
-        return result;
-      });
-    },
+  /**
+   * Modify the given create draft request promise so that it fails and throws
+   * an error if the response bears HTTP status 200 instead of HTTP 201.
+   *
+   * @see Issue 7763
+   * @param {Promise} promise The original promise.
+   * @return {Promise} The modified promise.
+   */
+  _failForCreate200(promise) {
+    return promise.then(result => {
+      if (result.status === 200) {
+        // Read the response headers into an object representation.
+        const headers = Array.from(result.headers.entries())
+            .reduce((obj, [key, val]) => {
+              if (!HEADER_REPORTING_BLACKLIST.test(key)) {
+                obj[key] = val;
+              }
+              return obj;
+            }, {});
+        const err = new Error([
+          CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
+          JSON.stringify(headers),
+        ].join('\n'));
+        // Throw the error so that it is caught by gr-reporting.
+        throw err;
+      }
+      return result;
+    });
+  }
 
-    /**
-     * Fetch a project dashboard definition.
-     * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
-     *
-     * @param {string} project
-     * @param {string} dashboard
-     * @param {function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @return {!Promise<!Object>}
-     */
-    getDashboard(project, dashboard, opt_errFn) {
-      const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
-          encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL({
-        url,
-        errFn: opt_errFn,
-        anonymizedUrl: '/projects/*/dashboards/*',
-      });
-    },
+  /**
+   * Fetch a project dashboard definition.
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
+   *
+   * @param {string} project
+   * @param {string} dashboard
+   * @param {function(?Response, string=)=} opt_errFn
+   *    passed as null sometimes.
+   * @return {!Promise<!Object>}
+   */
+  getDashboard(project, dashboard, opt_errFn) {
+    const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
+        encodeURIComponent(dashboard);
+    return this._fetchSharedCacheURL({
+      url,
+      errFn: opt_errFn,
+      anonymizedUrl: '/projects/*/dashboards/*',
+    });
+  }
 
-    /**
-     * @param {string} filter
-     * @return {!Promise<?Object>}
-     */
-    getDocumentationSearches(filter) {
-      filter = filter.trim();
-      const encodedFilter = encodeURIComponent(filter);
+  /**
+   * @param {string} filter
+   * @return {!Promise<?Object>}
+   */
+  getDocumentationSearches(filter) {
+    filter = filter.trim();
+    const encodedFilter = encodeURIComponent(filter);
 
-      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-      // supports it.
-      return this._fetchSharedCacheURL({
-        url: `/Documentation/?q=${encodedFilter}`,
-        anonymizedUrl: '/Documentation/?*',
-      });
-    },
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: `/Documentation/?q=${encodedFilter}`,
+      anonymizedUrl: '/Documentation/?*',
+    });
+  }
 
-    getMergeable(changeNum) {
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/revisions/current/mergeable',
-        parseResponse: true,
-        reportEndpointAsIs: true,
-      });
-    },
+  getMergeable(changeNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/revisions/current/mergeable',
+      parseResponse: true,
+      reportEndpointAsIs: true,
+    });
+  }
 
-    deleteDraftComments(query) {
-      return this._restApiHelper.send({
-        method: 'POST',
-        url: '/accounts/self/drafts:delete',
-        body: {query},
-      });
-    },
-  });
-})();
+  deleteDraftComments(query) {
+    return this._restApiHelper.send({
+      method: 'POST',
+      url: '/accounts/self/drafts:delete',
+      body: {query},
+    });
+  }
+}
+
+customElements.define(GrRestApiInterface.is, GrRestApiInterface);
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 635e0f5..0a51d26 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<link rel="import" href="gr-rest-api-interface.html">
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,91 +31,153 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-rest-api-interface tests', () => {
-    let element;
-    let sandbox;
-    let ctr = 0;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-rest-api-interface.js';
+import {mockPromise} from '../../../test/test-utils.js';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
+import {authService} from './gr-auth.js';
 
-    setup(() => {
-      // Modify CANONICAL_PATH to effectively reset cache.
-      ctr += 1;
-      window.CANONICAL_PATH = `test${ctr}`;
+suite('gr-rest-api-interface tests', () => {
+  let element;
+  let sandbox;
+  let ctr = 0;
 
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element._projectLookup = {};
-      const testJSON = ')]}\'\n{"hello": "bonjour"}';
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({
-        ok: true,
-        text() {
-          return Promise.resolve(testJSON);
-        },
-      }));
-    });
+  setup(() => {
+    // Modify CANONICAL_PATH to effectively reset cache.
+    ctr += 1;
+    window.CANONICAL_PATH = `test${ctr}`;
 
-    teardown(() => {
-      sandbox.restore();
-    });
+    sandbox = sinon.sandbox.create();
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sandbox.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
+    // fake auth
+    sandbox.stub(authService, 'authCheck').returns(Promise.resolve(true));
+    element = fixture('basic');
+    element._projectLookup = {};
+  });
 
-    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',
-            },
-          ],
-        });
-      });
-      element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
-          obj => {
-            assert.equal(obj.baseComments.length, 1);
-            assert.deepEqual(obj.baseComments[0], {
-              side: 'PARENT',
-              message: 'how did this work in the first place?',
-              path: 'sieve.go',
-              updated: '2017-02-03 22:33:28.000000000',
-            });
-            assert.equal(obj.comments.length, 1);
-            assert.deepEqual(obj.comments[0], {
-              message: 'this isn’t quite right',
-              path: 'sieve.go',
-              updated: '2017-02-03 22:32:28.000000000',
-            });
-            done();
-          });
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('_setRange', () => {
-      const comments = [
+  test('parent diff comments are properly grouped', done => {
+    sandbox.stub(element._restApiHelper, 'fetchJSON', () => Promise.resolve({
+      '/COMMIT_MSG': [],
+      'sieve.go': [
         {
-          id: 1,
+          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:32:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-        {
-          id: 2,
-          in_reply_to: 1,
-          message: 'this isn’t quite right',
           updated: '2017-02-03 22:33:28.000000000',
         },
-      ];
-      const expectedResult = {
+      ],
+    }));
+    element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
+        obj => {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            side: 'PARENT',
+            message: 'how did this work in the first place?',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:33:28.000000000',
+          });
+          assert.equal(obj.comments.length, 1);
+          assert.deepEqual(obj.comments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          done();
+        });
+  });
+
+  test('_setRange', () => {
+    const comments = [
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+      },
+    ];
+    const expectedResult = {
+      id: 2,
+      in_reply_to: 1,
+      message: 'this isn’t quite right',
+      updated: '2017-02-03 22:33:28.000000000',
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 2,
+        end_character: 1,
+      },
+    };
+    const comment = comments[1];
+    assert.deepEqual(element._setRange(comments, comment), expectedResult);
+  });
+
+  test('_setRanges', () => {
+    const comments = [
+      {
+        id: 3,
+        in_reply_to: 2,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000',
+      },
+      {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+      },
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    const expectedResult = [
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
         id: 2,
         in_reply_to: 1,
         message: 'this isn’t quite right',
@@ -131,1408 +188,1262 @@
           end_line: 2,
           end_character: 1,
         },
-      };
-      const comment = comments[1];
-      assert.deepEqual(element._setRange(comments, comment), expectedResult);
-    });
+      },
+      {
+        id: 3,
+        in_reply_to: 2,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    assert.deepEqual(element._setRanges(comments), expectedResult);
+  });
 
-    test('_setRanges', () => {
-      const comments = [
-        {
-          id: 3,
-          in_reply_to: 2,
-          message: 'this isn’t quite right either',
-          updated: '2017-02-03 22:34:28.000000000',
-        },
-        {
-          id: 2,
-          in_reply_to: 1,
-          message: 'this isn’t quite right',
-          updated: '2017-02-03 22:33:28.000000000',
-        },
-        {
-          id: 1,
-          side: 'PARENT',
-          message: 'how did this work in the first place?',
-          updated: '2017-02-03 22:32:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-      ];
-      const expectedResult = [
-        {
-          id: 1,
-          side: 'PARENT',
-          message: 'how did this work in the first place?',
-          updated: '2017-02-03 22:32:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-        {
-          id: 2,
-          in_reply_to: 1,
-          message: 'this isn’t quite right',
-          updated: '2017-02-03 22:33:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-        {
-          id: 3,
-          in_reply_to: 2,
-          message: 'this isn’t quite right either',
-          updated: '2017-02-03 22:34:28.000000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 2,
-            end_character: 1,
-          },
-        },
-      ];
-      assert.deepEqual(element._setRanges(comments), expectedResult);
-    });
-
-    test('differing patch diff comments are properly grouped', done => {
-      sandbox.stub(element, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sandbox.stub(element._restApiHelper, 'fetchJSON', request => {
-        const url = request.url;
-        if (url === '/changes/test~42/revisions/1') {
-          return Promise.resolve({
-            '/COMMIT_MSG': [],
-            'sieve.go': [
-              {
-                message: 'this isn’t quite right',
-                updated: '2017-02-03 22:32:28.000000000',
-              },
-              {
-                side: 'PARENT',
-                message: 'how did this work in the first place?',
-                updated: '2017-02-03 22:33:28.000000000',
-              },
-            ],
-          });
-        } else if (url === '/changes/test~42/revisions/2') {
-          return Promise.resolve({
-            '/COMMIT_MSG': [],
-            'sieve.go': [
-              {
-                message: 'What on earth are you thinking, here?',
-                updated: '2017-02-03 22:32:28.000000000',
-              },
-              {
-                side: 'PARENT',
-                message: 'Yeah not sure how this worked either?',
-                updated: '2017-02-03 22:33:28.000000000',
-              },
-              {
-                message: '¯\\_(ツ)_/¯',
-                updated: '2017-02-04 22:33:28.000000000',
-              },
-            ],
-          });
-        }
-      });
-      element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
-          obj => {
-            assert.equal(obj.baseComments.length, 1);
-            assert.deepEqual(obj.baseComments[0], {
+  test('differing patch diff comments are properly grouped', done => {
+    sandbox.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    sandbox.stub(element._restApiHelper, 'fetchJSON', request => {
+      const url = request.url;
+      if (url === '/changes/test~42/revisions/1') {
+        return Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
               message: 'this isn’t quite right',
-              path: 'sieve.go',
               updated: '2017-02-03 22:32:28.000000000',
-            });
-            assert.equal(obj.comments.length, 2);
-            assert.deepEqual(obj.comments[0], {
+            },
+            {
+              side: 'PARENT',
+              message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+          ],
+        });
+      } else if (url === '/changes/test~42/revisions/2') {
+        return Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
               message: 'What on earth are you thinking, here?',
-              path: 'sieve.go',
               updated: '2017-02-03 22:32:28.000000000',
-            });
-            assert.deepEqual(obj.comments[1], {
+            },
+            {
+              side: 'PARENT',
+              message: 'Yeah not sure how this worked either?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+            {
               message: '¯\\_(ツ)_/¯',
-              path: 'sieve.go',
               updated: '2017-02-04 22:33:28.000000000',
-            });
-            done();
+            },
+          ],
+        });
+      }
+    });
+    element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
+        obj => {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
           });
+          assert.equal(obj.comments.length, 2);
+          assert.deepEqual(obj.comments[0], {
+            message: 'What on earth are you thinking, here?',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          assert.deepEqual(obj.comments[1], {
+            message: '¯\\_(ツ)_/¯',
+            path: 'sieve.go',
+            updated: '2017-02-04 22:33:28.000000000',
+          });
+          done();
+        });
+  });
+
+  test('special file path sorting', () => {
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.a', '.b', 'file']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
+            element.specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
+            element.specialFilePathCompare),
+        ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
+
+    // Regression test for Issue 4448.
+    assert.deepEqual(
+        [
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_thread_writer.cc',
+          'minidump/minidump_thread_writer.h',
+        ].sort(element.specialFilePathCompare),
+        [
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_thread_writer.h',
+          'minidump/minidump_thread_writer.cc',
+        ]);
+
+    // Regression test for Issue 4545.
+    assert.deepEqual(
+        [
+          'task_test.go',
+          'task.go',
+        ].sort(element.specialFilePathCompare),
+        [
+          'task.go',
+          'task_test.go',
+        ]);
+  });
+
+  test('server error', done => {
+    const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
+    window.fetch.returns(Promise.resolve({ok: false}));
+    const serverErrorEventPromise = new Promise(resolve => {
+      element.addEventListener('server-error', resolve);
     });
 
-    test('special file path sorting', () => {
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.a', '.b', 'file']);
-
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
-
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
-
-      assert.deepEqual(
-          ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
-              element.specialFilePathCompare),
-          ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
-
-      assert.deepEqual(
-          ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
-              element.specialFilePathCompare),
-          ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
-
-      // Regression test for Issue 4448.
-      assert.deepEqual(
-          [
-            'minidump/minidump_memory_writer.cc',
-            'minidump/minidump_memory_writer.h',
-            'minidump/minidump_thread_writer.cc',
-            'minidump/minidump_thread_writer.h',
-          ].sort(element.specialFilePathCompare),
-          [
-            'minidump/minidump_memory_writer.h',
-            'minidump/minidump_memory_writer.cc',
-            'minidump/minidump_thread_writer.h',
-            'minidump/minidump_thread_writer.cc',
-          ]);
-
-      // Regression test for Issue 4545.
-      assert.deepEqual(
-          [
-            'task_test.go',
-            'task.go',
-          ].sort(element.specialFilePathCompare),
-          [
-            'task.go',
-            'task_test.go',
-          ]);
+    element._restApiHelper.fetchJSON({}).then(response => {
+      assert.isUndefined(response);
+      assert.isTrue(getResponseObjectStub.notCalled);
+      serverErrorEventPromise.then(() => done());
     });
+  });
 
-    test('server error', done => {
-      const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
-      window.fetch.returns(Promise.resolve({ok: false}));
-      const serverErrorEventPromise = new Promise(resolve => {
-        element.addEventListener('server-error', resolve);
+  test('legacy n,z key in change url is replaced', async () => {
+    sandbox.stub(element, 'getConfig', async () => { return {}; });
+    const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
+        .returns(Promise.resolve([]));
+    await element.getChanges(1, null, 'n,z');
+    assert.equal(stub.lastCall.args[0].params.S, 0);
+  });
+
+  test('saveDiffPreferences invalidates cache line', () => {
+    const cacheKey = '/accounts/self/preferences.diff';
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element._cache.set(cacheKey, {tab_size: 4});
+    element.saveDiffPreferences({tab_size: 8});
+    assert.isTrue(sendStub.called);
+    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+  });
+
+  test('getAccount when resp is null does not add anything to the cache',
+      done => {
+        const cacheKey = '/accounts/self/detail';
+        const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
+            () => Promise.resolve());
+
+        element.getAccount().then(() => {
+          assert.isTrue(stub.called);
+          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+          done();
+        });
+
+        element._restApiHelper._cache.set(cacheKey, 'fake cache');
+        stub.lastCall.args[0].errFn();
       });
 
-      element._restApiHelper.fetchJSON({}).then(response => {
-        assert.isUndefined(response);
-        assert.isTrue(getResponseObjectStub.notCalled);
-        serverErrorEventPromise.then(() => done());
-      });
-    });
+  test('getAccount does not add to the cache when resp.status is 403',
+      done => {
+        const cacheKey = '/accounts/self/detail';
+        const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
+            () => Promise.resolve());
 
-    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'));
+        element.getAccount().then(() => {
+          assert.isTrue(stub.called);
+          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+          done();
+        });
+        element._cache.set(cacheKey, 'fake cache');
+        stub.lastCall.args[0].errFn({status: 403});
+      });
+
+  test('getAccount when resp is successful', done => {
+    const cacheKey = '/accounts/self/detail';
+    const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
+        () => Promise.resolve());
+
+    element.getAccount().then(response => {
+      assert.isTrue(stub.called);
+      assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
+      done();
+    });
+    element._restApiHelper._cache.set(cacheKey, 'fake cache');
+
+    stub.lastCall.args[0].errFn({});
+  });
+
+  const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+    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',
+      done => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = true;
+        const smallScreen = true;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
           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'));
+  test('getPreferences returns correctly on small screens not logged in',
+      done => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = false;
+        const smallScreen = true;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
           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('getPreferences returns correctly on larger screens logged in',
+      done => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = true;
+        const smallScreen = false;
 
-    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());
-        }
-      });
+        preferenceSetup(testJSON, loggedIn, smallScreen);
 
-      element.getLoggedIn().then(account => {
-        assert.isNotOk(account);
-        element.checkCredentials().then(account => {
-          assert.isOk(account);
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'UNIFIED_DIFF');
           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('getPreferences returns correctly on larger screens not logged in',
+      done => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = false;
+        const smallScreen = false;
 
-    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');
-    });
+        preferenceSetup(testJSON, loggedIn, smallScreen);
 
-    test('legacy n,z key in change url is replaced', () => {
-      const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([]));
-      element.getChanges(1, null, 'n,z');
-      assert.equal(stub.lastCall.args[0].params.S, 0);
-    });
-
-    test('saveDiffPreferences invalidates cache line', () => {
-      const cacheKey = '/accounts/self/preferences.diff';
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element._cache.set(cacheKey, {tab_size: 4});
-      element.saveDiffPreferences({tab_size: 8});
-      assert.isTrue(sendStub.called);
-      assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-    });
-
-    test('getAccount when resp is null does not add anything to the cache',
-        done => {
-          const cacheKey = '/accounts/self/detail';
-          const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-              () => Promise.resolve());
-
-          element.getAccount().then(() => {
-            assert.isTrue(stub.called);
-            assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-            done();
-          });
-
-          element._restApiHelper._cache.set(cacheKey, 'fake cache');
-          stub.lastCall.args[0].errFn();
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+          done();
         });
-
-    test('getAccount does not add to the cache when resp.status is 403',
-        done => {
-          const cacheKey = '/accounts/self/detail';
-          const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-              () => Promise.resolve());
-
-          element.getAccount().then(() => {
-            assert.isTrue(stub.called);
-            assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-            done();
-          });
-          element._cache.set(cacheKey, 'fake cache');
-          stub.lastCall.args[0].errFn({status: 403});
-        });
-
-    test('getAccount when resp is successful', done => {
-      const cacheKey = '/accounts/self/detail';
-      const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-          () => Promise.resolve());
-
-      element.getAccount().then(response => {
-        assert.isTrue(stub.called);
-        assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
-        done();
       });
-      element._restApiHelper._cache.set(cacheKey, 'fake cache');
 
-      stub.lastCall.args[0].errFn({});
+  test('savPreferences normalizes download scheme', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element.savePreferences({download_scheme: 'HTTP'});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
+  });
+
+  test('getDiffPreferences returns correct defaults', done => {
+    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
+
+    element.getDiffPreferences().then(obj => {
+      assert.equal(obj.auto_hide_diff_table_header, true);
+      assert.equal(obj.context, 10);
+      assert.equal(obj.cursor_blink_rate, 0);
+      assert.equal(obj.font_size, 12);
+      assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
+      assert.equal(obj.intraline_difference, true);
+      assert.equal(obj.line_length, 100);
+      assert.equal(obj.line_wrapping, false);
+      assert.equal(obj.show_line_endings, true);
+      assert.equal(obj.show_tabs, true);
+      assert.equal(obj.show_whitespace_errors, true);
+      assert.equal(obj.syntax_highlighting, true);
+      assert.equal(obj.tab_size, 8);
+      assert.equal(obj.theme, 'DEFAULT');
+      done();
     });
+  });
 
-    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);
-      });
-    };
+  test('saveDiffPreferences set show_tabs to false', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element.saveDiffPreferences({show_tabs: false});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
+  });
 
-    test('getPreferences returns correctly on small screens logged in',
-        done => {
-          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-          const loggedIn = true;
-          const smallScreen = true;
+  test('getEditPreferences returns correct defaults', done => {
+    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
 
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-            done();
-          });
-        });
-
-    test('getPreferences returns correctly on small screens not logged in',
-        done => {
-          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-          const loggedIn = false;
-          const smallScreen = true;
-
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-            done();
-          });
-        });
-
-    test('getPreferences returns correctly on larger screens logged in',
-        done => {
-          const testJSON = {diff_view: 'UNIFIED_DIFF'};
-          const loggedIn = true;
-          const smallScreen = false;
-
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-            assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-            done();
-          });
-        });
-
-    test('getPreferences returns correctly on larger screens not logged in',
-        done => {
-          const testJSON = {diff_view: 'UNIFIED_DIFF'};
-          const loggedIn = false;
-          const smallScreen = false;
-
-          preferenceSetup(testJSON, loggedIn, smallScreen);
-
-          element.getPreferences().then(obj => {
-            assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
-            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-            done();
-          });
-        });
-
-    test('savPreferences normalizes download scheme', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element.savePreferences({download_scheme: 'HTTP'});
-      assert.isTrue(sendStub.called);
-      assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
+    element.getEditPreferences().then(obj => {
+      assert.equal(obj.auto_close_brackets, false);
+      assert.equal(obj.cursor_blink_rate, 0);
+      assert.equal(obj.hide_line_numbers, false);
+      assert.equal(obj.hide_top_menu, false);
+      assert.equal(obj.indent_unit, 2);
+      assert.equal(obj.indent_with_tabs, false);
+      assert.equal(obj.key_map_type, 'DEFAULT');
+      assert.equal(obj.line_length, 100);
+      assert.equal(obj.line_wrapping, false);
+      assert.equal(obj.match_brackets, true);
+      assert.equal(obj.show_base, false);
+      assert.equal(obj.show_tabs, true);
+      assert.equal(obj.show_whitespace_errors, true);
+      assert.equal(obj.syntax_highlighting, true);
+      assert.equal(obj.tab_size, 8);
+      assert.equal(obj.theme, 'DEFAULT');
+      done();
     });
+  });
 
-    test('getDiffPreferences returns correct defaults', done => {
-      sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
+  test('saveEditPreferences set show_tabs to false', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send');
+    element.saveEditPreferences({show_tabs: false});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
+  });
 
-      element.getDiffPreferences().then(obj => {
-        assert.equal(obj.auto_hide_diff_table_header, true);
-        assert.equal(obj.context, 10);
-        assert.equal(obj.cursor_blink_rate, 0);
-        assert.equal(obj.font_size, 12);
-        assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
-        assert.equal(obj.intraline_difference, true);
-        assert.equal(obj.line_length, 100);
-        assert.equal(obj.line_wrapping, false);
-        assert.equal(obj.show_line_endings, true);
-        assert.equal(obj.show_tabs, true);
-        assert.equal(obj.show_whitespace_errors, true);
-        assert.equal(obj.syntax_highlighting, true);
-        assert.equal(obj.tab_size, 8);
-        assert.equal(obj.theme, 'DEFAULT');
-        done();
-      });
-    });
+  test('confirmEmail', () => {
+    const sendStub = sandbox.spy(element._restApiHelper, 'send');
+    element.confirmEmail('foo');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+    assert.equal(sendStub.lastCall.args[0].url,
+        '/config/server/email.confirm');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+  });
 
-    test('saveDiffPreferences set show_tabs to false', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element.saveDiffPreferences({show_tabs: false});
-      assert.isTrue(sendStub.called);
-      assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-    });
-
-    test('getEditPreferences returns correct defaults', done => {
-      sandbox.stub(element, 'getLoggedIn', () => {
-        return Promise.resolve(false);
-      });
-
-      element.getEditPreferences().then(obj => {
-        assert.equal(obj.auto_close_brackets, false);
-        assert.equal(obj.cursor_blink_rate, 0);
-        assert.equal(obj.hide_line_numbers, false);
-        assert.equal(obj.hide_top_menu, false);
-        assert.equal(obj.indent_unit, 2);
-        assert.equal(obj.indent_with_tabs, false);
-        assert.equal(obj.key_map_type, 'DEFAULT');
-        assert.equal(obj.line_length, 100);
-        assert.equal(obj.line_wrapping, false);
-        assert.equal(obj.match_brackets, true);
-        assert.equal(obj.show_base, false);
-        assert.equal(obj.show_tabs, true);
-        assert.equal(obj.show_whitespace_errors, true);
-        assert.equal(obj.syntax_highlighting, true);
-        assert.equal(obj.tab_size, 8);
-        assert.equal(obj.theme, 'DEFAULT');
-        done();
-      });
-    });
-
-    test('saveEditPreferences set show_tabs to false', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send');
-      element.saveEditPreferences({show_tabs: false});
-      assert.isTrue(sendStub.called);
-      assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-    });
-
-    test('confirmEmail', () => {
-      const sendStub = sandbox.spy(element._restApiHelper, 'send');
-      element.confirmEmail('foo');
+  test('setAccountStatus', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve('OOO'));
+    element._cache.set('/accounts/self/detail', {});
+    return element.setAccountStatus('OOO').then(() => {
       assert.isTrue(sendStub.calledOnce);
       assert.equal(sendStub.lastCall.args[0].method, 'PUT');
       assert.equal(sendStub.lastCall.args[0].url,
-          '/config/server/email.confirm');
-      assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
-    });
-
-    test('setAccountStatus', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send')
-          .returns(Promise.resolve('OOO'));
-      element._cache.set('/accounts/self/detail', {});
-      return element.setAccountStatus('OOO').then(() => {
-        assert.isTrue(sendStub.calledOnce);
-        assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-        assert.equal(sendStub.lastCall.args[0].url,
-            '/accounts/self/status');
-        assert.deepEqual(sendStub.lastCall.args[0].body,
-            {status: 'OOO'});
-        assert.deepEqual(element._restApiHelper
-            ._cache.get('/accounts/self/detail'),
-        {status: 'OOO'});
-      });
-    });
-
-    suite('draft comments', () => {
-      test('_sendDiffDraftRequest pending requests tracked', () => {
-        const obj = element._pendingRequests;
-        sandbox.stub(element, '_getChangeURLAndSend', () => mockPromise());
-        assert.notOk(element.hasPendingDiffDrafts());
-
-        element._sendDiffDraftRequest(null, null, null, {});
-        assert.equal(obj.sendDiffDraft.length, 1);
-        assert.isTrue(!!element.hasPendingDiffDrafts());
-
-        element._sendDiffDraftRequest(null, null, null, {});
-        assert.equal(obj.sendDiffDraft.length, 2);
-        assert.isTrue(!!element.hasPendingDiffDrafts());
-
-        for (const promise of obj.sendDiffDraft) { promise.resolve(); }
-
-        return element.awaitPendingDiffDrafts().then(() => {
-          assert.equal(obj.sendDiffDraft.length, 0);
-          assert.isFalse(!!element.hasPendingDiffDrafts());
-        });
-      });
-
-      suite('_failForCreate200', () => {
-        test('_sendDiffDraftRequest checks for 200 on create', () => {
-          const sendPromise = Promise.resolve();
-          sandbox.stub(element, '_getChangeURLAndSend').returns(sendPromise);
-          const failStub = sandbox.stub(element, '_failForCreate200')
-              .returns(Promise.resolve());
-          return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
-            assert.isTrue(failStub.calledOnce);
-            assert.isTrue(failStub.calledWithExactly(sendPromise));
-          });
-        });
-
-        test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-          sandbox.stub(element, '_getChangeURLAndSend')
-              .returns(Promise.resolve());
-          const failStub = sandbox.stub(element, '_failForCreate200')
-              .returns(Promise.resolve());
-          return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
-              .then(() => {
-                assert.isFalse(failStub.called);
-              });
-        });
-
-        test('_failForCreate200 fails on 200', done => {
-          const result = {
-            ok: true,
-            status: 200,
-            headers: {entries: () => [
-              ['Set-CoOkiE', 'secret'],
-              ['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();
-          });
-        });
-
-        test('_failForCreate200 does not fail on 201', done => {
-          const result = {
-            ok: true,
-            status: 201,
-            headers: {entries: () => []},
-          };
-          element._failForCreate200(Promise.resolve(result)).then(() => {
-            done();
-          }).catch(e => {
-            assert.isTrue(false, 'Promise should not fail');
-          });
-        });
-      });
-    });
-
-    test('saveChangeEdit', () => {
-      element._projectLookup = {1: 'test'};
-      const change_num = '1';
-      const file_name = 'index.php';
-      const file_contents = '<?php';
-      sandbox.stub(element._restApiHelper, 'send').returns(
-          Promise.resolve([change_num, file_name, file_contents]));
-      sandbox.stub(element, 'getResponseObject')
-          .returns(Promise.resolve([change_num, file_name, file_contents]));
-      element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
-      return element.saveChangeEdit(change_num, file_name, file_contents)
-          .then(() => {
-            assert.isTrue(element._restApiHelper.send.calledOnce);
-            assert.equal(element._restApiHelper.send.lastCall.args[0].method,
-                'PUT');
-            assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-                '/changes/test~1/edit/' + file_name);
-            assert.equal(element._restApiHelper.send.lastCall.args[0].body,
-                file_contents);
-          });
-    });
-
-    test('putChangeCommitMessage', () => {
-      element._projectLookup = {1: 'test'};
-      const change_num = '1';
-      const message = 'this is a commit message';
-      sandbox.stub(element._restApiHelper, 'send').returns(
-          Promise.resolve([change_num, message]));
-      sandbox.stub(element, 'getResponseObject')
-          .returns(Promise.resolve([change_num, message]));
-      element._cache.set('/changes/' + change_num + '/message', {});
-      return element.putChangeCommitMessage(change_num, message).then(() => {
-        assert.isTrue(element._restApiHelper.send.calledOnce);
-        assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
-        assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-            '/changes/test~1/message');
-        assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
-            {message});
-      });
-    });
-
-    test('startWorkInProgress', () => {
-      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve('ok'));
-      element.startWorkInProgress('42');
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-      assert.deepEqual(sendStub.lastCall.args[0].body, {});
-
-      element.startWorkInProgress('42', 'revising...');
-      assert.isTrue(sendStub.calledTwice);
-      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-      assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+          '/accounts/self/status');
       assert.deepEqual(sendStub.lastCall.args[0].body,
-          {message: 'revising...'});
+          {status: 'OOO'});
+      assert.deepEqual(element._restApiHelper
+          ._cache.get('/accounts/self/detail'),
+      {status: 'OOO'});
     });
+  });
 
-    test('startReview', () => {
-      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve({}));
-      element.startReview('42', {message: 'Please review.'});
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-      assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
-      assert.deepEqual(sendStub.lastCall.args[0].body,
-          {message: 'Please review.'});
-    });
+  suite('draft comments', () => {
+    test('_sendDiffDraftRequest pending requests tracked', () => {
+      const obj = element._pendingRequests;
+      sandbox.stub(element, '_getChangeURLAndSend', () => mockPromise());
+      assert.notOk(element.hasPendingDiffDrafts());
 
-    test('deleteComment', () => {
-      const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve('some response'));
-      return element.deleteComment('foo', 'bar', '01234', 'removal reason')
-          .then(response => {
-            assert.equal(response, 'some response');
-            assert.isTrue(sendStub.calledOnce);
-            assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
-            assert.equal(sendStub.lastCall.args[0].method, 'POST');
-            assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
-            assert.equal(sendStub.lastCall.args[0].endpoint,
-                '/comments/01234/delete');
-            assert.deepEqual(sendStub.lastCall.args[0].body,
-                {reason: 'removal reason'});
-          });
-    });
+      element._sendDiffDraftRequest(null, null, null, {});
+      assert.equal(obj.sendDiffDraft.length, 1);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
 
-    test('createRepo encodes name', () => {
-      const sendStub = sandbox.stub(element._restApiHelper, 'send')
-          .returns(Promise.resolve());
-      return element.createRepo({name: 'x/y'}).then(() => {
-        assert.isTrue(sendStub.calledOnce);
-        assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+      element._sendDiffDraftRequest(null, null, null, {});
+      assert.equal(obj.sendDiffDraft.length, 2);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      for (const promise of obj.sendDiffDraft) { promise.resolve(); }
+
+      return element.awaitPendingDiffDrafts().then(() => {
+        assert.equal(obj.sendDiffDraft.length, 0);
+        assert.isFalse(!!element.hasPendingDiffDrafts());
       });
     });
 
-    test('queryChangeFiles', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
-        assert.equal(fetchStub.lastCall.args[0].endpoint,
-            '/files?q=test%2Fpath.js');
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
-      });
-    });
-
-    test('normal use', () => {
-      const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-
-      assert.equal(element._getReposUrl('test', 25),
-          '/projects/?n=26&S=0&query=test');
-
-      assert.equal(element._getReposUrl(null, 25),
-          `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-      assert.equal(element._getReposUrl('test', 25, 25),
-          '/projects/?n=26&S=25&query=test');
-    });
-
-    test('invalidateReposCache', () => {
-      const url = '/projects/?n=26&S=0&query=test';
-
-      element._cache.set(url, {});
-
-      element.invalidateReposCache();
-
-      assert.isUndefined(element._sharedFetchPromises[url]);
-
-      assert.isFalse(element._cache.has(url));
-    });
-
-    suite('getRepos', () => {
-      const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-      let fetchCacheURLStub;
-      setup(() => {
-        fetchCacheURLStub =
-            sandbox.stub(element._restApiHelper, 'fetchCacheURL');
-      });
-
-      test('normal use', () => {
-        element.getRepos('test', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=test');
-
-        element.getRepos(null, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-        element.getRepos('test', 25, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=25&query=test');
-      });
-
-      test('with blank', () => {
-        element.getRepos('test/test', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
-      });
-
-      test('with hyphen', () => {
-        element.getRepos('foo-bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('with leading hyphen', () => {
-        element.getRepos('-bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Abar');
-      });
-
-      test('with trailing hyphen', () => {
-        element.getRepos('foo-bar-', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('with underscore', () => {
-        element.getRepos('foo_bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('with underscore', () => {
-        element.getRepos('foo_bar', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-      });
-
-      test('hyphen only', () => {
-        element.getRepos('-', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            `/projects/?n=26&S=0&query=${defaultQuery}`);
-      });
-    });
-
-    test('_getGroupsUrl normal use', () => {
-      assert.equal(element._getGroupsUrl('test', 25),
-          '/groups/?n=26&S=0&m=test');
-
-      assert.equal(element._getGroupsUrl(null, 25),
-          '/groups/?n=26&S=0');
-
-      assert.equal(element._getGroupsUrl('test', 25, 25),
-          '/groups/?n=26&S=25&m=test');
-    });
-
-    test('invalidateGroupsCache', () => {
-      const url = '/groups/?n=26&S=0&m=test';
-
-      element._cache.set(url, {});
-
-      element.invalidateGroupsCache();
-
-      assert.isUndefined(element._sharedFetchPromises[url]);
-
-      assert.isFalse(element._cache.has(url));
-    });
-
-    suite('getGroups', () => {
-      let fetchCacheURLStub;
-      setup(() => {
-        fetchCacheURLStub =
-            sandbox.stub(element._restApiHelper, 'fetchCacheURL');
-      });
-
-      test('normal use', () => {
-        element.getGroups('test', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=0&m=test');
-
-        element.getGroups(null, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=0');
-
-        element.getGroups('test', 25, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=25&m=test');
-      });
-
-      test('regex', () => {
-        element.getGroups('^test.*', 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=0&r=%5Etest.*');
-
-        element.getGroups('^test.*', 25, 25);
-        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-            '/groups/?n=26&S=25&r=%5Etest.*');
-      });
-    });
-
-    test('gerrit auth is used', () => {
-      sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
-      element._restApiHelper.fetchJSON({url: 'foo'});
-      assert(Gerrit.Auth.fetch.called);
-    });
-
-    test('getSuggestedAccounts does not return _fetchJSON', () => {
-      const _fetchJSONSpy = sandbox.spy(element._restApiHelper, 'fetchJSON');
-      return element.getSuggestedAccounts().then(accts => {
-        assert.isFalse(_fetchJSONSpy.called);
-        assert.equal(accts.length, 0);
-      });
-    });
-
-    test('_fetchJSON gets called by getSuggestedAccounts', () => {
-      const _fetchJSONStub = sandbox.stub(element._restApiHelper, 'fetchJSON',
-          () => Promise.resolve());
-      return element.getSuggestedAccounts('own').then(() => {
-        assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
-          q: 'own',
-          suggest: null,
-        });
-      });
-    });
-
-    suite('getChangeDetail', () => {
-      suite('change detail options', () => {
-        let toHexStub;
-
-        setup(() => {
-          toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
-              options => 'deadbeef');
-          sandbox.stub(element, '_getChangeDetail',
-              async (changeNum, options) => ({changeNum, options}));
-        });
-
-        test('signed pushes disabled', async () => {
-          const {PUSH_CERTIFICATES} = element.ListChangesOption;
-          sandbox.stub(element, 'getConfig', async () => ({}));
-          const {changeNum, options} = await element.getChangeDetail(123);
-          assert.strictEqual(123, changeNum);
-          assert.strictEqual('deadbeef', options);
-          assert.isTrue(toHexStub.calledOnce);
-          assert.isFalse(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
-        });
-
-        test('signed pushes enabled', async () => {
-          const {PUSH_CERTIFICATES} = element.ListChangesOption;
-          sandbox.stub(element, 'getConfig', async () => {
-            return {receive: {enable_signed_push: true}};
-          });
-          const {changeNum, options} = await element.getChangeDetail(123);
-          assert.strictEqual(123, changeNum);
-          assert.strictEqual('deadbeef', options);
-          assert.isTrue(toHexStub.calledOnce);
-          assert.isTrue(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
-        });
-      });
-
-      test('GrReviewerUpdatesParser.parse is used', () => {
-        sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
-            Promise.resolve('foo'));
-        return element.getChangeDetail(42).then(result => {
-          assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-          assert.equal(result, 'foo');
-        });
-      });
-
-      test('_getChangeDetail passes params to ETags decorator', () => {
-        const changeNum = 4321;
-        element._projectLookup[changeNum] = 'test';
-        const expectedUrl =
-            window.CANONICAL_PATH + '/changes/test~4321/detail?'+
-            '0=5&1=1&2=6&3=7&4=1&5=4';
-        sandbox.stub(element._etags, 'getOptions');
-        sandbox.stub(element._etags, 'collect');
-        return element._getChangeDetail(changeNum, '516714').then(() => {
-          assert.isTrue(element._etags.getOptions.calledWithExactly(
-              expectedUrl));
-          assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
-        });
-      });
-
-      test('_getChangeDetail calls errFn on 500', () => {
-        const errFn = sinon.stub();
-        sandbox.stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(''));
-        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({ok: false, status: 500}));
-        return element._getChangeDetail(123, '516714', errFn).then(() => {
-          assert.isTrue(errFn.called);
-        });
-      });
-
-      test('_getChangeDetail accepts only json', () => {
-        const authFetchStub = sandbox.stub(element._auth, 'fetch')
+    suite('_failForCreate200', () => {
+      test('_sendDiffDraftRequest checks for 200 on create', () => {
+        const sendPromise = Promise.resolve();
+        sandbox.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        const failStub = sandbox.stub(element, '_failForCreate200')
             .returns(Promise.resolve());
-        const errFn = sinon.stub();
-        element._getChangeDetail(123, '516714', errFn);
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            'application/json');
-      });
-
-      test('_getChangeDetail populates _projectLookup', () => {
-        sandbox.stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(''));
-        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({ok: true}));
-
-        const mockResponse = {_number: 1, project: 'test'};
-        sandbox.stub(element._restApiHelper, 'readResponsePayload')
-            .returns(Promise.resolve({
-              parsed: mockResponse,
-              raw: JSON.stringify(mockResponse),
-            }));
-        return element._getChangeDetail(1, '516714').then(() => {
-          assert.equal(Object.keys(element._projectLookup).length, 1);
-          assert.equal(element._projectLookup[1], 'test');
+        return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
+          assert.isTrue(failStub.calledOnce);
+          assert.isTrue(failStub.calledWithExactly(sendPromise));
         });
       });
 
-      suite('_getChangeDetail ETag cache', () => {
-        let requestUrl;
-        let mockResponseSerial;
-        let collectSpy;
-        let getPayloadSpy;
-
-        setup(() => {
-          requestUrl = '/foo/bar';
-          const mockResponse = {foo: 'bar', baz: 42};
-          mockResponseSerial = element.JSON_PREFIX +
-              JSON.stringify(mockResponse);
-          sandbox.stub(element._restApiHelper, 'urlWithParams')
-              .returns(requestUrl);
-          sandbox.stub(element, 'getChangeActionURL')
-              .returns(Promise.resolve(requestUrl));
-          collectSpy = sandbox.spy(element._etags, 'collect');
-          getPayloadSpy = sandbox.spy(element._etags, 'getCachedPayload');
-        });
-
-        test('contributes to cache', () => {
-          sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-              .returns(Promise.resolve({
-                text: () => Promise.resolve(mockResponseSerial),
-                status: 200,
-                ok: true,
-              }));
-
-          return element._getChangeDetail(123, '516714').then(detail => {
-            assert.isFalse(getPayloadSpy.called);
-            assert.isTrue(collectSpy.calledOnce);
-            const cachedResponse = element._etags.getCachedPayload(requestUrl);
-            assert.equal(cachedResponse, mockResponseSerial);
-          });
-        });
-
-        test('uses cache on HTTP 304', () => {
-          sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-              .returns(Promise.resolve({
-                text: () => Promise.resolve(mockResponseSerial),
-                status: 304,
-                ok: true,
-              }));
-
-          return element._getChangeDetail(123, {}).then(detail => {
-            assert.isFalse(collectSpy.called);
-            assert.isTrue(getPayloadSpy.calledOnce);
-          });
-        });
-      });
-    });
-
-    test('setInProjectLookup', () => {
-      element.setInProjectLookup('test', 'project');
-      assert.deepEqual(element._projectLookup, {test: 'project'});
-    });
-
-    suite('getFromProjectLookup', () => {
-      test('getChange fails', () => {
-        sandbox.stub(element, 'getChange')
-            .returns(Promise.resolve(null));
-        return element.getFromProjectLookup().then(val => {
-          assert.strictEqual(val, undefined);
-          assert.deepEqual(element._projectLookup, {});
-        });
-      });
-
-      test('getChange succeeds, no project', () => {
-        sandbox.stub(element, 'getChange').returns(Promise.resolve(null));
-        return element.getFromProjectLookup().then(val => {
-          assert.strictEqual(val, undefined);
-          assert.deepEqual(element._projectLookup, {});
-        });
-      });
-
-      test('getChange succeeds with project', () => {
-        sandbox.stub(element, 'getChange')
-            .returns(Promise.resolve({project: 'project'}));
-        return element.getFromProjectLookup('test').then(val => {
-          assert.equal(val, 'project');
-          assert.deepEqual(element._projectLookup, {test: 'project'});
-        });
-      });
-    });
-
-    suite('getChanges populates _projectLookup', () => {
-      test('multiple queries', () => {
-        sandbox.stub(element._restApiHelper, 'fetchJSON')
-            .returns(Promise.resolve([
-              [
-                {_number: 1, project: 'test'},
-                {_number: 2, project: 'test'},
-              ], [
-                {_number: 3, project: 'test/test'},
-              ],
-            ]));
-        // When opt_query instanceof Array, _fetchJSON returns
-        // Array<Array<Object>>.
-        return element.getChanges(null, []).then(() => {
-          assert.equal(Object.keys(element._projectLookup).length, 3);
-          assert.equal(element._projectLookup[1], 'test');
-          assert.equal(element._projectLookup[2], 'test');
-          assert.equal(element._projectLookup[3], 'test/test');
-        });
-      });
-
-      test('no query', () => {
-        sandbox.stub(element._restApiHelper, 'fetchJSON')
-            .returns(Promise.resolve([
-              {_number: 1, project: 'test'},
-              {_number: 2, project: 'test'},
-              {_number: 3, project: 'test/test'},
-            ]));
-
-        // When opt_query !instanceof Array, _fetchJSON returns
-        // Array<Object>.
-        return element.getChanges().then(() => {
-          assert.equal(Object.keys(element._projectLookup).length, 3);
-          assert.equal(element._projectLookup[1], 'test');
-          assert.equal(element._projectLookup[2], 'test');
-          assert.equal(element._projectLookup[3], 'test/test');
-        });
-      });
-    });
-
-    test('_getChangeURLAndFetch', () => {
-      element._projectLookup = {1: 'test'};
-      const fetchStub = sandbox.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve());
-      const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
-      return element._getChangeURLAndFetch(req).then(() => {
-        assert.equal(fetchStub.lastCall.args[0].url,
-            '/changes/test~1/revisions/1/test');
-      });
-    });
-
-    test('_getChangeURLAndSend', () => {
-      element._projectLookup = {1: 'test'};
-      const sendStub = sandbox.stub(element._restApiHelper, 'send')
-          .returns(Promise.resolve());
-
-      const req = {
-        changeNum: 1,
-        method: 'POST',
-        patchNum: 1,
-        endpoint: '/test',
-      };
-      return element._getChangeURLAndSend(req).then(() => {
-        assert.isTrue(sendStub.calledOnce);
-        assert.equal(sendStub.lastCall.args[0].method, 'POST');
-        assert.equal(sendStub.lastCall.args[0].url,
-            '/changes/test~1/revisions/1/test');
-      });
-    });
-
-    suite('reading responses', () => {
-      test('_readResponsePayload', () => {
-        const mockObject = {foo: 'bar', baz: 'foo'};
-        const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
-        const mockResponse = {text: () => Promise.resolve(serial)};
-        return element._restApiHelper.readResponsePayload(mockResponse)
-            .then(payload => {
-              assert.deepEqual(payload.parsed, mockObject);
-              assert.equal(payload.raw, serial);
+      test('_sendDiffDraftRequest no checks for 200 on non create', () => {
+        sandbox.stub(element, '_getChangeURLAndSend')
+            .returns(Promise.resolve());
+        const failStub = sandbox.stub(element, '_failForCreate200')
+            .returns(Promise.resolve());
+        return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
+            .then(() => {
+              assert.isFalse(failStub.called);
             });
       });
 
-      test('_parsePrefixedJSON', () => {
-        const obj = {x: 3, y: {z: 4}, w: 23};
-        const serial = element.JSON_PREFIX + JSON.stringify(obj);
-        const result = element._restApiHelper.parsePrefixedJSON(serial);
-        assert.deepEqual(result, obj);
-      });
-    });
-
-    test('setChangeTopic', () => {
-      const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
-      return element.setChangeTopic(123, 'foo-bar').then(() => {
-        assert.isTrue(sendSpy.calledOnce);
-        assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
-      });
-    });
-
-    test('setChangeHashtag', () => {
-      const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
-      return element.setChangeHashtag(123, 'foo-bar').then(() => {
-        assert.isTrue(sendSpy.calledOnce);
-        assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
-      });
-    });
-
-    test('generateAccountHttpPassword', () => {
-      const sendSpy = sandbox.spy(element._restApiHelper, 'send');
-      return element.generateAccountHttpPassword().then(() => {
-        assert.isTrue(sendSpy.calledOnce);
-        assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
-      });
-    });
-
-    suite('getChangeFiles', () => {
-      test('patch only', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        const range = {basePatchNum: 'PARENT', patchNum: 2};
-        return element.getChangeFiles(123, range).then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
-          assert.isNotOk(fetchStub.lastCall.args[0].params);
-        });
+      test('_failForCreate200 fails on 200', done => {
+        const result = {
+          ok: true,
+          status: 200,
+          headers: {entries: () => [
+            ['Set-CoOkiE', 'secret'],
+            ['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();
+            });
       });
 
-      test('simple range', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        const range = {basePatchNum: 4, patchNum: 5};
-        return element.getChangeFiles(123, range).then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        });
-      });
-
-      test('parent index', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        const range = {basePatchNum: -3, patchNum: 5};
-        return element.getChangeFiles(123, range).then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-        });
-      });
-    });
-
-    suite('getDiff', () => {
-      test('patchOnly', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        });
-      });
-
-      test('simple range', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-        });
-      });
-
-      test('parent index', () => {
-        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-            .returns(Promise.resolve());
-        return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
-          assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-          assert.isOk(fetchStub.lastCall.args[0].params);
-          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-        });
-      });
-    });
-
-    test('getDashboard', () => {
-      const fetchCacheURLStub = sandbox.stub(element._restApiHelper,
-          'fetchCacheURL');
-      element.getDashboard('gerrit/project', 'default:main');
-      assert.isTrue(fetchCacheURLStub.calledOnce);
-      assert.equal(
-          fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/gerrit%2Fproject/dashboards/default%3Amain');
-    });
-
-    test('getFileContent', () => {
-      sandbox.stub(element, '_getChangeURLAndSend')
-          .returns(Promise.resolve({
-            ok: 'true',
-            headers: {
-              get(header) {
-                if (header === 'X-FYI-Content-Type') {
-                  return 'text/java';
-                }
-              },
-            },
-          }));
-
-      sandbox.stub(element, 'getResponseObject')
-          .returns(Promise.resolve('new content'));
-
-      const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
-        assert.deepEqual(res,
-            {content: 'new content', type: 'text/java', ok: true});
-      });
-
-      const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
-        assert.deepEqual(res,
-            {content: 'new content', type: 'text/java', ok: true});
-      });
-
-      return Promise.all([edit, normal]);
-    });
-
-    test('getFileContent suppresses 404s', done => {
-      const res = {status: 404};
-      const handler = e => {
-        assert.isFalse(e.detail.res.status === 404);
-        done();
-      };
-      element.addEventListener('server-error', handler);
-      sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve(res));
-      sandbox.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
-      element.getFileContent('1', 'tst/path', '1').then(() => {
-        flushAsynchronousOperations();
-
-        res.status = 500;
-        element.getFileContent('1', 'tst/path', '1');
-      });
-    });
-
-    test('getChangeFilesOrEditFiles is edit-sensitive', () => {
-      const fn = element.getChangeOrEditFiles.bind(element);
-      const getChangeFilesStub = sandbox.stub(element, 'getChangeFiles')
-          .returns(Promise.resolve({}));
-      const getChangeEditFilesStub = sandbox.stub(element, 'getChangeEditFiles')
-          .returns(Promise.resolve({}));
-
-      return fn('1', {patchNum: 'edit'}).then(() => {
-        assert.isTrue(getChangeEditFilesStub.calledOnce);
-        assert.isFalse(getChangeFilesStub.called);
-        return fn('1', {patchNum: '1'}).then(() => {
-          assert.isTrue(getChangeEditFilesStub.calledOnce);
-          assert.isTrue(getChangeFilesStub.calledOnce);
-        });
-      });
-    });
-
-    test('_fetch forwards request and logs', () => {
-      const logStub = sandbox.stub(element._restApiHelper, '_logCall');
-      const response = {status: 404, text: sinon.stub()};
-      const url = 'my url';
-      const fetchOptions = {method: 'DELETE'};
-      sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
-      const startTime = 123;
-      sandbox.stub(Date, 'now').returns(startTime);
-      const req = {url, fetchOptions};
-      return element._restApiHelper.fetch(req).then(() => {
-        assert.isTrue(logStub.calledOnce);
-        assert.isTrue(logStub.calledWith(req, startTime, response.status));
-        assert.isFalse(response.text.called);
-      });
-    });
-
-    test('_logCall only reports requests with anonymized URLss', () => {
-      sandbox.stub(Date, 'now').returns(200);
-      const handler = sinon.stub();
-      element.addEventListener('rpc-log', handler);
-
-      element._restApiHelper._logCall({url: 'url'}, 100, 200);
-      assert.isFalse(handler.called);
-
-      element._restApiHelper
-          ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
-      flushAsynchronousOperations();
-      assert.isTrue(handler.calledOnce);
-    });
-
-    test('saveChangeStarred', async () => {
-      sandbox.stub(element, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      const sendStub =
-          sandbox.stub(element._restApiHelper, 'send').returns(Promise.resolve());
-
-      await element.saveChangeStarred(123, true);
-      assert.isTrue(sendStub.calledOnce);
-      assert.deepEqual(sendStub.lastCall.args[0], {
-        method: 'PUT',
-        url: '/accounts/self/starred.changes/test~123',
-        anonymizedUrl: '/accounts/self/starred.changes/*',
-      });
-
-      await element.saveChangeStarred(456, false);
-      assert.isTrue(sendStub.calledTwice);
-      assert.deepEqual(sendStub.lastCall.args[0], {
-        method: 'DELETE',
-        url: '/accounts/self/starred.changes/test~456',
-        anonymizedUrl: '/accounts/self/starred.changes/*',
+      test('_failForCreate200 does not fail on 201', done => {
+        const result = {
+          ok: true,
+          status: 201,
+          headers: {entries: () => []},
+        };
+        element._failForCreate200(Promise.resolve(result))
+            .then(() => {
+              done();
+            })
+            .catch(e => {
+              assert.isTrue(false, 'Promise should not fail');
+            });
       });
     });
   });
+
+  test('saveChangeEdit', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const file_name = 'index.php';
+    const file_contents = '<?php';
+    sandbox.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, file_name, file_contents]));
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, file_name, file_contents]));
+    element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
+    return element.saveChangeEdit(change_num, file_name, file_contents)
+        .then(() => {
+          assert.isTrue(element._restApiHelper.send.calledOnce);
+          assert.equal(element._restApiHelper.send.lastCall.args[0].method,
+              'PUT');
+          assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+              '/changes/test~1/edit/' + file_name);
+          assert.equal(element._restApiHelper.send.lastCall.args[0].body,
+              file_contents);
+        });
+  });
+
+  test('putChangeCommitMessage', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const message = 'this is a commit message';
+    sandbox.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, message]));
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, message]));
+    element._cache.set('/changes/' + change_num + '/message', {});
+    return element.putChangeCommitMessage(change_num, message).then(() => {
+      assert.isTrue(element._restApiHelper.send.calledOnce);
+      assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
+      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/message');
+      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
+          {message});
+    });
+  });
+
+  test('deleteChangeCommitMessage', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const messageId = 'abc';
+    sandbox.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, messageId]));
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, messageId]));
+    return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
+      assert.isTrue(element._restApiHelper.send.calledOnce);
+      assert.equal(
+          element._restApiHelper.send.lastCall.args[0].method,
+          'DELETE'
+      );
+      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/messages/abc');
+    });
+  });
+
+  test('startWorkInProgress', () => {
+    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve('ok'));
+    element.startWorkInProgress('42');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
+    element.startWorkInProgress('42', 'revising...');
+    assert.isTrue(sendStub.calledTwice);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body,
+        {message: 'revising...'});
+  });
+
+  test('startReview', () => {
+    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve({}));
+    element.startReview('42', {message: 'Please review.'});
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
+    assert.deepEqual(sendStub.lastCall.args[0].body,
+        {message: 'Please review.'});
+  });
+
+  test('deleteComment', () => {
+    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve('some response'));
+    return element.deleteComment('foo', 'bar', '01234', 'removal reason')
+        .then(response => {
+          assert.equal(response, 'some response');
+          assert.isTrue(sendStub.calledOnce);
+          assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
+          assert.equal(sendStub.lastCall.args[0].method, 'POST');
+          assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
+          assert.equal(sendStub.lastCall.args[0].endpoint,
+              '/comments/01234/delete');
+          assert.deepEqual(sendStub.lastCall.args[0].body,
+              {reason: 'removal reason'});
+        });
+  });
+
+  test('createRepo encodes name', () => {
+    const sendStub = sandbox.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve());
+    return element.createRepo({name: 'x/y'}).then(() => {
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+    });
+  });
+
+  test('queryChangeFiles', () => {
+    const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+        .returns(Promise.resolve());
+    return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
+      assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
+      assert.equal(fetchStub.lastCall.args[0].endpoint,
+          '/files?q=test%2Fpath.js');
+      assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
+    });
+  });
+
+  test('normal use', () => {
+    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
+
+    assert.equal(element._getReposUrl('test', 25),
+        '/projects/?n=26&S=0&query=test');
+
+    assert.equal(element._getReposUrl(null, 25),
+        `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+    assert.equal(element._getReposUrl('test', 25, 25),
+        '/projects/?n=26&S=25&query=test');
+  });
+
+  test('invalidateReposCache', () => {
+    const url = '/projects/?n=26&S=0&query=test';
+
+    element._cache.set(url, {});
+
+    element.invalidateReposCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    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;
+    setup(() => {
+      fetchCacheURLStub =
+          sandbox.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getRepos('test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=test');
+
+      element.getRepos(null, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+      element.getRepos('test', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=25&query=test');
+    });
+
+    test('with blank', () => {
+      element.getRepos('test/test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
+    });
+
+    test('with hyphen', () => {
+      element.getRepos('foo-bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with leading hyphen', () => {
+      element.getRepos('-bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Abar');
+    });
+
+    test('with trailing hyphen', () => {
+      element.getRepos('foo-bar-', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('hyphen only', () => {
+      element.getRepos('-', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&query=${defaultQuery}`);
+    });
+  });
+
+  test('_getGroupsUrl normal use', () => {
+    assert.equal(element._getGroupsUrl('test', 25),
+        '/groups/?n=26&S=0&m=test');
+
+    assert.equal(element._getGroupsUrl(null, 25),
+        '/groups/?n=26&S=0');
+
+    assert.equal(element._getGroupsUrl('test', 25, 25),
+        '/groups/?n=26&S=25&m=test');
+  });
+
+  test('invalidateGroupsCache', () => {
+    const url = '/groups/?n=26&S=0&m=test';
+
+    element._cache.set(url, {});
+
+    element.invalidateGroupsCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getGroups', () => {
+    let fetchCacheURLStub;
+    setup(() => {
+      fetchCacheURLStub =
+          sandbox.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getGroups('test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&m=test');
+
+      element.getGroups(null, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0');
+
+      element.getGroups('test', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&m=test');
+    });
+
+    test('regex', () => {
+      element.getGroups('^test.*', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&r=%5Etest.*');
+
+      element.getGroups('^test.*', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&r=%5Etest.*');
+    });
+  });
+
+  test('gerrit auth is used', () => {
+    sandbox.stub(authService, 'fetch').returns(Promise.resolve());
+    element._restApiHelper.fetchJSON({url: 'foo'});
+    assert(authService.fetch.called);
+  });
+
+  test('getSuggestedAccounts does not return _fetchJSON', () => {
+    const _fetchJSONSpy = sandbox.spy(element._restApiHelper, 'fetchJSON');
+    return element.getSuggestedAccounts().then(accts => {
+      assert.isFalse(_fetchJSONSpy.called);
+      assert.equal(accts.length, 0);
+    });
+  });
+
+  test('_fetchJSON gets called by getSuggestedAccounts', () => {
+    const _fetchJSONStub = sandbox.stub(element._restApiHelper, 'fetchJSON',
+        () => Promise.resolve());
+    return element.getSuggestedAccounts('own').then(() => {
+      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
+        q: 'own',
+        suggest: null,
+      });
+    });
+  });
+
+  suite('getChangeDetail', () => {
+    suite('change detail options', () => {
+      let toHexStub;
+
+      setup(() => {
+        toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
+            options => 'deadbeef');
+        sandbox.stub(element, '_getChangeDetail',
+            async (changeNum, options) => { return {changeNum, options}; });
+      });
+
+      test('signed pushes disabled', async () => {
+        const {PUSH_CERTIFICATES} = element.ListChangesOption;
+        sandbox.stub(element, 'getConfig', async () => { return {}; });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.strictEqual('deadbeef', options);
+        assert.isTrue(toHexStub.calledOnce);
+        assert.isFalse(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+      });
+
+      test('signed pushes enabled', async () => {
+        const {PUSH_CERTIFICATES} = element.ListChangesOption;
+        sandbox.stub(element, 'getConfig', async () => {
+          return {receive: {enable_signed_push: true}};
+        });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.strictEqual('deadbeef', options);
+        assert.isTrue(toHexStub.calledOnce);
+        assert.isTrue(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+      });
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', () => {
+      sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
+          Promise.resolve('foo'));
+      return element.getChangeDetail(42).then(result => {
+        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
+        assert.equal(result, 'foo');
+      });
+    });
+
+    test('_getChangeDetail passes params to ETags decorator', () => {
+      const changeNum = 4321;
+      element._projectLookup[changeNum] = 'test';
+      const expectedUrl =
+          window.CANONICAL_PATH + '/changes/test~4321/detail?'+
+          '0=5&1=1&2=6&3=7&4=1&5=4';
+      sandbox.stub(element._etags, 'getOptions');
+      sandbox.stub(element._etags, 'collect');
+      return element._getChangeDetail(changeNum, '516714').then(() => {
+        assert.isTrue(element._etags.getOptions.calledWithExactly(
+            expectedUrl));
+        assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
+      });
+    });
+
+    test('_getChangeDetail calls errFn on 500', () => {
+      const errFn = sinon.stub();
+      sandbox.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+          .returns(Promise.resolve({ok: false, status: 500}));
+      return element._getChangeDetail(123, '516714', errFn).then(() => {
+        assert.isTrue(errFn.called);
+      });
+    });
+
+    test('_getChangeDetail populates _projectLookup', () => {
+      sandbox.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+          .returns(Promise.resolve({ok: true}));
+
+      const mockResponse = {_number: 1, project: 'test'};
+      sandbox.stub(element._restApiHelper, 'readResponsePayload')
+          .returns(Promise.resolve({
+            parsed: mockResponse,
+            raw: JSON.stringify(mockResponse),
+          }));
+      return element._getChangeDetail(1, '516714').then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 1);
+        assert.equal(element._projectLookup[1], 'test');
+      });
+    });
+
+    suite('_getChangeDetail ETag cache', () => {
+      let requestUrl;
+      let mockResponseSerial;
+      let collectSpy;
+      let getPayloadSpy;
+
+      setup(() => {
+        requestUrl = '/foo/bar';
+        const mockResponse = {foo: 'bar', baz: 42};
+        mockResponseSerial = element.JSON_PREFIX +
+            JSON.stringify(mockResponse);
+        sandbox.stub(element._restApiHelper, 'urlWithParams')
+            .returns(requestUrl);
+        sandbox.stub(element, 'getChangeActionURL')
+            .returns(Promise.resolve(requestUrl));
+        collectSpy = sandbox.spy(element._etags, 'collect');
+        getPayloadSpy = sandbox.spy(element._etags, 'getCachedPayload');
+      });
+
+      test('contributes to cache', () => {
+        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+            .returns(Promise.resolve({
+              text: () => Promise.resolve(mockResponseSerial),
+              status: 200,
+              ok: true,
+            }));
+
+        return element._getChangeDetail(123, '516714').then(detail => {
+          assert.isFalse(getPayloadSpy.called);
+          assert.isTrue(collectSpy.calledOnce);
+          const cachedResponse = element._etags.getCachedPayload(requestUrl);
+          assert.equal(cachedResponse, mockResponseSerial);
+        });
+      });
+
+      test('uses cache on HTTP 304', () => {
+        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+            .returns(Promise.resolve({
+              text: () => Promise.resolve(mockResponseSerial),
+              status: 304,
+              ok: true,
+            }));
+
+        return element._getChangeDetail(123, {}).then(detail => {
+          assert.isFalse(collectSpy.called);
+          assert.isTrue(getPayloadSpy.calledOnce);
+        });
+      });
+    });
+  });
+
+  test('setInProjectLookup', () => {
+    element.setInProjectLookup('test', 'project');
+    assert.deepEqual(element._projectLookup, {test: 'project'});
+  });
+
+  suite('getFromProjectLookup', () => {
+    test('getChange fails', () => {
+      sandbox.stub(element, 'getChange')
+          .returns(Promise.resolve(null));
+      return element.getFromProjectLookup().then(val => {
+        assert.strictEqual(val, undefined);
+        assert.deepEqual(element._projectLookup, {});
+      });
+    });
+
+    test('getChange succeeds, no project', () => {
+      sandbox.stub(element, 'getChange').returns(Promise.resolve(null));
+      return element.getFromProjectLookup().then(val => {
+        assert.strictEqual(val, undefined);
+        assert.deepEqual(element._projectLookup, {});
+      });
+    });
+
+    test('getChange succeeds with project', () => {
+      sandbox.stub(element, 'getChange')
+          .returns(Promise.resolve({project: 'project'}));
+      return element.getFromProjectLookup('test').then(val => {
+        assert.equal(val, 'project');
+        assert.deepEqual(element._projectLookup, {test: 'project'});
+      });
+    });
+  });
+
+  suite('getChanges populates _projectLookup', () => {
+    test('multiple queries', () => {
+      sandbox.stub(element._restApiHelper, 'fetchJSON')
+          .returns(Promise.resolve([
+            [
+              {_number: 1, project: 'test'},
+              {_number: 2, project: 'test'},
+            ], [
+              {_number: 3, project: 'test/test'},
+            ],
+          ]));
+      // When opt_query instanceof Array, _fetchJSON returns
+      // Array<Array<Object>>.
+      return element.getChanges(null, []).then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 3);
+        assert.equal(element._projectLookup[1], 'test');
+        assert.equal(element._projectLookup[2], 'test');
+        assert.equal(element._projectLookup[3], 'test/test');
+      });
+    });
+
+    test('no query', () => {
+      sandbox.stub(element._restApiHelper, 'fetchJSON')
+          .returns(Promise.resolve([
+            {_number: 1, project: 'test'},
+            {_number: 2, project: 'test'},
+            {_number: 3, project: 'test/test'},
+          ]));
+
+      // When opt_query !instanceof Array, _fetchJSON returns
+      // Array<Object>.
+      return element.getChanges().then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 3);
+        assert.equal(element._projectLookup[1], 'test');
+        assert.equal(element._projectLookup[2], 'test');
+        assert.equal(element._projectLookup[3], 'test/test');
+      });
+    });
+  });
+
+  test('_getChangeURLAndFetch', () => {
+    element._projectLookup = {1: 'test'};
+    const fetchStub = sandbox.stub(element._restApiHelper, 'fetchJSON')
+        .returns(Promise.resolve());
+    const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
+    return element._getChangeURLAndFetch(req).then(() => {
+      assert.equal(fetchStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test');
+    });
+  });
+
+  test('_getChangeURLAndSend', () => {
+    element._projectLookup = {1: 'test'};
+    const sendStub = sandbox.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve());
+
+    const req = {
+      changeNum: 1,
+      method: 'POST',
+      patchNum: 1,
+      endpoint: '/test',
+    };
+    return element._getChangeURLAndSend(req).then(() => {
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.equal(sendStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test');
+    });
+  });
+
+  suite('reading responses', () => {
+    test('_readResponsePayload', () => {
+      const mockObject = {foo: 'bar', baz: 'foo'};
+      const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
+      const mockResponse = {text: () => Promise.resolve(serial)};
+      return element._restApiHelper.readResponsePayload(mockResponse)
+          .then(payload => {
+            assert.deepEqual(payload.parsed, mockObject);
+            assert.equal(payload.raw, serial);
+          });
+    });
+
+    test('_parsePrefixedJSON', () => {
+      const obj = {x: 3, y: {z: 4}, w: 23};
+      const serial = element.JSON_PREFIX + JSON.stringify(obj);
+      const result = element._restApiHelper.parsePrefixedJSON(serial);
+      assert.deepEqual(result, obj);
+    });
+  });
+
+  test('setChangeTopic', () => {
+    const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
+    return element.setChangeTopic(123, 'foo-bar').then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+    });
+  });
+
+  test('setChangeHashtag', () => {
+    const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
+    return element.setChangeHashtag(123, 'foo-bar').then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
+    });
+  });
+
+  test('generateAccountHttpPassword', () => {
+    const sendSpy = sandbox.spy(element._restApiHelper, 'send');
+    return element.generateAccountHttpPassword().then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+    });
+  });
+
+  suite('getChangeFiles', () => {
+    test('patch only', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: 'PARENT', patchNum: 2};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.isNotOk(fetchStub.lastCall.args[0].params);
+      });
+    });
+
+    test('simple range', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: 4, patchNum: 5};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+      });
+    });
+
+    test('parent index', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: -3, patchNum: 5};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
+      });
+    });
+  });
+
+  suite('getDiff', () => {
+    test('patchOnly', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+      });
+    });
+
+    test('simple range', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+      });
+    });
+
+    test('parent index', () => {
+      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
+      });
+    });
+  });
+
+  test('getDashboard', () => {
+    const fetchCacheURLStub = sandbox.stub(element._restApiHelper,
+        'fetchCacheURL');
+    element.getDashboard('gerrit/project', 'default:main');
+    assert.isTrue(fetchCacheURLStub.calledOnce);
+    assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/gerrit%2Fproject/dashboards/default%3Amain');
+  });
+
+  test('getFileContent', () => {
+    sandbox.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve({
+          ok: 'true',
+          headers: {
+            get(header) {
+              if (header === 'X-FYI-Content-Type') {
+                return 'text/java';
+              }
+            },
+          },
+        }));
+
+    sandbox.stub(element, 'getResponseObject')
+        .returns(Promise.resolve('new content'));
+
+    const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
+      assert.deepEqual(res,
+          {content: 'new content', type: 'text/java', ok: true});
+    });
+
+    const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
+      assert.deepEqual(res,
+          {content: 'new content', type: 'text/java', ok: true});
+    });
+
+    return Promise.all([edit, normal]);
+  });
+
+  test('getFileContent suppresses 404s', done => {
+    const res = {status: 404};
+    const handler = e => {
+      assert.isFalse(e.detail.res.status === 404);
+      done();
+    };
+    element.addEventListener('server-error', handler);
+    sandbox.stub(authService, 'fetch').returns(Promise.resolve(res));
+    sandbox.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
+    element.getFileContent('1', 'tst/path', '1').then(() => {
+      flushAsynchronousOperations();
+
+      res.status = 500;
+      element.getFileContent('1', 'tst/path', '1');
+    });
+  });
+
+  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
+    const fn = element.getChangeOrEditFiles.bind(element);
+    const getChangeFilesStub = sandbox.stub(element, 'getChangeFiles')
+        .returns(Promise.resolve({}));
+    const getChangeEditFilesStub = sandbox.stub(element, 'getChangeEditFiles')
+        .returns(Promise.resolve({}));
+
+    return fn('1', {patchNum: 'edit'}).then(() => {
+      assert.isTrue(getChangeEditFilesStub.calledOnce);
+      assert.isFalse(getChangeFilesStub.called);
+      return fn('1', {patchNum: '1'}).then(() => {
+        assert.isTrue(getChangeEditFilesStub.calledOnce);
+        assert.isTrue(getChangeFilesStub.calledOnce);
+      });
+    });
+  });
+
+  test('_fetch forwards request and logs', () => {
+    const logStub = sandbox.stub(element._restApiHelper, '_logCall');
+    const response = {status: 404, text: sinon.stub()};
+    const url = 'my url';
+    const fetchOptions = {method: 'DELETE'};
+    sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
+    const startTime = 123;
+    sandbox.stub(Date, 'now').returns(startTime);
+    const req = {url, fetchOptions};
+    return element._restApiHelper.fetch(req).then(() => {
+      assert.isTrue(logStub.calledOnce);
+      assert.isTrue(logStub.calledWith(req, startTime, response.status));
+      assert.isFalse(response.text.called);
+    });
+  });
+
+  test('_logCall only reports requests with anonymized URLss', () => {
+    sandbox.stub(Date, 'now').returns(200);
+    const handler = sinon.stub();
+    element.addEventListener('rpc-log', handler);
+
+    element._restApiHelper._logCall({url: 'url'}, 100, 200);
+    assert.isFalse(handler.called);
+
+    element._restApiHelper
+        ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+    flushAsynchronousOperations();
+    assert.isTrue(handler.calledOnce);
+  });
+
+  test('saveChangeStarred', async () => {
+    sandbox.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    const sendStub =
+        sandbox.stub(element._restApiHelper, 'send').returns(Promise.resolve());
+
+    await element.saveChangeStarred(123, true);
+    assert.isTrue(sendStub.calledOnce);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: 'PUT',
+      url: '/accounts/self/starred.changes/test~123',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+
+    await element.saveChangeStarred(456, false);
+    assert.isTrue(sendStub.calledTwice);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: 'DELETE',
+      url: '/accounts/self/starred.changes/test~456',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+  });
+});
 </script>
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..bc70791 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
@@ -14,418 +14,393 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+const JSON_PREFIX = ')]}\'';
 
-  const JSON_PREFIX = ')]}\'';
-  const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
+/**
+ * Wrapper around Map for caching server responses. Site-based so that
+ * changes to CANONICAL_PATH will result in a different cache going into
+ * effect.
+ */
+export class SiteBasedCache {
+  constructor() {
+    // Container of per-canonical-path caches.
+    this._data = new Map();
+    if (window.INITIAL_DATA != undefined) {
+      // Put all data shipped with index.html into the cache. This makes it
+      // so that we spare more round trips to the server when the app loads
+      // initially.
+      Object
+          .entries(window.INITIAL_DATA)
+          .forEach(e => this._cache().set(e[0], e[1]));
+    }
+  }
+
+  // Returns the cache for the current canonical path.
+  _cache() {
+    if (!this._data.has(window.CANONICAL_PATH)) {
+      this._data.set(window.CANONICAL_PATH, new Map());
+    }
+    return this._data.get(window.CANONICAL_PATH);
+  }
+
+  has(key) {
+    return this._cache().has(key);
+  }
+
+  get(key) {
+    return this._cache().get(key);
+  }
+
+  set(key, value) {
+    this._cache().set(key, value);
+  }
+
+  delete(key) {
+    this._cache().delete(key);
+  }
+
+  invalidatePrefix(prefix) {
+    const newMap = new Map();
+    for (const [key, value] of this._cache().entries()) {
+      if (!key.startsWith(prefix)) {
+        newMap.set(key, value);
+      }
+    }
+    this._data.set(window.CANONICAL_PATH, newMap);
+  }
+}
+
+export class FetchPromisesCache {
+  constructor() {
+    this._data = {};
+  }
+
+  has(key) {
+    return !!this._data[key];
+  }
+
+  get(key) {
+    return this._data[key];
+  }
+
+  set(key, value) {
+    this._data[key] = value;
+  }
+
+  invalidatePrefix(prefix) {
+    const newData = {};
+    Object.entries(this._data).forEach(([key, value]) => {
+      if (!key.startsWith(prefix)) {
+        newData[key] = value;
+      }
+    });
+    this._data = newData;
+  }
+}
+
+export class GrRestApiHelper {
+  /**
+   * @param {SiteBasedCache} cache
+   * @param {object} auth
+   * @param {FetchPromisesCache} fetchPromisesCache
+   * @param {object} restApiInterface
+   */
+  constructor(cache, auth, fetchPromisesCache,
+      restApiInterface) {
+    this._cache = cache;// TODO: make it public
+    this._auth = auth;
+    this._fetchPromisesCache = fetchPromisesCache;
+    this._restApiInterface = restApiInterface;
+  }
 
   /**
-   * Wrapper around Map for caching server responses. Site-based so that
-   * changes to CANONICAL_PATH will result in a different cache going into
-   * effect.
+   * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
+   * with timing and logging.
+   *
+   * @param {Gerrit.FetchRequest} req
    */
-  class SiteBasedCache {
-    constructor() {
-      // Container of per-canonical-path caches.
-      this._data = new Map();
-      if (window.INITIAL_DATA != undefined) {
-        // Put all data shipped with index.html into the cache. This makes it
-        // so that we spare more round trips to the server when the app loads
-        // initially.
-        Object
-            .entries(window.INITIAL_DATA)
-            .forEach(e => this._cache().set(e[0], e[1]));
-      }
-    }
+  fetch(req) {
+    const start = Date.now();
+    const xhr = this._auth.fetch(req.url, req.fetchOptions);
 
-    // Returns the cache for the current canonical path.
-    _cache() {
-      if (!this._data.has(window.CANONICAL_PATH)) {
-        this._data.set(window.CANONICAL_PATH, new Map());
-      }
-      return this._data.get(window.CANONICAL_PATH);
-    }
+    // Log the call after it completes.
+    xhr.then(res => this._logCall(req, start, res ? res.status : null));
 
-    has(key) {
-      return this._cache().has(key);
-    }
+    // Return the XHR directly (without the log).
+    return xhr;
+  }
 
-    get(key) {
-      return this._cache().get(key);
-    }
-
-    set(key, value) {
-      this._cache().set(key, value);
-    }
-
-    delete(key) {
-      this._cache().delete(key);
-    }
-
-    invalidatePrefix(prefix) {
-      const newMap = new Map();
-      for (const [key, value] of this._cache().entries()) {
-        if (!key.startsWith(prefix)) {
-          newMap.set(key, value);
-        }
-      }
-      this._data.set(window.CANONICAL_PATH, newMap);
+  /**
+   * Log information about a REST call. Because the elapsed time is determined
+   * by this method, it should be called immediately after the request
+   * finishes.
+   *
+   * @param {Gerrit.FetchRequest} req
+   * @param {number} startTime the time that the request was started.
+   * @param {number} status the HTTP status of the response. The status value
+   *     is used here rather than the response object so there is no way this
+   *     method can read the body stream.
+   */
+  _logCall(req, startTime, status) {
+    const method = (req.fetchOptions && req.fetchOptions.method) ?
+      req.fetchOptions.method : 'GET';
+    const endTime = Date.now();
+    const elapsed = (endTime - startTime);
+    const startAt = new Date(startTime);
+    const endAt = new Date(endTime);
+    console.log([
+      'HTTP',
+      status,
+      method,
+      elapsed + 'ms',
+      req.anonymizedUrl || req.url,
+      `(${startAt.toISOString()}, ${endAt.toISOString()})`,
+    ].join(' '));
+    if (req.anonymizedUrl) {
+      this.dispatchEvent(new CustomEvent('rpc-log', {
+        detail: {status, method, elapsed, anonymizedUrl: req.anonymizedUrl},
+        composed: true, bubbles: true,
+      }));
     }
   }
 
-  class FetchPromisesCache {
-    constructor() {
-      this._data = {};
-    }
-
-    has(key) {
-      return !!this._data[key];
-    }
-
-    get(key) {
-      return this._data[key];
-    }
-
-    set(key, value) {
-      this._data[key] = value;
-    }
-
-    invalidatePrefix(prefix) {
-      const newData = {};
-      Object.entries(this._data).forEach(([key, value]) => {
-        if (!key.startsWith(prefix)) {
-          newData[key] = value;
-        }
-      });
-      this._data = newData;
-    }
-  }
-
-  class GrRestApiHelper {
-    /**
-     * @param {SiteBasedCache} cache
-     * @param {object} auth
-     * @param {FetchPromisesCache} fetchPromisesCache
-     * @param {object} credentialCheck
-     * @param {object} restApiInterface
-     */
-    constructor(cache, auth, fetchPromisesCache, credentialCheck,
-        restApiInterface) {
-      this._cache = cache;// TODO: make it public
-      this._auth = auth;
-      this._fetchPromisesCache = fetchPromisesCache;
-      this._credentialCheck = credentialCheck;
-      this._restApiInterface = restApiInterface;
-    }
-
-    /**
-     * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
-     * with timing and logging.
-     *
-     * @param {Gerrit.FetchRequest} req
-     */
-    fetch(req) {
-      const start = Date.now();
-      const xhr = this._auth.fetch(req.url, req.fetchOptions);
-
-      // Log the call after it completes.
-      xhr.then(res => this._logCall(req, start, res ? res.status : null));
-
-      // Return the XHR directly (without the log).
-      return xhr;
-    }
-
-    /**
-     * Log information about a REST call. Because the elapsed time is determined
-     * by this method, it should be called immediately after the request
-     * finishes.
-     *
-     * @param {Gerrit.FetchRequest} req
-     * @param {number} startTime the time that the request was started.
-     * @param {number} status the HTTP status of the response. The status value
-     *     is used here rather than the response object so there is no way this
-     *     method can read the body stream.
-     */
-    _logCall(req, startTime, status) {
-      const method = (req.fetchOptions && req.fetchOptions.method) ?
-        req.fetchOptions.method : 'GET';
-      const endTime = Date.now();
-      const elapsed = (endTime - startTime);
-      const startAt = new Date(startTime);
-      const endAt = new Date(endTime);
-      console.log([
-        'HTTP',
-        status,
-        method,
-        elapsed + 'ms',
-        req.anonymizedUrl || req.url,
-        `(${startAt.toISOString()}, ${endAt.toISOString()})`,
-      ].join(' '));
-      if (req.anonymizedUrl) {
-        this.fire('rpc-log',
-            {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
-      }
-    }
-
-    /**
-     * Fetch JSON from url provided.
-     * Returns a Promise that resolves to a native Response.
-     * Doesn't do error checking. Supports cancel condition. Performs auth.
-     * Validates auth expiry errors.
-     *
-     * @param {Gerrit.FetchJSONRequest} req
-     */
-    fetchRawJSON(req) {
-      const urlWithParams = this.urlWithParams(req.url, req.params);
-      const fetchReq = {
-        url: urlWithParams,
-        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 {
+  /**
+   * Fetch JSON from url provided.
+   * Returns a Promise that resolves to a native Response.
+   * Doesn't do error checking. Supports cancel condition. Performs auth.
+   * Validates auth expiry errors.
+   *
+   * @param {Gerrit.FetchJSONRequest} req
+   */
+  fetchRawJSON(req) {
+    const urlWithParams = this.urlWithParams(req.url, req.params);
+    const fetchReq = {
+      url: urlWithParams,
+      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 => {
           if (req.errFn) {
             req.errFn.call(undefined, null, err);
           } else {
-            this.fire('network-error', {error: err});
+            this.dispatchEvent(new CustomEvent('network-error', {
+              detail: {error: err},
+              composed: true, bubbles: true,
+            }));
           }
-        }
-        throw err;
-      });
-    }
-
-    /**
-     * Fetch JSON from url provided.
-     * Returns a Promise that resolves to a parsed response.
-     * Same as {@link fetchRawJSON}, plus error handling.
-     *
-     * @param {Gerrit.FetchJSONRequest} req
-     */
-    fetchJSON(req) {
-      req = this.addAcceptJsonHeader(req);
-      return this.fetchRawJSON(req).then(response => {
-        if (!response) {
-          return;
-        }
-        if (!response.ok) {
-          if (req.errFn) {
-            req.errFn.call(null, response);
-            return;
-          }
-          this.fire('server-error', {request: req, response});
-          return;
-        }
-        return response && this.getResponseObject(response);
-      });
-    }
-
-    /**
-     * @param {string} url
-     * @param {?Object|string=} opt_params URL params, key-value hash.
-     * @return {string}
-     */
-    urlWithParams(url, opt_params) {
-      if (!opt_params) { return this.getBaseUrl() + url; }
-
-      const params = [];
-      for (const p in opt_params) {
-        if (!opt_params.hasOwnProperty(p)) { continue; }
-        if (opt_params[p] == null) {
-          params.push(encodeURIComponent(p));
-          continue;
-        }
-        for (const value of [].concat(opt_params[p])) {
-          params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
-        }
-      }
-      return this.getBaseUrl() + url + '?' + params.join('&');
-    }
-
-    /**
-     * @param {!Object} response
-     * @return {?}
-     */
-    getResponseObject(response) {
-      return this.readResponsePayload(response)
-          .then(payload => payload.parsed);
-    }
-
-    /**
-     * @param {!Object} response
-     * @return {!Object}
-     */
-    readResponsePayload(response) {
-      return response.text().then(text => {
-        let result;
-        try {
-          result = this.parsePrefixedJSON(text);
-        } catch (_) {
-          result = null;
-        }
-        return {parsed: result, raw: text};
-      });
-    }
-
-    /**
-     * @param {string} source
-     * @return {?}
-     */
-    parsePrefixedJSON(source) {
-      return JSON.parse(source.substring(JSON_PREFIX.length));
-    }
-
-    /**
-     * @param {Gerrit.FetchJSONRequest} req
-     * @return {Gerrit.FetchJSONRequest}
-     */
-    addAcceptJsonHeader(req) {
-      if (!req.fetchOptions) req.fetchOptions = {};
-      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
-      if (!req.fetchOptions.headers.has('Accept')) {
-        req.fetchOptions.headers.append('Accept', 'application/json');
-      }
-      return req;
-    }
-
-    getBaseUrl() {
-      return this._restApiInterface.getBaseUrl();
-    }
-
-    fire(type, detail, options) {
-      return this._restApiInterface.fire(type, detail, options);
-    }
-
-    /**
-     * @param {Gerrit.FetchJSONRequest} req
-     */
-    fetchCacheURL(req) {
-      if (this._fetchPromisesCache.has(req.url)) {
-        return this._fetchPromisesCache.get(req.url);
-      }
-      // TODO(andybons): Periodic cache invalidation.
-      if (this._cache.has(req.url)) {
-        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;
-          })
-      );
-      return this._fetchPromisesCache.get(req.url);
-    }
-
-    /**
-     * Send an XHR.
-     *
-     * @param {Gerrit.SendRequest} req
-     * @return {Promise}
-     */
-    send(req) {
-      const options = {method: req.method};
-      if (req.body) {
-        options.headers = new Headers();
-        options.headers.set(
-            'Content-Type', req.contentType || 'application/json');
-        options.body = typeof req.body === 'string' ?
-          req.body : JSON.stringify(req.body);
-      }
-      if (req.headers) {
-        if (!options.headers) { options.headers = new Headers(); }
-        for (const header in req.headers) {
-          if (!req.headers.hasOwnProperty(header)) { continue; }
-          options.headers.set(header, req.headers[header]);
-        }
-      }
-      const url = req.url.startsWith('http') ?
-        req.url : this.getBaseUrl() + req.url;
-      const fetchReq = {
-        url,
-        fetchOptions: options,
-        anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
-      };
-      const xhr = this.fetch(fetchReq).then(response => {
-        if (!response.ok) {
-          if (req.errFn) {
-            return req.errFn.call(undefined, response);
-          }
-          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;
-        }
-      });
-
-      if (req.parseResponse) {
-        return xhr.then(res => this.getResponseObject(res));
-      }
-
-      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
-     */
-    invalidateFetchPromisesPrefix(prefix) {
-      this._fetchPromisesCache.invalidatePrefix(prefix);
-      this._cache.invalidatePrefix(prefix);
-    }
+        });
   }
 
-  window.SiteBasedCache = SiteBasedCache;
-  window.FetchPromisesCache = FetchPromisesCache;
-  window.GrRestApiHelper = GrRestApiHelper;
-})(window);
+  /**
+   * Fetch JSON from url provided.
+   * Returns a Promise that resolves to a parsed response.
+   * Same as {@link fetchRawJSON}, plus error handling.
+   *
+   * @param {Gerrit.FetchJSONRequest} req
+   * @param {boolean} noAcceptHeader - don't add default accept json header
+   */
+  fetchJSON(req, noAcceptHeader) {
+    if (!noAcceptHeader) {
+      req = this.addAcceptJsonHeader(req);
+    }
+    return this.fetchRawJSON(req).then(response => {
+      if (!response) {
+        return;
+      }
+      if (!response.ok) {
+        if (req.errFn) {
+          req.errFn.call(null, response);
+          return;
+        }
+        this.dispatchEvent(new CustomEvent('server-error', {
+          detail: {request: req, response},
+          composed: true, bubbles: true,
+        }));
+        return;
+      }
+      return response && this.getResponseObject(response);
+    });
+  }
 
+  /**
+   * @param {string} url
+   * @param {?Object|string=} opt_params URL params, key-value hash.
+   * @return {string}
+   */
+  urlWithParams(url, opt_params) {
+    if (!opt_params) { return this.getBaseUrl() + url; }
+
+    const params = [];
+    for (const p in opt_params) {
+      if (!opt_params.hasOwnProperty(p)) { continue; }
+      if (opt_params[p] == null) {
+        params.push(encodeURIComponent(p));
+        continue;
+      }
+      for (const value of [].concat(opt_params[p])) {
+        params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
+      }
+    }
+    return this.getBaseUrl() + url + '?' + params.join('&');
+  }
+
+  /**
+   * @param {!Object} response
+   * @return {?}
+   */
+  getResponseObject(response) {
+    return this.readResponsePayload(response)
+        .then(payload => payload.parsed);
+  }
+
+  /**
+   * @param {!Object} response
+   * @return {!Object}
+   */
+  readResponsePayload(response) {
+    return response.text().then(text => {
+      let result;
+      try {
+        result = this.parsePrefixedJSON(text);
+      } catch (_) {
+        result = null;
+      }
+      return {parsed: result, raw: text};
+    });
+  }
+
+  /**
+   * @param {string} source
+   * @return {?}
+   */
+  parsePrefixedJSON(source) {
+    return JSON.parse(source.substring(JSON_PREFIX.length));
+  }
+
+  /**
+   * @param {Gerrit.FetchJSONRequest} req
+   * @return {Gerrit.FetchJSONRequest}
+   */
+  addAcceptJsonHeader(req) {
+    if (!req.fetchOptions) req.fetchOptions = {};
+    if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+    if (!req.fetchOptions.headers.has('Accept')) {
+      req.fetchOptions.headers.append('Accept', 'application/json');
+    }
+    return req;
+  }
+
+  getBaseUrl() {
+    return this._restApiInterface.getBaseUrl();
+  }
+
+  dispatchEvent(type, detail) {
+    return this._restApiInterface.dispatchEvent(type, detail);
+  }
+
+  /**
+   * @param {Gerrit.FetchJSONRequest} req
+   */
+  fetchCacheURL(req) {
+    if (this._fetchPromisesCache.has(req.url)) {
+      return this._fetchPromisesCache.get(req.url);
+    }
+    // TODO(andybons): Periodic cache invalidation.
+    if (this._cache.has(req.url)) {
+      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;
+            })
+    );
+    return this._fetchPromisesCache.get(req.url);
+  }
+
+  /**
+   * Send an XHR.
+   *
+   * @param {Gerrit.SendRequest} req
+   * @return {Promise}
+   */
+  send(req) {
+    const options = {method: req.method};
+    if (req.body) {
+      options.headers = new Headers();
+      options.headers.set(
+          'Content-Type', req.contentType || 'application/json');
+      options.body = typeof req.body === 'string' ?
+        req.body : JSON.stringify(req.body);
+    }
+    if (req.headers) {
+      if (!options.headers) { options.headers = new Headers(); }
+      for (const header in req.headers) {
+        if (!req.headers.hasOwnProperty(header)) { continue; }
+        options.headers.set(header, req.headers[header]);
+      }
+    }
+    const url = req.url.startsWith('http') ?
+      req.url : this.getBaseUrl() + req.url;
+    const fetchReq = {
+      url,
+      fetchOptions: options,
+      anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+    };
+    const xhr = this.fetch(fetchReq)
+        .then(response => {
+          if (!response.ok) {
+            if (req.errFn) {
+              return req.errFn.call(undefined, response);
+            }
+            this.dispatchEvent(new CustomEvent('server-error', {
+              detail: {request: fetchReq, response},
+              composed: true, bubbles: true,
+            }));
+          }
+          return response;
+        })
+        .catch(err => {
+          this.dispatchEvent(new CustomEvent('network-error', {
+            detail: {error: err},
+            composed: true, bubbles: true,
+          }));
+          if (req.errFn) {
+            return req.errFn.call(undefined, null, err);
+          } else {
+            throw err;
+          }
+        });
+
+    if (req.parseResponse) {
+      return xhr.then(res => this.getResponseObject(res));
+    }
+
+    return xhr;
+  }
+
+  /**
+   * @param {string} prefix
+   */
+  invalidateFetchPromisesPrefix(prefix) {
+    this._fetchPromisesCache.invalidatePrefix(prefix);
+    this._cache.invalidatePrefix(prefix);
+  }
+}
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..32d2166 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
@@ -17,161 +17,158 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../../test/common-test-setup.html"/>
-<script src="../../../../scripts/util.js"></script>
-<script src="../gr-auth.js"></script>
-<script src="gr-rest-api-helper.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-<script>
-  suite('gr-rest-api-helper tests', () => {
-    let helper;
-    let sandbox;
-    let cache;
-    let fetchPromisesCache;
+<script type="module">
+import '../../../../test/common-test-setup.js';
+import {SiteBasedCache} from './gr-rest-api-helper.js';
+import {FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
+import {authService} from '../gr-auth.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      cache = new SiteBasedCache();
-      fetchPromisesCache = new FetchPromisesCache();
-      const credentialCheck = {checking: false};
+suite('gr-rest-api-helper tests', () => {
+  let helper;
+  let sandbox;
+  let cache;
+  let fetchPromisesCache;
 
-      window.CANONICAL_PATH = 'testhelper';
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    cache = new SiteBasedCache();
+    fetchPromisesCache = new FetchPromisesCache();
 
-      const mockRestApiInterface = {
-        getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
-        fire: sinon.stub(),
-      };
+    window.CANONICAL_PATH = 'testhelper';
 
-      const testJSON = ')]}\'\n{"hello": "bonjour"}';
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({
-        ok: true,
-        text() {
-          return Promise.resolve(testJSON);
-        },
-      }));
+    const mockRestApiInterface = {
+      getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
+      fire: sinon.stub(),
+    };
 
-      helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache,
-          credentialCheck, mockRestApiInterface);
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sandbox.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
+
+    helper = new GrRestApiHelper(cache, authService, fetchPromisesCache,
+        mockRestApiInterface);
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  suite('fetchJSON()', () => {
+    test('Sets header to accept application/json', () => {
+      const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+          .returns(Promise.resolve());
+      helper.fetchJSON({url: '/dummy/url'});
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+          'application/json');
     });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('fetchJSON()', () => {
-      test('Sets header to accept application/json', () => {
-        const authFetchStub = sandbox.stub(helper._auth, 'fetch')
-            .returns(Promise.resolve());
-        helper.fetchJSON({url: '/dummy/url'});
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            'application/json');
-      });
-
-      test('Use header option accept when provided', () => {
-        const authFetchStub = sandbox.stub(helper._auth, 'fetch')
-            .returns(Promise.resolve());
-        const headers = new Headers();
-        headers.append('Accept', '*/*');
-        const fetchOptions = {headers};
-        helper.fetchJSON({url: '/dummy/url', fetchOptions});
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            '*/*');
-      });
-    });
-
-    test('JSON prefix is properly removed', done => {
-      helper.fetchJSON({url: '/dummy/url'}).then(obj => {
-        assert.deepEqual(obj, {hello: 'bonjour'});
-        done();
-      });
-    });
-
-    test('cached results', done => {
-      let n = 0;
-      sandbox.stub(helper, 'fetchJSON', () => {
-        return Promise.resolve(++n);
-      });
-      const promises = [];
-      promises.push(helper.fetchCacheURL('/foo'));
-      promises.push(helper.fetchCacheURL('/foo'));
-      promises.push(helper.fetchCacheURL('/foo'));
-
-      Promise.all(promises).then(results => {
-        assert.deepEqual(results, [1, 1, 1]);
-        helper.fetchCacheURL('/foo').then(foo => {
-          assert.equal(foo, 1);
-          done();
-        });
-      });
-    });
-
-    test('cached promise', done => {
-      const promise = Promise.reject(new Error('foo'));
-      cache.set('/foo', promise);
-      helper.fetchCacheURL({url: '/foo'}).catch(p => {
-        assert.equal(p.message, 'foo');
-        done();
-      });
-    });
-
-    test('cache invalidation', () => {
-      cache.set('/foo/bar', 1);
-      cache.set('/bar', 2);
-      fetchPromisesCache.set('/foo/bar', 3);
-      fetchPromisesCache.set('/bar', 4);
-      helper.invalidateFetchPromisesPrefix('/foo/');
-      assert.isFalse(cache.has('/foo/bar'));
-      assert.isTrue(cache.has('/bar'));
-      assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
-      assert.strictEqual(4, fetchPromisesCache.get('/bar'));
-    });
-
-    test('params are properly encoded', () => {
-      let url = helper.urlWithParams('/path/', {
-        sp: 'hola',
-        gr: 'guten tag',
-        noval: null,
-      });
-      assert.equal(url,
-          window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
-
-      url = helper.urlWithParams('/path/', {
-        sp: 'hola',
-        en: ['hey', 'hi'],
-      });
-      assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
-
-      // Order must be maintained with array params.
-      url = helper.urlWithParams('/path/', {
-        l: ['c', 'b', 'a'],
-      });
-      assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
-    });
-
-    test('request callbacks can be canceled', done => {
-      let cancelCalled = false;
-      window.fetch.returns(Promise.resolve({
-        body: {
-          cancel() { cancelCalled = true; },
-        },
-      }));
-      const cancelCondition = () => { return true; };
-      helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
-          obj => {
-            assert.isUndefined(obj);
-            assert.isTrue(cancelCalled);
-            done();
-          });
+    test('Use header option accept when provided', () => {
+      const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+          .returns(Promise.resolve());
+      const headers = new Headers();
+      headers.append('Accept', '*/*');
+      const fetchOptions = {headers};
+      helper.fetchJSON({url: '/dummy/url', fetchOptions});
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+          '*/*');
     });
   });
+
+  test('JSON prefix is properly removed', done => {
+    helper.fetchJSON({url: '/dummy/url'}).then(obj => {
+      assert.deepEqual(obj, {hello: 'bonjour'});
+      done();
+    });
+  });
+
+  test('cached results', done => {
+    let n = 0;
+    sandbox.stub(helper, 'fetchJSON', () => Promise.resolve(++n));
+    const promises = [];
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+
+    Promise.all(promises).then(results => {
+      assert.deepEqual(results, [1, 1, 1]);
+      helper.fetchCacheURL('/foo').then(foo => {
+        assert.equal(foo, 1);
+        done();
+      });
+    });
+  });
+
+  test('cached promise', done => {
+    const promise = Promise.reject(new Error('foo'));
+    cache.set('/foo', promise);
+    helper.fetchCacheURL({url: '/foo'}).catch(p => {
+      assert.equal(p.message, 'foo');
+      done();
+    });
+  });
+
+  test('cache invalidation', () => {
+    cache.set('/foo/bar', 1);
+    cache.set('/bar', 2);
+    fetchPromisesCache.set('/foo/bar', 3);
+    fetchPromisesCache.set('/bar', 4);
+    helper.invalidateFetchPromisesPrefix('/foo/');
+    assert.isFalse(cache.has('/foo/bar'));
+    assert.isTrue(cache.has('/bar'));
+    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
+    assert.strictEqual(4, fetchPromisesCache.get('/bar'));
+  });
+
+  test('params are properly encoded', () => {
+    let url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      gr: 'guten tag',
+      noval: null,
+    });
+    assert.equal(url,
+        window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
+
+    url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      en: ['hey', 'hi'],
+    });
+    assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
+
+    // Order must be maintained with array params.
+    url = helper.urlWithParams('/path/', {
+      l: ['c', 'b', 'a'],
+    });
+    assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
+  });
+
+  test('request callbacks can be canceled', done => {
+    let cancelCalled = false;
+    window.fetch.returns(Promise.resolve({
+      body: {
+        cancel() { cancelCalled = true; },
+      },
+    }));
+    const cancelCondition = () => true;
+    helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
+        obj => {
+          assert.isUndefined(obj);
+          assert.isTrue(cancelCalled);
+          done();
+        });
+  });
+});
 </script>
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..3d1ce05 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
@@ -14,216 +14,216 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  // Prevent redefinition.
-  if (window.GrReviewerUpdatesParser) { return; }
+import {util} from '../../../scripts/util.js';
 
-  function GrReviewerUpdatesParser(change) {
-    this.result = Object.assign({}, change);
-    this._lastState = {};
+/** @constructor */
+export function GrReviewerUpdatesParser(change) {
+  this.result = Object.assign({}, change);
+  this._lastState = {};
+}
+
+GrReviewerUpdatesParser.parse = function(change) {
+  if (!change ||
+      !change.messages ||
+      !change.reviewer_updates ||
+      !change.reviewer_updates.length) {
+    return change;
   }
+  const parser = new GrReviewerUpdatesParser(change);
+  parser._filterRemovedMessages();
+  parser._groupUpdates();
+  parser._formatUpdates();
+  parser._advanceUpdates();
+  return parser.result;
+};
 
-  GrReviewerUpdatesParser.parse = function(change) {
-    if (!change ||
-        !change.messages ||
-        !change.reviewer_updates ||
-        !change.reviewer_updates.length) {
-      return change;
+GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
+GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
+
+GrReviewerUpdatesParser.prototype.result = null;
+GrReviewerUpdatesParser.prototype._batch = null;
+GrReviewerUpdatesParser.prototype._updateItems = null;
+GrReviewerUpdatesParser.prototype._lastState = null;
+
+/**
+ * Removes messages that describe removed reviewers, since reviewer_updates
+ * are used.
+ */
+GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
+  this.result.messages = this.result.messages
+      .filter(
+          message => message.tag !== 'autogenerated:gerrit:deleteReviewer'
+      );
+};
+
+/**
+ * Is a part of _groupUpdates(). Creates a new batch of updates.
+ *
+ * @param {Object} update instance of ReviewerUpdateInfo
+ */
+GrReviewerUpdatesParser.prototype._startBatch = function(update) {
+  this._updateItems = [];
+  return {
+    author: update.updated_by,
+    date: update.updated,
+    type: 'REVIEWER_UPDATE',
+  };
+};
+
+/**
+ * Is a part of _groupUpdates(). Validates current batch:
+ * - filters out updates that don't change reviewer state.
+ * - updates current reviewer state.
+ *
+ * @param {Object} update instance of ReviewerUpdateInfo
+ */
+GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
+  const items = [];
+  for (const accountId in this._updateItems) {
+    if (!this._updateItems.hasOwnProperty(accountId)) continue;
+    const updateItem = this._updateItems[accountId];
+    if (this._lastState[accountId] !== updateItem.state) {
+      this._lastState[accountId] = updateItem.state;
+      items.push(updateItem);
     }
-    const parser = new GrReviewerUpdatesParser(change);
-    parser._filterRemovedMessages();
-    parser._groupUpdates();
-    parser._formatUpdates();
-    parser._advanceUpdates();
-    return parser.result;
-  };
+  }
+  if (items.length) {
+    this._batch.updates = items;
+  }
+};
 
-  GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
-  GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
-
-  GrReviewerUpdatesParser.prototype.result = null;
-  GrReviewerUpdatesParser.prototype._batch = null;
-  GrReviewerUpdatesParser.prototype._updateItems = null;
-  GrReviewerUpdatesParser.prototype._lastState = null;
-
-  /**
-   * Removes messages that describe removed reviewers, since reviewer_updates
-   * are used.
-   */
-  GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
-    this.result.messages = this.result.messages.filter(message => {
-      return message.tag !== 'autogenerated:gerrit:deleteReviewer';
-    });
-  };
-
-  /**
-   * Is a part of _groupUpdates(). Creates a new batch of updates.
-   *
-   * @param {Object} update instance of ReviewerUpdateInfo
-   */
-  GrReviewerUpdatesParser.prototype._startBatch = function(update) {
-    this._updateItems = [];
-    return {
-      author: update.updated_by,
-      date: update.updated,
-      type: 'REVIEWER_UPDATE',
+/**
+ * Groups reviewer updates. Sequential updates are grouped if:
+ * - They were performed within short timeframe (6 seconds)
+ * - Made by the same person
+ * - Non-change updates are discarded within a group
+ * - Groups with no-change updates are discarded (eg CC -> CC)
+ */
+GrReviewerUpdatesParser.prototype._groupUpdates = function() {
+  const updates = this.result.reviewer_updates;
+  const newUpdates = updates.reduce((newUpdates, update) => {
+    if (!this._batch) {
+      this._batch = this._startBatch(update);
+    }
+    const updateDate = util.parseDate(update.updated).getTime();
+    const batchUpdateDate = util.parseDate(this._batch.date).getTime();
+    const reviewerId = update.reviewer._account_id.toString();
+    if (updateDate - batchUpdateDate >
+        GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
+        update.updated_by._account_id !== this._batch.author._account_id) {
+      // Next sequential update should form new group.
+      this._completeBatch();
+      if (this._batch.updates && this._batch.updates.length) {
+        newUpdates.push(this._batch);
+      }
+      this._batch = this._startBatch(update);
+    }
+    this._updateItems[reviewerId] = {
+      reviewer: update.reviewer,
+      state: update.state,
     };
-  };
-
-  /**
-   * Is a part of _groupUpdates(). Validates current batch:
-   * - filters out updates that don't change reviewer state.
-   * - updates current reviewer state.
-   *
-   * @param {Object} update instance of ReviewerUpdateInfo
-   */
-  GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
-    const items = [];
-    for (const accountId in this._updateItems) {
-      if (!this._updateItems.hasOwnProperty(accountId)) continue;
-      const updateItem = this._updateItems[accountId];
-      if (this._lastState[accountId] !== updateItem.state) {
-        this._lastState[accountId] = updateItem.state;
-        items.push(updateItem);
-      }
+    if (this._lastState[reviewerId]) {
+      this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
     }
-    if (items.length) {
-      this._batch.updates = items;
-    }
-  };
+    return newUpdates;
+  }, []);
+  this._completeBatch();
+  if (this._batch.updates && this._batch.updates.length) {
+    newUpdates.push(this._batch);
+  }
+  this.result.reviewer_updates = newUpdates;
+};
 
-  /**
-   * Groups reviewer updates. Sequential updates are grouped if:
-   * - They were performed within short timeframe (6 seconds)
-   * - Made by the same person
-   * - Non-change updates are discarded within a group
-   * - Groups with no-change updates are discarded (eg CC -> CC)
-   */
-  GrReviewerUpdatesParser.prototype._groupUpdates = function() {
-    const updates = this.result.reviewer_updates;
-    const newUpdates = updates.reduce((newUpdates, update) => {
-      if (!this._batch) {
-        this._batch = this._startBatch(update);
-      }
-      const updateDate = util.parseDate(update.updated).getTime();
-      const batchUpdateDate = util.parseDate(this._batch.date).getTime();
-      const reviewerId = update.reviewer._account_id.toString();
-      if (updateDate - batchUpdateDate >
-          GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
-          update.updated_by._account_id !== this._batch.author._account_id) {
-        // Next sequential update should form new group.
-        this._completeBatch();
-        if (this._batch.updates && this._batch.updates.length) {
-          newUpdates.push(this._batch);
-        }
-        this._batch = this._startBatch(update);
-      }
-      this._updateItems[reviewerId] = {
-        reviewer: update.reviewer,
-        state: update.state,
-      };
-      if (this._lastState[reviewerId]) {
-        this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
-      }
-      return newUpdates;
-    }, []);
-    this._completeBatch();
-    if (this._batch.updates && this._batch.updates.length) {
-      newUpdates.push(this._batch);
-    }
-    this.result.reviewer_updates = newUpdates;
-  };
-
-  /**
-   * Generates update message for reviewer state change.
-   *
-   * @param {string} prev previous reviewer state.
-   * @param {string} state current reviewer state.
-   * @return {string}
-   */
-  GrReviewerUpdatesParser.prototype._getUpdateMessage = function(prev, state) {
-    if (prev === 'REMOVED' || !prev) {
-      return 'added to ' + state + ': ';
-    } else if (state === 'REMOVED') {
-      if (prev) {
-        return 'removed from ' + prev + ': ';
-      } else {
-        return 'removed : ';
-      }
+/**
+ * Generates update message for reviewer state change.
+ *
+ * @param {string} prev previous reviewer state.
+ * @param {string} state current reviewer state.
+ * @return {string}
+ */
+GrReviewerUpdatesParser.prototype._getUpdateMessage = function(prev, state) {
+  if (prev === 'REMOVED' || !prev) {
+    return 'Added to ' + state.toLowerCase() + ': ';
+  } else if (state === 'REMOVED') {
+    if (prev) {
+      return 'Removed from ' + prev.toLowerCase() + ': ';
     } else {
-      return 'moved from ' + prev + ' to ' + state + ': ';
+      return 'Removed : ';
     }
-  };
+  } else {
+    return 'Moved from ' + prev.toLowerCase() + ' to ' + state.toLowerCase() +
+        ': ';
+  }
+};
 
-  /**
-   * Groups updates for same category (eg CC->CC) into a hash arrays of
-   * reviewers.
-   *
-   * @param {!Array<!Object>} updates Array of ReviewerUpdateItemInfo.
-   * @return {!Object} Hash of arrays of AccountInfo, message as key.
-   */
-  GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
-    return updates.reduce((result, item) => {
-      const message = this._getUpdateMessage(item.prev_state, item.state);
-      if (!result[message]) {
-        result[message] = [];
-      }
-      result[message].push(item.reviewer);
-      return result;
-    }, {});
-  };
-
-  /**
-   * Generates text messages for grouped reviewer updates.
-   * Formats reviewer updates to a (not yet implemented) EventInfo instance.
-   *
-   * @see https://gerrit-review.googlesource.com/c/94490/
-   */
-  GrReviewerUpdatesParser.prototype._formatUpdates = function() {
-    for (const update of this.result.reviewer_updates) {
-      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
-      const newUpdates = [];
-      for (const message in grouppedReviewers) {
-        if (grouppedReviewers.hasOwnProperty(message)) {
-          newUpdates.push({
-            message,
-            reviewers: grouppedReviewers[message],
-          });
-        }
-      }
-      update.updates = newUpdates;
+/**
+ * Groups updates for same category (eg CC->CC) into a hash arrays of
+ * reviewers.
+ *
+ * @param {!Array<!Object>} updates Array of ReviewerUpdateItemInfo.
+ * @return {!Object} Hash of arrays of AccountInfo, message as key.
+ */
+GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
+  return updates.reduce((result, item) => {
+    const message = this._getUpdateMessage(item.prev_state, item.state);
+    if (!result[message]) {
+      result[message] = [];
     }
-  };
+    result[message].push(item.reviewer);
+    return result;
+  }, {});
+};
 
-  /**
-   * Moves reviewer updates that are within short time frame of change messages
-   * back in time so they would come before change messages.
-   * TODO(viktard): Remove when server-side serves reviewer updates like so.
-   */
-  GrReviewerUpdatesParser.prototype._advanceUpdates = function() {
-    const updates = this.result.reviewer_updates;
-    const messages = this.result.messages;
-    messages.forEach((message, index) => {
-      const messageDate = util.parseDate(message.date).getTime();
-      const nextMessageDate = index === messages.length - 1 ? null :
-        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)) {
-          const timestamp = util.parseDate(update.date).getTime() -
-              GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
-          update.date = new Date(timestamp)
-              .toISOString().replace('T', ' ').replace('Z', '000000');
-        }
-        if (nextMessageDate && date > nextMessageDate) {
-          break;
-        }
+/**
+ * Generates text messages for grouped reviewer updates.
+ * Formats reviewer updates to a (not yet implemented) EventInfo instance.
+ *
+ * @see https://gerrit-review.googlesource.com/c/94490/
+ */
+GrReviewerUpdatesParser.prototype._formatUpdates = function() {
+  for (const update of this.result.reviewer_updates) {
+    const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+    const newUpdates = [];
+    for (const message in grouppedReviewers) {
+      if (grouppedReviewers.hasOwnProperty(message)) {
+        newUpdates.push({
+          message,
+          reviewers: grouppedReviewers[message],
+        });
       }
-    });
-  };
+    }
+    update.updates = newUpdates;
+  }
+};
 
-  window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
-})(window);
+/**
+ * Moves reviewer updates that are within short time frame of change messages
+ * back in time so they would come before change messages.
+ * TODO(viktard): Remove when server-side serves reviewer updates like so.
+ */
+GrReviewerUpdatesParser.prototype._advanceUpdates = function() {
+  const updates = this.result.reviewer_updates;
+  const messages = this.result.messages;
+  messages.forEach((message, index) => {
+    const messageDate = util.parseDate(message.date).getTime();
+    const nextMessageDate = index === messages.length - 1 ? null :
+      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)) {
+        const timestamp = util.parseDate(update.date).getTime() -
+            GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
+        update.date = new Date(timestamp)
+            .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..f2ccfb7 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
@@ -17,289 +17,290 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-<script src="gr-reviewer-updates-parser.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-reviewer-updates-parser tests', () => {
-    let sandbox;
-    let instance;
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../../test/common-test-setup.js';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
+import {util} from '../../../scripts/util.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-    });
+suite('gr-reviewer-updates-parser tests', () => {
+  let sandbox;
+  let instance;
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+  });
 
-    test('ignores changes without messages', () => {
-      const change = {};
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_groupUpdates');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_formatUpdates');
-      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._groupUpdates.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._formatUpdates.called);
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('ignores changes without reviewer updates', () => {
-      const change = {
-        messages: [],
-      };
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_groupUpdates');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_formatUpdates');
-      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._groupUpdates.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._formatUpdates.called);
-    });
+  test('ignores changes without messages', () => {
+    const change = {};
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
 
-    test('ignores changes with empty reviewer updates', () => {
-      const change = {
-        messages: [],
-        reviewer_updates: [],
-      };
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_groupUpdates');
-      sandbox.stub(
-          GrReviewerUpdatesParser.prototype, '_formatUpdates');
-      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._groupUpdates.called);
-      assert.isFalse(
-          GrReviewerUpdatesParser.prototype._formatUpdates.called);
-    });
+  test('ignores changes without reviewer updates', () => {
+    const change = {
+      messages: [],
+    };
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
 
-    test('filter removed messages', () => {
-      const change = {
-        messages: [
-          {
-            message: 'msg1',
-            tag: 'autogenerated:gerrit:deleteReviewer',
-          },
-          {
-            message: 'msg2',
-            tag: 'foo',
-          },
-        ],
-      };
-      instance = new GrReviewerUpdatesParser(change);
-      instance._filterRemovedMessages();
-      assert.deepEqual(instance.result, {
-        messages: [{
+  test('ignores changes with empty reviewer updates', () => {
+    const change = {
+      messages: [],
+      reviewer_updates: [],
+    };
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sandbox.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
+
+  test('filter removed messages', () => {
+    const change = {
+      messages: [
+        {
+          message: 'msg1',
+          tag: 'autogenerated:gerrit:deleteReviewer',
+        },
+        {
           message: 'msg2',
           tag: 'foo',
-        }],
-      });
-    });
-
-    test('group reviewer updates', () => {
-      const reviewer1 = {_account_id: 1};
-      const reviewer2 = {_account_id: 2};
-      const date1 = '2017-01-26 12:11:50.000000000';
-      const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
-      const date3 = '2017-01-26 12:33:50.000000000';
-      const date4 = '2017-01-26 12:44:50.000000000';
-      const makeItem = function(state, reviewer, opt_date, opt_author) {
-        return {
-          reviewer,
-          updated: opt_date || date1,
-          updated_by: opt_author || reviewer1,
-          state,
-        };
-      };
-      let change = {
-        reviewer_updates: [
-          makeItem('REVIEWER', reviewer1), // New group.
-          makeItem('CC', reviewer2), // Appended.
-          makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
-
-          makeItem('CC', reviewer1, date2, reviewer2), // New group.
-
-          makeItem('REMOVED', reviewer2, date3), // Group has no state change.
-          makeItem('REVIEWER', reviewer2, date3),
-
-          makeItem('CC', reviewer1, date4), // No change, removed.
-          makeItem('REVIEWER', reviewer1, date4), // Forms new group
-          makeItem('REMOVED', reviewer2, date4), // Should be grouped.
-        ],
-      };
-
-      instance = new GrReviewerUpdatesParser(change);
-      instance._groupUpdates();
-      change = instance.result;
-
-      assert.equal(change.reviewer_updates.length, 3);
-      assert.equal(change.reviewer_updates[0].updates.length, 2);
-      assert.equal(change.reviewer_updates[1].updates.length, 1);
-      assert.equal(change.reviewer_updates[2].updates.length, 2);
-
-      assert.equal(change.reviewer_updates[0].date, date1);
-      assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
-      assert.deepEqual(change.reviewer_updates[0].updates, [
-        {
-          reviewer: reviewer1,
-          state: 'REVIEWER',
         },
-        {
-          reviewer: reviewer2,
-          state: 'REVIEWER',
-        },
-      ]);
-
-      assert.equal(change.reviewer_updates[1].date, date2);
-      assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
-      assert.deepEqual(change.reviewer_updates[1].updates, [
-        {
-          reviewer: reviewer1,
-          state: 'CC',
-          prev_state: 'REVIEWER',
-        },
-      ]);
-
-      assert.equal(change.reviewer_updates[2].date, date4);
-      assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
-      assert.deepEqual(change.reviewer_updates[2].updates, [
-        {
-          reviewer: reviewer1,
-          prev_state: 'CC',
-          state: 'REVIEWER',
-        },
-        {
-          reviewer: reviewer2,
-          prev_state: 'REVIEWER',
-          state: 'REMOVED',
-        },
-      ]);
-    });
-
-    test('format reviewer updates', () => {
-      const reviewer1 = {_account_id: 1};
-      const reviewer2 = {_account_id: 2};
-      const makeItem = function(prev, state, opt_reviewer) {
-        return {
-          reviewer: opt_reviewer || reviewer1,
-          prev_state: prev,
-          state,
-        };
-      };
-      const makeUpdate = function(items) {
-        return {
-          author: reviewer1,
-          updated: '',
-          updates: items,
-        };
-      };
-      const change = {
-        reviewer_updates: [
-          makeUpdate([
-            makeItem(undefined, 'CC'),
-            makeItem(undefined, 'CC', reviewer2),
-          ]),
-          makeUpdate([
-            makeItem('CC', 'REVIEWER'),
-            makeItem('REVIEWER', 'REMOVED'),
-            makeItem('REMOVED', 'REVIEWER'),
-            makeItem(undefined, 'REVIEWER', reviewer2),
-          ]),
-        ],
-      };
-
-      instance = new GrReviewerUpdatesParser(change);
-      instance._formatUpdates();
-
-      assert.equal(change.reviewer_updates.length, 2);
-      assert.equal(change.reviewer_updates[0].updates.length, 1);
-      assert.equal(change.reviewer_updates[1].updates.length, 3);
-
-      let items = change.reviewer_updates[0].updates;
-      assert.equal(items[0].message, 'added to CC: ');
-      assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
-
-      items = change.reviewer_updates[1].updates;
-      assert.equal(items[0].message, 'moved from CC to REVIEWER: ');
-      assert.deepEqual(items[0].reviewers, [reviewer1]);
-      assert.equal(items[1].message, 'removed from REVIEWER: ');
-      assert.deepEqual(items[1].reviewers, [reviewer1]);
-      assert.equal(items[2].message, 'added to REVIEWER: ');
-      assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
-    });
-
-    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 change = {
-        reviewer_updates: [{
-          date: tplus(0),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'same time update',
-          }],
-        }, {
-          date: tplus(200),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'update within threshold',
-          }],
-        }, {
-          date: tplus(600),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'update between messages',
-          }],
-        }, {
-          date: tplus(1000),
-          type: 'REVIEWER_UPDATE',
-          updates: [{
-            message: 'late update',
-          }],
-        }],
-        messages: [{
-          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
-          date: tplus(0),
-          message: 'Uploaded patch set 1.',
-        }, {
-          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
-          date: tplus(800),
-          message: 'Uploaded patch set 2.',
-        }],
-      };
-      instance = new GrReviewerUpdatesParser(change);
-      instance._advanceUpdates();
-      const updates = instance.result.reviewer_updates;
-      assert.isBelow(util.parseDate(updates[0].date).getTime(), T0);
-      assert.isBelow(util.parseDate(updates[1].date).getTime(), T0);
-      assert.equal(updates[2].date, tplus(100));
-      assert.equal(updates[3].date, tplus(500));
+      ],
+    };
+    instance = new GrReviewerUpdatesParser(change);
+    instance._filterRemovedMessages();
+    assert.deepEqual(instance.result, {
+      messages: [{
+        message: 'msg2',
+        tag: 'foo',
+      }],
     });
   });
+
+  test('group reviewer updates', () => {
+    const reviewer1 = {_account_id: 1};
+    const reviewer2 = {_account_id: 2};
+    const date1 = '2017-01-26 12:11:50.000000000';
+    const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
+    const date3 = '2017-01-26 12:33:50.000000000';
+    const date4 = '2017-01-26 12:44:50.000000000';
+    const makeItem = function(state, reviewer, opt_date, opt_author) {
+      return {
+        reviewer,
+        updated: opt_date || date1,
+        updated_by: opt_author || reviewer1,
+        state,
+      };
+    };
+    let change = {
+      reviewer_updates: [
+        makeItem('REVIEWER', reviewer1), // New group.
+        makeItem('CC', reviewer2), // Appended.
+        makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
+
+        makeItem('CC', reviewer1, date2, reviewer2), // New group.
+
+        makeItem('REMOVED', reviewer2, date3), // Group has no state change.
+        makeItem('REVIEWER', reviewer2, date3),
+
+        makeItem('CC', reviewer1, date4), // No change, removed.
+        makeItem('REVIEWER', reviewer1, date4), // Forms new group
+        makeItem('REMOVED', reviewer2, date4), // Should be grouped.
+      ],
+    };
+
+    instance = new GrReviewerUpdatesParser(change);
+    instance._groupUpdates();
+    change = instance.result;
+
+    assert.equal(change.reviewer_updates.length, 3);
+    assert.equal(change.reviewer_updates[0].updates.length, 2);
+    assert.equal(change.reviewer_updates[1].updates.length, 1);
+    assert.equal(change.reviewer_updates[2].updates.length, 2);
+
+    assert.equal(change.reviewer_updates[0].date, date1);
+    assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
+    assert.deepEqual(change.reviewer_updates[0].updates, [
+      {
+        reviewer: reviewer1,
+        state: 'REVIEWER',
+      },
+      {
+        reviewer: reviewer2,
+        state: 'REVIEWER',
+      },
+    ]);
+
+    assert.equal(change.reviewer_updates[1].date, date2);
+    assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
+    assert.deepEqual(change.reviewer_updates[1].updates, [
+      {
+        reviewer: reviewer1,
+        state: 'CC',
+        prev_state: 'REVIEWER',
+      },
+    ]);
+
+    assert.equal(change.reviewer_updates[2].date, date4);
+    assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
+    assert.deepEqual(change.reviewer_updates[2].updates, [
+      {
+        reviewer: reviewer1,
+        prev_state: 'CC',
+        state: 'REVIEWER',
+      },
+      {
+        reviewer: reviewer2,
+        prev_state: 'REVIEWER',
+        state: 'REMOVED',
+      },
+    ]);
+  });
+
+  test('format reviewer updates', () => {
+    const reviewer1 = {_account_id: 1};
+    const reviewer2 = {_account_id: 2};
+    const makeItem = function(prev, state, opt_reviewer) {
+      return {
+        reviewer: opt_reviewer || reviewer1,
+        prev_state: prev,
+        state,
+      };
+    };
+    const makeUpdate = function(items) {
+      return {
+        author: reviewer1,
+        updated: '',
+        updates: items,
+      };
+    };
+    const change = {
+      reviewer_updates: [
+        makeUpdate([
+          makeItem(undefined, 'CC'),
+          makeItem(undefined, 'CC', reviewer2),
+        ]),
+        makeUpdate([
+          makeItem('CC', 'REVIEWER'),
+          makeItem('REVIEWER', 'REMOVED'),
+          makeItem('REMOVED', 'REVIEWER'),
+          makeItem(undefined, 'REVIEWER', reviewer2),
+        ]),
+      ],
+    };
+
+    instance = new GrReviewerUpdatesParser(change);
+    instance._formatUpdates();
+
+    assert.equal(change.reviewer_updates.length, 2);
+    assert.equal(change.reviewer_updates[0].updates.length, 1);
+    assert.equal(change.reviewer_updates[1].updates.length, 3);
+
+    let items = change.reviewer_updates[0].updates;
+    assert.equal(items[0].message, 'Added to cc: ');
+    assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
+
+    items = change.reviewer_updates[1].updates;
+    assert.equal(items[0].message, 'Moved from cc to reviewer: ');
+    assert.deepEqual(items[0].reviewers, [reviewer1]);
+    assert.equal(items[1].message, 'Removed from reviewer: ');
+    assert.deepEqual(items[1].reviewers, [reviewer1]);
+    assert.equal(items[2].message, 'Added to reviewer: ');
+    assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
+  });
+
+  test('_advanceUpdates', () => {
+    const T0 = util.parseDate('2017-02-17 19:04:18.000000000').getTime();
+    const tplus = delta => new Date(T0 + delta)
+        .toISOString()
+        .replace('T', ' ')
+        .replace('Z', '000000');
+    const change = {
+      reviewer_updates: [{
+        date: tplus(0),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'same time update',
+        }],
+      }, {
+        date: tplus(200),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'update within threshold',
+        }],
+      }, {
+        date: tplus(600),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'update between messages',
+        }],
+      }, {
+        date: tplus(1000),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'late update',
+        }],
+      }],
+      messages: [{
+        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+        date: tplus(0),
+        message: 'Uploaded patch set 1.',
+      }, {
+        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+        date: tplus(800),
+        message: 'Uploaded patch set 2.',
+      }],
+    };
+    instance = new GrReviewerUpdatesParser(change);
+    instance._advanceUpdates();
+    const updates = instance.result.reviewer_updates;
+    assert.isBelow(util.parseDate(updates[0].date).getTime(), T0);
+    assert.isBelow(util.parseDate(updates[1].date).getTime(), T0);
+    assert.equal(updates[2].date, tplus(100));
+    assert.equal(updates[3].date, tplus(500));
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
deleted file mode 100644
index 015d71e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
+++ /dev/null
@@ -1,168 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<dom-module id="mock-diff-response">
-  <template></template>
-  <script>
-    (function() {
-      'use strict';
-
-      const RESPONSE = {
-        meta_a: {
-          name: 'lorem-ipsum.txt',
-          content_type: 'text/plain',
-          lines: 45,
-        },
-        meta_b: {
-          name: 'lorem-ipsum.txt',
-          content_type: 'text/plain',
-          lines: 48,
-        },
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        diff_header: [
-          'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
-          'index b2adcf4..554ae49 100644',
-          '--- a/lorem-ipsum.txt',
-          '+++ b/lorem-ipsum.txt',
-        ],
-        content: [
-          {
-            ab: [
-              'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
-                'nulla phasellus.',
-              'Mattis lectus.',
-              'Sodales duis.',
-              'Orci a faucibus.',
-            ],
-          },
-          {
-            b: [
-              'Nullam neque, ligula ac, id blandit.',
-              'Sagittis tincidunt torquent, tempor nunc amet.',
-              'At rhoncus id.',
-            ],
-          },
-          {
-            ab: [
-              'Sem nascetur, erat ut, non in.',
-              'A donec, venenatis pellentesque dis.',
-              'Mauris mauris.',
-              'Quisque nisl duis, facilisis viverra.',
-              'Justo purus, semper eget et.',
-            ],
-          },
-          {
-            a: [
-              'Est amet, vestibulum pellentesque.',
-              'Erat ligula.',
-              'Justo eros.',
-              'Fringilla quisque.',
-            ],
-          },
-          {
-            ab: [
-              'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-              'Eros suspendisse.',
-            ],
-          },
-          {
-            a: [
-              'Rhoncus tempor, ultricies aliquam ipsum.',
-            ],
-            b: [
-              'Rhoncus tempor, ultricies praesent ipsum.',
-            ],
-            edit_a: [
-              [
-                26,
-                7,
-              ],
-            ],
-            edit_b: [
-              [
-                26,
-                8,
-              ],
-            ],
-          },
-          {
-            ab: [
-              'Sollicitudin duis.',
-              'Blandit blandit, ante nisl fusce.',
-              'Felis ac at, tellus consectetuer.',
-              'Sociis ligula sapien, egestas leo.',
-              'Cum pulvinar, sed mauris, cursus neque velit.',
-              'Augue porta lobortis.',
-              'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
-              'Id quam ipsum, id urna et, massa suspendisse.',
-              'Ac nec, nibh praesent.',
-              'Rutrum vestibulum.',
-              'Est tellus, bibendum habitasse.',
-              'Justo facilisis, vel nulla.',
-              'Donec eu, vulputate neque aliquam, nulla dui.',
-              'Risus adipiscing in.',
-              'Lacus arcu arcu.',
-              'Urna velit.',
-              'Urna a dolor.',
-              'Lectus magna augue, convallis mattis tortor, sed tellus ' +
-                'consequat.',
-              'Etiam dui, blandit wisi.',
-              'Mi nec.',
-              'Vitae eget vestibulum.',
-              'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
-              'Ac eget.',
-              'Vel fringilla, interdum pellentesque placerat, proin ante.',
-            ],
-          },
-          {
-            b: [
-              'Eu congue risus.',
-              'Enim ac, quis elementum.',
-              'Non et elit.',
-              'Etiam aliquam, diam vel nunc.',
-            ],
-          },
-          {
-            ab: [
-              'Nec at.',
-              'Arcu mauris, venenatis lacus fermentum, praesent duis.',
-              'Pellentesque amet et, tellus duis.',
-              'Ipsum arcu vitae, justo elit, sed libero tellus.',
-              'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
-            ],
-          },
-        ],
-      };
-
-      Polymer({
-        is: 'mock-diff-response',
-
-        properties: {
-          diffResponse: {
-            type: Object,
-            value() {
-              return RESPONSE;
-            },
-          },
-        },
-      });
-    })();
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
deleted file mode 100644
index f1ef86a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-
-<dom-module id="gr-select">
-  <slot></slot>
-  <script src="gr-select.js"></script>
-</dom-module>
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..e061e93 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -14,60 +14,83 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-select',
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+const $_documentContainer = document.createElement('template');
 
-    properties: {
+$_documentContainer.innerHTML = `<dom-module id="gr-select">
+  <slot></slot>
+  
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/**
+ * @extends Polymer.Element
+ */
+class GrSelect extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
+  static get is() { return 'gr-select'; }
+
+  static get properties() {
+    return {
       bindValue: {
         type: String,
         notify: true,
         observer: '_updateValue',
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  get nativeSelect() {
+    // gr-select is not a shadow component
+    // TODO(taoalpha): maybe we should convert
+    // it into a shadow dom component instead
+    return this.querySelector('select');
+  }
 
-    listeners: {
-      'change': '_valueChanged',
-      'dom-change': '_updateValue',
-    },
-
-    get nativeSelect() {
-      return this.$$('select');
-    },
-
-    _updateValue() {
-      // It's possible to have a value of 0.
-      if (this.bindValue !== undefined) {
-        // Set for chrome/safari so it happens instantly
+  _updateValue() {
+    // It's possible to have a value of 0.
+    if (this.bindValue !== undefined) {
+      // Set for chrome/safari so it happens instantly
+      this.nativeSelect.value = this.bindValue;
+      // Async needed for firefox to populate value. It was trying to do it
+      // before options from a dom-repeat were rendered previously.
+      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+      this.async(() => {
         this.nativeSelect.value = this.bindValue;
-        // Async needed for firefox to populate value. It was trying to do it
-        // before options from a dom-repeat were rendered previously.
-        // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
-        this.async(() => {
-          this.nativeSelect.value = this.bindValue;
-        }, 1);
-      }
-    },
+      }, 1);
+    }
+  }
 
-    _valueChanged() {
+  _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;
-    },
+    }
+  }
+}
 
-    focus() {
-      this.nativeSelect.focus();
-    },
-
-    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..670f383 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-select.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -49,59 +46,63 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-select tests', () => {
-    let element;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-select.js';
+suite('gr-select tests', () => {
+  let element;
 
-    setup(() => {
-      element = fixture('basic');
-    });
+  setup(() => {
+    element = fixture('basic');
+  });
 
-    test('bindValue must be set to the first option value', () => {
-      assert.equal(element.bindValue, '1');
-    });
+  test('bindValue must be set to the first option value', () => {
+    assert.equal(element.bindValue, '1');
+  });
 
-    test('value of 0 should still trigger value updates', () => {
-      element.bindValue = 0;
-      assert.equal(element.nativeSelect.value, 0);
-    });
+  test('value of 0 should still trigger value updates', () => {
+    element.bindValue = 0;
+    assert.equal(element.nativeSelect.value, 0);
+  });
 
-    test('bidirectional binding property-to-attribute', () => {
-      const changeStub = sinon.stub();
-      element.addEventListener('bind-value-changed', changeStub);
+  test('bidirectional binding property-to-attribute', () => {
+    const changeStub = sinon.stub();
+    element.addEventListener('bind-value-changed', changeStub);
 
-      // The selected element should be the first one by default.
-      assert.equal(element.nativeSelect.value, '1');
-      assert.equal(element.bindValue, '1');
-      assert.isFalse(changeStub.called);
+    // The selected element should be the first one by default.
+    assert.equal(element.nativeSelect.value, '1');
+    assert.equal(element.bindValue, '1');
+    assert.isFalse(changeStub.called);
 
-      // Now change the value.
-      element.bindValue = '2';
+    // Now change the value.
+    element.bindValue = '2';
 
-      // It should be updated.
-      assert.equal(element.nativeSelect.value, '2');
-      assert.equal(element.bindValue, '2');
-      assert.isTrue(changeStub.called);
-    });
+    // It should be updated.
+    assert.equal(element.nativeSelect.value, '2');
+    assert.equal(element.bindValue, '2');
+    assert.isTrue(changeStub.called);
+  });
 
-    test('bidirectional binding attribute-to-property', () => {
-      const changeStub = sinon.stub();
-      element.addEventListener('bind-value-changed', changeStub);
+  test('bidirectional binding attribute-to-property', () => {
+    const changeStub = sinon.stub();
+    element.addEventListener('bind-value-changed', changeStub);
 
-      // The selected element should be the first one by default.
-      assert.equal(element.nativeSelect.value, '1');
-      assert.equal(element.bindValue, '1');
-      assert.isFalse(changeStub.called);
+    // The selected element should be the first one by default.
+    assert.equal(element.nativeSelect.value, '1');
+    assert.equal(element.bindValue, '1');
+    assert.isFalse(changeStub.called);
 
-      // Now change the value.
-      element.nativeSelect.value = '3';
-      element.fire('change');
+    // Now change the value.
+    element.nativeSelect.value = '3';
+    element.dispatchEvent(
+        new CustomEvent('change', {
+          composed: true, bubbles: true,
+        }));
 
-      // It should be updated.
-      assert.equal(element.nativeSelect.value, '3');
-      assert.equal(element.bindValue, '3');
-      assert.isTrue(changeStub.called);
-    });
+    // It should be updated.
+    assert.equal(element.nativeSelect.value, '3');
+    assert.equal(element.bindValue, '3');
+    assert.isTrue(changeStub.called);
   });
 
   suite('gr-select no options tests', () => {
@@ -115,4 +116,5 @@
       assert.isUndefined(element.bindValue);
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
deleted file mode 100644
index 15e282f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.html
+++ /dev/null
@@ -1,63 +0,0 @@
-<!--
-@license
-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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
-
-<dom-module id="gr-shell-command">
-  <template>
-    <style include="shared-styles">
-      .commandContainer {
-        margin-bottom: var(--spacing-m);
-      }
-      .commandContainer {
-        background-color: var(--shell-command-background-color);
-        /* Should be spacing-m larger than the :before width. */
-        padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) calc(3*var(--spacing-m) + 0.5em);
-        position: relative;
-        width: 100%;
-      }
-      .commandContainer:before {
-        content: '$';
-        position: absolute;
-        display: block;
-        box-sizing: border-box;
-        background: var(--shell-command-decoration-background-color);
-        top: 0;
-        bottom: 0;
-        left: 0;
-        /* Should be spacing-m smaller than the .commandContainer padding-left. */
-        width: calc(2*var(--spacing-m) + 0.5em);
-        /* Should vertically match the padding of .commandContainer. */
-        padding: var(--spacing-m);
-        /* Should roughly match the height of .commandContainer without padding. */
-        line-height: 26px;
-      }
-      .commandContainer gr-copy-clipboard {
-        --text-container-style: {
-          border: none;
-        }
-      }
-    </style>
-    <label>[[label]]</label>
-    <div class="commandContainer">
-      <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
-    </div>
-  </template>
-  <script src="gr-shell-command.js"></script>
-</dom-module>
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..151498c 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
@@ -14,19 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-shell-command',
+import '../../../styles/shared-styles.js';
+import '../gr-copy-clipboard/gr-copy-clipboard.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-shell-command_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrShellCommand extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-shell-command'; }
+
+  static get properties() {
+    return {
       command: String,
       label: String,
-    },
+    };
+  }
 
-    focusOnCopy() {
-      this.$$('gr-copy-clipboard').focusOnCopy();
-    },
-  });
-})();
+  focusOnCopy() {
+    this.shadowRoot.querySelector('gr-copy-clipboard').focusOnCopy();
+  }
+}
+
+customElements.define(GrShellCommand.is, GrShellCommand);
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
new file mode 100644
index 0000000..4a4480e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .commandContainer {
+      margin-bottom: var(--spacing-m);
+    }
+    .commandContainer {
+      background-color: var(--shell-command-background-color);
+      /* Should be spacing-m larger than the :before width. */
+      padding: var(--spacing-m) var(--spacing-m) var(--spacing-m)
+        calc(3 * var(--spacing-m) + 0.5em);
+      position: relative;
+      width: 100%;
+    }
+    .commandContainer:before {
+      content: '$';
+      position: absolute;
+      display: block;
+      box-sizing: border-box;
+      background: var(--shell-command-decoration-background-color);
+      top: 0;
+      bottom: 0;
+      left: 0;
+      /* Should be spacing-m smaller than the .commandContainer padding-left. */
+      width: calc(2 * var(--spacing-m) + 0.5em);
+      /* Should vertically match the padding of .commandContainer. */
+      padding: var(--spacing-m);
+      /* Should roughly match the height of .commandContainer without padding. */
+      line-height: 26px;
+    }
+    .commandContainer gr-copy-clipboard {
+      --text-container-style: {
+        border: none;
+      }
+    }
+  </style>
+  <label>[[label]]</label>
+  <div class="commandContainer">
+    <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
+  </div>
+`;
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..ee0b64f 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
@@ -17,16 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-shell-command.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,28 +31,31 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-shell-command tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-shell-command.js';
+suite('gr-shell-command tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.text = `git fetch http://gerrit@localhost:8080/a/test-project
-          refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('focusOnCopy', () => {
-      const focusStub = sandbox.stub(element.$$('gr-copy-clipboard'),
-          'focusOnCopy');
-      element.focusOnCopy();
-      assert.isTrue(focusStub.called);
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    flushAsynchronousOperations();
   });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('focusOnCopy', () => {
+    const focusStub = sandbox.stub(element.shadowRoot
+        .querySelector('gr-copy-clipboard'),
+    'focusOnCopy');
+    element.focusOnCopy();
+    assert.isTrue(focusStub.called);
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
deleted file mode 100644
index 7215b26..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<dom-module id="gr-storage">
-  <script src="gr-storage.js"></script>
-</dom-module>
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..8f5c486 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -14,24 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  // Date cutoff is one day:
-  const CLEANUP_MAX_AGE = 24 * 60 * 60 * 1000;
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-  // Clean up old entries no more frequently than one day.
-  const CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
+const DURATION_DAY = 24 * 60 * 60 * 1000;
 
-  const CLEANUP_PREFIXES = [
-    'draft:',
-    'editablecontent:',
-  ];
+// Clean up old entries no more frequently than one day.
+const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
 
-  Polymer({
-    is: 'gr-storage',
+const CLEANUP_PREFIXES_MAX_AGE_MAP = {
+  // respectfultip has a 14-day expiration
+  'respectfultip:': 14 * DURATION_DAY,
+  'draft:': DURATION_DAY,
+  'editablecontent:': DURATION_DAY,
+};
 
-    properties: {
+/** @extends Polymer.Element */
+class GrStorage extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get is() { return 'gr-storage'; }
+
+  static get properties() {
+    return {
       _lastCleanup: Number,
       /** @type {?Storage} */
       _storage: {
@@ -44,98 +52,112 @@
         type: Boolean,
         value: false,
       },
-    },
+    };
+  }
 
-    getDraftComment(location) {
-      this._cleanupItems();
-      return this._getObject(this._getDraftKey(location));
-    },
+  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()});
-    },
+  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);
-    },
+  eraseDraftComment(location) {
+    const key = this._getDraftKey(location);
+    this._storage.removeItem(key);
+  }
 
-    getEditableContentItem(key) {
-      this._cleanupItems();
-      return this._getObject(this._getEditableContentKey(key));
-    },
+  getEditableContentItem(key) {
+    this._cleanupItems();
+    return this._getObject(this._getEditableContentKey(key));
+  }
 
-    setEditableContentItem(key, message) {
-      this._setObject(this._getEditableContentKey(key),
-          {message, updated: Date.now()});
-    },
+  setEditableContentItem(key, message) {
+    this._setObject(this._getEditableContentKey(key),
+        {message, updated: Date.now()});
+  }
 
-    eraseEditableContentItem(key) {
-      this._storage.removeItem(this._getEditableContentKey(key));
-    },
+  getRespectfulTipVisibility() {
+    this._cleanupItems();
+    return this._getObject('respectfultip:visibility');
+  }
 
-    _getDraftKey(location) {
-      const range = location.range ?
-        `${location.range.start_line}-${location.range.start_character}` +
-              `-${location.range.end_character}-${location.range.end_line}` :
-        null;
-      let key = ['draft', location.changeNum, location.patchNum, location.path,
-        location.line || ''].join(':');
-      if (range) {
-        key = key + ':' + range;
-      }
-      return key;
-    },
+  setRespectfulTipVisibility(delayDays = 0) {
+    this._cleanupItems();
+    this._setObject(
+        'respectfultip:visibility',
+        {updated: Date.now() + delayDays * DURATION_DAY}
+    );
+  }
 
-    _getEditableContentKey(key) {
-      return `editablecontent:${key}`;
-    },
+  eraseEditableContentItem(key) {
+    this._storage.removeItem(this._getEditableContentKey(key));
+  }
 
-    _cleanupItems() {
-      // Throttle cleanup to the throttle interval.
-      if (this._lastCleanup &&
-          Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
-        return;
-      }
-      this._lastCleanup = Date.now();
+  _getDraftKey(location) {
+    const range = location.range ?
+      `${location.range.start_line}-${location.range.start_character}` +
+            `-${location.range.end_character}-${location.range.end_line}` :
+      null;
+    let key = ['draft', location.changeNum, location.patchNum, location.path,
+      location.line || ''].join(':');
+    if (range) {
+      key = key + ':' + range;
+    }
+    return key;
+  }
 
-      let item;
-      for (const key in this._storage) {
-        if (!this._storage.hasOwnProperty(key)) { continue; }
-        for (const prefix of CLEANUP_PREFIXES) {
-          if (key.startsWith(prefix)) {
-            item = this._getObject(key);
-            if (Date.now() - item.updated > CLEANUP_MAX_AGE) {
-              this._storage.removeItem(key);
-            }
-            break;
+  _getEditableContentKey(key) {
+    return `editablecontent:${key}`;
+  }
+
+  _cleanupItems() {
+    // Throttle cleanup to the throttle interval.
+    if (this._lastCleanup &&
+        Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
+      return;
+    }
+    this._lastCleanup = Date.now();
+
+    let item;
+    Object.keys(this._storage).forEach(key => {
+      Object.keys(CLEANUP_PREFIXES_MAX_AGE_MAP).forEach(prefix => {
+        if (key.startsWith(prefix)) {
+          item = this._getObject(key);
+          const expiration = CLEANUP_PREFIXES_MAX_AGE_MAP[prefix];
+          if (Date.now() - item.updated > expiration) {
+            this._storage.removeItem(key);
           }
         }
-      }
-    },
+      });
+    });
+  }
 
-    _getObject(key) {
-      const serial = this._storage.getItem(key);
-      if (!serial) { return null; }
-      return JSON.parse(serial);
-    },
+  _getObject(key) {
+    const serial = this._storage.getItem(key);
+    if (!serial) { return null; }
+    return JSON.parse(serial);
+  }
 
-    _setObject(key, obj) {
-      if (this._exceededQuota) { return; }
-      try {
-        this._storage.setItem(key, JSON.stringify(obj));
-      } catch (exc) {
-        // Catch for QuotaExceededError and disable writes on local storage the
-        // first time that it occurs.
-        if (exc.code === 22) {
-          this._exceededQuota = true;
-          console.warn('Local storage quota exceeded: disabling');
-          return;
-        } else {
-          throw exc;
-        }
+  _setObject(key, obj) {
+    if (this._exceededQuota) { return; }
+    try {
+      this._storage.setItem(key, JSON.stringify(obj));
+    } catch (exc) {
+      // Catch for QuotaExceededError and disable writes on local storage the
+      // first time that it occurs.
+      if (exc.code === 22) {
+        this._exceededQuota = true;
+        console.warn('Local storage quota exceeded: disabling');
+        return;
+      } else {
+        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..b560c56 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
@@ -16,16 +16,13 @@
 limitations under the License.
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-storage.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -33,164 +30,166 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-storage tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-storage.js';
+suite('gr-storage tests', () => {
+  let element;
+  let sandbox;
 
-    function mockStorage(opt_quotaExceeded) {
-      return {
-        getItem(key) { return this[key]; },
-        removeItem(key) { delete this[key]; },
-        setItem(key, value) {
-          // eslint-disable-next-line no-throw-literal
-          if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
-          this[key] = value;
-        },
-      };
-    }
+  function mockStorage(opt_quotaExceeded) {
+    return {
+      getItem(key) { return this[key]; },
+      removeItem(key) { delete this[key]; },
+      setItem(key, value) {
+        // eslint-disable-next-line no-throw-literal
+        if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
+        this[key] = value;
+      },
+    };
+  }
 
-    setup(() => {
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      element._storage = mockStorage();
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('storing, retrieving and erasing drafts', () => {
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-
-      // The key is in the expected format.
-      const key = element._getDraftKey(location);
-      assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
-
-      // There should be no draft initially.
-      const draft = element.getDraftComment(location);
-      assert.isNotOk(draft);
-
-      // Setting the draft stores it under the expected key.
-      element.setDraftComment(location, 'my comment');
-      assert.isOk(element._storage.getItem(key));
-      assert.equal(JSON.parse(element._storage.getItem(key)).message,
-          'my comment');
-      assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
-
-      // Erasing the draft removes the key.
-      element.eraseDraftComment(location);
-      assert.isNotOk(element._storage.getItem(key));
-    });
-
-    test('automatically removes old drafts', () => {
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-
-      const key = element._getDraftKey(location);
-
-      // Make sure that the call to cleanup doesn't get throttled.
-      element._lastCleanup = 0;
-
-      const cleanupSpy = sandbox.spy(element, '_cleanupItems');
-
-      // Create a message with a timestamp that is a second behind the max age.
-      element._storage.setItem(key, JSON.stringify({
-        message: 'old message',
-        updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
-      }));
-
-      // Getting the draft should cause it to be removed.
-      const draft = element.getDraftComment(location);
-
-      assert.isTrue(cleanupSpy.called);
-      assert.isNotOk(draft);
-      assert.isNotOk(element._storage.getItem(key));
-    });
-
-    test('_getDraftKey', () => {
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-      let expectedResult = 'draft:1234:5:my_source_file.js:123';
-      assert.equal(element._getDraftKey(location), expectedResult);
-      location.range = {
-        start_character: 1,
-        start_line: 1,
-        end_character: 1,
-        end_line: 2,
-      };
-      expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-      assert.equal(element._getDraftKey(location), expectedResult);
-    });
-
-    test('exceeded quota disables storage', () => {
-      element._storage = mockStorage(true);
-      assert.isFalse(element._exceededQuota);
-
-      const changeNum = 1234;
-      const patchNum = 5;
-      const path = 'my_source_file.js';
-      const line = 123;
-      const location = {
-        changeNum,
-        patchNum,
-        path,
-        line,
-      };
-      const key = element._getDraftKey(location);
-      element.setDraftComment(location, 'my comment');
-      assert.isTrue(element._exceededQuota);
-      assert.isNotOk(element._storage.getItem(key));
-    });
-
-    test('editable content items', () => {
-      const cleanupStub = sandbox.stub(element, '_cleanupItems');
-      const key = 'testKey';
-      const computedKey = element._getEditableContentKey(key);
-      // Key correctly computed.
-      assert.equal(computedKey, 'editablecontent:testKey');
-
-      element.setEditableContentItem(key, 'my content');
-
-      // Setting the draft stores it under the expected key.
-      let item = element._storage.getItem(computedKey);
-      assert.isOk(item);
-      assert.equal(JSON.parse(item).message, 'my content');
-      assert.isOk(JSON.parse(item).updated);
-
-      // getEditableContentItem performs as expected.
-      item = element.getEditableContentItem(key);
-      assert.isOk(item);
-      assert.equal(item.message, 'my content');
-      assert.isOk(item.updated);
-      assert.isTrue(cleanupStub.called);
-
-      // eraseEditableContentItem performs as expected.
-      element.eraseEditableContentItem(key);
-      assert.isNotOk(element._storage.getItem(computedKey));
-    });
+  setup(() => {
+    element = fixture('basic');
+    sandbox = sinon.sandbox.create();
+    element._storage = mockStorage();
   });
+
+  teardown(() => sandbox.restore());
+
+  test('storing, retrieving and erasing drafts', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+
+    // The key is in the expected format.
+    const key = element._getDraftKey(location);
+    assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
+
+    // There should be no draft initially.
+    const draft = element.getDraftComment(location);
+    assert.isNotOk(draft);
+
+    // Setting the draft stores it under the expected key.
+    element.setDraftComment(location, 'my comment');
+    assert.isOk(element._storage.getItem(key));
+    assert.equal(JSON.parse(element._storage.getItem(key)).message,
+        'my comment');
+    assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
+
+    // Erasing the draft removes the key.
+    element.eraseDraftComment(location);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('automatically removes old drafts', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+
+    const key = element._getDraftKey(location);
+
+    // Make sure that the call to cleanup doesn't get throttled.
+    element._lastCleanup = 0;
+
+    const cleanupSpy = sandbox.spy(element, '_cleanupItems');
+
+    // Create a message with a timestamp that is a second behind the max age.
+    element._storage.setItem(key, JSON.stringify({
+      message: 'old message',
+      updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
+    }));
+
+    // Getting the draft should cause it to be removed.
+    const draft = element.getDraftComment(location);
+
+    assert.isTrue(cleanupSpy.called);
+    assert.isNotOk(draft);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('_getDraftKey', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+    let expectedResult = 'draft:1234:5:my_source_file.js:123';
+    assert.equal(element._getDraftKey(location), expectedResult);
+    location.range = {
+      start_character: 1,
+      start_line: 1,
+      end_character: 1,
+      end_line: 2,
+    };
+    expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
+    assert.equal(element._getDraftKey(location), expectedResult);
+  });
+
+  test('exceeded quota disables storage', () => {
+    element._storage = mockStorage(true);
+    assert.isFalse(element._exceededQuota);
+
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+    const key = element._getDraftKey(location);
+    element.setDraftComment(location, 'my comment');
+    assert.isTrue(element._exceededQuota);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('editable content items', () => {
+    const cleanupStub = sandbox.stub(element, '_cleanupItems');
+    const key = 'testKey';
+    const computedKey = element._getEditableContentKey(key);
+    // Key correctly computed.
+    assert.equal(computedKey, 'editablecontent:testKey');
+
+    element.setEditableContentItem(key, 'my content');
+
+    // Setting the draft stores it under the expected key.
+    let item = element._storage.getItem(computedKey);
+    assert.isOk(item);
+    assert.equal(JSON.parse(item).message, 'my content');
+    assert.isOk(JSON.parse(item).updated);
+
+    // getEditableContentItem performs as expected.
+    item = element.getEditableContentItem(key);
+    assert.isOk(item);
+    assert.equal(item.message, 'my content');
+    assert.isOk(item.updated);
+    assert.isTrue(cleanupStub.called);
+
+    // eraseEditableContentItem performs as expected.
+    element.eraseEditableContentItem(key);
+    assert.isNotOk(element._storage.getItem(computedKey));
+  });
+});
 </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
deleted file mode 100644
index 6803eb9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ /dev/null
@@ -1,112 +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.
--->
-<link rel="import" href="/bower_components/polymer/polymer.html">
-
-<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
-<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
-<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-
-<dom-module id="gr-textarea">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: flex;
-        position: relative;
-      }
-      :host(.monospace) {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-mono);
-        line-height: var(--line-height-mono);
-        font-weight: var(--font-weight-normal);
-      }
-      :host(.code) {
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        line-height: var(--line-height-code);
-        font-weight: var(--font-weight-normal);
-      }
-      #emojiSuggestions {
-        font-family: var(--font-family);
-      }
-      gr-autocomplete {
-        display: inline-block
-      }
-      #textarea {
-        background-color: var(--view-background-color);
-        width: 100%;
-      }
-      #hiddenText #emojiSuggestions {
-        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 */
-        --iron-autogrow-textarea_-_white-space: pre-wrap;
-      }
-      #textarea.noBorder {
-        border: none;
-      }
-      #hiddenText {
-        display: block;
-        float: left;
-        position: absolute;
-        visibility: hidden;
-        width: 100%;
-        white-space: pre-wrap;
-      }
-    </style>
-    <div id="hiddenText"></div>
-    <!-- When the autocomplete is open, the span is moved at the end of
-      hiddenText in order to correctly position the dropdown. After being moved,
-      it is set as the positionTarget for the emojiSuggestions dropdown. -->
-    <span id="caratSpan"></span>
-    <gr-autocomplete-dropdown
-        vertical-align="top"
-        horizontal-align="left"
-        dynamic-align
-        id="emojiSuggestions"
-        suggestions="[[_suggestions]]"
-        index="[[_index]]"
-        vertical-offset="[[_verticalOffset]]"
-        on-dropdown-closed="_resetEmojiDropdown"
-        on-item-selected="_handleEmojiSelect">
-    </gr-autocomplete-dropdown>
-    <iron-autogrow-textarea
-        id="textarea"
-        autocomplete="[[autocomplete]]"
-        placeholder=[[placeholder]]
-        disabled="[[disabled]]"
-        rows="[[rows]]"
-        max-rows="[[maxRows]]"
-        value="{{text}}"
-        on-bind-value-changed="_onValueChanged"></iron-autogrow-textarea>
-    <gr-reporting id="reporting"></gr-reporting>
-  </template>
-  <script src="gr-textarea.js"></script>
-</dom-module>
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..15ab8e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -14,52 +14,75 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  const MAX_ITEMS_DROPDOWN = 10;
+import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
+import '../gr-cursor-manager/gr-cursor-manager.js';
+import '../gr-overlay/gr-overlay.js';
+import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
+import '../../../styles/shared-styles.js';
+import '../../core/gr-reporting/gr-reporting.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-textarea_html.js';
+import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 
-  const ALL_SUGGESTIONS = [
-    {value: '😊', match: 'smile :)'},
-    {value: '👍', match: 'thumbs up'},
-    {value: '😄', match: 'laugh :D'},
-    {value: '🎉', match: 'party'},
-    {value: '😞', match: 'sad :('},
-    {value: '😂', match: 'tears :\')'},
-    {value: '🙏', match: 'pray'},
-    {value: '😐', match: 'neutral :|'},
-    {value: '😮', match: 'shock :O'},
-    {value: '👎', match: 'thumbs down'},
-    {value: '😎', match: 'cool |;)'},
-    {value: '😕', match: 'confused'},
-    {value: '👌', match: 'ok'},
-    {value: '🔥', match: 'fire'},
-    {value: '👊', match: 'fistbump'},
-    {value: '💯', match: '100'},
-    {value: '💔', match: 'broken heart'},
-    {value: '🍺', match: 'beer'},
-    {value: '✔', match: 'check'},
-    {value: '😋', match: 'tongue'},
-    {value: '😭', match: 'crying :\'('},
-    {value: '🐨', match: 'koala'},
-    {value: '🤓', match: 'glasses'},
-    {value: '😆', match: 'grin'},
-    {value: '💩', match: 'poop'},
-    {value: '😢', match: 'tear'},
-    {value: '😒', match: 'unamused'},
-    {value: '😉', match: 'wink ;)'},
-    {value: '🍷', match: 'wine'},
-    {value: '😜', match: 'winking tongue ;)'},
-  ];
+const MAX_ITEMS_DROPDOWN = 10;
 
-  Polymer({
-    is: 'gr-textarea',
+const ALL_SUGGESTIONS = [
+  {value: '😊', match: 'smile :)'},
+  {value: '👍', match: 'thumbs up'},
+  {value: '😄', match: 'laugh :D'},
+  {value: '🎉', match: 'party'},
+  {value: '😞', match: 'sad :('},
+  {value: '😂', match: 'tears :\')'},
+  {value: '🙏', match: 'pray'},
+  {value: '😐', match: 'neutral :|'},
+  {value: '😮', match: 'shock :O'},
+  {value: '👎', match: 'thumbs down'},
+  {value: '😎', match: 'cool |;)'},
+  {value: '😕', match: 'confused'},
+  {value: '👌', match: 'ok'},
+  {value: '🔥', match: 'fire'},
+  {value: '👊', match: 'fistbump'},
+  {value: '💯', match: '100'},
+  {value: '💔', match: 'broken heart'},
+  {value: '🍺', match: 'beer'},
+  {value: '✔', match: 'check'},
+  {value: '😋', match: 'tongue'},
+  {value: '😭', match: 'crying :\'('},
+  {value: '🐨', match: 'koala'},
+  {value: '🤓', match: 'glasses'},
+  {value: '😆', match: 'grin'},
+  {value: '💩', match: 'poop'},
+  {value: '😢', match: 'tear'},
+  {value: '😒', match: 'unamused'},
+  {value: '😉', match: 'wink ;)'},
+  {value: '🍷', match: 'wine'},
+  {value: '😜', match: 'winking tongue ;)'},
+];
 
-    /**
-     * @event bind-value-changed
-     */
+/**
+ * @extends Polymer.Element
+ */
+class GrTextarea extends mixinBehaviors( [
+  KeyboardShortcutBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
 
-    properties: {
+  static get is() { return 'gr-textarea'; }
+  /**
+   * @event bind-value-changed
+   */
+
+  static get properties() {
+    return {
       autocomplete: Boolean,
       disabled: Boolean,
       rows: Number,
@@ -80,12 +103,12 @@
         value: false,
       },
       /** Text input should be rendered in code font, which is smaller than the
-          standard monospace font. */
+        standard monospace font. */
       code: {
         type: Boolean,
         value: false,
       },
-      /** @type(?number) */
+      /** @type {?number} */
       _colonIndex: Number,
       _currentSearchString: {
         type: String,
@@ -103,217 +126,222 @@
         value: 20,
         readOnly: true,
       },
-    },
+    };
+  }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
+  get keyBindings() {
+    return {
       esc: '_handleEscKey',
       tab: '_handleEnterByKey',
       enter: '_handleEnterByKey',
       up: '_handleUpKey',
       down: '_handleDownKey',
-    },
+    };
+  }
 
-    ready() {
-      if (this.monospace) {
-        this.classList.add('monospace');
+  /** @override */
+  ready() {
+    super.ready();
+    if (this.monospace) {
+      this.classList.add('monospace');
+    }
+    if (this.code) {
+      this.classList.add('code');
+    }
+    if (this.hideBorder) {
+      this.$.textarea.classList.add('noBorder');
+    }
+  }
+
+  closeDropdown() {
+    return this.$.emojiSuggestions.close();
+  }
+
+  getNativeTextarea() {
+    return this.$.textarea.textarea;
+  }
+
+  putCursorAtEnd() {
+    const textarea = this.getNativeTextarea();
+    // Put the cursor at the end always.
+    textarea.selectionStart = textarea.value.length;
+    textarea.selectionEnd = textarea.selectionStart;
+    this.async(() => {
+      textarea.focus();
+    });
+  }
+
+  _handleEscKey(e) {
+    if (this._hideAutocomplete) { return; }
+    e.preventDefault();
+    e.stopPropagation();
+    this._resetEmojiDropdown();
+  }
+
+  _handleUpKey(e) {
+    if (this._hideAutocomplete) { return; }
+    e.preventDefault();
+    e.stopPropagation();
+    this.$.emojiSuggestions.cursorUp();
+    this.$.textarea.textarea.focus();
+    this.disableEnterKeyForSelectingEmoji = false;
+  }
+
+  _handleDownKey(e) {
+    if (this._hideAutocomplete) { return; }
+    e.preventDefault();
+    e.stopPropagation();
+    this.$.emojiSuggestions.cursorDown();
+    this.$.textarea.textarea.focus();
+    this.disableEnterKeyForSelectingEmoji = false;
+  }
+
+  _handleEnterByKey(e) {
+    if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+      return;
+    }
+    e.preventDefault();
+    e.stopPropagation();
+    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+  }
+
+  _handleEmojiSelect(e) {
+    this._setEmoji(e.detail.selected.dataset.value);
+  }
+
+  _setEmoji(text) {
+    const colonIndex = this._colonIndex;
+    this.text = this._getText(text);
+    this.$.textarea.selectionStart = colonIndex + 1;
+    this.$.textarea.selectionEnd = colonIndex + 1;
+    this.$.reporting.reportInteraction('select-emoji', {type: text});
+    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
+   * to the end and is set to be the positionTarget for the dropdown. Together
+   * this allows the dropdown to appear near where the user is typing.
+   */
+  _updateCaratPosition() {
+    this._hideAutocomplete = false;
+    this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
+        this.$.textarea.selectionStart);
+
+    const caratSpan = this.$.caratSpan;
+    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
+   * autocomplete options.
+   */
+  _onValueChanged(e) {
+    // Relay the event.
+    this.dispatchEvent(new CustomEvent('bind-value-changed', {
+      detail: e,
+      composed: true, bubbles: true,
+    }));
+
+    // If cursor is not in textarea (just opened with colon as last char),
+    // Don't do anything.
+    if (!e.currentTarget.focused) { return; }
+
+    const charAtCursor = e.detail && e.detail.value ?
+      e.detail.value[this.$.textarea.selectionStart - 1] : '';
+    if (charAtCursor !== ':' && this._colonIndex == null) { return; }
+
+    // When a colon is detected, set a colon index. We are interested only on
+    // colons after space or in beginning of textarea
+    if (charAtCursor === ':') {
+      if (this.$.textarea.selectionStart < 2 ||
+          e.detail.value[this.$.textarea.selectionStart - 2] === ' ') {
+        this._colonIndex = this.$.textarea.selectionStart - 1;
       }
-      if (this.code) {
-        this.classList.add('code');
-      }
-      if (this.hideBorder) {
-        this.$.textarea.classList.add('noBorder');
-      }
-    },
+    }
 
-    closeDropdown() {
-      return this.$.emojiSuggestions.close();
-    },
-
-    getNativeTextarea() {
-      return this.$.textarea.textarea;
-    },
-
-    putCursorAtEnd() {
-      const textarea = this.getNativeTextarea();
-      // Put the cursor at the end always.
-      textarea.selectionStart = textarea.value.length;
-      textarea.selectionEnd = textarea.selectionStart;
-      this.async(() => {
-        textarea.focus();
-      });
-    },
-
-    _handleEscKey(e) {
-      if (this._hideAutocomplete) { return; }
-      e.preventDefault();
-      e.stopPropagation();
+    this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
+        this.$.textarea.selectionStart - this._colonIndex - 1);
+    // Under the following conditions, close and reset the dropdown:
+    // - The cursor is no longer at the end of the current search string
+    // - The search string is an space or new line
+    // - The colon has been removed
+    // - There are no suggestions that match the search string
+    if (this.$.textarea.selectionStart !==
+        this._currentSearchString.length + this._colonIndex + 1 ||
+        this._currentSearchString === ' ' ||
+        this._currentSearchString === '\n' ||
+        !(e.detail.value[this._colonIndex] === ':') ||
+        !this._suggestions.length) {
       this._resetEmojiDropdown();
-    },
+    // Otherwise open the dropdown and set the position to be just below the
+    // cursor.
+    } else if (this.$.emojiSuggestions.isHidden) {
+      this._updateCaratPosition();
+    }
+    this.$.textarea.textarea.focus();
+  }
 
-    _handleUpKey(e) {
-      if (this._hideAutocomplete) { return; }
-      e.preventDefault();
-      e.stopPropagation();
-      this.$.emojiSuggestions.cursorUp();
-      this.$.textarea.textarea.focus();
+  _openEmojiDropdown() {
+    this.$.emojiSuggestions.open();
+    this.$.reporting.reportInteraction('open-emoji-dropdown');
+  }
+
+  _formatSuggestions(matchedSuggestions) {
+    const suggestions = [];
+    for (const suggestion of matchedSuggestions) {
+      suggestion.dataValue = suggestion.value;
+      suggestion.text = suggestion.value + ' ' + suggestion.match;
+      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 => suggestion.match.includes(emojiText))
+          .slice(0, MAX_ITEMS_DROPDOWN);
+      this._formatSuggestions(matches);
       this.disableEnterKeyForSelectingEmoji = false;
-    },
+    }
+  }
 
-    _handleDownKey(e) {
-      if (this._hideAutocomplete) { return; }
-      e.preventDefault();
-      e.stopPropagation();
-      this.$.emojiSuggestions.cursorDown();
-      this.$.textarea.textarea.focus();
-      this.disableEnterKeyForSelectingEmoji = false;
-    },
+  _resetEmojiDropdown() {
+    // hide and reset the autocomplete dropdown.
+    flush();
+    this._currentSearchString = '';
+    this._hideAutocomplete = true;
+    this.closeDropdown();
+    this._colonIndex = null;
+    this.$.textarea.textarea.focus();
+  }
 
-    _handleEnterByKey(e) {
-      if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
-        return;
-      }
-      e.preventDefault();
-      e.stopPropagation();
-      this._setEmoji(this.$.emojiSuggestions.getCurrentText());
-    },
+  _handleTextChanged(text) {
+    this.dispatchEvent(
+        new CustomEvent('value-changed', {detail: {value: text}}));
+  }
+}
 
-    _handleEmojiSelect(e) {
-      this._setEmoji(e.detail.selected.dataset.value);
-    },
-
-    _setEmoji(text) {
-      const colonIndex = this._colonIndex;
-      this.text = this._getText(text);
-      this.$.textarea.selectionStart = colonIndex + 1;
-      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
-     * to the end and is set to be the positionTarget for the dropdown. Together
-     * this allows the dropdown to appear near where the user is typing.
-     */
-    _updateCaratPosition() {
-      this._hideAutocomplete = false;
-      this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
-          this.$.textarea.selectionStart);
-
-      const caratSpan = this.$.caratSpan;
-      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
-     * autocomplete options.
-     */
-    _onValueChanged(e) {
-      // Relay the event.
-      this.fire('bind-value-changed', e);
-
-      // If cursor is not in textarea (just opened with colon as last char),
-      // Don't do anything.
-      if (!e.currentTarget.focused) { return; }
-
-      const charAtCursor = e.detail && e.detail.value ?
-        e.detail.value[this.$.textarea.selectionStart - 1] : '';
-      if (charAtCursor !== ':' && this._colonIndex == null) { return; }
-
-      // When a colon is detected, set a colon index. We are interested only on
-      // colons after space or in beginning of textarea
-      if (charAtCursor === ':') {
-        if (this.$.textarea.selectionStart < 2 ||
-            e.detail.value[this.$.textarea.selectionStart - 2] === ' ') {
-          this._colonIndex = this.$.textarea.selectionStart - 1;
-        }
-      }
-
-      this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
-          this.$.textarea.selectionStart - this._colonIndex - 1);
-      // Under the following conditions, close and reset the dropdown:
-      // - The cursor is no longer at the end of the current search string
-      // - The search string is an space or new line
-      // - The colon has been removed
-      // - There are no suggestions that match the search string
-      if (this.$.textarea.selectionStart !==
-          this._currentSearchString.length + this._colonIndex + 1 ||
-          this._currentSearchString === ' ' ||
-          this._currentSearchString === '\n' ||
-          !(e.detail.value[this._colonIndex] === ':') ||
-          !this._suggestions.length) {
-        this._resetEmojiDropdown();
-      // Otherwise open the dropdown and set the position to be just below the
-      // cursor.
-      } else if (this.$.emojiSuggestions.isHidden) {
-        this._updateCaratPosition();
-      }
-      this.$.textarea.textarea.focus();
-    },
-
-    _openEmojiDropdown() {
-      this.$.emojiSuggestions.open();
-      this.$.reporting.reportInteraction('open-emoji-dropdown');
-    },
-
-    _formatSuggestions(matchedSuggestions) {
-      const suggestions = [];
-      for (const suggestion of matchedSuggestions) {
-        suggestion.dataValue = suggestion.value;
-        suggestion.text = suggestion.value + ' ' + suggestion.match;
-        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);
-        this._formatSuggestions(matches);
-        this.disableEnterKeyForSelectingEmoji = false;
-      }
-    },
-
-    _resetEmojiDropdown() {
-      // hide and reset the autocomplete dropdown.
-      Polymer.dom.flush();
-      this._currentSearchString = '';
-      this._hideAutocomplete = true;
-      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_html.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
new file mode 100644
index 0000000..61e530a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
@@ -0,0 +1,94 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: flex;
+      position: relative;
+    }
+    :host(.monospace) {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      font-weight: var(--font-weight-normal);
+    }
+    :host(.code) {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: var(--line-height-code);
+      font-weight: var(--font-weight-normal);
+    }
+    #emojiSuggestions {
+      font-family: var(--font-family);
+    }
+    gr-autocomplete {
+      display: inline-block;
+    }
+    #textarea {
+      background-color: var(--view-background-color);
+      width: 100%;
+    }
+    #hiddenText #emojiSuggestions {
+      visibility: visible;
+      white-space: normal;
+    }
+    iron-autogrow-textarea {
+      position: relative;
+    }
+    #textarea.noBorder {
+      border: none;
+    }
+    #hiddenText {
+      display: block;
+      float: left;
+      position: absolute;
+      visibility: hidden;
+      width: 100%;
+      white-space: pre-wrap;
+    }
+  </style>
+  <div id="hiddenText"></div>
+  <!-- When the autocomplete is open, the span is moved at the end of
+      hiddenText in order to correctly position the dropdown. After being moved,
+      it is set as the positionTarget for the emojiSuggestions dropdown. -->
+  <span id="caratSpan"></span>
+  <gr-autocomplete-dropdown
+    vertical-align="top"
+    horizontal-align="left"
+    dynamic-align=""
+    id="emojiSuggestions"
+    suggestions="[[_suggestions]]"
+    index="[[_index]]"
+    vertical-offset="[[_verticalOffset]]"
+    on-dropdown-closed="_resetEmojiDropdown"
+    on-item-selected="_handleEmojiSelect"
+  >
+  </gr-autocomplete-dropdown>
+  <iron-autogrow-textarea
+    id="textarea"
+    autocomplete="[[autocomplete]]"
+    placeholder="[[placeholder]]"
+    disabled="[[disabled]]"
+    rows="[[rows]]"
+    max-rows="[[maxRows]]"
+    value="{{text}}"
+    on-bind-value-changed="_onValueChanged"
+  ></iron-autogrow-textarea>
+  <gr-reporting id="reporting"></gr-reporting>
+`;
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..c33b2ae 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
@@ -17,16 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-textarea.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+
 <test-fixture id="basic">
   <template>
     <gr-textarea></gr-textarea>
@@ -45,292 +43,296 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-textarea tests', () => {
-    let element;
-    let sandbox;
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-textarea.js';
+suite('gr-textarea tests', () => {
+  let element;
+  let sandbox;
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      sandbox.stub(element.$.reporting, 'reportInteraction');
-    });
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    sandbox.stub(element.$.reporting, 'reportInteraction');
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('monospace is set properly', () => {
-      assert.isFalse(element.classList.contains('monospace'));
-    });
+  test('monospace is set properly', () => {
+    assert.isFalse(element.classList.contains('monospace'));
+  });
 
-    test('hideBorder is set properly', () => {
-      assert.isFalse(element.$.textarea.classList.contains('noBorder'));
-    });
+  test('hideBorder is set properly', () => {
+    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+  });
 
-    test('emoji selector is not open with the textarea lacks focus', () => {
+  test('emoji selector is not open with the textarea lacks focus', () => {
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector is not open when a general text is entered', () => {
+    MockInteractions.focus(element.$.textarea);
+    element.$.textarea.selectionStart = 9;
+    element.$.textarea.selectionEnd = 9;
+    element.text = 'some text';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector opens when a colon is typed & the textarea has focus',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+
+  test('emoji selector opens when a colon is typed after space',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 2;
+        element.$.textarea.selectionEnd = 2;
+        element.text = ' :';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 1);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+
+  test('emoji selector doesn\`t open when a colon is typed after character',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 5;
+        element.$.textarea.selectionEnd = 5;
+        element.text = 'test:';
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.emojiSuggestions.isHidden);
+        assert.isTrue(element._hideAutocomplete);
+      });
+
+  test('emoji selector opens when a colon is typed and some substring',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        element.$.textarea.selectionStart = 2;
+        element.$.textarea.selectionEnd = 2;
+        element.text = ':t';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, 't');
+      });
+
+  test('emoji selector opens when a colon is typed in middle of text',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        // Since selectionStart is on Chrome set always on end of text, we
+        // stub it to 1
+        const text = ': hello';
+        sandbox.stub(element.$, 'textarea', {
+          selectionStart: 1,
+          value: text,
+          textarea: {
+            focus: () => {},
+          },
+        });
+        element.text = text;
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+  test('emoji selector closes when text changes before the colon', () => {
+    const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
+    MockInteractions.focus(element.$.textarea);
+    flushAsynchronousOperations();
+    element.$.textarea.selectionStart = 10;
+    element.$.textarea.selectionEnd = 10;
+    element.text = 'test test ';
+    element.$.textarea.selectionStart = 12;
+    element.$.textarea.selectionEnd = 12;
+    element.text = 'test test :';
+    element.$.textarea.selectionStart = 15;
+    element.$.textarea.selectionEnd = 15;
+    element.text = 'test test :smi';
+
+    assert.equal(element._currentSearchString, 'smi');
+    assert.isFalse(resetStub.called);
+    element.text = 'test test test :smi';
+    assert.isTrue(resetStub.called);
+  });
+
+  test('_resetEmojiDropdown', () => {
+    const closeSpy = sandbox.spy(element, 'closeDropdown');
+    element._resetEmojiDropdown();
+    assert.equal(element._currentSearchString, '');
+    assert.isTrue(element._hideAutocomplete);
+    assert.equal(element._colonIndex, null);
+
+    element.$.emojiSuggestions.open();
+    flushAsynchronousOperations();
+    element._resetEmojiDropdown();
+    assert.isTrue(closeSpy.called);
+  });
+
+  test('_determineSuggestions', () => {
+    const emojiText = 'tear';
+    const formatSpy = sandbox.spy(element, '_formatSuggestions');
+    element._determineSuggestions(emojiText);
+    assert.isTrue(formatSpy.called);
+    assert.isTrue(formatSpy.lastCall.calledWithExactly(
+        [{dataValue: '😂', value: '😂', match: 'tears :\')',
+          text: '😂 tears :\')'},
+        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+        ]));
+  });
+
+  test('_formatSuggestions', () => {
+    const matchedSuggestions = [{value: '😢', match: 'tear'},
+      {value: '😂', match: 'tears'}];
+    element._formatSuggestions(matchedSuggestions);
+    assert.deepEqual(
+        [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
+          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
+        element._suggestions);
+  });
+
+  test('_handleEmojiSelect', () => {
+    element.$.textarea.selectionStart = 16;
+    element.$.textarea.selectionEnd = 16;
+    element.text = 'test test :tears';
+    element._colonIndex = 10;
+    const selectedItem = {dataset: {value: '😂'}};
+    const event = {detail: {selected: selectedItem}};
+    element._handleEmojiSelect(event);
+    assert.equal(element.text, 'test test 😂');
+  });
+
+  test('_updateCaratPosition', () => {
+    element.$.textarea.selectionStart = 4;
+    element.$.textarea.selectionEnd = 4;
+    element.text = 'test';
+    element._updateCaratPosition();
+    assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
+        element.$.caratSpan.outerHTML);
+  });
+
+  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
+    const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
+    element.$.emojiSuggestions.dispatchEvent(
+        new CustomEvent('dropdown-closed', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(resetSpy.called);
+  });
+
+  test('_onValueChanged fires bind-value-changed', () => {
+    const listenerStub = sinon.stub();
+    const eventObject = {currentTarget: {focused: false}};
+    element.addEventListener('bind-value-changed', listenerStub);
+    element._onValueChanged(eventObject);
+    assert.isTrue(listenerStub.called);
+  });
+
+  suite('keyboard shortcuts', () => {
+    function setupDropdown(callback) {
+      MockInteractions.focus(element.$.textarea);
       element.$.textarea.selectionStart = 1;
       element.$.textarea.selectionEnd = 1;
       element.text = ':';
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
-    });
-
-    test('emoji selector is not open when a general text is entered', () => {
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 9;
-      element.$.textarea.selectionEnd = 9;
-      element.text = 'some text';
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
-    });
-
-    test('emoji selector opens when a colon is typed & the textarea has focus',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 1;
-          element.$.textarea.selectionEnd = 1;
-          element.text = ':';
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 0);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, '');
-        });
-
-    test('emoji selector opens when a colon is typed after space',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 2;
-          element.$.textarea.selectionEnd = 2;
-          element.text = ' :';
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 1);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, '');
-        });
-
-    test('emoji selector doesn\`t open when a colon is typed after character',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 5;
-          element.$.textarea.selectionEnd = 5;
-          element.text = 'test:';
-          flushAsynchronousOperations();
-          assert.isTrue(element.$.emojiSuggestions.isHidden);
-          assert.isTrue(element._hideAutocomplete);
-        });
-
-    test('emoji selector opens when a colon is typed and some substring',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 1;
-          element.$.textarea.selectionEnd = 1;
-          element.text = ':';
-          element.$.textarea.selectionStart = 2;
-          element.$.textarea.selectionEnd = 2;
-          element.text = ':t';
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 0);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, 't');
-        });
-
-    test('emoji selector opens when a colon is typed in middle of text',
-        () => {
-          MockInteractions.focus(element.$.textarea);
-          // Needed for Safari tests. selectionStart is not updated when text is
-          // updated.
-          element.$.textarea.selectionStart = 1;
-          element.$.textarea.selectionEnd = 1;
-          // Since selectionStart is on Chrome set always on end of text, we
-          // stub it to 1
-          const text = ': hello';
-          sandbox.stub(element.$, 'textarea', {
-            selectionStart: 1,
-            value: text,
-            textarea: {
-              focus: () => {},
-            },
-          });
-          element.text = text;
-          flushAsynchronousOperations();
-          assert.isFalse(element.$.emojiSuggestions.isHidden);
-          assert.equal(element._colonIndex, 0);
-          assert.isFalse(element._hideAutocomplete);
-          assert.equal(element._currentSearchString, '');
-        });
-    test('emoji selector closes when text changes before the colon', () => {
-      const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
-      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 2;
+      element.text = ':1';
       flushAsynchronousOperations();
-      element.$.textarea.selectionStart = 10;
-      element.$.textarea.selectionEnd = 10;
-      element.text = 'test test ';
-      element.$.textarea.selectionStart = 12;
-      element.$.textarea.selectionEnd = 12;
-      element.text = 'test test :';
-      element.$.textarea.selectionStart = 15;
-      element.$.textarea.selectionEnd = 15;
-      element.text = 'test test :smi';
+    }
 
-      assert.equal(element._currentSearchString, 'smi');
-      assert.isFalse(resetStub.called);
-      element.text = 'test test test :smi';
-      assert.isTrue(resetStub.called);
-    });
-
-    test('_resetEmojiDropdown', () => {
-      const closeSpy = sandbox.spy(element, 'closeDropdown');
-      element._resetEmojiDropdown();
-      assert.equal(element._currentSearchString, '');
-      assert.isTrue(element._hideAutocomplete);
-      assert.equal(element._colonIndex, null);
-
-      element.$.emojiSuggestions.open();
-      flushAsynchronousOperations();
-      element._resetEmojiDropdown();
-      assert.isTrue(closeSpy.called);
-    });
-
-    test('_determineSuggestions', () => {
-      const emojiText = 'tear';
-      const formatSpy = sandbox.spy(element, '_formatSuggestions');
-      element._determineSuggestions(emojiText);
-      assert.isTrue(formatSpy.called);
-      assert.isTrue(formatSpy.lastCall.calledWithExactly(
-          [{dataValue: '😂', value: '😂', match: 'tears :\')',
-            text: '😂 tears :\')'},
-          {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
-          ]));
-    });
-
-    test('_formatSuggestions', () => {
-      const matchedSuggestions = [{value: '😢', match: 'tear'},
-        {value: '😂', match: 'tears'}];
-      element._formatSuggestions(matchedSuggestions);
-      assert.deepEqual(
-          [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
-            {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
-          element._suggestions);
-    });
-
-    test('_handleEmojiSelect', () => {
-      element.$.textarea.selectionStart = 16;
-      element.$.textarea.selectionEnd = 16;
-      element.text = 'test test :tears';
-      element._colonIndex = 10;
-      const selectedItem = {dataset: {value: '😂'}};
-      const event = {detail: {selected: selectedItem}};
-      element._handleEmojiSelect(event);
-      assert.equal(element.text, 'test test 😂');
-    });
-
-    test('_updateCaratPosition', () => {
-      element.$.textarea.selectionStart = 4;
-      element.$.textarea.selectionEnd = 4;
-      element.text = 'test';
-      element._updateCaratPosition();
-      assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
-          element.$.caratSpan.outerHTML);
-    });
-
-    test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
+    test('escape key', () => {
       const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
-      element.$.emojiSuggestions.fire('dropdown-closed');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      assert.isFalse(resetSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
       assert.isTrue(resetSpy.called);
+      assert.isFalse(!element.$.emojiSuggestions.isHidden);
     });
 
-    test('_onValueChanged fires bind-value-changed', () => {
-      const listenerStub = sinon.stub();
-      const eventObject = {currentTarget: {focused: false}};
-      element.addEventListener('bind-value-changed', listenerStub);
-      element._onValueChanged(eventObject);
-      assert.isTrue(listenerStub.called);
+    test('up key', () => {
+      const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isFalse(upSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isTrue(upSpy.called);
     });
 
-    suite('keyboard shortcuts', () => {
-      function setupDropdown(callback) {
-        MockInteractions.focus(element.$.textarea);
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ':1';
-        flushAsynchronousOperations();
-      }
+    test('down key', () => {
+      const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isFalse(downSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isTrue(downSpy.called);
+    });
 
-      test('escape key', () => {
-        const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-        assert.isFalse(resetSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-        assert.isTrue(resetSpy.called);
-        assert.isFalse(!element.$.emojiSuggestions.isHidden);
-      });
+    test('enter key', () => {
+      const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+          'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isTrue(enterSpy.called);
+      flushAsynchronousOperations();
+      assert.equal(element.text, '💯');
+    });
 
-      test('up key', () => {
-        const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-        assert.isFalse(upSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-        assert.isTrue(upSpy.called);
-      });
-
-      test('down key', () => {
-        const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-        assert.isFalse(downSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-        assert.isTrue(downSpy.called);
-      });
-
-      test('enter key', () => {
-        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
-            'getCursorTarget');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isFalse(enterSpy.called);
-        setupDropdown();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isTrue(enterSpy.called);
-        flushAsynchronousOperations();
-        assert.equal(element.text, '💯');
-      });
-
-      test('enter key - ignored on just colon without more information', () => {
-        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
-            'getCursorTarget');
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isFalse(enterSpy.called);
-        MockInteractions.focus(element.$.textarea);
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        flushAsynchronousOperations();
-        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-        assert.isFalse(enterSpy.called);
-      });
+    test('enter key - ignored on just colon without more information', () => {
+      const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+          'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
     });
   });
 
   suite('gr-textarea monospace', () => {
-    // gr-textarea set monospace class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
+  // gr-textarea set monospace class in the ready() method.
+  // In Polymer2, ready() is called from the fixture(...) method,
+  // If ready() is called again later, some nested elements doesn't
+  // handle it correctly. A separate test-fixture is used to set
+  // properties before ready() is called.
 
     let element;
     let sandbox;
@@ -350,11 +352,11 @@
   });
 
   suite('gr-textarea hideBorder', () => {
-    // gr-textarea set noBorder class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
+  // gr-textarea set noBorder class in the ready() method.
+  // In Polymer2, ready() is called from the fixture(...) method,
+  // If ready() is called again later, some nested elements doesn't
+  // handle it correctly. A separate test-fixture is used to set
+  // properties before ready() is called.
 
     let element;
     let sandbox;
@@ -372,4 +374,5 @@
       assert.isTrue(element.$.textarea.classList.contains('noBorder'));
     });
   });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
deleted file mode 100644
index b4fefe1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
+++ /dev/null
@@ -1,32 +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.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-
-<dom-module id="gr-tooltip-content">
-  <template>
-    <style>
-      .arrow {
-        color: var(--arrow-color);
-      }
-    </style>
-    <slot></slot><!--
- --><span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
-  </template>
-  <script src="gr-tooltip-content.js"></script>
-</dom-module>
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..160f50a 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
@@ -14,34 +14,40 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-tooltip-content',
+import '../gr-icons/gr-icons.js';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-tooltip-content_html.js';
+import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 
-    properties: {
-      title: {
-        type: String,
-        reflectToAttribute: true,
-      },
+/**
+ * @extends Polymer.Element
+ */
+class GrTooltipContent extends mixinBehaviors( [
+  TooltipBehavior,
+], GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement))) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-tooltip-content'; }
+
+  static get properties() {
+    return {
       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_html.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
new file mode 100644
index 0000000..e5a2813
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style>
+    iron-icon {
+      width: var(--line-height-normal);
+      height: var(--line-height-normal);
+      vertical-align: top;
+    }
+  </style>
+  <slot></slot
+  ><!--
+ --><iron-icon icon="gr-icons:info" hidden$="[[!showIcon]]"></iron-icon>
+`;
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..a8fc18a 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
@@ -16,16 +16,13 @@
 limitations under the License.
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-tooltip-content.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,28 +31,31 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip-content tests', () => {
-    let element;
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('icon is not visible by default', () => {
-      assert.equal(Polymer.dom(element.root)
-          .querySelector('.arrow').hidden, true);
-    });
-
-    test('position-below attribute is reflected', () => {
-      assert.isFalse(element.hasAttribute('position-below'));
-      element.positionBelow = true;
-      assert.isTrue(element.hasAttribute('position-below'));
-    });
-
-    test('icon is visible with showIcon property', () => {
-      element.showIcon = true;
-      assert.equal(Polymer.dom(element.root)
-          .querySelector('.arrow').hidden, false);
-    });
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-tooltip-content.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+suite('gr-tooltip-content tests', () => {
+  let element;
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('icon is not visible by default', () => {
+    assert.equal(dom(element.root)
+        .querySelector('iron-icon').hidden, true);
+  });
+
+  test('position-below attribute is reflected', () => {
+    assert.isFalse(element.hasAttribute('position-below'));
+    element.positionBelow = true;
+    assert.isTrue(element.hasAttribute('position-below'));
+  });
+
+  test('icon is visible with showIcon property', () => {
+    element.showIcon = true;
+    assert.equal(dom(element.root)
+        .querySelector('iron-icon').hidden, false);
+  });
+});
 </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
deleted file mode 100644
index 75d9c4b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ /dev/null
@@ -1,71 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-tooltip">
-  <template>
-    <style include="shared-styles">
-      :host {
-        --gr-tooltip-arrow-size: .5em;
-        --gr-tooltip-arrow-center-offset: 0;
-
-        background-color: var(--tooltip-background-color);
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        color: var(--tooltip-text-color);
-        font-size: var(--font-size-small);
-        position: absolute;
-        z-index: 1000;
-        max-width: var(--tooltip-max-width);
-      }
-      :host .tooltip {
-        padding: var(--spacing-m) var(--spacing-l);
-      }
-      :host .arrowPositionBelow,
-      :host([position-below]) .arrowPositionAbove  {
-        display: none;
-      }
-      :host([position-below]) .arrowPositionBelow {
-        display: initial;
-      }
-      .arrow {
-        border-left: var(--gr-tooltip-arrow-size) solid transparent;
-        border-right: var(--gr-tooltip-arrow-size) solid transparent;
-        height: 0;
-        position: absolute;
-        left: calc(50% - var(--gr-tooltip-arrow-size));
-        margin-left: var(--gr-tooltip-arrow-center-offset);
-        width: 0;
-      }
-      .arrowPositionAbove {
-        border-top: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color);
-        bottom: calc(-1 * var(--gr-tooltip-arrow-size));
-      }
-      .arrowPositionBelow {
-        border-bottom: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color);
-        top: calc(-1 * var(--gr-tooltip-arrow-size));
-      }
-    </style>
-    <div class="tooltip">
-      <i class="arrowPositionBelow arrow"></i>
-      [[text]]
-      <i class="arrowPositionAbove arrow"></i>
-    </div>
-  </template>
-  <script src="gr-tooltip.js"></script>
-</dom-module>
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..0cd2d7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -14,13 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function() {
-  'use strict';
+import '../../../scripts/bundled-polymer.js';
 
-  Polymer({
-    is: 'gr-tooltip',
+import '../../../styles/shared-styles.js';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-tooltip_html.js';
 
-    properties: {
+/** @extends Polymer.Element */
+class GrTooltip extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get template() { return htmlTemplate; }
+
+  static get is() { return 'gr-tooltip'; }
+
+  static get properties() {
+    return {
       text: String,
       maxWidth: {
         type: String,
@@ -30,10 +41,12 @@
         type: Boolean,
         reflectToAttribute: true,
       },
-    },
+    };
+  }
 
-    _updateWidth(maxWidth) {
-      this.updateStyles({'--tooltip-max-width': maxWidth});
-    },
-  });
-})();
+  _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_html.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
new file mode 100644
index 0000000..3f02fc5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      --gr-tooltip-arrow-size: 0.5em;
+      --gr-tooltip-arrow-center-offset: 0;
+
+      background-color: var(--tooltip-background-color);
+      box-shadow: var(--elevation-level-2);
+      color: var(--tooltip-text-color);
+      font-size: var(--font-size-small);
+      position: absolute;
+      z-index: 1000;
+      max-width: var(--tooltip-max-width);
+    }
+    :host .tooltip {
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    :host .arrowPositionBelow,
+    :host([position-below]) .arrowPositionAbove {
+      display: none;
+    }
+    :host([position-below]) .arrowPositionBelow {
+      display: initial;
+    }
+    .arrow {
+      border-left: var(--gr-tooltip-arrow-size) solid transparent;
+      border-right: var(--gr-tooltip-arrow-size) solid transparent;
+      height: 0;
+      position: absolute;
+      left: calc(50% - var(--gr-tooltip-arrow-size));
+      margin-left: var(--gr-tooltip-arrow-center-offset);
+      width: 0;
+    }
+    .arrowPositionAbove {
+      border-top: var(--gr-tooltip-arrow-size) solid
+        var(--tooltip-background-color);
+      bottom: calc(-1 * var(--gr-tooltip-arrow-size));
+    }
+    .arrowPositionBelow {
+      border-bottom: var(--gr-tooltip-arrow-size) solid
+        var(--tooltip-background-color);
+      top: calc(-1 * var(--gr-tooltip-arrow-size));
+    }
+  </style>
+  <div class="tooltip">
+    <i class="arrowPositionBelow arrow"></i>
+    [[text]]
+    <i class="arrowPositionAbove arrow"></i>
+  </div>
+`;
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..b69d945 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
@@ -16,16 +16,13 @@
 limitations under the License.
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-tooltip.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -34,30 +31,36 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-tooltip tests', () => {
-    let element;
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('max-width is respected if set', () => {
-      element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
-          ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
-      element.maxWidth = '50px';
-      assert.equal(getComputedStyle(element).width, '50px');
-    });
-
-    test('the correct arrow is displayed', () => {
-      assert.equal(getComputedStyle(element.$$('.arrowPositionBelow')).display,
-          'none');
-      assert.notEqual(getComputedStyle(element.$$('.arrowPositionAbove'))
-          .display, 'none');
-      element.positionBelow = true;
-      assert.notEqual(getComputedStyle(element.$$('.arrowPositionBelow'))
-          .display, 'none');
-      assert.equal(getComputedStyle(element.$$('.arrowPositionAbove'))
-          .display, 'none');
-    });
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './gr-tooltip.js';
+suite('gr-tooltip tests', () => {
+  let element;
+  setup(() => {
+    element = fixture('basic');
   });
+
+  test('max-width is respected if set', () => {
+    element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
+        ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
+    element.maxWidth = '50px';
+    assert.equal(getComputedStyle(element).width, '50px');
+  });
+
+  test('the correct arrow is displayed', () => {
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionBelow')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionAbove'))
+        .display, 'none');
+    element.positionBelow = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionBelow'))
+        .display, 'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionAbove'))
+        .display, 'none');
+  });
+});
 </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
deleted file mode 100644
index 48e488a..0000000
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
+++ /dev/null
@@ -1,87 +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.
--->
-<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<script>
-  (function() {
-    'use strict';
-
-    /**
-     * @param {Object} change A change object resulting from a change detail
-     *     call that includes revision information.
-     */
-    function RevisionInfo(change) {
-      this._change = change;
-    }
-
-    /**
-     * Get the largest number of parents of the commit in any revision. For
-     * example, with normal changes this will always return 1. For merge changes
-     * wherein the revisions are merge commits this will return 2 or potentially
-     * more.
-     *
-     * @return {Number}
-     */
-    RevisionInfo.prototype.getMaxParents = function() {
-      if (!this._change || !this._change.revisions) {
-        return 0;
-      }
-      return Object.values(this._change.revisions)
-          .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 0);
-    };
-
-    /**
-     * Get an object that maps revision numbers to the number of parents of the
-     * commit of that revision.
-     *
-     * @return {!Object}
-     */
-    RevisionInfo.prototype.getParentCountMap = function() {
-      const result = {};
-      if (!this._change || !this._change.revisions) {
-        return {};
-      }
-      Object.values(this._change.revisions)
-          .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
-      return result;
-    };
-
-    /**
-     * @param {number|string} patchNum
-     * @return {number}
-     */
-    RevisionInfo.prototype.getParentCount = function(patchNum) {
-      return this.getParentCountMap()[patchNum];
-    };
-
-    /**
-     * Get the commit ID of the (0-offset) indexed parent in the given revision
-     * number.
-     *
-     * @param {number|string} patchNum
-     * @param {number} parentIndex (0-offset)
-     * @return {string}
-     */
-    RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
-      const rev = Object.values(this._change.revisions).find(rev =>
-        Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum));
-      return rev.commit.parents[parentIndex].commit;
-    };
-
-    window.Gerrit = window.Gerrit || {};
-    window.Gerrit.RevisionInfo = RevisionInfo;
-  })();
-</script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
new file mode 100644
index 0000000..3d9c2bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+
+/**
+ * @constructor
+ * @param {Object} change A change object resulting from a change detail
+ *     call that includes revision information.
+ */
+export function RevisionInfo(change) {
+  this._change = change;
+}
+
+/**
+ * Get the largest number of parents of the commit in any revision. For
+ * example, with normal changes this will always return 1. For merge changes
+ * wherein the revisions are merge commits this will return 2 or potentially
+ * more.
+ *
+ * @return {number}
+ */
+RevisionInfo.prototype.getMaxParents = function() {
+  if (!this._change || !this._change.revisions) {
+    return 0;
+  }
+  return Object.values(this._change.revisions)
+      .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 0);
+};
+
+/**
+ * Get an object that maps revision numbers to the number of parents of the
+ * commit of that revision.
+ *
+ * @return {!Object}
+ */
+RevisionInfo.prototype.getParentCountMap = function() {
+  const result = {};
+  if (!this._change || !this._change.revisions) {
+    return {};
+  }
+  Object.values(this._change.revisions)
+      .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
+  return result;
+};
+
+/**
+ * @param {number|string} patchNum
+ * @return {number}
+ */
+RevisionInfo.prototype.getParentCount = function(patchNum) {
+  return this.getParentCountMap()[patchNum];
+};
+
+/**
+ * Get the commit ID of the (0-offset) indexed parent in the given revision
+ * number.
+ *
+ * @param {number|string} patchNum
+ * @param {number} parentIndex (0-offset)
+ * @return {string}
+ */
+RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
+  const rev = Object.values(this._change.revisions).find(rev =>
+    PatchSetBehavior.patchNumEquals(rev._number, patchNum));
+  return rev.commit.parents[parentIndex].commit;
+};
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..2d89b30 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
@@ -17,72 +17,74 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="revision-info.html">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('revision-info tests', () => {
-    let mockChange;
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
-    setup(() => {
-      mockChange = {
-        revisions: {
-          r1: {_number: 1, commit: {parents: [
-            {commit: 'p1'},
-            {commit: 'p2'},
-            {commit: 'p3'},
-          ]}},
-          r2: {_number: 2, commit: {parents: [
-            {commit: 'p1'},
-            {commit: 'p4'},
-          ]}},
-          r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
-          r4: {_number: 4, commit: {parents: [
-            {commit: 'p2'},
-            {commit: 'p3'},
-          ]}},
-          r5: {_number: 5, commit: {parents: [
-            {commit: 'p5'},
-            {commit: 'p2'},
-            {commit: 'p3'},
-          ]}},
-        },
-      };
-    });
+<script type="module">
+import '../../../test/common-test-setup.js';
+import './revision-info.js';
+import {RevisionInfo} from './revision-info.js';
+suite('revision-info tests', () => {
+  let mockChange;
 
-    test('getMaxParents', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.equal(ri.getMaxParents(), 3);
-    });
-
-    test('getParentCountMap', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
-    });
-
-    test('getParentCount', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentCount(1), 3);
-      assert.deepEqual(ri.getParentCount(3), 1);
-    });
-
-    test('getParentCount', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentCount(1), 3);
-      assert.deepEqual(ri.getParentCount(3), 1);
-    });
-
-    test('getParentId', () => {
-      const ri = new window.Gerrit.RevisionInfo(mockChange);
-      assert.deepEqual(ri.getParentId(1, 2), 'p3');
-      assert.deepEqual(ri.getParentId(2, 1), 'p4');
-      assert.deepEqual(ri.getParentId(3, 0), 'p5');
-    });
+  setup(() => {
+    mockChange = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+        r2: {_number: 2, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p4'},
+        ]}},
+        r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
+        r4: {_number: 4, commit: {parents: [
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+        r5: {_number: 5, commit: {parents: [
+          {commit: 'p5'},
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+      },
+    };
   });
+
+  test('getMaxParents', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.equal(ri.getMaxParents(), 3);
+  });
+
+  test('getParentCountMap', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1), 3);
+    assert.deepEqual(ri.getParentCount(3), 1);
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1), 3);
+    assert.deepEqual(ri.getParentCount(3), 1);
+  });
+
+  test('getParentId', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentId(1, 2), 'p3');
+    assert.deepEqual(ri.getParentId(2, 1), 'p4');
+    assert.deepEqual(ri.getParentId(3, 0), 'p5');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/embed/README.md b/polygerrit-ui/app/embed/README.md
new file mode 100644
index 0000000..bef098b
--- /dev/null
+++ b/polygerrit-ui/app/embed/README.md
@@ -0,0 +1,13 @@
+This folder contains shared components that can be used independently from Gerrit.
+
+### gr-diff
+
+`gr-diff.js` is the `gr-diff` component used in gerrit to render diff. If you want to use it, feel free to import it and use it in your project as:
+
+```
+<gr-diff></gr-diff>
+```
+
+All supported attributes defined in `polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js`, you can pass them by just assigning them to the `gr-app` element.
+
+To customize the style of the diff, you can use `css variables`, all supported varibled defined in `polygerrit-ui/app/styles/themes/app-theme.html` and `polygerrit-ui/app/styles/themes/dark-theme.html`.
diff --git a/polygerrit-ui/app/embed/app-context-init.js b/polygerrit-ui/app/embed/app-context-init.js
new file mode 100644
index 0000000..55a5866
--- /dev/null
+++ b/polygerrit-ui/app/embed/app-context-init.js
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {appContext} from '../services/app-context.js';
+
+class MockFlagsService {
+  isEnabled(experimentId) {
+    return false;
+  }
+
+  /**
+   * @returns {string[]} array of all enabled experiments.
+   */
+  get enabledExperiments() {
+    return [];
+  }
+}
+
+// Setup mocks for appContext.
+// This is a temporary solution
+// TODO(dmfilippov): find a better solution for gr-diff
+export function initDiffAppContext() {
+  appContext.flagsService = new MockFlagsService();
+}
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/gr-diff.html b/polygerrit-ui/app/embed/gr-diff.html
deleted file mode 100644
index f5f74bd..0000000
--- a/polygerrit-ui/app/embed/gr-diff.html
+++ /dev/null
@@ -1,21 +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.
--->
-<script>
-  window.Gerrit = window.Gerrit || {};
-</script>
-<link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
-<link rel="import" href="../elements/diff/gr-diff-cursor/gr-diff-cursor.html">
diff --git a/polygerrit-ui/app/embed/gr-diff.js b/polygerrit-ui/app/embed/gr-diff.js
new file mode 100644
index 0000000..a8b7e03
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+window.Gerrit = window.Gerrit || {};
+import '../elements/diff/gr-diff/gr-diff.js';
+import '../elements/diff/gr-diff-cursor/gr-diff-cursor.js';
+import {initDiffAppContext} from './app-context-init.js';
+import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line.js';
+import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation.js';
+
+// Setup appContext for diff.
+// TODO (dmfilippov): find a better solution
+initDiffAppContext();
+// Setup global variables for existing usages of this component
+window.GrDiffLine = GrDiffLine;
+window.GrAnnotation = GrAnnotation;
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/gr-diff/gr-diff-root.html b/polygerrit-ui/app/gr-diff/gr-diff-root.html
deleted file mode 100644
index b3f0d34..0000000
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.html
+++ /dev/null
@@ -1,4 +0,0 @@
-<script>
-  window.Gerrit = window.Gerrit || {};
-</script>
-<link rel="import" href="../elements/diff/gr-diff/gr-diff.html">
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.js b/polygerrit-ui/app/gr-diff/gr-diff-root.js
new file mode 100644
index 0000000..bb5d602
--- /dev/null
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+window.Gerrit = window.Gerrit || {};
+import '../elements/diff/gr-diff/gr-diff.js';
diff --git a/polygerrit-ui/app/lint_test.sh b/polygerrit-ui/app/lint_test.sh
deleted file mode 100755
index f8e233d8..0000000
--- a/polygerrit-ui/app/lint_test.sh
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/bin/sh
-
-set -ex
-
-npm_bin=$(which npm) && true
-if [ -z "$npm_bin" ]; then
-    echo "NPM must be on the path."
-    exit 1
-fi
-
-eslint_bin=$(which eslint) && true
-eslint_config=$(npm list -g | grep -c eslint-config-google) && true
-eslint_plugin=$(npm list -g | grep -c eslint-plugin-html) && true
-if [ -z "$eslint_bin" ] || [ "$eslint_config" -eq "0" ] || [ "$eslint_plugin" -eq "0" ]; then
-    echo "You must install ESLint and its dependencies from NPM."
-    echo "> npm install -g eslint eslint-config-google eslint-plugin-html"
-    echo "For more information, view the README:"
-    echo "https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/#Style-guide"
-    exit 1
-fi
-
-# get absolute path to lint_test.sh path
-SCRIPT=$(readlink -f "$0")
-UI_PATH=$(dirname "$SCRIPT")
-
-# To make sure npm link happens in the right place
-cd ${UI_PATH}
-
-# Linking global eslint packages to the local project. Required to use eslint plugins with a global
-# 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}
diff --git a/polygerrit-ui/app/node_modules_licenses/.gitignore b/polygerrit-ui/app/node_modules_licenses/.gitignore
new file mode 100644
index 0000000..6a3417b
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/.gitignore
@@ -0,0 +1 @@
+/out/
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
new file mode 100644
index 0000000..92a3db8
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -0,0 +1,60 @@
+load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("//tools/node_tools/node_modules_licenses:node_modules_licenses.bzl", "node_modules_licenses")
+
+filegroup(
+    name = "licenses-texts",
+    srcs = glob(["licenses/*.txt"]),
+)
+
+ts_library(
+    name = "licenses-config",
+    srcs = [
+        "licenses.ts",
+    ],
+    compiler = "//tools/node_tools:tsc_wrapped-bin",
+    node_modules = "@tools_npm//:node_modules",
+    tsconfig = "tsconfig.json",
+    deps = [
+        "//tools/node_tools/node_modules_licenses:licenses-map",
+        "@tools_npm//@types/node",
+    ],
+)
+
+# (TODO)dmfilippov Find a better way to fix it (another workaround or submit a bug to
+# plugin's authors or to a ts_config rule author).
+# The following genrule is a workaround for a bazel intellij plugin's bug.
+# According to the documentation, the ts_config_rules section should be added
+# to a .bazelproject file if a project uses typescript
+# (https://ij.bazel.build/docs/dynamic-languages-typescript.html)
+# Unfortunately, this doesn't work. It seems, that the plugin expects some output from
+# the ts_config rule, but the rule doesn't produce any output.
+# To workaround the issue, the tsconfig_editor genrule was added. The genrule only copies
+# input file to the output file, but this is enough to make bazel plugins works.
+# So, if you have any problem a typescript editor (import errors, types not found, etc...) -
+# try to build this rule from the command line
+# (bazel build tools/node_tools/node_modules/licenses:tsconfig_editor) and then sync bazel project
+# in intellij.
+genrule(
+    name = "tsconfig_editor",
+    srcs = [":tsconfig.json"],
+    outs = ["tsconfig_editor.json"],
+    cmd = "cp $< $@",
+)
+
+# filegroup is enough (instead of rollup-bundle), because we are not going to run licenses.ts file
+filegroup(
+    name = "licenses-config-js",
+    srcs = [":licenses-config"],
+    output_group = "es5_sources",
+)
+
+# Generate polygerrit-licenses.json for files in @ui_npm workspace.
+# For details - see comments for node_modules_licenses rule and
+# tools/node_tools/node_modules_licenses/license-map-generator.ts file
+node_modules_licenses(
+    name = "polygerrit-licenses",
+    licenses_config = "licenses-config-js",
+    licenses_texts = [":licenses-texts"],
+    node_modules = "@ui_npm//:node_modules",
+    visibility = ["//visibility:public"],
+)
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
new file mode 100644
index 0000000..aabaca6
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -0,0 +1,321 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Ugly import path due to the following bugs:
+// https://github.com/bazelbuild/rules_nodejs/issues/1522
+// https://github.com/bazelbuild/rules_nodejs/issues/1380
+import {PackageInfo, LicenseType, LicenseInfo} from "../../../tools/node_tools/node_modules_licenses/package-license-info";
+import * as path from "path";
+
+class LicenseTypes {
+  public static Mit: LicenseType = {
+    name: "MIT",
+    allowed: true
+  };
+  public static Apache2_0: LicenseType = {
+    name: "Apache 2.0",
+    allowed: true
+  };
+
+  public static Bsd3: LicenseType = {
+    name: "BSD-3-Clause",
+    allowed: true
+  };
+}
+
+/** List of licenses texts. Add the licenses here if there is no text file with license
+ * in package. For details - see comments for {@link LicenseInfo} and {@link PackageInfo} */
+class SharedLicenses {
+  public static Polymer2014: LicenseInfo = {
+    name: "Polymer-2014",
+    type: LicenseTypes.Bsd3,
+    sharedLicenseFile: "polymer-2014.txt",
+  };
+
+  public static Polymer2015: LicenseInfo = {
+    name: "Polymer-2015",
+    type: LicenseTypes.Bsd3,
+    sharedLicenseFile: "polymer-2015.txt",
+  };
+
+  public static Polymer2017: LicenseInfo = {
+    name: "Polymer-2017",
+    type: LicenseTypes.Bsd3,
+    sharedLicenseFile: "polymer-2017.txt",
+  };
+
+  public static Polymer2018: LicenseInfo = {
+    name: "Polymer-2018",
+    type: LicenseTypes.Bsd3,
+    sharedLicenseFile: "polymer-2018.txt",
+  };
+
+  public static IsArray: LicenseInfo = {
+    name: "isarray",
+    type: LicenseTypes.Mit,
+    sharedLicenseFile: "isarray.txt"
+  };
+
+  public static Page: LicenseInfo = {
+    name: "page",
+    type: LicenseTypes.Mit,
+    sharedLicenseFile: "page.txt"
+  }
+}
+
+const fontsRobotoFilter = (fileName: string) =>
+    fileName.startsWith("fonts/roboto/") && path.basename(fileName) !== "DESCRIPTION.en_us.html";
+
+const fontsRobotomonoFilter = (fileName: string) =>
+    fileName.startsWith("fonts/robotomono/") && path.basename(fileName) !== "DESCRIPTION.en_us.html";
+
+
+const packages: PackageInfo[] = [
+  {
+    name: "@polymer/font-roboto",
+    license: SharedLicenses.Polymer2015,
+  },
+  {
+    name: "@polymer/font-roboto-local",
+    license: SharedLicenses.Polymer2015,
+    filesFilter: fileName => !fontsRobotoFilter(fileName) && !fontsRobotomonoFilter(fileName)
+  },
+  {
+    name: "@polymer/font-roboto-local",
+    license: {
+      name: "font-roboto-local-fonts-roboto",
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: "fonts/roboto/LICENSE.txt"
+    },
+    filesFilter: fontsRobotoFilter
+  },
+  {
+    name: "@polymer/font-roboto-local",
+    license: {
+      name: "font-roboto-local-fonts-robotomono",
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: "fonts/robotomono/LICENSE.txt"
+    },
+    filesFilter: fontsRobotomonoFilter
+  },
+  {
+    name: "@polymer/iron-a11y-announcer",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-a11y-keys-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-autogrow-textarea",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-behaviors",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-checked-element-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-dropdown",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-fit-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-flex-layout",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-form-element-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-icon",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-iconset-svg",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-input",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-menu-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-meta",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-overlay-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-resizable-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-selector",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/iron-validatable-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/neon-animation",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-behaviors",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-button",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-dialog",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-dialog-behavior",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-dialog-scrollable",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-icon-button",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-input",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-item",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-listbox",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-ripple",
+    license: SharedLicenses.Polymer2014
+  },
+  {
+    name: "@polymer/paper-styles",
+    license: SharedLicenses.Polymer2014
+  },
+  {
+    name: "@polymer/paper-tabs",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-toggle-button",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/polymer",
+    license: SharedLicenses.Polymer2017
+  },
+  {
+    name: "@webcomponents/shadycss",
+    license: SharedLicenses.Polymer2017
+  },
+  {
+    name: "@webcomponents/webcomponentsjs",
+    license: SharedLicenses.Polymer2018
+  },
+  {
+    name: "ba-linkify",
+    license: {
+      name: "ba-linkify",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE-MIT",
+    }
+  },
+  {
+    name: "es6-promise",
+    license: {
+      name: "es6-promise",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
+    name: "isarray",
+    license: SharedLicenses.IsArray
+  },
+  {
+    name: "moment",
+    license: {
+      name: "moment",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
+    name: "page",
+    license: SharedLicenses.Page
+  },
+  {
+    name: "shadow-selection-polyfill",
+    license: {
+      name: "shadow-selection-polyfill",
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
+    name: "path-to-regexp",
+    license: {
+      name: "path-to-regexp",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
+    name: "polymer-resin",
+    license: SharedLicenses.Polymer2018
+  },
+  {
+    name: "polymer-bridges",
+    license: SharedLicenses.Polymer2018
+  },
+  {
+    name: "whatwg-fetch",
+    license: {
+      name: "whatwg-fetch",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  }
+];
+
+export default packages;
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/isarray.txt b/polygerrit-ui/app/node_modules_licenses/licenses/isarray.txt
new file mode 100644
index 0000000..f42263e
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/isarray.txt
@@ -0,0 +1,21 @@
+(MIT)
+
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/page.txt b/polygerrit-ui/app/node_modules_licenses/licenses/page.txt
new file mode 100644
index 0000000..f527394
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/page.txt
@@ -0,0 +1,22 @@
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2014.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2014.txt
new file mode 100644
index 0000000..9a35430
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2014.txt
@@ -0,0 +1,34 @@
+Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2015.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2015.txt
new file mode 100644
index 0000000..d19f02f
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2015.txt
@@ -0,0 +1,34 @@
+Copyright (c) 2015 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2017.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2017.txt
new file mode 100644
index 0000000..440f70f
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2017.txt
@@ -0,0 +1,34 @@
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2018.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2018.txt
new file mode 100644
index 0000000..9381974
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2018.txt
@@ -0,0 +1,34 @@
+Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/node_modules_licenses/tsconfig.json b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
new file mode 100644
index 0000000..6f4254f
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "target": "es6",
+    "module": "commonjs",
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "strict": true,
+    "moduleResolution": "node",
+    "outDir": "out",
+    "types": ["node"]
+  },
+  "include": ["**/*.ts"]
+}
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
new file mode 100644
index 0000000..2032430
--- /dev/null
+++ b/polygerrit-ui/app/package.json
@@ -0,0 +1,39 @@
+{
+  "name": "polygerrit-ui-dependencies",
+  "description": "Gerrit Code Review - Polygerrit dependencies",
+  "browser": true,
+  "dependencies": {
+    "@polymer/font-roboto-local": "^3.0.2",
+    "@polymer/iron-a11y-keys-behavior": "^3.0.1",
+    "@polymer/iron-autogrow-textarea": "^3.0.1",
+    "@polymer/iron-dropdown": "^3.0.1",
+    "@polymer/iron-fit-behavior": "^3.0.1",
+    "@polymer/iron-icon": "^3.0.1",
+    "@polymer/iron-iconset-svg": "^3.0.1",
+    "@polymer/iron-input": "^3.0.1",
+    "@polymer/iron-overlay-behavior": "^3.0.2",
+    "@polymer/iron-selector": "^3.0.1",
+    "@polymer/paper-button": "^3.0.1",
+    "@polymer/paper-dialog": "^3.0.1",
+    "@polymer/paper-dialog-behavior": "^3.0.1",
+    "@polymer/paper-dialog-scrollable": "^3.0.1",
+    "@polymer/paper-input": "^3.0.2",
+    "@polymer/paper-item": "^3.0.1",
+    "@polymer/paper-listbox": "^3.0.1",
+    "@polymer/paper-tabs": "^3.1.0",
+    "@polymer/paper-toggle-button": "^3.0.1",
+    "@polymer/polymer": "^3.3.0",
+    "@webcomponents/shadycss": "^1.9.2",
+    "@webcomponents/webcomponentsjs": "^1.3.3",
+    "es6-promise": "^3.3.1",
+    "moment": "^2.24.0",
+    "page": "^1.11.5",
+    "polymer-bridges": "file:../../polymer-bridges/",
+    "ba-linkify": "file:../../lib/ba-linkify/src/",
+    "polymer-resin": "^2.0.1",
+    "whatwg-fetch": "^3.0.0",
+    "shadow-selection-polyfill": "^1.1.0"
+  },
+  "license": "Apache-2.0",
+  "private": true
+}
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
index f6880a1..bc06c1c 100755
--- a/polygerrit-ui/app/polylint_test.sh
+++ b/polygerrit-ui/app/polylint_test.sh
@@ -2,19 +2,12 @@
 
 set -ex
 
-npm_bin=$(which npm)
-if [[ -z "$npm_bin" ]]; then
-    echo "NPM must be on the path."
-    exit 1
-fi
+DIR=$(pwd)
+ln -s $RUNFILES_DIR/ui_npm/node_modules $TEST_TMPDIR/node_modules
+cp $2 $TEST_TMPDIR/polymer.json
+cp -R -L polygerrit-ui/app/* $TEST_TMPDIR
 
-npx_bin=$(which npx)
-if [[ -z "$npx_bin" ]]; then
-    echo "NPX must be on the path."
-    echo "> npm i -g npx"
-    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 $TEST_TMPDIR/
+$DIR/$1 lint --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/rollup.config.js b/polygerrit-ui/app/rollup.config.js
new file mode 100644
index 0000000..cfc8c01
--- /dev/null
+++ b/polygerrit-ui/app/rollup.config.js
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const path = require('path');
+
+// In this file word "plugin" refers to rollup plugin, not Gerrit plugin.
+// By default, require(plugin_name) tries to find module plugin_name starting
+// from the folder where this file (rollup.config.js) is located
+// (see https://www.typescriptlang.org/docs/handbook/module-resolution.html#node
+// and https://nodejs.org/api/modules.html#modules_all_together).
+// So, rollup.config.js can't be in polygerrit-ui/app dir and it should be in
+// tools/node_tools directory (where all plugins are installed).
+// But rollup_bundle rule copy this .config.js file to another directory,
+// so require(plugin_name) can't find a plugin.
+// To fix it, requirePlugin tries:
+// 1. resolve module id using default behavior, i.e. it starts from __dirname
+// 2. if module not found - it tries to resolve module starting from rollupBin
+//    location.
+// This workaround also gives us additional power - we can place .config.js
+// file anywhere in a source tree and add all plugins in the same package.json
+// file as rollup node module.
+function requirePlugin(id) {
+  const rollupBinDir = path.dirname(process.argv[1]);
+  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  return require(pluginPath);
+}
+
+const resolve = requirePlugin('rollup-plugin-node-resolve');
+const {terser} = requirePlugin('rollup-plugin-terser');
+
+// @polymer/font-roboto-local uses import.meta.url value
+// as a base path to fonts. We should substitute a correct javascript
+// code to get a base path for font-roboto-local fonts.
+const importLocalFontMetaUrlResolver = function() {
+  return {
+    name: 'import-meta-url-resolver',
+    resolveImportMeta: function (property, data) {
+      if(property === 'url' && data.moduleId.endsWith('/@polymer/font-roboto-local/roboto.js')) {
+        return 'new URL("..", document.baseURI).href';
+      }
+      return null;
+    }
+  }
+};
+
+export default {
+  treeshake: false,
+  onwarn: warning => {
+    if(warning.code === 'CIRCULAR_DEPENDENCY') {
+      // Temporary allow CIRCULAR_DEPENDENCY.
+      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=12090
+      // Delete this code after bug is fixed.
+      return;
+    }
+    // No warnings from rollupjs are allowed.
+    // Most of the warnings are real error in our code (for example,
+    // if some import couldn't be resolved we can't continue, but rollup
+    // reports it as a warning)
+    throw new Error(warning.message);
+  },
+  output: {
+    format: 'iife',
+    compact: true,
+    plugins: [
+      terser({
+        output: {
+          comments: false
+        }
+      })
+    ]
+  },
+  //Context must be set to window to correctly processing global variables
+  context: 'window',
+  plugins: [resolve({
+    customResolveOptions: {
+      moduleDirectory: 'external/ui_npm/node_modules'
+    }
+  }), importLocalFontMetaUrlResolver()],
+};
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 7ef0ee3..9303f2b 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,55 +1,42 @@
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load(
-    "//tools/bzl:js.bzl",
-    "bundle_assets",
-)
+load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
 
-def polygerrit_bundle(name, srcs, outs, app):
-    appName = app.split(".html")[0].split("/").pop()  # eg: gr-app
+def polygerrit_bundle(name, srcs, outs, entry_point):
+    """Build .zip bundle from source code
 
-    closure_js_binary(
-        name = name + "_closure_bin",
-        # Known issue: Closure compilation not compatible with Polymer behaviors.
-        # See: https://github.com/google/closure-compiler/issues/2042
-        compilation_level = "WHITESPACE_ONLY",
-        defs = [
-            "--polymer_version=2",
-            "--jscomp_off=duplicate",
+    Args:
+        name: rule name
+        srcs: source files
+        outs: array with a single item - the output file name
+        entry_point: application entry-point
+    """
+
+    app_name = entry_point.split(".html")[0].split("/").pop()  # eg: gr-app
+
+    native.filegroup(
+        name = app_name + "-full-src",
+        srcs = srcs + [
+            "@ui_npm//:node_modules",
         ],
-        language = "ECMASCRIPT_2017",
-        deps = [name + "_closure_lib"],
-        dependency_mode = "PRUNE_LEGACY",
     )
 
-    closure_js_library(
-        name = name + "_closure_lib",
-        srcs = [appName + ".js"],
-        convention = "GOOGLE",
-        # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
-        # and remove this supression
-        suppress = [
-            "JSC_JSDOC_MISSING_TYPE_WARNING",
-            "JSC_UNNECESSARY_ESCAPE",
-        ],
+    rollup_bundle(
+        name = app_name + "-bundle-js",
+        srcs = [app_name + "-full-src"],
+        config_file = ":rollup.config.js",
+        entry_point = "elements/" + app_name + ".js",
+        rollup_bin = "//tools/node_tools:rollup-bin",
+        sourcemap = "hidden",
         deps = [
-            "//lib/polymer_externs:polymer_closure",
-            "@io_bazel_rules_closure//closure/library",
+            "@tools_npm//rollup-plugin-node-resolve",
         ],
     )
 
-    bundle_assets(
-        name = appName,
-        srcs = srcs,
-        app = app,
-        deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
-    )
-
     native.filegroup(
         name = name + "_app_sources",
         srcs = [
-            name + "_closure_bin.js",
-            appName + ".html",
+            app_name + "-bundle-js.js",
+            entry_point,
         ],
     )
 
@@ -74,6 +61,8 @@
         ],
     )
 
+    # Preserve bower_components directory in the final directory layout to
+    # avoid plugins break
     genrule2(
         name = name,
         srcs = [
@@ -82,26 +71,85 @@
             name + "_theme_sources",
             name + "_top_sources",
             "//lib/fonts:robotofonts",
-            "//lib/js:highlightjs_files",
-            # we extract from the zip, but depend on the component for license checking.
-            "@webcomponentsjs//:zipfile",
-            "//lib/js:webcomponentsjs",
-            "@font-roboto-local//:zipfile",
-            "//lib/js:font-roboto-local",
+            "//lib/js:highlightjs__files",
+            "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js",
+            "@ui_npm//@polymer/font-roboto-local",
+            "@ui_npm//:node_modules/@polymer/font-roboto-local/package.json",
         ],
         outs = outs,
         cmd = " && ".join([
-            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
-            "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + appName + ".$$ext; done",
+            "FONT_DIR=$$(dirname $(location @ui_npm//:node_modules/@polymer/font-roboto-local/package.json))/fonts",
+            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs},elements}",
+            "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + app_name + ".$$ext; done",
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
             "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
             "for f in $(locations " + name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
-            "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
-            "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
-            "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @font-roboto-local//:zipfile) font-roboto-local/fonts/\\*/\\*.ttf",
+            "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
+            "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
+            "cp $$FONT_DIR/roboto/*.ttf $$TMP/polygerrit_ui/fonts/roboto/",
+            "cp $$FONT_DIR/robotomono/*.ttf $$TMP/polygerrit_ui/fonts/robotomono/",
             "cd $$TMP",
             "find . -exec touch -t 198001010000 '{}' ';'",
             "zip -qr $$ROOT/$@ *",
         ]),
     )
+
+def _wct_test(name, srcs, split_index, split_count):
+    """Macro to define single WCT suite
+
+    Defines a private macro for a portion of test files with split_index.
+    The actual split happens in test/tests.js file
+
+    Args:
+        name: name of generated sh_test
+        srcs: source files
+        split_index: index WCT suite. Must be less than split_count
+        split_count: total number of WCT suites
+    """
+    str_index = str(split_index)
+    config_json = struct(splitIndex = split_index, splitCount = split_count).to_json()
+    native.sh_test(
+        name = name,
+        size = "enormous",
+        srcs = ["wct_test.sh"],
+        args = [
+            "$(location @ui_dev_npm//web-component-tester/bin:wct)",
+            config_json,
+        ],
+        data = [
+            "@ui_dev_npm//web-component-tester/bin:wct",
+        ] + srcs,
+        # Should not run sandboxed.
+        tags = [
+            "local",
+            "manual",
+        ],
+    )
+
+def wct_suite(name, srcs, split_count):
+    """Define test suites for WCT tests.
+
+    All tests files are splited to split_count WCT suites
+
+    Args:
+        name: rule name. The macro create a test suite rule with the name name+"_test"
+        srcs: source files
+        split_count: number of sh_test (i.e. WCT suites)
+    """
+    tests = []
+    for i in range(split_count):
+        test_name = "wct_test_" + str(i)
+        _wct_test(test_name, srcs, i, split_count)
+        tests.append(test_name)
+
+    native.test_suite(
+        name = name + "_test",
+        tests = tests,
+        # Setup tags for suite as well.
+        # This excludes tests from the wildcard expansion (//...)
+        tags = [
+            "local",
+            "manual",
+        ],
+    )
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index e9be18d..5f61de7 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -1,37 +1,5 @@
 #!/usr/bin/env bash
 
-npm_bin=$(which npm)
-if [[ -z "$npm_bin" ]]; then
-    echo "NPM must be on the path. (https://www.npmjs.com/)"
-    exit 1
-fi
-
-# From https://www.linuxquestions.org/questions/programming-9/bash-script-return-full-path-and-filename-680368/page3.html
-function abs_path {
-  if [[ -d "$1" ]]
-  then
-      pushd "$1" >/dev/null
-      pwd
-      popd >/dev/null
-  elif [[ -e $1 ]]
-  then
-      pushd "$(dirname "$1")" >/dev/null
-      echo "$(pwd)/$(basename "$1")"
-      popd >/dev/null
-  else
-      echo "$1" does not exist! >&2
-      return 127
-  fi
-}
-wct_bin=$(which wct)
-if [[ -z "$wct_bin" ]]; then
-  wct_bin=$(abs_path ./node_modules/web-component-tester/bin/wct);
-fi
-if [[ -z "$wct_bin" ]]; then
-    echo "wct_bin must be set or WCT locally installed (npm install wct)."
-    exit 1
-fi
-
 bazel_bin=$(which bazelisk 2>/dev/null)
 if [[ -z "$bazel_bin" ]]; then
     echo "Warning: bazelisk is not installed; falling back to bazel."
@@ -42,11 +10,8 @@
 # TODO(hanwen): does $DISPLAY even work on OSX?
 ${bazel_bin} test \
       --test_env="HOME=$HOME" \
-      --test_env="WCT=${wct_bin}" \
       --test_env="WCT_ARGS=${WCT_ARGS}" \
-      --test_env="NPM=${npm_bin}" \
       --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
deleted file mode 100644
index a28c462..0000000
--- a/polygerrit-ui/app/samples/bind-parameters.html
+++ /dev/null
@@ -1,38 +0,0 @@
-<dom-module id="bind-parameters">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerCustomComponent(
-          'change-view-integration', 'my-bind-sample');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="my-bind-sample">
-  <template>
-    Template example: Patchset number [[revision._number]]. <br/>
-    Computed example: [[computedExample]].
-  </template>
-  <script>
-    Polymer({
-      is: 'my-bind-sample',
-
-      properties: {
-        computedExample: {
-          type: String,
-          computed: '_computeExample(revision._number)',
-        },
-      },
-      attached() {
-        this.plugin.attributeHelper(this).bind(
-            'revision', this._onRevisionChanged.bind(this));
-      },
-      _computeExample(value) {
-        if (!value) { return '(empty)'; }
-        return `(patchset ${value} selected)`;
-      },
-      _onRevisionChanged(value) {
-        console.log(`(attributeHelper.bind) revision number: ${value._number}`);
-      },
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
new file mode 100644
index 0000000..8f08e27
--- /dev/null
+++ b/polygerrit-ui/app/samples/bind-parameters.js
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const {Element, html} = Polymer;
+
+class MyBindSample extends Element {
+  static get is() { return 'my-bind-sample'; }
+
+  static get properties() {
+    return {
+      computedExample: {
+        type: String,
+        computed: '_computeExample(revision._number)',
+      },
+      revision: {
+        type: Object,
+        observer: '_onRevisionChanged',
+      },
+    };
+  }
+
+  static get template() {
+    return html`
+    Template example: Patchset number [[revision._number]]. <br/>
+    Computed example: [[computedExample]].
+    `;
+  }
+
+  _computeExample(value) {
+    if (!value) { return '(empty)'; }
+    return `(patchset ${value} selected)`;
+  }
+
+  _onRevisionChanged(value) {
+    console.log(`(attributeHelper.bind) revision number: ${value._number}`);
+  }
+}
+
+// register the custom component
+customElements.define(MyBindSample.is, MyBindSample);
+
+/**
+ * This plugin will add a new section
+ * between the file list and change log with the
+ * `my-bind-sample` component.
+ */
+Gerrit.install(plugin => {
+  // You should see the above text with the right revision number shown
+  // between the file list and the change log
+  plugin.registerCustomComponent(
+      'change-view-integration', 'my-bind-sample');
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/coverage-plugin.html b/polygerrit-ui/app/samples/coverage-plugin.html
deleted file mode 100644
index d1d96a8..0000000
--- a/polygerrit-ui/app/samples/coverage-plugin.html
+++ /dev/null
@@ -1,79 +0,0 @@
-<dom-module id="coverage-plugin">
-  <script>
-
-    function populateWithDummyData(coverageData) {
-      coverageData['NewFile'] = {
-        linesMissingCoverage: [1, 2, 3],
-        totalLines: 5,
-        changeNum: 94,
-        patchNum: 2,
-      };
-      coverageData['/COMMIT_MSG'] = {
-        linesMissingCoverage: [3, 4, 7, 14],
-        totalLines: 14,
-        changeNum: 94,
-        patchNum: 2,
-      };
-      coverageData['DEPS'] = {
-        linesMissingCoverage: [3, 4, 7, 14],
-        totalLines: 16,
-        changeNum: 77001,
-        patchNum: 1,
-      };
-      coverageData['go/sklog/sklog.go'] = {
-        linesMissingCoverage: [3, 322, 323, 324],
-        totalLines: 350,
-        changeNum: 85963,
-        patchNum: 13,
-      };
-    }
-
-    Gerrit.install(plugin => {
-      const coverageData = {};
-      let displayCoverage = false;
-      const annotationApi = plugin.annotationApi();
-      const styleApi = plugin.styles();
-
-      const coverageStyle = styleApi.css('background-color: #EF9B9B !important');
-      const emptyStyle = styleApi.css('');
-
-      annotationApi.addLayer(context => {
-        if (Object.keys(coverageData).length === 0) {
-          // Coverage data is not ready yet.
-          return;
-        }
-        const path = context.path;
-        const line = context.line;
-        // Highlight lines missing coverage with this background color if
-        // coverage should be displayed, else do nothing.
-        const annotationStyle = displayCoverage
-          ? coverageStyle
-          : emptyStyle;
-        if (coverageData[path] &&
-              coverageData[path].changeNum === context.changeNum &&
-              coverageData[path].patchNum === context.patchNum) {
-          const linesMissingCoverage = coverageData[path].linesMissingCoverage;
-          if (linesMissingCoverage.includes(line.afterNumber)) {
-            context.annotateRange(0, line.text.length, annotationStyle, 'right');
-            context.annotateLineNumber(annotationStyle, 'right');
-          }
-        }
-      }).enableToggleCheckbox('Display Coverage', checkbox => {
-        // Checkbox is attached so now add the notifier that will be controlled
-        // by the checkbox.
-        annotationApi.addNotifier(notifyFunc => {
-          new Promise(resolve => setTimeout(resolve, 3000)).then(() => {
-            populateWithDummyData(coverageData);
-            checkbox.disabled = false;
-            checkbox.onclick = e => {
-              displayCoverage = e.target.checked;
-              Object.keys(coverageData).forEach(file => {
-                notifyFunc(file, 0, coverageData[file].totalLines, 'right');
-              });
-            };
-          });
-        });
-      });
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/samples/coverage-plugin.js b/polygerrit-ui/app/samples/coverage-plugin.js
new file mode 100644
index 0000000..9b2b687
--- /dev/null
+++ b/polygerrit-ui/app/samples/coverage-plugin.js
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+function populateWithDummyData(coverageData) {
+  coverageData['/COMMIT_MSG'] = {
+    linesMissingCoverage: [3, 4, 7, 14],
+    totalLines: 14,
+    changeNum: 94,
+    patchNum: 2,
+  };
+
+  // more coverage info on other files
+}
+
+/**
+ * This plugin will add a toggler on file diff page to
+ * display fake coverage data.
+ *
+ * As the fake coverage data only provided for COMMIT_MSG file,
+ * so it will only work for COMMIT_MSG file diff.
+ */
+Gerrit.install(plugin => {
+  const coverageData = {};
+  let displayCoverage = false;
+  const annotationApi = plugin.annotationApi();
+  const styleApi = plugin.styles();
+
+  const coverageStyle = styleApi.css('background-color: #EF9B9B !important');
+  const emptyStyle = styleApi.css('');
+
+  annotationApi.addLayer(context => {
+    if (Object.keys(coverageData).length === 0) {
+      // Coverage data is not ready yet.
+      return;
+    }
+    const path = context.path;
+    const line = context.line;
+    // Highlight lines missing coverage with this background color if
+    // coverage should be displayed, else do nothing.
+    const annotationStyle = displayCoverage
+      ? coverageStyle
+      : emptyStyle;
+
+    // ideally should check to make sure its the same patch for same change
+    // for demo purpose, this is only checking to make sure we have fake data
+    if (coverageData[path]) {
+      const linesMissingCoverage = coverageData[path].linesMissingCoverage;
+      if (linesMissingCoverage.includes(line.afterNumber)) {
+        context.annotateRange(0, line.text.length, annotationStyle, 'right');
+        context.annotateLineNumber(annotationStyle, 'right');
+      }
+    }
+  }).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 => {
+      populateWithDummyData(coverageData);
+      checkbox.disabled = false;
+      checkbox.onclick = e => {
+        displayCoverage = e.target.checked;
+        Object.keys(coverageData).forEach(file => {
+          notifyFunc(file, 0, coverageData[file].totalLines, 'right');
+        });
+      };
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/lgtm-plugin.html b/polygerrit-ui/app/samples/lgtm-plugin.html
deleted file mode 100644
index d58034d..0000000
--- a/polygerrit-ui/app/samples/lgtm-plugin.html
+++ /dev/null
@@ -1,16 +0,0 @@
-<dom-module id="lgtm-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      const replyApi = plugin.changeReply();
-      replyApi.addReplyTextChangedCallback(text => {
-        const label = 'Code-Review';
-        const labelValue = replyApi.getLabelValue(label);
-        if (labelValue &&
-            labelValue === ' 0' &&
-            text.indexOf('LGTM') === 0) {
-          replyApi.setLabelValue(label, '+1');
-        }
-      });
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/samples/lgtm-plugin.js b/polygerrit-ui/app/samples/lgtm-plugin.js
new file mode 100644
index 0000000..9de1496
--- /dev/null
+++ b/polygerrit-ui/app/samples/lgtm-plugin.js
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * This plugin will +1 on Code-Review label if detect that you have
+ * LGTM as start of your reply.
+ */
+Gerrit.install(plugin => {
+  const replyApi = plugin.changeReply();
+  replyApi.addReplyTextChangedCallback(text => {
+    const label = 'Code-Review';
+    const labelValue = replyApi.getLabelValue(label);
+    if (labelValue &&
+      labelValue === ' 0' &&
+      text.indexOf('LGTM') === 0) {
+      replyApi.setLabelValue(label, '+1');
+    }
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/repo-command.html b/polygerrit-ui/app/samples/repo-command.html
deleted file mode 100644
index 526d350..0000000
--- a/polygerrit-ui/app/samples/repo-command.html
+++ /dev/null
@@ -1,43 +0,0 @@
-<dom-module id="sample-repo-command">
-  <script>
-    Gerrit.install(plugin => {
-      // High-level API
-      plugin.project()
-          .createCommand('Bork', (repoName, projectConfig) => {
-            if (repoName !== 'All-Projects') {
-              return false;
-            }
-          }).onTap(() => {
-            alert('Bork, bork!');
-          });
-
-      // Low-level API
-      plugin.registerCustomComponent(
-          'repo-command', 'repo-command-low');
-    });
-  </script>
-</dom-module>
-
-<!-- Low-level custom component for repo command. -->
-<dom-module id="repo-command-low">
-  <template>
-    <gr-repo-command
-        title="Low-level bork"
-        on-command-tap="_handleCommandTap">
-    </gr-repo-command>
-  </template>
-  <script>
-    Polymer({
-      is: 'repo-command-low',
-
-      attached() {
-        console.log(this.repoName);
-        console.log(this.config);
-        this.hidden = this.repoName !== 'All-Projects';
-      },
-      _handleCommandTap() {
-        alert('(softly) bork, bork.');
-      },
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
new file mode 100644
index 0000000..00f95f5
--- /dev/null
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const {Element, html} = Polymer;
+
+class RepoCommandLow extends Element {
+  static get is() { return 'repo-command-low'; }
+
+  static get properties() {
+    return {
+      rootUrl: String,
+    };
+  }
+
+  static get template() {
+    return html`
+    <gr-repo-command
+      title="Low-level bork"
+      on-command-tap="_handleCommandTap">
+    </gr-repo-command>
+    `;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    console.log(this.repoName);
+    console.log(this.config);
+    this.hidden = this.repoName !== 'All-Projects';
+  }
+
+  _handleCommandTap() {
+    alert('(softly) bork, bork.');
+  }
+}
+
+// register the custom component
+customElements.define(RepoCommandLow.is, RepoCommandLow);
+
+/**
+ * This plugin will add two new commands in command page for
+ * All-Projects.
+ *
+ * The added commands will simply alert you when click.
+ */
+Gerrit.install(plugin => {
+  // High-level API
+  plugin.project()
+      .createCommand('Bork', (repoName, projectConfig) => {
+        if (repoName !== 'All-Projects') {
+          return false;
+        }
+      })
+      .onTap(() => {
+        alert('Bork, bork!');
+      });
+
+  // Low-level API
+  plugin.registerCustomComponent(
+      'repo-command', 'repo-command-low');
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/some-screen.html b/polygerrit-ui/app/samples/some-screen.html
deleted file mode 100644
index da025a2..0000000
--- a/polygerrit-ui/app/samples/some-screen.html
+++ /dev/null
@@ -1,50 +0,0 @@
-<dom-module id="some-screen">
-  <script>
-    Gerrit.install(plugin => {
-      // Recommended approach for screen() API.
-      plugin.screen('main', 'some-screen-main');
-
-      const mainUrl = plugin.screenUrl('main');
-
-      // Support for deprecated screen API.
-      plugin.deprecated.screen('foo', ({token, body, show}) => {
-        body.innerHTML = `This is a plugin screen at ${token}<br/>` +
-            `<a href="${mainUrl}">Go to main plugin screen</a>`;
-        show();
-      });
-
-      // Quick and dirty way to get something on screen.
-      plugin.screen('bar').onAttached(el => {
-        el.innerHTML = `This is a plugin screen at ${el.token}<br/>` +
-            `<a href="${mainUrl}">Go to main plugin screen</a>`;
-      });
-
-      // Add a "Plugin screen" link to the change view screen.
-      plugin.hook('change-metadata-item').onAttached(el => {
-        el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
-      });
-    });
-  </script>
-</dom-module>
-
-<dom-module id="some-screen-main">
-  <template>
-    This is the <b>main</b> plugin screen at [[token]]
-    <ul>
-      <li><a href$="[[rootUrl]]/foo">via deprecated</a></li>
-      <li><a href$="[[rootUrl]]/bar">without component</a></li>
-    </ul>
-  </template>
-  <script>
-    Polymer({
-      is: 'some-screen-main',
-
-      properties: {
-        rootUrl: String,
-      },
-      attached() {
-        this.rootUrl = `${this.plugin.screenUrl()}`;
-      },
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/samples/some-screen.js b/polygerrit-ui/app/samples/some-screen.js
new file mode 100644
index 0000000..09acc81
--- /dev/null
+++ b/polygerrit-ui/app/samples/some-screen.js
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const {Element, html} = Polymer;
+
+class SomeScreenMain extends Element {
+  static get is() { return 'some-screen-main'; }
+
+  static get properties() {
+    return {
+      rootUrl: String,
+    };
+  }
+
+  static get template() {
+    return html`
+      This is the <b>main</b> plugin screen at [[token]]
+      <ul>
+        <li><a href$="[[rootUrl]]/bar">without component</a></li>
+      </ul>
+    `;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.rootUrl = `${this.plugin.screenUrl()}`;
+  }
+}
+
+// register the custom component
+customElements.define(SomeScreenMain.is, SomeScreenMain);
+
+/**
+ * This plugin will add several things to gerrit:
+ * 1. two screens added by this plugin in two different ways
+ * 2. a link in change page under meta info to the added main screen
+ */
+Gerrit.install(plugin => {
+  // Recommended approach for screen() API.
+  plugin.screen('main', 'some-screen-main');
+
+  const mainUrl = plugin.screenUrl('main');
+
+  // Quick and dirty way to get something on screen.
+  plugin.screen('bar').onAttached(el => {
+    el.innerHTML = `This is a plugin screen at ${el.token}<br/>` +
+    `<a href="${mainUrl}">Go to main plugin screen</a>`;
+  });
+
+  // Add a "Plugin screen" link to the change view screen.
+  plugin.hook('change-metadata-item').onAttached(el => {
+    el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/suggest-vote.html b/polygerrit-ui/app/samples/suggest-vote.html
deleted file mode 100644
index 657fa73..0000000
--- a/polygerrit-ui/app/samples/suggest-vote.html
+++ /dev/null
@@ -1,23 +0,0 @@
-<dom-module id="suggested-vote">
-  <script>
-    Gerrit.install(plugin => {
-      const replyApi = plugin.changeReply();
-      let wasSuggested = false;
-      plugin.on('showchange', () => {
-        wasSuggested = false;
-      });
-      const CODE_REVIEW = 'Code-Review';
-      replyApi.addLabelValuesChangedCallback(({name, value}) => {
-        if (wasSuggested && name === CODE_REVIEW) {
-          replyApi.showMessage('');
-          wasSuggested = false;
-        } else if (replyApi.getLabelValue(CODE_REVIEW) === '+1' &&
-            !wasSuggested) {
-          replyApi.setLabelValue(CODE_REVIEW, '+2');
-          replyApi.showMessage(`Suggested ${CODE_REVIEW} upgrade: +2`);
-          wasSuggested = true;
-        }
-      });
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/samples/suggest-vote.js b/polygerrit-ui/app/samples/suggest-vote.js
new file mode 100644
index 0000000..b3c3046
--- /dev/null
+++ b/polygerrit-ui/app/samples/suggest-vote.js
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * This plugin will upgrade your +1 on Code-Review label
+ * to +2 and show a message below the voting labels.
+ */
+Gerrit.install(plugin => {
+  const replyApi = plugin.changeReply();
+  let wasSuggested = false;
+  plugin.on('showchange', () => {
+    wasSuggested = false;
+  });
+  const CODE_REVIEW = 'Code-Review';
+  replyApi.addLabelValuesChangedCallback(({name, value}) => {
+    if (wasSuggested && name === CODE_REVIEW) {
+      replyApi.showMessage('');
+      wasSuggested = false;
+    } else if (replyApi.getLabelValue(CODE_REVIEW) === '+1' &&
+    !wasSuggested) {
+      replyApi.setLabelValue(CODE_REVIEW, '+2');
+      replyApi.showMessage(`Suggested ${CODE_REVIEW} upgrade: +2`);
+      wasSuggested = true;
+    }
+  });
+});
diff --git a/polygerrit-ui/app/samples/theme-plugin.js b/polygerrit-ui/app/samples/theme-plugin.js
new file mode 100644
index 0000000..f3a8931
--- /dev/null
+++ b/polygerrit-ui/app/samples/theme-plugin.js
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const customTheme = document.createElement('dom-module');
+customTheme.id = 'theme-plugin';
+customTheme.innerHTML = `
+  <template>
+    <style>
+    html {
+      --primary-text-color: red;
+    }
+    </style>
+  </template>
+`;
+
+const darkCustomTheme = document.createElement('dom-module');
+darkCustomTheme.id = 'dark-theme-plugin';
+darkCustomTheme.innerHTML = `
+  <template>
+    <style>
+    html {
+      --background-color-primary: yellow;
+    }
+    </style>
+  </template>
+`;
+
+/**
+ * This plugin will change the primary text color to red.
+ *
+ * Also change the primary background color to yellow for dark theme.
+ */
+Gerrit.install(plugin => {
+  plugin.registerStyleModule('app-theme', 'theme-plugin');
+  plugin.registerStyleModule('app-theme-dark', 'dark-theme-plugin');
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.js b/polygerrit-ui/app/scripts/bundled-polymer.js
new file mode 100644
index 0000000..711d587
--- /dev/null
+++ b/polygerrit-ui/app/scripts/bundled-polymer.js
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// This file is a replacement for the
+// polymer-bridges/polymer/polymer.html file. The polymer.html file loads
+// other scripts to setup different global variables. Because polygerrit
+// code still uses global variables (like Polymer.importHref and other),
+// we must setup this global variables after conversion to es6 modules.
+//
+// The bundled-polymer.js imports all scripts in the same order as the
+// polymer.html does and must be imported in all es6-modules instead
+// of the polymer.html file.
+
+import 'polymer-bridges/polymer/lib/utils/boot_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/resolve-url_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/settings_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-module_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/style-gather_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/path_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/case-map_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/async_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/wrap_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/properties-changed_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/property-accessors_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/template-stamp_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/property-effects_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/telemetry_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/properties-mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/debounce_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/gestures_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/gesture-event-listeners_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/dir-mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/render-status_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/unresolved_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/array-splice_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/flattened-nodes-observer_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/flush_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/polymer.dom_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/legacy-element-mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/class_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/polymer-fn_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/mutable-data_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/templatize_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/templatizer-behavior_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-bind_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/html-tag_bridge.js';
+import 'polymer-bridges/polymer/polymer-element_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-repeat_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-if_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/array-selector_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
+import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
+import {importHref} from './import-href.js';
+
+Polymer.importHref = importHref;
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
index 6e503c7..62dc3ee 100644
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
@@ -14,58 +14,58 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+const ANONYMOUS_NAME = 'Anonymous';
 
-  if (window.GrDisplayNameUtils) {
-    return;
+export class GrDisplayNameUtils {
+  static getUserName(config, account) {
+    if (account && account.name) {
+      return account.name;
+    } else if (account && account.username) {
+      return account.username;
+    } else if (account && account.email) {
+      return account.email;
+    } else if (config && config.user &&
+        config.user.anonymous_coward_name !== 'Anonymous Coward') {
+      return config.user.anonymous_coward_name;
+    }
+
+    return ANONYMOUS_NAME;
   }
 
-  const ANONYMOUS_NAME = 'Anonymous';
-
-  class GrDisplayNameUtils {
-    /**
-     * enableEmail when true enables to fallback to using email if
-     * the account name is not avilable.
-     */
-    static getUserName(config, account, enableEmail) {
-      if (account && account.name) {
-        return account.name;
-      } else if (account && account.username) {
-        return account.username;
-      } else if (enableEmail && account && account.email) {
-        return account.email;
-      } else if (config && config.user &&
-          config.user.anonymous_coward_name !== 'Anonymous Coward') {
-        return config.user.anonymous_coward_name;
-      }
-
-      return ANONYMOUS_NAME;
+  static getDisplayName(config, account) {
+    if (account && account.display_name) {
+      return account.display_name;
     }
-
-    static getAccountDisplayName(config, account, enableEmail) {
-      const reviewerName = this._accountOrAnon(config, account, enableEmail);
-      const reviewerEmail = this._accountEmail(account.email);
-      const reviewerStatus = account.status ? '(' + account.status + ')' : '';
-      return [reviewerName, reviewerEmail, reviewerStatus]
-          .filter(p => p.length > 0).join(' ');
+    if (!account || !account.name || !config || !config.accounts) {
+      return this.getUserName(config, account);
     }
-
-    static _accountOrAnon(config, reviewer, enableEmail) {
-      return this.getUserName(config, reviewer, !!enableEmail);
+    if (config.accounts.default_display_name === 'USERNAME'
+        && account.username) {
+      return account.username;
     }
-
-    static _accountEmail(email) {
-      if (typeof email !== 'undefined') {
-        return '<' + email + '>';
-      }
-      return '';
+    if (config.accounts.default_display_name === 'FIRST_NAME') {
+      return account.name.trim().split(' ')[0];
     }
-
-    static getGroupDisplayName(group) {
-      return group.name + ' (group)';
-    }
+    // Treat every other value as FULL_NAME.
+    return account.name;
   }
 
-  window.GrDisplayNameUtils = GrDisplayNameUtils;
-})(window);
+  static getAccountDisplayName(config, account) {
+    const reviewerName = this.getUserName(config, account);
+    const reviewerEmail = this._accountEmail(account.email);
+    const reviewerStatus = account.status ? '(' + account.status + ')' : '';
+    return [reviewerName, reviewerEmail, reviewerStatus]
+        .filter(p => p.length > 0).join(' ');
+  }
+
+  static _accountEmail(email) {
+    if (typeof email !== 'undefined') {
+      return '<' + email + '>';
+    }
+    return '';
+  }
+
+  static getGroupDisplayName(group) {
+    return group.name + ' (group)';
+  }
+}
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..818ddaa 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
@@ -17,124 +17,187 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../test/common-test-setup.html"/>
-<script src="gr-display-name-utils.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>
-  suite('gr-display-name-utils tests', () => {
-    // eslint-disable-next-line no-unused-vars
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+<script type="module">
+import '../../test/common-test-setup.js';
+import {GrDisplayNameUtils} from './gr-display-name-utils.js';
+
+suite('gr-display-name-utils tests', () => {
+  // eslint-disable-next-line no-unused-vars
+  const config = {
+    user: {
+      anonymous_coward_name: 'Anonymous Coward',
+    },
+  };
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
+        'test-name');
+  });
+
+  test('getDisplayName prefer displayName', () => {
+    const account = {
+      name: 'test-name',
+      display_name: 'better-name',
+    };
+    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
+        'better-name');
+  });
+
+  test('getDisplayName prefer username default', () => {
+    const account = {
+      name: 'test-name',
+      username: 'user-name',
+    };
     const config = {
-      user: {
-        anonymous_coward_name: 'Anonymous Coward',
+      accounts: {
+        default_display_name: 'USERNAME',
       },
     };
-
-
-    test('getUserName name only', () => {
-      const account = {
-        name: 'test-name',
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
-          'test-name');
-    });
-
-    test('getUserName username only', () => {
-      const account = {
-        username: 'test-user',
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
-          'test-user');
-    });
-
-    test('getUserName email only', () => {
-      const account = {
-        email: 'test-user@test-url.com',
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
-          'test-user@test-url.com');
-    });
-
-    test('getUserName returns not Anonymous Coward as the anon name', () => {
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
-          'Anonymous');
-    });
-
-    test('getUserName for the config returning the anon name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Test Anon',
-        },
-      };
-      assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
-          'Test Anon');
-    });
-
-    test('getAccountDisplayName - account with name only', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config,
-              {name: 'Some user name'}),
-          'Some user name');
-    });
-
-    test('getAccountDisplayName - account with email only', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config,
-              {email: 'my@example.com'}),
-          'Anonymous <my@example.com>');
-    });
-
-    test('getAccountDisplayName - account with email only - allowEmail', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config,
-              {email: 'my@example.com'}, true),
-          'my@example.com <my@example.com>');
-    });
-
-    test('getAccountDisplayName - account with name and status', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config, {
-            name: 'Some name',
-            status: 'OOO',
-          }),
-          'Some name (OOO)');
-    });
-
-    test('getAccountDisplayName - account with name and email', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config, {
-            name: 'Some name',
-            email: 'my@example.com',
-          }),
-          'Some name <my@example.com>');
-    });
-
-    test('getAccountDisplayName - account with name, email and status', () => {
-      assert.equal(
-          GrDisplayNameUtils.getAccountDisplayName(config, {
-            name: 'Some name',
-            email: 'my@example.com',
-            status: 'OOO',
-          }),
-          'Some name <my@example.com> (OOO)');
-    });
-
-    test('getGroupDisplayName', () => {
-      assert.equal(
-          GrDisplayNameUtils.getGroupDisplayName({name: 'Some user name'}),
-          'Some user name (group)');
-    });
-
-    test('_accountEmail', () => {
-      assert.equal(
-          GrDisplayNameUtils._accountEmail('email@gerritreview.com'),
-          '<email@gerritreview.com>');
-      assert.equal(GrDisplayNameUtils._accountEmail(undefined), '');
-    });
+    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
+        'user-name');
   });
+
+  test('getDisplayName prefer first name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FIRST_NAME',
+      },
+    };
+    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
+        'firstname');
+  });
+
+  test('getDisplayName ignore leading whitespace for first name', () => {
+    const account = {
+      name: '   firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FIRST_NAME',
+      },
+    };
+    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
+        'firstname');
+  });
+
+  test('getDisplayName full name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FULL_NAME',
+      },
+    };
+    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
+        'firstname lastname');
+  });
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
+        'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
+        'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account = {
+      email: 'test-user@test-url.com',
+    };
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
+        'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null),
+        'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
+    const config = {
+      user: {
+        anonymous_coward_name: 'Test Anon',
+      },
+    };
+    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null),
+        'Test Anon');
+  });
+
+  test('getAccountDisplayName - account with name only', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config,
+            {name: 'Some user name'}),
+        'Some user name');
+  });
+
+  test('getAccountDisplayName - account with email only', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config,
+            {email: 'my@example.com'}),
+        'my@example.com <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name and status', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config, {
+          name: 'Some name',
+          status: 'OOO',
+        }),
+        'Some name (OOO)');
+  });
+
+  test('getAccountDisplayName - account with name and email', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+        }),
+        'Some name <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name, email and status', () => {
+    assert.equal(
+        GrDisplayNameUtils.getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+          status: 'OOO',
+        }),
+        'Some name <my@example.com> (OOO)');
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(
+        GrDisplayNameUtils.getGroupDisplayName({name: 'Some user name'}),
+        'Some user name (group)');
+  });
+
+  test('_accountEmail', () => {
+    assert.equal(
+        GrDisplayNameUtils._accountEmail('email@gerritreview.com'),
+        '<email@gerritreview.com>');
+    assert.equal(GrDisplayNameUtils._accountEmail(undefined), '');
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
index 67001d2..2d2deac 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
@@ -14,33 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
 
-  if (window.GrEmailSuggestionsProvider) {
-    return;
+export class GrEmailSuggestionsProvider {
+  constructor(restAPI) {
+    this._restAPI = restAPI;
   }
 
-  class GrEmailSuggestionsProvider {
-    constructor(restAPI) {
-      this._restAPI = restAPI;
-    }
-
-    getSuggestions(input) {
-      return this._restAPI.getSuggestedAccounts(`${input}`)
-          .then(accounts => {
-            if (!accounts) { return []; }
-            return accounts;
-          });
-    }
-
-    makeSuggestionItem(account) {
-      return {
-        name: GrDisplayNameUtils.getAccountDisplayName(null, account, true),
-        value: {account, count: 1},
-      };
-    }
+  getSuggestions(input) {
+    return this._restAPI.getSuggestedAccounts(`${input}`)
+        .then(accounts => {
+          if (!accounts) { return []; }
+          return accounts;
+        });
   }
 
-  window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
-})(window);
+  makeSuggestionItem(account) {
+    return {
+      name: GrDisplayNameUtils.getAccountDisplayName(null, account),
+      value: {account, count: 1},
+    };
+  }
+}
+
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..80d3590 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
@@ -17,19 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
-<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="gr-email-suggestions-provider.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -37,63 +31,67 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('GrEmailSuggestionsProvider tests', () => {
-    let sandbox;
-    let restAPI;
-    let provider;
-    const account1 = {
-      name: 'Some name',
-      email: 'some@example.com',
-    };
-    const account2 = {
-      email: 'other@example.com',
-      _account_id: 3,
-    };
+<script type="module">
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+suite('GrEmailSuggestionsProvider tests', () => {
+  let sandbox;
+  let restAPI;
+  let provider;
+  const account1 = {
+    name: 'Some name',
+    email: 'some@example.com',
+  };
+  const account2 = {
+    email: 'other@example.com',
+    _account_id: 3,
+  };
 
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      restAPI = fixture('basic');
-      provider = new GrEmailSuggestionsProvider(restAPI);
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
+    restAPI = fixture('basic');
+    provider = new GrEmailSuggestionsProvider(restAPI);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('getSuggestions', done => {
-      const getSuggestedAccountsStub =
-          sandbox.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([account1, account2]));
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sandbox.stub(restAPI, 'getSuggestedAccounts')
+            .returns(Promise.resolve([account1, account2]));
 
-      provider.getSuggestions('Some input').then(res => {
-        assert.deepEqual(res, [account1, account2]);
-        assert.isTrue(getSuggestedAccountsStub.calledOnce);
-        assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-        done();
-      });
-    });
-
-    test('makeSuggestionItem', () => {
-      assert.deepEqual(provider.makeSuggestionItem(account1), {
-        name: 'Some name <some@example.com>',
-        value: {
-          account: account1,
-          count: 1,
-        },
-      });
-
-      assert.deepEqual(provider.makeSuggestionItem(account2), {
-        name: 'other@example.com <other@example.com>',
-        value: {
-          account: account2,
-          count: 1,
-        },
-      });
+    provider.getSuggestions('Some input').then(res => {
+      assert.deepEqual(res, [account1, account2]);
+      assert.isTrue(getSuggestedAccountsStub.calledOnce);
+      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+      done();
     });
   });
+
+  test('makeSuggestionItem', () => {
+    assert.deepEqual(provider.makeSuggestionItem(account1), {
+      name: 'Some name <some@example.com>',
+      value: {
+        account: account1,
+        count: 1,
+      },
+    });
+
+    assert.deepEqual(provider.makeSuggestionItem(account2), {
+      name: 'other@example.com <other@example.com>',
+      value: {
+        account: account2,
+        count: 1,
+      },
+    });
+  });
+});
 </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..16b6aae 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
@@ -14,34 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  if (window.GrGroupSuggestionsProvider) {
-    return;
+export class GrGroupSuggestionsProvider {
+  constructor(restAPI) {
+    this._restAPI = restAPI;
   }
 
-  class GrGroupSuggestionsProvider {
-    constructor(restAPI) {
-      this._restAPI = restAPI;
-    }
-
-    getSuggestions(input) {
-      return this._restAPI.getSuggestedGroups(`${input}`)
-          .then(groups => {
-            if (!groups) { return []; }
-            const keys = Object.keys(groups);
-            return keys.map(key => {
-              return Object.assign({}, groups[key], {name: key});
-            });
-          });
-    }
-
-    makeSuggestionItem(suggestion) {
-      return {name: suggestion.name,
-        value: {group: {name: suggestion.name, id: suggestion.id}}};
-    }
+  getSuggestions(input) {
+    return this._restAPI.getSuggestedGroups(`${input}`)
+        .then(groups => {
+          if (!groups) { return []; }
+          const keys = Object.keys(groups);
+          return keys.map(key => Object.assign({}, groups[key], {name: key}));
+        });
   }
 
-  window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
-})(window);
+  makeSuggestionItem(suggestion) {
+    return {name: suggestion.name,
+      value: {group: {name: suggestion.name, id: suggestion.id}}};
+  }
+}
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..2111b7e 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
-<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="gr-group-suggestions-provider.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,71 +31,75 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('GrGroupSuggestionsProvider tests', () => {
-    let sandbox;
-    let restAPI;
-    let provider;
-    const group1 = {
-      name: 'Some name',
-      id: 1,
-    };
-    const group2 = {
-      name: 'Other name',
-      id: 3,
-      url: 'abcd',
-    };
+<script type="module">
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
 
-    setup(() => {
-      sandbox = sinon.sandbox.create();
+suite('GrGroupSuggestionsProvider tests', () => {
+  let sandbox;
+  let restAPI;
+  let provider;
+  const group1 = {
+    name: 'Some name',
+    id: 1,
+  };
+  const group2 = {
+    name: 'Other name',
+    id: 3,
+    url: 'abcd',
+  };
 
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-      });
-      restAPI = fixture('basic');
-      provider = new GrGroupSuggestionsProvider(restAPI);
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
     });
+    restAPI = fixture('basic');
+    provider = new GrGroupSuggestionsProvider(restAPI);
+  });
 
-    teardown(() => {
-      sandbox.restore();
-    });
+  teardown(() => {
+    sandbox.restore();
+  });
 
-    test('getSuggestions', done => {
-      const getSuggestedAccountsStub =
-          sandbox.stub(restAPI, 'getSuggestedGroups')
-              .returns(Promise.resolve({
-                'Some name': {id: 1},
-                'Other name': {id: 3, url: 'abcd'},
-              }));
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sandbox.stub(restAPI, 'getSuggestedGroups')
+            .returns(Promise.resolve({
+              'Some name': {id: 1},
+              'Other name': {id: 3, url: 'abcd'},
+            }));
 
-      provider.getSuggestions('Some input').then(res => {
-        assert.deepEqual(res, [group1, group2]);
-        assert.isTrue(getSuggestedAccountsStub.calledOnce);
-        assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-        done();
-      });
-    });
-
-    test('makeSuggestionItem', () => {
-      assert.deepEqual(provider.makeSuggestionItem(group1), {
-        name: 'Some name',
-        value: {
-          group: {
-            name: 'Some name',
-            id: 1,
-          },
-        },
-      });
-
-      assert.deepEqual(provider.makeSuggestionItem(group2), {
-        name: 'Other name',
-        value: {
-          group: {
-            name: 'Other name',
-            id: 3,
-          },
-        },
-      });
+    provider.getSuggestions('Some input').then(res => {
+      assert.deepEqual(res, [group1, group2]);
+      assert.isTrue(getSuggestedAccountsStub.calledOnce);
+      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+      done();
     });
   });
+
+  test('makeSuggestionItem', () => {
+    assert.deepEqual(provider.makeSuggestionItem(group1), {
+      name: 'Some name',
+      value: {
+        group: {
+          name: 'Some name',
+          id: 1,
+        },
+      },
+    });
+
+    assert.deepEqual(provider.makeSuggestionItem(group2), {
+      name: 'Other name',
+      value: {
+        group: {
+          name: 'Other name',
+          id: 3,
+        },
+      },
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
index ffcdba9..3a47ed3 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
@@ -14,101 +14,93 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
+import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
 
-  if (window.GrReviewerSuggestionsProvider) {
-    return;
+/**
+ * @enum {string}
+ */
+export const SUGGESTIONS_PROVIDERS_USERS_TYPES = {
+  REVIEWER: 'reviewers',
+  CC: 'ccs',
+  ANY: 'any',
+};
+
+export class GrReviewerSuggestionsProvider {
+  static create(restApi, changeNumber, usersType) {
+    switch (usersType) {
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
+        return new GrReviewerSuggestionsProvider(restApi, changeNumber,
+            input => restApi.getChangeSuggestedReviewers(changeNumber,
+                input));
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
+        return new GrReviewerSuggestionsProvider(restApi, changeNumber,
+            input => restApi.getChangeSuggestedCCs(changeNumber, input));
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
+        return new GrReviewerSuggestionsProvider(restApi, changeNumber,
+            input => restApi.getSuggestedAccounts(
+                `cansee:${changeNumber} ${input}`));
+      default:
+        throw new Error(`Unknown users type: ${usersType}`);
+    }
   }
 
-  /**
-   * @enum {string}
-   */
-  Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = {
-    REVIEWER: 'reviewers',
-    CC: 'ccs',
-    ANY: 'any',
-  };
+  constructor(restAPI, changeNumber, apiCall) {
+    this._changeNumber = changeNumber;
+    this._apiCall = apiCall;
+    this._restAPI = restAPI;
+  }
 
-  class GrReviewerSuggestionsProvider {
-    static create(restApi, changeNumber, usersType) {
-      switch (usersType) {
-        case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
-          return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-              input => restApi.getChangeSuggestedReviewers(changeNumber,
-                  input));
-        case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
-          return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-              input => restApi.getChangeSuggestedCCs(changeNumber, input));
-        case Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
-          return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-              input => restApi.getSuggestedAccounts(
-                  `cansee:${changeNumber} ${input}`));
-        default:
-          throw new Error(`Unknown users type: ${usersType}`);
-      }
-    }
-
-    constructor(restAPI, changeNumber, apiCall) {
-      this._changeNumber = changeNumber;
-      this._apiCall = apiCall;
-      this._restAPI = restAPI;
-    }
-
-    init() {
-      if (this._initPromise) {
-        return this._initPromise;
-      }
-      const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-        this._config = cfg;
-      });
-      const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-      this._initPromise = Promise.all([getConfigPromise, getLoggedInPromise])
-          .then(() => {
-            this._initialized = true;
-          });
+  init() {
+    if (this._initPromise) {
       return this._initPromise;
     }
-
-    getSuggestions(input) {
-      if (!this._initialized || !this._loggedIn) {
-        return Promise.resolve([]);
-      }
-
-      return this._apiCall(input)
-          .then(reviewers => (reviewers || []));
-    }
-
-    makeSuggestionItem(suggestion) {
-      if (suggestion.account) {
-        // Reviewer is an account suggestion from getChangeSuggestedReviewers.
-        return {
-          name: GrDisplayNameUtils.getAccountDisplayName(this._config,
-              suggestion.account, false),
-          value: suggestion,
-        };
-      }
-
-      if (suggestion.group) {
-        // Reviewer is a group suggestion from getChangeSuggestedReviewers.
-        return {
-          name: GrDisplayNameUtils.getGroupDisplayName(suggestion.group),
-          value: suggestion,
-        };
-      }
-
-      if (suggestion._account_id) {
-        // Reviewer is an account suggestion from getSuggestedAccounts.
-        return {
-          name: GrDisplayNameUtils.getAccountDisplayName(this._config,
-              suggestion, false),
-          value: {account: suggestion, count: 1},
-        };
-      }
-    }
+    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+    this._initPromise = Promise.all([getConfigPromise, getLoggedInPromise])
+        .then(() => {
+          this._initialized = true;
+        });
+    return this._initPromise;
   }
 
-  window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
-})(window);
+  getSuggestions(input) {
+    if (!this._initialized || !this._loggedIn) {
+      return Promise.resolve([]);
+    }
+
+    return this._apiCall(input)
+        .then(reviewers => (reviewers || []));
+  }
+
+  makeSuggestionItem(suggestion) {
+    if (suggestion.account) {
+      // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+      return {
+        name: GrDisplayNameUtils.getAccountDisplayName(this._config,
+            suggestion.account),
+        value: suggestion,
+      };
+    }
+
+    if (suggestion.group) {
+      // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+      return {
+        name: GrDisplayNameUtils.getGroupDisplayName(suggestion.group),
+        value: suggestion,
+      };
+    }
+
+    if (suggestion._account_id) {
+      // Reviewer is an account suggestion from getSuggestedAccounts.
+      return {
+        name: GrDisplayNameUtils.getAccountDisplayName(this._config,
+            suggestion),
+        value: {account: suggestion, count: 1},
+      };
+    }
+  }
+}
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..8774d48 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
@@ -17,18 +17,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
 <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>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
-<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
-<script src="gr-reviewer-suggestions-provider.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
-<script>void(0);</script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
 
 <test-fixture id="basic">
   <template>
@@ -36,225 +31,231 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('GrReviewerSuggestionsProvider tests', () => {
-    let sandbox;
-    let _nextAccountId = 0;
-    const makeAccount = function(opt_status) {
-      const accountId = ++_nextAccountId;
-      return {
-        _account_id: accountId,
-        name: 'name ' + accountId,
-        email: 'email ' + accountId,
-        status: opt_status,
-      };
+<script type="module">
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
+import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
+
+suite('GrReviewerSuggestionsProvider tests', () => {
+  let sandbox;
+  let _nextAccountId = 0;
+  const makeAccount = function(opt_status) {
+    const accountId = ++_nextAccountId;
+    return {
+      _account_id: accountId,
+      name: 'name ' + accountId,
+      email: 'email ' + accountId,
+      status: opt_status,
     };
-    let _nextAccountId2 = 0;
-    const makeAccount2 = function(opt_status) {
-      const accountId2 = ++_nextAccountId2;
-      return {
-        _account_id: accountId2,
-        name: 'name ' + accountId2,
-        status: opt_status,
-      };
+  };
+  let _nextAccountId2 = 0;
+  const makeAccount2 = function(opt_status) {
+    const accountId2 = ++_nextAccountId2;
+    return {
+      _account_id: accountId2,
+      name: 'name ' + accountId2,
+      status: opt_status,
+    };
+  };
+
+  let owner;
+  let existingReviewer1;
+  let existingReviewer2;
+  let suggestion1;
+  let suggestion2;
+  let suggestion3;
+  let restAPI;
+  let provider;
+
+  let redundantSuggestion1;
+  let redundantSuggestion2;
+  let redundantSuggestion3;
+  let change;
+
+  setup(done => {
+    owner = makeAccount();
+    existingReviewer1 = makeAccount();
+    existingReviewer2 = makeAccount();
+    suggestion1 = {account: makeAccount()};
+    suggestion2 = {account: makeAccount()};
+    suggestion3 = {
+      group: {
+        id: 'suggested group id',
+        name: 'suggested group',
+      },
     };
 
-    let owner;
-    let existingReviewer1;
-    let existingReviewer2;
-    let suggestion1;
-    let suggestion2;
-    let suggestion3;
-    let restAPI;
-    let provider;
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getConfig() { return Promise.resolve({}); },
+    });
 
-    let redundantSuggestion1;
-    let redundantSuggestion2;
-    let redundantSuggestion3;
-    let change;
+    restAPI = fixture('basic');
+    change = {
+      _number: 42,
+      owner,
+      reviewers: {
+        CC: [existingReviewer1],
+        REVIEWER: [existingReviewer2],
+      },
+    };
+    sandbox = sinon.sandbox.create();
+    return flush(done);
+  });
 
+  teardown(() => {
+    sandbox.restore();
+  });
+  suite('allowAnyUser set to false', () => {
     setup(done => {
-      owner = makeAccount();
-      existingReviewer1 = makeAccount();
-      existingReviewer2 = makeAccount();
-      suggestion1 = {account: makeAccount()};
-      suggestion2 = {account: makeAccount()};
-      suggestion3 = {
-        group: {
-          id: 'suggested group id',
-          name: 'suggested group',
-        },
-      };
-
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getConfig() { return Promise.resolve({}); },
+      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+          SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
+      provider.init().then(done);
+    });
+    suite('stubbed values for _getReviewerSuggestions', () => {
+      setup(() => {
+        stub('gr-rest-api-interface', {
+          getChangeSuggestedReviewers() {
+            redundantSuggestion1 = {account: existingReviewer1};
+            redundantSuggestion2 = {account: existingReviewer2};
+            redundantSuggestion3 = {account: owner};
+            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
+              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+          },
+        });
       });
 
-      restAPI = fixture('basic');
-      change = {
-        _number: 42,
-        owner,
-        reviewers: {
-          CC: [existingReviewer1],
-          REVIEWER: [existingReviewer2],
-        },
-      };
-      sandbox = sinon.sandbox.create();
-      return flush(done);
-    });
+      test('makeSuggestionItem formats account or group accordingly', () => {
+        let account = makeAccount();
+        const account3 = makeAccount2();
+        let suggestion = provider.makeSuggestionItem({account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account},
+        });
 
-    teardown(() => {
-      sandbox.restore();
-    });
-    suite('allowAnyUser set to false', () => {
-      setup(done => {
-        provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
-            Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-        provider.init().then(done);
+        const group = {name: 'test'};
+        suggestion = provider.makeSuggestionItem({group});
+        assert.deepEqual(suggestion, {
+          name: group.name + ' (group)',
+          value: {group},
+        });
+
+        suggestion = provider.makeSuggestionItem(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account, count: 1},
+        });
+
+        suggestion = provider.makeSuggestionItem({account: {}});
+        assert.deepEqual(suggestion, {
+          name: 'Anonymous',
+          value: {account: {}},
+        });
+
+        provider._config = {
+          user: {
+            anonymous_coward_name: 'Anonymous Coward Name',
+          },
+        };
+
+        suggestion = provider.makeSuggestionItem({account: {}});
+        assert.deepEqual(suggestion, {
+          name: 'Anonymous Coward Name',
+          value: {account: {}},
+        });
+
+        account = makeAccount('OOO');
+
+        suggestion = provider.makeSuggestionItem({account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account},
+        });
+
+        suggestion = provider.makeSuggestionItem(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account, count: 1},
+        });
+
+        sandbox.stub(GrDisplayNameUtils, '_accountEmail',
+            () => '');
+
+        suggestion = provider.makeSuggestionItem(account3);
+        assert.deepEqual(suggestion, {
+          name: account3.name,
+          value: {account: account3, count: 1},
+        });
       });
-      suite('stubbed values for _getReviewerSuggestions', () => {
-        setup(() => {
-          stub('gr-rest-api-interface', {
-            getChangeSuggestedReviewers() {
-              redundantSuggestion1 = {account: existingReviewer1};
-              redundantSuggestion2 = {account: existingReviewer2};
-              redundantSuggestion3 = {account: owner};
-              return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-            },
-          });
-        });
 
-        test('makeSuggestionItem formats account or group accordingly', () => {
-          let account = makeAccount();
-          const account3 = makeAccount2();
-          let suggestion = provider.makeSuggestionItem({account});
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '>',
-            value: {account},
-          });
+      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);
+      });
 
-          const group = {name: 'test'};
-          suggestion = provider.makeSuggestionItem({group});
-          assert.deepEqual(suggestion, {
-            name: group.name + ' (group)',
-            value: {group},
-          });
-
-          suggestion = provider.makeSuggestionItem(account);
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '>',
-            value: {account, count: 1},
-          });
-
-          suggestion = provider.makeSuggestionItem({account: {}});
-          assert.deepEqual(suggestion, {
-            name: 'Anonymous',
-            value: {account: {}},
-          });
-
-          provider._config = {
-            user: {
-              anonymous_coward_name: 'Anonymous Coward Name',
-            },
-          };
-
-          suggestion = provider.makeSuggestionItem({account: {}});
-          assert.deepEqual(suggestion, {
-            name: 'Anonymous Coward Name',
-            value: {account: {}},
-          });
-
-          account = makeAccount('OOO');
-
-          suggestion = provider.makeSuggestionItem({account});
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '> (OOO)',
-            value: {account},
-          });
-
-          suggestion = provider.makeSuggestionItem(account);
-          assert.deepEqual(suggestion, {
-            name: account.name + ' <' + account.email + '> (OOO)',
-            value: {account, count: 1},
-          });
-
-          sandbox.stub(GrDisplayNameUtils, '_accountEmail',
-              () => {
-                return '';
-              });
-
-          suggestion = provider.makeSuggestionItem(account3);
-          assert.deepEqual(suggestion, {
-            name: account3.name,
-            value: {account: account3, count: 1},
-          });
-        });
-
-        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);
-        });
-
-        test('getSuggestions short circuits when logged out', () => {
-          // API call is already stubbed.
-          const xhrSpy = restAPI.getChangeSuggestedReviewers;
-          provider._loggedIn = false;
+      test('getSuggestions short circuits when logged out', () => {
+        // API call is already stubbed.
+        const xhrSpy = restAPI.getChangeSuggestedReviewers;
+        provider._loggedIn = false;
+        return provider.getSuggestions('').then(() => {
+          assert.isFalse(xhrSpy.called);
+          provider._loggedIn = true;
           return provider.getSuggestions('').then(() => {
-            assert.isFalse(xhrSpy.called);
-            provider._loggedIn = true;
-            return provider.getSuggestions('').then(() => {
-              assert.isTrue(xhrSpy.called);
-            });
+            assert.isTrue(xhrSpy.called);
           });
         });
       });
-
-      test('getChangeSuggestedReviewers is used', done => {
-        const suggestReviewerStub =
-            sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
-                .returns(Promise.resolve([]));
-        const suggestAccountStub =
-            sandbox.stub(restAPI, 'getSuggestedAccounts')
-                .returns(Promise.resolve([]));
-
-        provider.getSuggestions('').then(() => {
-          assert.isTrue(suggestReviewerStub.calledOnce);
-          assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-          assert.isFalse(suggestAccountStub.called);
-          done();
-        });
-      });
     });
 
-    suite('allowAnyUser set to true', () => {
-      setup(done => {
-        provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
-            Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-        provider.init().then(done);
-      });
+    test('getChangeSuggestedReviewers is used', done => {
+      const suggestReviewerStub =
+          sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sandbox.stub(restAPI, 'getSuggestedAccounts')
+              .returns(Promise.resolve([]));
 
-      test('getSuggestedAccounts is used', done => {
-        const suggestReviewerStub =
-            sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
-                .returns(Promise.resolve([]));
-        const suggestAccountStub =
-            sandbox.stub(restAPI, 'getSuggestedAccounts')
-                .returns(Promise.resolve([]));
-
-        provider.getSuggestions('').then(() => {
-          assert.isFalse(suggestReviewerStub.called);
-          assert.isTrue(suggestAccountStub.calledOnce);
-          assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-          done();
-        });
+      provider.getSuggestions('').then(() => {
+        assert.isTrue(suggestReviewerStub.calledOnce);
+        assert.isTrue(suggestReviewerStub.calledWith(42, ''));
+        assert.isFalse(suggestAccountStub.called);
+        done();
       });
     });
   });
+
+  suite('allowAnyUser set to true', () => {
+    setup(done => {
+      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
+      provider.init().then(done);
+    });
+
+    test('getSuggestedAccounts is used', done => {
+      const suggestReviewerStub =
+          sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sandbox.stub(restAPI, 'getSuggestedAccounts')
+              .returns(Promise.resolve([]));
+
+      provider.getSuggestions('').then(() => {
+        assert.isFalse(suggestReviewerStub.called);
+        assert.isTrue(suggestAccountStub.calledOnce);
+        assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
+        done();
+      });
+    });
+  });
+});
 </script>
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.js b/polygerrit-ui/app/scripts/hiddenscroll.js
index c62e6fc..a580b05 100644
--- a/polygerrit-ui/app/scripts/hiddenscroll.js
+++ b/polygerrit-ui/app/scripts/hiddenscroll.js
@@ -15,18 +15,21 @@
  * limitations under the License.
  */
 
-(function(window) {
-  window.Gerrit = window.Gerrit || {};
-  if (window.Gerrit.hasOwnProperty('hiddenscroll')) { return; }
+let hiddenscroll = undefined;
 
-  window.Gerrit.hiddenscroll = undefined;
+window.addEventListener('WebComponentsReady', () => {
+  const elem = document.createElement('div');
+  elem.setAttribute(
+      'style', 'width:100px;height:100px;overflow:scroll');
+  document.body.appendChild(elem);
+  hiddenscroll = elem.offsetWidth === elem.clientWidth;
+  elem.remove();
+});
 
-  window.addEventListener('WebComponentsReady', () => {
-    const elem = document.createElement('div');
-    elem.setAttribute(
-        'style', 'width:100px;height:100px;overflow:scroll');
-    document.body.appendChild(elem);
-    window.Gerrit.hiddenscroll = elem.offsetWidth === elem.clientWidth;
-    elem.remove();
-  });
-})(window);
+export function _setHiddenScroll(value) {
+  hiddenscroll = value;
+}
+
+export function getHiddenScroll() {
+  return hiddenscroll;
+}
diff --git a/polygerrit-ui/app/scripts/import-href.js b/polygerrit-ui/app/scripts/import-href.js
new file mode 100644
index 0000000..6ff40a5
--- /dev/null
+++ b/polygerrit-ui/app/scripts/import-href.js
@@ -0,0 +1,108 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// This file is a replacement for the
+// polymer-bridges/polymer/lib/utils/import-href.html file. The html
+// file contains code inside <script>...</script> and can't be imported
+// in es6 modules.
+
+// run a callback when HTMLImports are ready or immediately if
+// this api is not available.
+function whenImportsReady(cb) {
+  if (window.HTMLImports) {
+    HTMLImports.whenReady(cb);
+  } else {
+    cb();
+  }
+}
+
+/**
+ * Convenience method for importing an HTML document imperatively.
+ *
+ * This method creates a new `<link rel="import">` element with
+ * the provided URL and appends it to the document to start loading.
+ * In the `onload` callback, the `import` property of the `link`
+ * element will contain the imported document contents.
+ *
+ * @memberof Polymer
+ * @param {string} href URL to document to load.
+ * @param {?function(!Event):void=} onload Callback to notify when an import successfully
+ *   loaded.
+ * @param {?function(!ErrorEvent):void=} onerror Callback to notify when an import
+ *   unsuccessfully loaded.
+ * @param {boolean=} optAsync True if the import should be loaded `async`.
+ *   Defaults to `false`.
+ * @return {!HTMLLinkElement} The link element for the URL to be loaded.
+ */
+export function importHref(href, onload, onerror, optAsync) {
+  let link = /** @type {HTMLLinkElement} */
+      (document.head.querySelector('link[href="' + href + '"][import-href]'));
+  if (!link) {
+    link = /** @type {HTMLLinkElement} */ (document.createElement('link'));
+    link.rel = 'import';
+    link.href = href;
+    link.setAttribute('import-href', '');
+  }
+  // always ensure link has `async` attribute if user specified one,
+  // even if it was previously not async. This is considered less confusing.
+  if (optAsync) {
+    link.setAttribute('async', '');
+  }
+  // NOTE: the link may now be in 3 states: (1) pending insertion,
+  // (2) inflight, (3) already loaded. In each case, we need to add
+  // event listeners to process callbacks.
+  const cleanup = function() {
+    link.removeEventListener('load', loadListener);
+    link.removeEventListener('error', errorListener);
+  };
+  const loadListener = function(event) {
+    cleanup();
+    // In case of a successful load, cache the load event on the link so
+    // that it can be used to short-circuit this method in the future when
+    // it is called with the same href param.
+    link.__dynamicImportLoaded = true;
+    if (onload) {
+      whenImportsReady(() => {
+        onload(event);
+      });
+    }
+  };
+  const errorListener = function(event) {
+    cleanup();
+    // In case of an error, remove the link from the document so that it
+    // will be automatically created again the next time `importHref` is
+    // called.
+    if (link.parentNode) {
+      link.parentNode.removeChild(link);
+    }
+    if (onerror) {
+      whenImportsReady(() => {
+        onerror(event);
+      });
+    }
+  };
+  link.addEventListener('load', loadListener);
+  link.addEventListener('error', errorListener);
+  if (link.parentNode == null) {
+    document.head.appendChild(link);
+    // if the link already loaded, dispatch a fake load event
+    // so that listeners are called and get a proper event argument.
+  } else if (link.__dynamicImportLoaded) {
+    link.dispatchEvent(new Event('load'));
+  }
+  return link;
+}
diff --git a/polygerrit-ui/app/scripts/rootElement.js b/polygerrit-ui/app/scripts/rootElement.js
index 619a7b1..4900ba2 100644
--- a/polygerrit-ui/app/scripts/rootElement.js
+++ b/polygerrit-ui/app/scripts/rootElement.js
@@ -15,9 +15,4 @@
  * limitations under the License.
  */
 
-(function(window) {
-  window.Gerrit = window.Gerrit || {};
-  if (window.Gerrit.hasOwnProperty('getRootElement')) { return; }
-
-  window.Gerrit.getRootElement = () => document.body;
-})(window);
+export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/shadow.js b/polygerrit-ui/app/scripts/shadow.js
deleted file mode 100644
index df43766..0000000
--- a/polygerrit-ui/app/scripts/shadow.js
+++ /dev/null
@@ -1,421 +0,0 @@
-/**
- * Copyright 2018 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not
- * use this file except in compliance with the License. You may obtain a copy of
- * the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
- * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
- * License for the specific language governing permissions and limitations under
- * the License.
- */
-
- /* Downloaded from [1] and adapted to be used as HTML-imported JavaScript
-  * rather than import module.
-  * TODO: to be removed when merging to stable-3.2, where the NPM package
-  * management would allow to actually consume the artifact directly without
-  * adaptation.
-  *
-  * [1] https://raw.githubusercontent.com/GoogleChromeLabs/shadow-selection-polyfill/master/shadow.js
-  */
-
-const debug = false;
-
-const hasShadow = 'attachShadow' in Element.prototype && 'getRootNode' in Element.prototype;
-const hasSelection = !!(hasShadow && document.createElement('div').attachShadow({ mode: 'open' }).getSelection);
-const hasShady = window.ShadyDOM && window.ShadyDOM.inUse;
-const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
-  /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
-const useDocument = !hasShadow || hasShady || (!hasSelection && !isSafari);
-
-const invalidPartialElements = /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|script|source|style|template|track|wbr)$/;
-
-const eventName = '-shadow-selectionchange';
-
-
-const validNodeTypes = [Node.ELEMENT_NODE, Node.TEXT_NODE, Node.DOCUMENT_FRAGMENT_NODE];
-function isValidNode(node) {
-  return validNodeTypes.includes(node.nodeType);
-}
-
-
-/**
- * @param {!Selection} s selection to use
- * @param {!Node} node to find caret position within a shadow root
- * @return {!Node|!ShadowRoot}
- */
-function findCaretFocus(s, node) {
-  const pending = [];
-  const pushAll = (nodeList) => {
-    for (let i = 0; i < nodeList.length; ++i) {
-      if (nodeList[i].shadowRoot) {
-        pending.push(nodeList[i].shadowRoot);
-      }
-    }
-  };
-
-  // We're told by Safari that a node containing a child with a Shadow Root is selected, but check
-  // the node directly too (just in case they change their mind later).
-  if (node.shadowRoot) {
-    pending.push(node.shadowRoot);
-  }
-  pushAll(node.childNodes);
-
-  while (pending.length) {
-    const root = pending.shift();
-
-    for (let i = 0; i < root.childNodes.length; ++i) {
-      if (s.containsNode(root.childNodes[i], true)) {
-        return root;
-      }
-    }
-
-    // The selection must be inside a further Shadow Root, but there's no good way to get a list of
-    // them. Safari won't tell you what regular node contains the root which has a selection. So,
-    // unfortunately if you stack them this will be slow(-ish).
-    pushAll(root.querySelectorAll('*'));
-  }
-
-  return null;
-}
-
-
-function findNode(s, parentNode, isLeft) {
-  const nodes = parentNode.childNodes || parentNode.children;
-  if (!nodes) {
-    return parentNode;  // found it, probably text
-  }
-
-  for (let i = 0; i < nodes.length; ++i) {
-    const j = isLeft ? i : (nodes.length - 1 - i);
-    const childNode = nodes[j];
-    if (!isValidNode(childNode)) {
-      continue;
-    }
-
-    debug && console.debug('checking child', childNode, 'IsLeft', isLeft);
-    if (s.containsNode(childNode, true)) {
-      if (s.containsNode(childNode, false)) {
-        debug && console.info('found child', childNode);
-        return childNode;
-      }
-      // Special-case elements that cannot have feasible children.
-      if (!invalidPartialElements.exec(childNode.localName || '')) {
-        debug && console.info('descending child', childNode);
-        return findNode(s, childNode, isLeft);
-      }
-    }
-    debug && console.info(parentNode, 'does NOT contain', childNode);
-  }
-  return parentNode;
-}
-
-
-let recentCaretRange = {node: null, offset: -1};
-
-
-(function() {
-  if (hasSelection || useDocument) {
-    // getSelection exists or document API can be used
-    document.addEventListener('selectionchange', (ev) => {
-      document.dispatchEvent(new CustomEvent(eventName));
-    });
-    return () => {};
-  }
-
-  let withinInternals = false;
-
-  document.addEventListener('selectionchange', (ev) => {
-    if (withinInternals) {
-      return;
-    }
-
-    withinInternals = true;
-
-    const s = window.getSelection();
-    if (s.type === 'Caret') {
-      const root = findCaretFocus(s, s.anchorNode);
-      if (root instanceof window.ShadowRoot) {
-        const range = getRange(root);
-        if (range) {
-          const node = range.startContainer;
-          const offset = range.startOffset;
-          recentCaretRange = {node, offset};
-        }
-      }
-    }
-
-    document.dispatchEvent(new CustomEvent('-shadow-selectionchange'));
-    window.requestAnimationFrame(() => {
-      withinInternals = false;
-    });
-  });
-})();
-
-
-/**
- * @param {!Selection} s the window selection to use
- * @param {!Node} node the node to walk from
- * @param {boolean} walkForward should this walk in natural direction
- * @return {boolean} whether the selection contains the following node (even partially)
- */
-function containsNextElement(s, node, walkForward) {
-  const start = node;
-  while (node = walkFromNode(node, walkForward)) {
-    // walking (left) can contain our own parent, which we don't want
-    if (!node.contains(start)) {
-      break;
-    }
-  }
-  if (!node) {
-    return false;
-  }
-  // we look for Element as .containsNode says true for _every_ text node, and we only care about
-  // elements themselves
-  return node instanceof Element && s.containsNode(node, true);
-}
-
-
-/**
- * @param {!Selection} s the window selection to use
- * @param {!Node} leftNode the left node
- * @param {!Node} rightNode the right node
- * @return {boolean|undefined} whether this has natural direction
- */
-function getSelectionDirection(s, leftNode, rightNode) {
-  if (s.type !== 'Range') {
-    return undefined;  // no direction
-  }
-  const measure = () => s.toString().length;
-
-  const initialSize = measure();
-  debug && console.info(`initial selection: "${s.toString()}"`)
-
-  let updatedSize;
-
-  // Try extending forward and seeing what happens.
-  s.modify('extend', 'forward', 'character');
-  updatedSize = measure();
-  debug && console.info(`forward selection: "${s.toString()}"`)
-
-  if (updatedSize > initialSize || containsNextElement(s, rightNode, true)) {
-    debug && console.info('got forward >, moving right')
-    s.modify('extend', 'backward', 'character');
-    return true;
-  } else if (updatedSize < initialSize || !s.containsNode(leftNode)) {
-    debug && console.info('got forward <, moving left')
-    s.modify('extend', 'backward', 'character');
-    return false;
-  }
-
-  // Maybe we were at the end of something. Extend backwards instead.
-  s.modify('extend', 'backward', 'character');
-  updatedSize = measure();
-  debug && console.info(`backward selection: "${s.toString()}"`)
-
-  if (updatedSize > initialSize || containsNextElement(s, leftNode, false)) {
-    debug && console.info('got backwards >, moving left')
-    s.modify('extend', 'forward', 'character');
-    return false;
-  } else if (updatedSize < initialSize || !s.containsNode(rightNode)) {
-    debug && console.info('got backwards <, moving right')
-    s.modify('extend', 'forward', 'character');
-    return true;
-  }
-
-  // This is likely a select-all.
-  return undefined;
-}
-
-/**
- * Returns the next valid node (element or text). This is needed as Safari doesn't support
- * TreeWalker inside Shadow DOM. Don't escape shadow roots.
- *
- * @param {!Node} node to start from
- * @param {boolean} walkForward should this walk in natural direction
- * @return {Node} node found, if any
- */
-function walkFromNode(node, walkForward) {
-  if (!walkForward) {
-    return node.previousSibling || node.parentNode || null;
-  }
-  while (node) {
-    if (node.nextSibling) {
-      return node.nextSibling;
-    }
-    node = node.parentNode;
-  }
-  return null;
-}
-
-
-const cachedRange = new Map();
-function getRange(root) {
-  if (hasShady) {
-    const s = document.getSelection();
-    return s.rangeCount ? s.getRangeAt(0) : null;
-  } else if (useDocument) {
-    // Document pierces Shadow Root for selection, so actively filter it down to the right node.
-    // This is only for Firefox, which does not allow selection across Shadow Root boundaries.
-    const s = document.getSelection();
-    if (s.containsNode(root, true)) {
-      return s.getRangeAt(0);
-    }
-    return null;
-  } else if (hasSelection) {
-    const s = root.getSelection();
-    return s.rangeCount ? s.getRangeAt(0) : null;
-  }
-
-  const thisFrame = cachedRange.get(root);
-  if (thisFrame) {
-    return thisFrame;
-  }
-
-  const result = internalGetShadowSelection(root);
-
-  cachedRange.set(root, result.range);
-  window.setTimeout(() => {
-    cachedRange.delete(root);
-  }, 0);
-  debug && console.debug('getRange got', result);
-  return result.range;
-}
-
-
-function internalGetShadowSelection(root) {
-  // nb. We used to check whether the selection contained the host, but this broke in Safari 13.
-  // This is "nicely formatted" whitespace as per the browser's renderer. This is fine, and we only
-  // provide selection information at this granularity.
-  const s = window.getSelection();
-
-  if (s.type === 'None') {
-    return {range: null, type: 'none'};
-  } else if (!(s.type === 'Caret' || s.type === 'Range')) {
-    throw new TypeError('unexpected type: ' + s.type);
-  }
-
-  const leftNode = findNode(s, root, true);
-  if (leftNode === root) {
-    return {range: null, mode: 'none'};
-  }
-
-  const range = document.createRange();
-
-  let rightNode = null;
-  let isNaturalDirection = undefined;
-  if (s.type === 'Range') {
-    rightNode = findNode(s, root, false);  // get right node here _before_ getSelectionDirection
-    isNaturalDirection = getSelectionDirection(s, leftNode, rightNode);
-
-    // isNaturalDirection means "going right"
-
-    if (isNaturalDirection === undefined) {
-      // This occurs when we can't move because we can't extend left or right to measure the
-      // direction we're moving in... because it's the entire range. Hooray!
-      range.setStart(leftNode, 0);
-      range.setEnd(rightNode, rightNode.length);
-      return {range, mode: 'all'};
-    }
-  }
-
-  const initialSize = s.toString().length;
-
-  // Dumbest possible approach: remove characters from left side until no more selection,
-  // re-add.
-
-  // Try right side first, as we can trim characters until selection gets shorter.
-
-  let leftOffset = 0;
-  let rightOffset = 0;
-
-  if (rightNode === null) {
-    // This is a caret selection, do nothing.
-  } else if (rightNode.nodeType === Node.TEXT_NODE) {
-    const rightText = rightNode.textContent;
-    const existingNextSibling = rightNode.nextSibling;
-
-    for (let i = rightText.length - 1; i >= 0; --i) {
-      rightNode.splitText(i);
-      const updatedSize = s.toString().length;
-      if (updatedSize !== initialSize) {
-        rightOffset = i + 1;
-        break;
-      }
-    }
-
-    // We don't use .normalize() here, as the user might already have a weird node arrangement
-    // they need to maintain.
-    rightNode.insertData(rightNode.length, rightText.substr(rightNode.length));
-    while (rightNode.nextSibling !== existingNextSibling) {
-      rightNode.nextSibling.remove();
-    }
-  }
-
-  if (leftNode.nodeType === Node.TEXT_NODE) {
-    if (leftNode !== rightNode) {
-      // If we're at the end of a text node, it's impossible to extend the selection, so add an
-      // extra character to select (that we delete later).
-      leftNode.appendData('?');
-      s.collapseToStart();
-      s.modify('extend', 'right', 'character');
-    }
-
-    const leftText = leftNode.textContent;
-    const existingNextSibling = leftNode.nextSibling;
-
-    const start = (leftNode === rightNode ? rightOffset : leftText.length - 1);
-
-    for (let i = start; i >= 0; --i) {
-      leftNode.splitText(i);
-      if (s.toString() === '') {
-        leftOffset = i;
-        break;
-      }
-    }
-
-    // As above, we don't want to use .normalize().
-    leftNode.insertData(leftNode.length, leftText.substr(leftNode.length));
-    while (leftNode.nextSibling !== existingNextSibling) {
-      leftNode.nextSibling.remove();
-    }
-
-    if (leftNode !== rightNode) {
-      leftNode.deleteData(leftNode.length - 1, 1);
-    }
-
-    if (rightNode === null) {
-      rightNode = leftNode;
-      rightOffset = leftOffset;
-    }
-
-  } else if (rightNode === null) {
-    rightNode = leftNode;
-  }
-
-  // Work around common browser bug. Single character selction is always seen as 'forward'. Check
-  // if it's actually supposed to be backward.
-  if (initialSize === 1 && recentCaretRange && recentCaretRange.node === leftNode) {
-    if (recentCaretRange.offset > leftOffset && isNaturalDirection) {
-      isNaturalDirection = false;
-    }
-  }
-
-  if (isNaturalDirection === true) {
-    s.collapse(leftNode, leftOffset);
-    s.extend(rightNode, rightOffset);
-  } else if (isNaturalDirection === false) {
-    s.collapse(rightNode, rightOffset);
-    s.extend(leftNode, leftOffset);
-  } else {
-    s.setPosition(leftNode, leftOffset);
-  }
-
-  range.setStart(leftNode, leftOffset);
-  range.setEnd(rightNode, rightOffset);
-  return {range, mode: 'normal'};
-}
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index e26d6d9..46fa1ad 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -14,20 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window) {
-  'use strict';
 
-  const util = window.util || {};
-
-  util.parseDate = function(dateStr) {
+function getPathFromNode(el) {
+  if (!el.tagName || el.tagName === 'GR-APP'
+      || el instanceof DocumentFragment
+      || el instanceof HTMLSlotElement) {
+    return '';
+  }
+  let path = el.tagName.toLowerCase();
+  if (el.id) path += `#${el.id}`;
+  if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
+  return path;
+}
+// TODO (dmfilippov): Each function must be exported separately. According to
+// the code style guide, a namespacing is not allowed.
+export const util = {
+  parseDate(dateStr) {
     // Timestamps are given in UTC and have the format
     // "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
     // nanoseconds.
     // Munge the date into an ISO 8061 format and parse that.
     return new Date(dateStr.replace(' ', 'T') + 'Z');
-  };
+  },
 
-  util.getCookie = function(name) {
+  getCookie(name) {
     const key = name + '=';
     const cookies = document.cookie.split(';');
     for (let i = 0; i < cookies.length; i++) {
@@ -40,7 +50,7 @@
       }
     }
     return '';
-  };
+  },
 
   /**
    * Make the promise cancelable.
@@ -50,7 +60,7 @@
    * {isCancelled: true} synchronously. If the inner promise for a cancelled
    * promise resolves or rejects this is ignored.
    */
-  util.makeCancelable = promise => {
+  makeCancelable: promise => {
     // True if the promise is either resolved or reject (possibly cancelled)
     let isDone = false;
 
@@ -73,7 +83,7 @@
       isDone = true;
     };
     return wrappedPromise;
-  };
+  },
 
   /**
    * Get computed style value.
@@ -83,7 +93,7 @@
    * Otherwise fallback to native method (in polymer 2).
    *
    */
-  util.getComputedStyleValue = (name, el) => {
+  getComputedStyleValue: (name, el) => {
     let style;
     if (window.ShadyCSS) {
       style = ShadyCSS.getComputedStyleValue(el, name);
@@ -93,7 +103,7 @@
       style = getComputedStyle(el).getPropertyValue(name);
     }
     return style;
-  };
+  },
 
   /**
    * Query selector on a dom element.
@@ -103,9 +113,9 @@
    * multiple shadow hosts.
    *
    */
-  util.querySelector = (el, selector) => {
+  querySelector: (el, selector) => {
     let nodes = [el];
-    let element = null;
+    let result = null;
     while (nodes.length) {
       const node = nodes.pop();
 
@@ -113,20 +123,26 @@
       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));
+      }
+
+      // Add all nodes with shadowRoot and loop through
+      const allShadowNodes = [...node.querySelectorAll('*')]
+          .filter(child => !!child.shadowRoot)
+          .map(child => child.shadowRoot);
+      nodes = nodes.concat(allShadowNodes);
+
+      // Add shadowRoot of current node if has one
+      // as its not included in node.querySelectorAll('*')
+      if (node.shadowRoot) {
         nodes.push(node.shadowRoot);
-      } else {
-        nodes = nodes.concat(Array.from(node.children));
       }
     }
-    return element;
-  };
+    return result;
+  },
 
   /**
    * Query selector all dom elements matching with certain selector.
@@ -137,7 +153,7 @@
    *
    * Note: this can be very expensive, only use when have to.
    */
-  util.querySelectorAll = (el, selector) => {
+  querySelectorAll: (el, selector) => {
     let nodes = [el];
     const results = new Set();
     while (nodes.length) {
@@ -162,7 +178,39 @@
       }
     }
     return [...results];
-  };
+  },
 
-  window.util = util;
-})(window);
+  /**
+   * Retrieves the dom path of the current event.
+   *
+   * If the event object contains a `path` property, then use it,
+   * otherwise, construct the dom path based on the event target.
+   *
+   * @param {!Event} e
+   * @return {string}
+   * @example
+   *
+   * domNode.onclick = e => {
+   *  getEventPath(e); // eg: div.class1>p#pid.class2
+   * }
+   */
+  getEventPath: e => {
+    if (!e) return '';
+
+    let path = e.path;
+    if (!path || !path.length) {
+      path = [];
+      let el = e.target;
+      while (el) {
+        path.push(el);
+        el = el.parentNode || el.host;
+      }
+    }
+
+    return path.reduce((domPath, curEl) => {
+      const pathForEl = getPathFromNode(curEl);
+      if (!pathForEl) return domPath;
+      return domPath ? `${pathForEl}>${domPath}` : pathForEl;
+    }, '');
+  },
+};
diff --git a/polygerrit-ui/app/scripts/util_test.html b/polygerrit-ui/app/scripts/util_test.html
new file mode 100644
index 0000000..a3893d2
--- /dev/null
+++ b/polygerrit-ui/app/scripts/util_test.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2020 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+
+<test-fixture id="basic">
+  <template>
+    <div id="test" class="a b c">
+      <a class="testBtn"></a>
+    </div>
+  </template>
+</test-fixture>
+
+<script type="module">
+  import '../test/common-test-setup.js';
+  import {util} from './util.js';
+  suite('util tests', () => {
+    suite('getEventPath', () => {
+      test('empty event', () => {
+        assert.equal(util.getEventPath(), '');
+        assert.equal(util.getEventPath(null), '');
+        assert.equal(util.getEventPath(undefined), '');
+        assert.equal(util.getEventPath({}), '');
+      });
+
+      test('event with fake path', () => {
+        assert.equal(util.getEventPath({path: []}), '');
+        assert.equal(util.getEventPath({path: [
+          {tagName: 'dd'},
+        ]}), 'dd');
+      });
+
+      test('event with fake complicated path', () => {
+        assert.equal(util.getEventPath({path: [
+          {tagName: 'dd', id: 'test', className: 'a b'},
+          {tagName: 'DIV', id: 'test2', className: 'a b c'},
+        ]}), 'div#test2.a.b.c>dd#test.a.b');
+      });
+
+      test('event with fake target', () => {
+        const fakeTargetParent2 = {
+          tagName: 'DIV', id: 'test2', className: 'a b c',
+        };
+        const fakeTargetParent1 = {
+          parentNode: fakeTargetParent2,
+          tagName: 'dd',
+          id: 'test',
+          className: 'a b',
+        };
+        const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
+        assert.equal(
+            util.getEventPath({target: fakeTarget}),
+            'div#test2.a.b.c>dd#test.a.b>span'
+        );
+      });
+
+      test('event with real click', () => {
+        const element = fixture('basic');
+        const aLink = element.querySelector('a');
+        let path;
+        aLink.onclick = e => path = util.getEventPath(e);
+        MockInteractions.click(aLink);
+        assert.equal(
+            path,
+            'html>body>test-fixture#basic>div#test.a.b.c>a.testBtn'
+        );
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/services/README.md b/polygerrit-ui/app/services/README.md
new file mode 100644
index 0000000..b88532b
--- /dev/null
+++ b/polygerrit-ui/app/services/README.md
@@ -0,0 +1,54 @@
+## What is services folder
+
+'services' folder contains services used across multiple components.
+
+## What should be considered as services in Gerrit UI
+
+Services should be stateful, if its just pure functions, consider having them in 'utils' instead.
+
+Regarding all stateful should be considered as services or not, it's still TBD. Will update as soon
+as it's finalized.
+
+## How to access service
+
+We use AppContext to access instance of service. It helps in mocking service in tests as well.
+We prefer setting instance of service in constructor and then accessing it from variable. We also
+allow access straight from appContext especially in static methods.
+
+```
+import {appContext} from '../../../services/app-context.js';
+
+class T {
+  constructor() {
+    super();
+    this.flagsService = appContext.flagsService;
+  }
+
+  action1() {
+    if (this.flagsService.isEnabled('test)) {
+      // do something
+    }
+  }
+}
+
+staticMethod() {
+  if (appContext.flagsService.isEnabled('test)) {
+    // do something
+  }
+}
+```
+
+## What services we have
+
+### Flags
+
+'flags' is a service to provide easy access to all enabled experiments.
+
+```
+import {appContext} from '../../../services/app-context.js';
+
+// check if an experiment is enabled or not
+if (appContext.flagsService.isEnabled('test')) {
+  // do something
+}
+```
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/app-context-init.js b/polygerrit-ui/app/services/app-context-init.js
new file mode 100644
index 0000000..1c32eee
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init.js
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {appContext} from './app-context.js';
+import {FlagsService} from './flags.js';
+
+const initializedServices = new Map();
+
+function getService(serviceName, serviceInit) {
+  if (!initializedServices[serviceName]) {
+    initializedServices[serviceName] = serviceInit();
+  }
+  return initializedServices[serviceName];
+}
+
+/**
+ * The AppContext lazy initializator for all services
+ */
+export function initAppContext() {
+  const registeredServices = {};
+  function addService(serviceName, serviceCreator) {
+    if (registeredServices[serviceName]) {
+      throw new Error(`Service ${serviceName} already registered.`);
+    }
+    registeredServices[serviceName] = {
+      get() {
+        return getService(serviceName, serviceCreator);
+      },
+    };
+  }
+
+  addService('flagsService', () => new FlagsService());
+
+  Object.defineProperties(appContext, registeredServices);
+}
diff --git a/polygerrit-ui/app/services/app-context-init_test.html b/polygerrit-ui/app/services/app-context-init_test.html
new file mode 100644
index 0000000..f5dc7d1
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init_test.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2020 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+
+<script type="module">
+  import '../test/common-test-setup.js';
+  import {appContext} from './app-context.js';
+  import {initAppContext} from './app-context-init.js';
+  suite('app context initializer tests', () => {
+    setup(() => {
+      initAppContext();
+    });
+
+    test('all services initialized and are singletons', () => {
+      Object.keys(appContext).forEach(serviceName => {
+        const service = appContext[serviceName];
+        assert.isNotNull(service);
+        const service2 = appContext[serviceName];
+        assert.strictEqual(service, service2);
+      });
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/app-context.js b/polygerrit-ui/app/services/app-context.js
new file mode 100644
index 0000000..e10ced5
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context.js
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The AppContext holds immortal singleton instances of services. It's a
+ * convenient way to provide singletons that can be swapped out for testing.
+ *
+ * AppContext is initialized in ./app-context-init.js
+ */
+export const appContext = {
+  flagsService: null,
+};
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/flags.js b/polygerrit-ui/app/services/flags.js
new file mode 100644
index 0000000..8f04f4a
--- /dev/null
+++ b/polygerrit-ui/app/services/flags.js
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Flags service.
+ *
+ * Provides all related methods / properties regarding on feature flags.
+ */
+export class FlagsService {
+  constructor() {
+    // stores all enabled experiments
+    this._experiments = new Set();
+    this._loadExperiments();
+  }
+
+  /**
+   * @param {string} experimentId
+   * @returns {boolean}
+   */
+  isEnabled(experimentId) {
+    return this._experiments.has(experimentId);
+  }
+
+  _loadExperiments() {
+    this._experiments = new Set(window.ENABLED_EXPERIMENTS);
+  }
+
+  /**
+   * @returns {string[]} array of all enabled experiments.
+   */
+  get enabledExperiments() {
+    return [...this._experiments];
+  }
+}
diff --git a/polygerrit-ui/app/services/flags_test.html b/polygerrit-ui/app/services/flags_test.html
new file mode 100644
index 0000000..51efb0d
--- /dev/null
+++ b/polygerrit-ui/app/services/flags_test.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2020 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<meta charset="utf-8">
+<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/components/wct-browser-legacy/browser.js"></script>
+
+<script>
+  window.ENABLED_EXPERIMENTS = ['a', 'a'];
+</script>
+
+<script type="module">
+  import '../test/common-test-setup.js';
+  import {FlagsService} from './flags.js';
+  suite('flags tests', () => {
+    const flags = new FlagsService();
+
+    test('isEnabled', () => {
+      assert.equal(flags.isEnabled('a'), true);
+      assert.equal(flags.isEnabled('random'), false);
+    });
+
+    test('enabledExperiments', () => {
+      assert.deepEqual(flags.enabledExperiments, ['a']);
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.html b/polygerrit-ui/app/styles/dashboard-header-styles.html
deleted file mode 100644
index 88d50c0..0000000
--- a/polygerrit-ui/app/styles/dashboard-header-styles.html
+++ /dev/null
@@ -1,48 +0,0 @@
-<!--
-@license
-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.
--->
-
-<dom-module id="dashboard-header-styles">
-  <template>
-    <style>
-      :host {
-        background-color: var(--view-background-color);
-        display: block;
-        min-height: 9em;
-        width: 100%;
-      }
-      gr-avatar {
-        display: inline-block;
-        height: 7em;
-        left: 1em;
-        margin: 1em;
-        top: 1em;
-        width: 7em;
-      }
-      .info {
-        display: inline-block;
-        padding: var(--spacing-l);
-        vertical-align: top;
-      }
-      .info > div > span {
-        display: inline-block;
-        font-weight: var(--font-weight-bold);
-        text-align: right;
-        width: 4em;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.js b/polygerrit-ui/app/styles/dashboard-header-styles.js
new file mode 100644
index 0000000..683202e
--- /dev/null
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.js
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * 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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="dashboard-header-styles">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+        min-height: 9em;
+        width: 100%;
+      }
+      gr-avatar {
+        display: inline-block;
+        height: 7em;
+        left: 1em;
+        margin: 1em;
+        top: 1em;
+        width: 7em;
+      }
+      .info {
+        display: inline-block;
+        padding: var(--spacing-l);
+        vertical-align: top;
+      }
+      .info > div > span {
+        display: inline-block;
+        font-weight: var(--font-weight-bold);
+        text-align: right;
+        width: 4em;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css
index c837492..d81c296 100644
--- a/polygerrit-ui/app/styles/fonts.css
+++ b/polygerrit-ui/app/styles/fonts.css
@@ -1,62 +1,167 @@
+/* This file has been produced by downloading this file on Feb 18, 2020:
+ * https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap
+ * Then we have just retained 'latin' and 'latin-ext' char ranges.
+ *
+ */
 /* latin-ext */
 @font-face {
-  font-family: 'Roboto Mono';
+  font-family: 'Open Sans';
   font-style: normal;
   font-weight: 400;
-  src: local('Roboto Mono'), local('RobotoMono-Regular'),
-       url('../fonts/RobotoMono-Regular.woff2') format('woff2'),
-       url('../fonts/RobotoMono-Regular.woff') format('woff');
-  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+  font-display: swap;
+  src: local('Open Sans Regular'), local('OpenSans-Regular'), url(../fonts/opensans-latin-ext-400.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
 }
 /* latin */
 @font-face {
-  font-family: 'Roboto Mono';
+  font-family: 'Open Sans';
   font-style: normal;
   font-weight: 400;
-  src: local('Roboto Mono'), local('RobotoMono-Regular'),
-       url('../fonts/RobotoMono-Regular.woff2') format('woff2'),
-       url('../fonts/RobotoMono-Regular.woff') format('woff');
-  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+  font-display: swap;
+  src: local('Open Sans Regular'), local('OpenSans-Regular'), url(../fonts/opensans-latin-400.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
 }
-
+/* latin-ext */
+@font-face {
+  font-family: 'Open Sans';
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+  src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'), url(../fonts/opensans-latin-ext-600.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Open Sans';
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+  src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'), url(../fonts/opensans-latin-600.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Open Sans';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: local('Open Sans Bold'), local('OpenSans-Bold'), url(../fonts/opensans-latin-ext-700.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Open Sans';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: local('Open Sans Bold'), local('OpenSans-Bold'), url(../fonts/opensans-latin-700.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
 /* latin-ext */
 @font-face {
   font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local('Roboto'), local('Roboto-Regular'),
-       url('../fonts/Roboto-Regular.woff2') format('woff2'),
-       url('../fonts/Roboto-Regular.woff') format('woff');
-  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+  font-display: swap;
+  src: local('Roboto'), local('Roboto-Regular'), url(../fonts/roboto-latin-ext-400.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
 }
 /* latin */
 @font-face {
   font-family: 'Roboto';
   font-style: normal;
   font-weight: 400;
-  src: local('Roboto'), local('Roboto-Regular'),
-       url('../fonts/Roboto-Regular.woff2') format('woff2'),
-       url('../fonts/Roboto-Regular.woff') format('woff');
-  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+  font-display: swap;
+  src: local('Roboto'), local('Roboto-Regular'), url(../fonts/roboto-latin-400.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
 }
-
 /* latin-ext */
 @font-face {
   font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local('Roboto Medium'), local('Roboto-Medium'),
-       url('../fonts/Roboto-Medium.woff2') format('woff2'),
-       url('../fonts/Roboto-Medium.woff') format('woff');
-  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+  font-display: swap;
+  src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/roboto-latin-ext-500.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
 }
 /* latin */
 @font-face {
   font-family: 'Roboto';
   font-style: normal;
   font-weight: 500;
-  src: local('Roboto Medium'), local('Roboto-Medium'),
-       url('../fonts/Roboto-Medium.woff2') format('woff2'),
-       url('../fonts/Roboto-Medium.woff') format('woff');
-  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+  font-display: swap;
+  src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/roboto-latin-500.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: local('Roboto Bold'), local('Roboto-Bold'), url(../fonts/roboto-latin-ext-700.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: local('Roboto Bold'), local('Roboto-Bold'), url(../fonts/roboto-latin-700.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto Mono';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: local('Roboto Mono'), local('RobotoMono-Regular'), url(../fonts/roboto-mono-latin-ext-400.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto Mono';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: local('Roboto Mono'), local('RobotoMono-Regular'), url(../fonts/roboto-mono-latin-400.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto Mono';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: local('Roboto Mono Medium'), local('RobotoMono-Medium'), url(../fonts/roboto-mono-latin-ext-500.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto Mono';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: local('Roboto Mono Medium'), local('RobotoMono-Medium'), url(../fonts/roboto-mono-latin-500.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto Mono';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: local('Roboto Mono Bold'), local('RobotoMono-Bold'), url(../fonts/roboto-mono-latin-ext-700.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto Mono';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: local('Roboto Mono Bold'), local('RobotoMono-Bold'), url(../fonts/roboto-mono-latin-700.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
 }
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
deleted file mode 100644
index 345363b..0000000
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ /dev/null
@@ -1,216 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<dom-module id="gr-change-list-styles">
-  <template>
-    <style>
-      gr-change-list-item,
-      tr {
-        border-top: 1px solid var(--border-color);
-      }
-      gr-change-list-item[selected],
-      gr-change-list-item:focus {
-        background-color: var(--selection-background-color);
-      }
-      /* The border-collapse attribute only works on sibling elements, not
-        cousin elements. So, if we want the table to have a sticky header and
-        have borders between each row, we must disable the border-top on the
-        elements directly below a .topHeader. */
-      .topHeader ~ gr-change-list-item:first-of-type,
-      .topHeader + .groupHeader {
-        border-top: none;
-      }
-      /* Needed to show a border on top of the first gr-change-list-item when a
-        groupHeader exists. Cannot use + selector because of dom-repeats
-        existing in the DOM tree. */
-      .topHeader ~ .groupHeader ~ gr-change-list-item {
-        border-top: 1px solid var(--border-color);
-      }
-      tbody {
-        border-bottom: 1px solid var(--border-color);
-      }
-      tr.topHeader {
-        border: none;
-      }
-      th {
-        text-align: left;
-      }
-      th,
-      .cell {
-        vertical-align: middle;
-      }
-      th:not(.label):not(.endpoint),
-      .cell:not(.label):not(.endpoint) {
-        padding-right: 8px;
-      }
-      th.label {
-        border-left: none;
-      }
-      .topHeader,
-      .groupHeader {
-        font-weight: var(--font-weight-bold);
-      }
-      .topHeader th {
-        background-color: var(--table-header-background-color);
-        height: 3rem;
-        position: -webkit-sticky;
-        position: sticky;
-        top: -1px; /* Offset for top borders */
-        z-index: 1;
-      }
-      /* :after pseudoelements are used here because borders on sticky table
-        headers with a background color are broken. */
-      th:after {
-        border-bottom: 1px solid var(--border-color);
-        bottom: 0;
-        content: '';
-        left: 0;
-        position: absolute;
-        width: 100%;
-      }
-      th.label:after {
-        border-left: 1px solid var(--border-color);
-        top: 0;
-      }
-      .groupHeader {
-        background-color: var(--table-subheader-background-color);
-      }
-      .groupHeader a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      .groupHeader a:hover {
-        text-decoration: underline;
-      }
-      .cell {
-        padding: var(--spacing-s) 0;
-      }
-      .star {
-        padding: 0;
-      }
-      gr-change-star {
-        vertical-align: middle;
-      }
-      .branch,
-      .star,
-      .label,
-      .number,
-      .owner,
-      .assignee,
-      .updated,
-      .size,
-      .status,
-      .repo {
-        white-space: nowrap;
-      }
-      .star {
-        vertical-align: middle;
-      }
-      .leftPadding {
-        width: var(--spacing-l);
-      }
-      .star {
-        width: 30px;
-      }
-      .label, .endpoint {
-        border-left: 1px solid var(--border-color);
-      }
-      .label {
-        text-align: center;
-        width: 3rem;
-      }
-      .topHeader .label {
-        border: none;
-      }
-      .truncatedRepo {
-        display: none;
-      }
-      @media only screen and (max-width: 150em) {
-        .assignee,
-        .branch,
-        .owner {
-          overflow: hidden;
-          max-width: 18rem;
-          text-overflow: ellipsis;
-        }
-        .truncatedRepo {
-          display: inline-block;
-        }
-        .fullRepo {
-          display: none;
-        }
-      }
-      @media only screen and (max-width: 100em) {
-        .assignee,
-        .branch,
-        .owner {
-          max-width: 10rem;
-        }
-      }
-      @media only screen and (max-width: 50em) {
-        :host {
-          font-size: var(--font-size-h3);
-        }
-        gr-change-list-item {
-          flex-wrap: wrap;
-          justify-content: space-between;
-          padding: var(--spacing-xs) var(--spacing-m);
-        }
-        gr-change-list-item[selected],
-        gr-change-list-item:focus {
-          background-color: var(--view-background-color);
-          border: none;
-          border-top: 1px solid var(--border-color);
-        }
-        gr-change-list-item:hover {
-          background-color: var(--view-background-color);
-        }
-        .cell {
-          align-items: center;
-          display: flex;
-        }
-        .topHeader,
-        .leftPadding,
-        .status,
-        .repo,
-        .branch,
-        .updated,
-        .label,
-        .assignee,
-        .groupHeader .star,
-        .noChanges .star {
-          display: none;
-        }
-        .groupHeader .cell,
-        .noChanges .cell {
-          padding: 0 var(--spacing-m);
-        }
-        .subject {
-          margin-bottom: var(--spacing-xs);
-          width: calc(100% - 2em);
-        }
-        .owner,
-        .size {
-          max-width: none;
-        }
-        .noChanges .cell {
-          display: block;
-          height: auto;
-        }
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.js b/polygerrit-ui/app/styles/gr-change-list-styles.js
new file mode 100644
index 0000000..4f4d7e3
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.js
@@ -0,0 +1,194 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-list-styles">
+  <template>
+    <style>
+      gr-change-list-item {
+        border-top: 1px solid var(--border-color);
+      }
+      gr-change-list-item[selected],
+      gr-change-list-item:focus {
+        background-color: var(--selection-background-color);
+      }
+      .groupTitle td,
+      .cell {
+        vertical-align: middle;
+      }
+      .groupTitle td:not(.label):not(.endpoint),
+      .cell:not(.label):not(.endpoint) {
+        padding-right: 8px;
+      }
+      .groupTitle td {
+        color: var(--deemphasized-text-color);
+        text-align: left;
+      }
+      .groupHeader {
+        background-color: transparent;
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      .groupContent {
+        background-color: var(--background-color-primary);
+        box-shadow: var(--elevation-level-1);
+      }
+      .groupHeader a {
+        color: var(--primary-text-color);
+        text-decoration: none;
+      }
+      .groupHeader a:hover {
+        text-decoration: underline;
+      }
+      .groupTitle td,
+      .cell {
+        padding: var(--spacing-s) 0;
+      }
+      .groupHeader .cell {
+        padding-top: var(--spacing-l);
+      }
+      .star {
+        padding: 0;
+      }
+      gr-change-star {
+        vertical-align: middle;
+      }
+      .branch,
+      .star,
+      .label,
+      .number,
+      .owner,
+      .assignee,
+      .updated,
+      .size,
+      .status,
+      .repo {
+        white-space: nowrap;
+      }
+      .star {
+        vertical-align: middle;
+      }
+      .leftPadding {
+        width: var(--spacing-l);
+      }
+      .star {
+        width: 30px;
+      }
+      .reviewers div {
+        overflow: hidden;
+      }
+      .label, .endpoint {
+        border-left: 1px solid var(--border-color);
+      }
+      .groupTitle td.label,
+      .label {
+        text-align: center;
+        width: 3rem;
+      }
+      .truncatedRepo {
+        display: none;
+      }
+      @media only screen and (max-width: 150em) {
+        .assignee,
+        .branch,
+        .owner {
+          overflow: hidden;
+          max-width: 18rem;
+          text-overflow: ellipsis;
+        }
+        .truncatedRepo {
+          display: inline-block;
+        }
+        .fullRepo {
+          display: none;
+        }
+      }
+      @media only screen and (max-width: 100em) {
+        .assignee,
+        .branch,
+        .owner,
+        .reviewers {
+          max-width: 10rem;
+        }
+      }
+      @media only screen and (max-width: 50em) {
+        :host {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        gr-change-list-item {
+          flex-wrap: wrap;
+          justify-content: space-between;
+          padding: var(--spacing-xs) var(--spacing-m);
+        }
+        gr-change-list-item[selected],
+        gr-change-list-item:focus {
+          background-color: var(--view-background-color);
+          border: none;
+          border-top: 1px solid var(--border-color);
+        }
+        gr-change-list-item:hover {
+          background-color: var(--view-background-color);
+        }
+        .cell {
+          align-items: center;
+          display: flex;
+        }
+        .groupTitle,
+        .leftPadding,
+        .status,
+        .repo,
+        .branch,
+        .updated,
+        .label,
+        .assignee,
+        .groupHeader .star,
+        .noChanges .star {
+          display: none;
+        }
+        .groupHeader .cell,
+        .noChanges .cell {
+          padding-left: var(--spacing-m);
+        }
+        .subject {
+          margin-bottom: var(--spacing-xs);
+          width: calc(100% - 2em);
+        }
+        .owner,
+        .size {
+          max-width: none;
+        }
+        .noChanges .cell {
+          display: block;
+          height: auto;
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html
deleted file mode 100644
index fef3872..0000000
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html
+++ /dev/null
@@ -1,48 +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.
--->
-<dom-module id="gr-change-metadata-shared-styles">
-  <template>
-    <style include="shared-styles"></style>
-    <style>
-      section {
-        display: table-row;
-      }
-
-      section:not(:first-of-type) .title,
-      section:not(:first-of-type) .value {
-        padding-top: var(--spacing-s);
-      }
-
-      .title,
-      .value {
-        display: table-cell;
-      }
-
-      .title {
-        color: var(--deemphasized-text-color);
-        max-width: 20em;
-        padding-left: var(--metadata-horizontal-padding);
-        padding-right: var(--metadata-horizontal-padding);
-        word-break: break-word;
-      }
-
-      .value {
-        padding-right: var(--metadata-horizontal-padding);
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
new file mode 100644
index 0000000..aabdde5
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
@@ -0,0 +1,58 @@
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-shared-styles">
+  <template>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style>
+      section {
+        display: table-row;
+      }
+
+      section:not(:first-of-type) .title,
+      section:not(:first-of-type) .value {
+        padding-top: var(--spacing-s);
+      }
+
+      .title,
+      .value {
+        display: table-cell;
+        vertical-align: top;
+      }
+
+      .title {
+        color: var(--deemphasized-text-color);
+        max-width: 20em;
+        padding-left: var(--metadata-horizontal-padding);
+        padding-right: var(--metadata-horizontal-padding);
+        word-break: break-word;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html
deleted file mode 100644
index 834f64a..0000000
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html
+++ /dev/null
@@ -1,54 +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.
--->
-<!--
-  This is shared styles for change-view-integration endpoints.
-  All plugins that registered that endpoint should include this in
-  the component to have a consistent UX:
-
-  <style include="gr-change-view-integration-shared-styles"></style>
-
-  And use those defined class to apply these styles.
--->
-<dom-module id="gr-change-view-integration-shared-styles">
-  <template>
-    <style include="shared-styles"></style>
-    <style>
-      .header {
-        color: var(--primary-text-color);
-        background-color: var(--table-header-background-color);
-        justify-content: space-between;
-        padding: var(--spacing-m) var(--spacing-l);
-        border-bottom: 1px solid var(--border-color);
-      }
-      .header .label {
-        font-weight: var(--font-weight-bold);
-        font-size: var(--font-size-h3);
-        margin: 0 var(--spacing-l) 0 0;
-      }
-      .header .note {
-        color: var(--deemphasized-text-color);
-      }
-      .content {
-        background-color: var(--view-background-color);
-      }
-      .header a,
-      .content a {
-        color: var(--link-color);
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
new file mode 100644
index 0000000..4bfb742
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
@@ -0,0 +1,73 @@
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
+  <template>
+    <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);
+        justify-content: space-between;
+        padding: var(--spacing-m) var(--spacing-l);
+        border-bottom: 1px solid var(--border-color);
+      }
+      .header .label {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+        margin: 0 var(--spacing-l) 0 0;
+      }
+      .header .note {
+        color: var(--deemphasized-text-color);
+      }
+      .content {
+        background-color: var(--view-background-color);
+      }
+      .header a,
+      .content a {
+        color: var(--link-color);
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  This is shared styles for change-view-integration endpoints.
+  All plugins that registered that endpoint should include this in
+  the component to have a consistent UX:
+
+  <style include="gr-change-view-integration-shared-styles"></style>
+
+  And use those defined class to apply these styles.
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
deleted file mode 100644
index 3fe0a72..0000000
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ /dev/null
@@ -1,132 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<dom-module id="gr-form-styles">
-  <template>
-    <style>
-      .gr-form-styles input {
-        background-color: var(--view-background-color);
-        color: var(--primary-text-color);
-      }
-      .gr-form-styles select {
-        background-color: var(--select-background-color);
-        color: var(--primary-text-color);
-      }
-      .gr-form-styles h1,
-      .gr-form-styles h2 {
-        margin-bottom: var(--spacing-s);
-      }
-      .gr-form-styles h4 {
-        font-weight: var(--font-weight-bold);
-      }
-      .gr-form-styles fieldset {
-        border: none;
-        margin-bottom: var(--spacing-xxl);
-      }
-      .gr-form-styles section {
-        display: flex;
-        margin: var(--spacing-s) 0;
-        min-height: 2em;
-      }
-      .gr-form-styles section * {
-        vertical-align: middle;
-      }
-      .gr-form-styles .title,
-      .gr-form-styles .value {
-        display: inline-block;
-      }
-      .gr-form-styles .title {
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-bold);
-        padding-right: var(--spacing-m);
-        width: 15em;
-      }
-      .gr-form-styles th {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-        vertical-align: bottom;
-      }
-      .gr-form-styles td,
-      .gr-form-styles tfoot th {
-        height: 2em;
-        padding: var(--spacing-s) 0;
-        vertical-align: middle;
-      }
-      .gr-form-styles .emptyHeader {
-        text-align: right;
-      }
-      .gr-form-styles table {
-        width: 50em;
-      }
-      .gr-form-styles th:first-child,
-      .gr-form-styles td:first-child {
-        width: 15em;
-      }
-      .gr-form-styles th:first-child input,
-      .gr-form-styles td:first-child input {
-        width: 14em;
-      }
-      .gr-form-styles input:not([type="checkbox"]),
-      .gr-form-styles select,
-      .gr-form-styles textarea {
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        height: 2em;
-        padding: 0 var(--spacing-xs);
-      }
-      .gr-form-styles td:last-child {
-        width: 5em;
-      }
-      .gr-form-styles th:last-child gr-button,
-      .gr-form-styles td:last-child gr-button {
-        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);
-        }
-      }
-      .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;
-        }
-      }
-      @media only screen and (max-width: 40em) {
-        .gr-form-styles section {
-          margin-bottom: var(--spacing-l);
-        }
-        .gr-form-styles .title,
-        .gr-form-styles .value {
-          display: block;
-        }
-        .gr-form-styles table {
-          width: 100%;
-        }
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-form-styles.js b/polygerrit-ui/app/styles/gr-form-styles.js
new file mode 100644
index 0000000..91763c5
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-form-styles.js
@@ -0,0 +1,127 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-form-styles">
+  <template>
+    <style>
+      .gr-form-styles input {
+        background-color: var(--view-background-color);
+        color: var(--primary-text-color);
+      }
+      .gr-form-styles select {
+        background-color: var(--select-background-color);
+        color: var(--primary-text-color);
+      }
+      .gr-form-styles h1,
+      .gr-form-styles h2 {
+        margin-bottom: var(--spacing-s);
+      }
+      .gr-form-styles h4 {
+        font-weight: var(--font-weight-bold);
+      }
+      .gr-form-styles fieldset {
+        border: none;
+        margin-bottom: var(--spacing-xxl);
+      }
+      .gr-form-styles section {
+        display: flex;
+        margin: var(--spacing-s) 0;
+        min-height: 2em;
+      }
+      .gr-form-styles section * {
+        vertical-align: middle;
+      }
+      .gr-form-styles .title,
+      .gr-form-styles .value {
+        display: inline-block;
+      }
+      .gr-form-styles .title {
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-bold);
+        padding-right: var(--spacing-m);
+        width: 15em;
+      }
+      .gr-form-styles th {
+        color: var(--deemphasized-text-color);
+        text-align: left;
+        vertical-align: bottom;
+      }
+      .gr-form-styles td,
+      .gr-form-styles tfoot th {
+        padding: var(--spacing-s) 0;
+        vertical-align: middle;
+      }
+      .gr-form-styles .emptyHeader {
+        text-align: right;
+      }
+      .gr-form-styles table {
+        width: 50em;
+      }
+      .gr-form-styles th:first-child,
+      .gr-form-styles td:first-child {
+        width: 15em;
+      }
+      .gr-form-styles th:first-child input,
+      .gr-form-styles td:first-child input {
+        width: 14em;
+      }
+      .gr-form-styles input:not([type="checkbox"]),
+      .gr-form-styles select,
+      .gr-form-styles textarea {
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s);
+      }
+      .gr-form-styles td:last-child {
+        width: 5em;
+      }
+      .gr-form-styles th:last-child gr-button,
+      .gr-form-styles td:last-child gr-button {
+        width: 100%;
+      }
+      .gr-form-styles iron-autogrow-textarea {
+        height: auto;
+        min-height: 4em;
+      }
+      .gr-form-styles gr-autocomplete {
+        width: 14em;
+      }
+      @media only screen and (max-width: 40em) {
+        .gr-form-styles section {
+          margin-bottom: var(--spacing-l);
+        }
+        .gr-form-styles .title,
+        .gr-form-styles .value {
+          display: block;
+        }
+        .gr-form-styles table {
+          width: 100%;
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.html b/polygerrit-ui/app/styles/gr-menu-page-styles.html
deleted file mode 100644
index d3b95b8..0000000
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<dom-module id="gr-menu-page-styles">
-  <template>
-    <style>
-      :host {
-        background-color: var(--view-background-color);
-        display: block;
-      }
-      main {
-        margin: var(--spacing-xxl) auto;
-        max-width: 50em;
-      }
-      .mainHeader {
-        margin-left: 14em;
-        padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
-      }
-      main.table,
-      .mainHeader {
-        margin-top: 0;
-        margin-right: 0;
-        margin-left: 14em;
-        max-width: none;
-      }
-      h2.edited:after {
-        color: var(--deemphasized-text-color);
-        content: ' *';
-      }
-      .loading {
-        color: var(--deemphasized-text-color);
-        padding: var(--spacing-l);
-      }
-      @media only screen and (max-width: 67em) {
-        main {
-          margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
-        }
-        main.table {
-          margin-left: 14em;
-        }
-      }
-      @media only screen and (max-width: 53em) {
-        .loading {
-          padding: 0 var(--spacing-l);
-        }
-        main {
-          margin: var(--spacing-xxl) var(--spacing-l);
-        }
-        main.table {
-          margin: 0;
-        }
-        .mainHeader {
-          margin-left: 0;
-          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-l);
-        }
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.js b/polygerrit-ui/app/styles/gr-menu-page-styles.js
new file mode 100644
index 0000000..e52a895
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.js
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-menu-page-styles">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      main {
+        margin: var(--spacing-xxl) auto;
+        max-width: 50em;
+      }
+      .mainHeader {
+        margin-left: 14em;
+        padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
+      }
+      main.table,
+      .mainHeader {
+        margin-top: 0;
+        margin-right: 0;
+        margin-left: 14em;
+        max-width: none;
+      }
+      h2.edited:after {
+        color: var(--deemphasized-text-color);
+        content: ' *';
+      }
+      .loading {
+        color: var(--deemphasized-text-color);
+        padding: var(--spacing-l);
+      }
+      @media only screen and (max-width: 67em) {
+        main {
+          margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
+        }
+        main.table {
+          margin-left: 14em;
+        }
+      }
+      @media only screen and (max-width: 53em) {
+        .loading {
+          padding: 0 var(--spacing-l);
+        }
+        main {
+          margin: var(--spacing-xxl) var(--spacing-l);
+        }
+        main.table {
+          margin: 0;
+        }
+        .mainHeader {
+          margin-left: 0;
+          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-l);
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
deleted file mode 100644
index ced6ecb..0000000
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.html
+++ /dev/null
@@ -1,64 +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.
--->
-<dom-module id="gr-page-nav-styles">
-  <template>
-    <style>
-      .navStyles ul {
-        padding: var(--spacing-l) 0;
-      }
-      .navStyles li {
-        border-bottom: 1px solid transparent;
-        border-top: 1px solid transparent;
-        display: block;
-        padding: 0 var(--spacing-xl);
-      }
-      .navStyles li a {
-        display: block;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      .navStyles .subsectionItem {
-        padding-left: var(--spacing-xxl);
-      }
-      .navStyles .hideSubsection {
-        display: none;
-      }
-      .navStyles li.sectionTitle {
-        padding: 0 var(--spacing-xxl) 0 var(--spacing-l);
-      }
-      .navStyles li.sectionTitle:not(:first-child) {
-        margin-top: var(--spacing-l);
-      }
-      .navStyles .title {
-        font-weight: var(--font-weight-bold);
-        margin: var(--spacing-s) 0;
-      }
-      .navStyles .selected {
-        background-color: var(--view-background-color);
-        border-bottom: 1px solid var(--border-color);
-        border-top: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-      }
-      .navStyles a {
-        color: var(--primary-text-color);
-        display: inline-block;
-        margin: var(--spacing-s) 0;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.js b/polygerrit-ui/app/styles/gr-page-nav-styles.js
new file mode 100644
index 0000000..97f1a03
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.js
@@ -0,0 +1,75 @@
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-page-nav-styles">
+  <template>
+    <style>
+      .navStyles ul {
+        padding: var(--spacing-l) 0;
+      }
+      .navStyles li {
+        border-bottom: 1px solid transparent;
+        border-top: 1px solid transparent;
+        display: block;
+        padding: 0 var(--spacing-xl);
+      }
+      .navStyles li a {
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .navStyles .subsectionItem {
+        padding-left: var(--spacing-xxl);
+      }
+      .navStyles .hideSubsection {
+        display: none;
+      }
+      .navStyles li.sectionTitle {
+        padding: 0 var(--spacing-xxl) 0 var(--spacing-l);
+      }
+      .navStyles li.sectionTitle:not(:first-child) {
+        margin-top: var(--spacing-l);
+      }
+      .navStyles .title {
+        font-weight: var(--font-weight-bold);
+        margin: var(--spacing-s) 0;
+      }
+      .navStyles .selected {
+        background-color: var(--view-background-color);
+        border-bottom: 1px solid var(--border-color);
+        border-top: 1px solid var(--border-color);
+        font-weight: var(--font-weight-bold);
+      }
+      .navStyles a {
+        color: var(--primary-text-color);
+        display: inline-block;
+        margin: var(--spacing-s) 0;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.html b/polygerrit-ui/app/styles/gr-subpage-styles.html
deleted file mode 100644
index 222c38b..0000000
--- a/polygerrit-ui/app/styles/gr-subpage-styles.html
+++ /dev/null
@@ -1,34 +0,0 @@
-<!--
-@license
-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.
--->
-<dom-module id="gr-subpage-styles">
-  <template>
-    <style>
-      main {
-        margin: var(--spacing-l);
-      }
-      .loading {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.js b/polygerrit-ui/app/styles/gr-subpage-styles.js
new file mode 100644
index 0000000..f94cc9c
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.js
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * 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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-subpage-styles">
+  <template>
+    <style>
+      main {
+        margin: var(--spacing-l);
+      }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-table-styles.html b/polygerrit-ui/app/styles/gr-table-styles.html
deleted file mode 100644
index d4e4bcf..0000000
--- a/polygerrit-ui/app/styles/gr-table-styles.html
+++ /dev/null
@@ -1,103 +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.
--->
-
-<dom-module id="gr-table-styles">
-  <template>
-    <style>
-      .genericList {
-        border-collapse: collapse;
-        width: 100%;
-      }
-      .genericList td {
-        height: 2.25rem;
-        padding: var(--spacing-s) 0;
-        vertical-align: middle;
-      }
-      .genericList tr {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .genericList tr:hover {
-        background-color: var(--hover-background-color);
-      }
-      .genericList th {
-        white-space: nowrap;
-      }
-      .genericList th,
-      .genericList td {
-        padding-right: var(--spacing-l);
-      }
-      .genericList tr th:first-of-type,
-      .genericList tr td:first-of-type {
-        padding-left: var(--spacing-l);
-      }
-      .genericList tr:first-of-type {
-        border-top: 1px solid var(--border-color);
-      }
-      .genericList tr th:last-of-type,
-      .genericList tr td:last-of-type {
-        border-left: 1px solid var(--border-color);
-        text-align: center;
-        padding: 0 var(--spacing-l);
-      }
-      .genericList tr th.delete,
-      .genericList tr td.delete,
-      .genericList tr.loadingMsg td {
-        border-left: none;
-      }
-      .genericList .loading {
-        border: none;
-        display: none;
-      }
-      .genericList td {
-        flex-shrink: 0;
-      }
-      .genericList .topHeader,
-      .genericList .groupHeader {
-        color: var(--primary-text-color);
-        font-weight: var(--font-weight-bold);
-        text-align: left;
-        vertical-align: middle
-      }
-      .genericList .topHeader {
-        background-color: var(--table-header-background-color);
-        height: 3rem;
-      }
-      .genericList .groupHeader {
-        background-color: var(--table-subheader-background-color);
-        font-size: var(--font-size-h3);
-      }
-      .genericList a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      .genericList a:hover {
-        text-decoration: underline;
-      }
-      .genericList .description {
-        width: 99%;
-      }
-      .genericList .loadingMsg {
-        color: var(--deemphasized-text-color);
-        display: block;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .genericList .loadingMsg:not(.loading) {
-        display: none;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-table-styles.js b/polygerrit-ui/app/styles/gr-table-styles.js
new file mode 100644
index 0000000..ceac675
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-table-styles.js
@@ -0,0 +1,119 @@
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-table-styles">
+  <template>
+    <style>
+      .genericList {
+        background-color: var(--background-color-primary);
+        border-collapse: collapse;
+        width: 100%;
+      }
+      .genericList th,
+      .genericList td {
+        padding: var(--spacing-m) 0;
+        vertical-align: middle;
+      }
+      .genericList tr {
+        border-bottom: 1px solid var(--border-color);
+      }
+      .genericList tr:hover {
+        background-color: var(--hover-background-color);
+      }
+      .genericList th {
+        white-space: nowrap;
+      }
+      .genericList th,
+      .genericList td {
+        padding-right: var(--spacing-l);
+      }
+      .genericList tr th:first-of-type,
+      .genericList tr td:first-of-type {
+        padding-left: var(--spacing-l);
+      }
+      .genericList tr:first-of-type {
+        border-top: 1px solid var(--border-color);
+      }
+      .genericList tr th:last-of-type,
+      .genericList tr td:last-of-type {
+        border-left: 1px solid var(--border-color);
+        text-align: center;
+        padding-left: var(--spacing-l);
+      }
+      .genericList tr th.delete,
+      .genericList tr td.delete {
+        padding-top: 0;
+        padding-bottom: 0;
+      }
+      .genericList tr th.delete,
+      .genericList tr td.delete,
+      .genericList tr.loadingMsg td,
+      .genericList tr.groupHeader td {
+        border-left: none;
+      }
+      .genericList .loading {
+        border: none;
+        display: none;
+      }
+      .genericList td {
+        flex-shrink: 0;
+      }
+      .genericList .topHeader,
+      .genericList .groupHeader {
+        color: var(--primary-text-color);
+        font-weight: var(--font-weight-bold);
+        text-align: left;
+        vertical-align: middle
+      }
+      .genericList .groupHeader {
+        background-color: var(--background-color-secondary);
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      .genericList a {
+        color: var(--primary-text-color);
+        text-decoration: none;
+      }
+      .genericList a:hover {
+        text-decoration: underline;
+      }
+      .genericList .description {
+        width: 99%;
+      }
+      .genericList .loadingMsg {
+        color: var(--deemphasized-text-color);
+        display: block;
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      .genericList .loadingMsg:not(.loading) {
+        display: none;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.html b/polygerrit-ui/app/styles/gr-voting-styles.html
deleted file mode 100644
index eec79be..0000000
--- a/polygerrit-ui/app/styles/gr-voting-styles.html
+++ /dev/null
@@ -1,32 +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.
--->
-
-<dom-module id="gr-voting-styles">
-  <template>
-    <style>
-      :host {
-        --vote-chip-styles: {
-          border: 1px solid rgba(0,0,0,.12);
-          border-radius: 1em;
-          box-shadow: none;
-          box-sizing: border-box;
-          min-width: 3em;
-        }
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.js b/polygerrit-ui/app/styles/gr-voting-styles.js
new file mode 100644
index 0000000..4860428
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-voting-styles.js
@@ -0,0 +1,42 @@
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-voting-styles">
+  <template>
+    <style>
+      :host {
+        --vote-chip-styles: {
+          border: 1px solid rgba(0,0,0,.12);
+          border-radius: 1em;
+          box-shadow: none;
+          box-sizing: border-box;
+          min-width: 3em;
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
deleted file mode 100644
index 5314741..0000000
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ /dev/null
@@ -1,154 +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.
--->
-<dom-module id="shared-styles">
-  <template>
-    <style>
-
-      /* CSS reset */
-
-      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
-      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
-      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
-      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
-      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
-      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
-      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
-        border: 0;
-        box-sizing: border-box;
-        font-size: 100%;
-        font: inherit;
-        margin: 0;
-        padding: 0;
-        vertical-align: baseline;
-      }
-      *::after,
-      *::before {
-        box-sizing: border-box;
-      }
-      input {
-        background-color: inherit;
-        border: 1px solid var(--border-color);
-        box-sizing: border-box;
-        color: var(--primary-text-color);
-        margin: 0;
-        padding: 0;
-      }
-      iron-autogrow-textarea {
-        background-color: inherit;
-        color: var(--primary-text-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        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;
-        };
-      }
-      a {
-        color: var(--link-color);
-      }
-      input,
-      textarea,
-      select,
-      button {
-        font: inherit;
-      }
-      ol, ul {
-        list-style: none;
-      }
-      blockquote, q {
-        quotes: none;
-      }
-      blockquote:before, blockquote:after,
-      q:before, q:after {
-        content: '';
-        content: none;
-      }
-      table {
-        border-collapse: collapse;
-        border-spacing: 0;
-      }
-
-      /* Fonts */
-
-      .font-normal {
-        font-size: var(--font-size-normal);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-normal);
-      }
-      .font-small {
-        font-size: var(--font-size-small);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-small);
-      }
-      h1, .font-h1 {
-        font-size: var(--font-size-h1);
-        font-weight: var(--font-weight-bold);
-        line-height: var(--line-height-h1);
-      }
-      h2, .font-h2 {
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-bold);
-        line-height: var(--line-height-h2);
-      }
-      h3, .font-h3 {
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-bold);
-        line-height: var(--line-height-h3);
-      }
-      iron-icon {
-        color: var(--deemphasized-text-color);
-        --iron-icon-height: 20px;
-        --iron-icon-width: 20px;
-      }
-
-      /* Stopgap solution until we remove hidden$ attributes. */
-
-      [hidden] {
-        display: none !important;
-      }
-      .separator {
-        border-left: 1px solid var(--border-color);
-        height: 20px;
-        margin: 0 8px;
-      }
-      .separator.transparent {
-        border-color: transparent;
-      }
-      paper-toggle-button {
-        --paper-toggle-button-checked-bar-color: var(--link-color);
-        --paper-toggle-button-checked-button-color: var(--link-color);
-      }
-      paper-tabs {
-        --paper-tab-content-focused: {
-          /* paper-tabs uses 700 here, which can look awkward */
-          font-weight: var(--font-weight-normal);
-        };
-        --paper-tab-content-unselected: {
-          /* paper-tabs uses 0.8 here, but we want to control the color directly */
-          opacity: 1;
-          color: var(--deemphasized-text-color);
-        };      }
-      strong {
-        font-weight: var(--font-weight-bold);
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/shared-styles.js b/polygerrit-ui/app/styles/shared-styles.js
new file mode 100644
index 0000000..f5e048f
--- /dev/null
+++ b/polygerrit-ui/app/styles/shared-styles.js
@@ -0,0 +1,199 @@
+/**
+ * @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.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="shared-styles">
+  <template>
+    <style>
+
+      /* CSS reset */
+
+      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
+      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
+      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
+      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
+      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
+      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
+      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
+        border: 0;
+        box-sizing: border-box;
+        font-size: 100%;
+        font: inherit;
+        margin: 0;
+        padding: 0;
+        vertical-align: baseline;
+      }
+      *::after,
+      *::before {
+        box-sizing: border-box;
+      }
+      input {
+        background-color: var(--background-color-primary);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-sizing: border-box;
+        color: var(--primary-text-color);
+        margin: 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: {
+          box-sizing: border-box;
+          padding: var(--spacing-s);
+        };
+      }
+      a {
+        color: var(--link-color);
+      }
+      input,
+      textarea,
+      select,
+      button {
+        font: inherit;
+      }
+      ol, ul {
+        list-style: none;
+      }
+      blockquote, q {
+        quotes: none;
+      }
+      blockquote:before, blockquote:after,
+      q:before, q:after {
+        content: '';
+        content: none;
+      }
+      table {
+        border-collapse: collapse;
+        border-spacing: 0;
+      }
+
+      /* Fonts */
+
+      .font-normal {
+        font-size: var(--font-size-normal);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-normal);
+      }
+      .font-small {
+        font-size: var(--font-size-small);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-small);
+      }
+      h1, .font-h1 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h1);
+        font-weight: var(--font-weight-h1);
+        line-height: var(--line-height-h1);
+      }
+      h2, .font-h2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      h3, .font-h3 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      iron-icon {
+        color: var(--deemphasized-text-color);
+        --iron-icon-height: 20px;
+        --iron-icon-width: 20px;
+      }
+
+      /* Stopgap solution until we remove hidden\$ attributes. */
+
+      [hidden] {
+        display: none !important;
+      }
+      .separator {
+        border-left: 1px solid var(--border-color);
+        height: 20px;
+        margin: 0 8px;
+      }
+      .separator.transparent {
+        border-color: transparent;
+      }
+      paper-toggle-button {
+        --paper-toggle-button-checked-bar-color: var(--link-color);
+        --paper-toggle-button-checked-button-color: var(--link-color);
+      }
+      paper-tabs {
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+        --paper-font-common-base: {
+          font-family: var(--header-font-family);
+          -webkit-font-smoothing: initial;
+        };
+        --paper-tab-content-focused: {
+          /* paper-tabs uses 700 here, which can look awkward */
+          font-weight: var(--font-weight-h3);
+        };
+        --paper-tab-content-unselected: {
+          /* paper-tabs uses 0.8 here, but we want to control the color directly */
+          opacity: 1;
+          color: var(--deemphasized-text-color);
+        };
+      }
+      iron-autogrow-textarea {
+        /** This is needed for firefox */
+        --iron-autogrow-textarea_-_white-space: pre-wrap;
+      }
+      strong {
+        font-weight: var(--font-weight-bold);
+      }
+
+      /** BEGIN: loading spiner */
+      .loadingSpin {
+        border: 2px solid var(--disabled-button-background-color);
+        border-top: 2px solid var(--primary-button-background-color);
+        border-radius: 50%;
+        width: 10px;
+        height: 10px;
+        animation: spin 2s linear infinite;
+        margin-right: var(--spacing-s);
+      }
+      @keyframes spin {
+        0% { transform: rotate(0deg); }
+        100% { transform: rotate(360deg); }
+      }
+      /** END: loading spiner */
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/themes/app-theme.html b/polygerrit-ui/app/styles/themes/app-theme.html
deleted file mode 100644
index bb477c2..0000000
--- a/polygerrit-ui/app/styles/themes/app-theme.html
+++ /dev/null
@@ -1,182 +0,0 @@
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<custom-style><style is="custom-style">
-html {
-  /**
-   * When adding a new color variable make sure to also add it to the other
-   * theme files in the same directory.
-   *
-   * For colors prefer lower case hex colors.
-   *
-   * Note that plugins might be using these variables, so removing a variable
-   * can be a breaking change that should go into the release notes.
-   */
-
-  /* text colors */
-  --primary-text-color: black;
-  --link-color: #2a66d9;
-  --comment-text-color: black;
-  --deemphasized-text-color: #5F6368;
-  --default-button-text-color: #2a66d9;
-  --error-text-color: red;
-  --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 */
-  --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;
-  --vote-color-approved: #9fcc6b;
-  --vote-color-disliked: #f7c4cb;
-  --vote-color-neutral: #ebf5fb;
-  --vote-color-recommended: #c9dfaf;
-  --vote-color-rejected: #f7a1ad;
-
-  /* misc colors */
-  --border-color: #ddd;
-
-  /* fonts */
-  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
-  --font-size-code: 12px;     /* 12px mono */
-  --font-size-mono: .929rem;  /* 13px mono */
-  --font-size-small: .857rem; /* 12px */
-  --font-size-normal: 1rem;   /* 14px */
-  --font-size-h3: 1.143rem;   /* 16px */
-  --font-size-h2: 1.429rem;   /* 20px */
-  --font-size-h1: 1.714rem;   /* 24px */
-  --line-height-code: 1.334;      /* 16px */
-  --line-height-mono: 1.286rem;   /* 18px */
-  --line-height-small: 1.143rem;  /* 16px */
-  --line-height-normal: 1.429rem; /* 20px */
-  --line-height-h3: 1.714rem;     /* 24px */
-  --line-height-h2: 2rem;         /* 28px */
-  --line-height-h1: 2.286rem;     /* 32px */
-  --font-weight-normal: 400;
-  --font-weight-bold: 500;
-
-  /* spacing */
-  --spacing-xxs: 1px;
-  --spacing-xs: 2px;
-  --spacing-s: 4px;
-  --spacing-m: 8px;
-  --spacing-l: 12px;
-  --spacing-xl: 16px;
-  --spacing-xxl: 24px;
-
-  /* header and footer */
-  --footer-background-color: #eee;
-  --footer-border-top: 1px solid var(--border-color);
-  --header-background-color: #eee;
-  --header-border-bottom: 1px solid var(--border-color);
-  --header-border-image: '';
-  --header-box-shadow: none;
-  --header-padding: 0 var(--spacing-l);
-  --header-icon-size: 0em;
-  --header-icon: none;
-  --header-text-color: black;
-  --header-title-content: 'Gerrit';
-  --header-title-font-size: 1.75rem;
-
-  /* diff colors */
-  --dark-add-highlight-color: #aaf2aa;
-  --dark-rebased-add-highlight-color: #d7d7f9;
-  --dark-rebased-remove-highlight-color: #f7e8b7;
-  --dark-remove-highlight-color: #ffcdd2;
-  --diff-blank-background-color: white;
-  --diff-context-control-background-color: #fff7d4;
-  --diff-context-control-border-color: #f6e6a5;
-  --diff-context-control-color: var(--deemphasized-text-color);
-  --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
-  --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
-  --diff-selection-background-color: #c7dbf9;
-  --diff-tab-indicator-color: var(--deemphasized-text-color);
-  --diff-trailing-whitespace-indicator: #ff9ad2;
-  --light-add-highlight-color: #d8fed8;
-  --light-rebased-add-highlight-color: #eef;
-  --light-remove-add-highlight-color: #fff8dc;
-  --light-remove-highlight-color: #ffebee;
-
-  /* syntax colors */
-  --syntax-attr-color: #219;
-  --syntax-attribute-color: var(--primary-text-color);
-  --syntax-built_in-color: #30a;
-  --syntax-comment-color: #3f7f5f;
-  --syntax-default-color: var(--primary-text-color);
-  --syntax-doctag-weight: bold;
-  --syntax-function-color: var(--primary-text-color);
-  --syntax-keyword-color: #9e0069;
-  --syntax-link-color: #219;
-  --syntax-literal-color: #219;
-  --syntax-meta-color: #ff1717;
-  --syntax-meta-keyword-color: #219;
-  --syntax-number-color: #164;
-  --syntax-params-color: var(--primary-text-color);
-  --syntax-regexp-color: #fa8602;
-  --syntax-selector-attr-color: #fa8602;
-  --syntax-selector-class-color: #164;
-  --syntax-selector-id-color: #2a00ff;
-  --syntax-selector-pseudo-color: #fa8602;
-  --syntax-string-color: #2a00ff;
-  --syntax-tag-color: #170;
-  --syntax-template-tag-color: #fa8602;
-  --syntax-template-variable-color: #0000c0;
-  --syntax-title-color: #0000c0;
-  --syntax-type-color: #2a66d9;
-  --syntax-variable-color: var(--primary-text-color);
-  /* misc */
-  --border-radius: 4px;
-  --reply-overlay-z-index: 1000;
-  --iron-overlay-backdrop: {
-    transition: none;
-  };
-}
-@media screen and (max-width: 50em) {
-  html {
-    --spacing-xxs: 1px;
-    --spacing-xs: 1px;
-    --spacing-s: 2px;
-    --spacing-m: 4px;
-    --spacing-l: 8px;
-    --spacing-xl: 12px;
-    --spacing-xxl: 16px;
-  }
-}
-</style></custom-style>
diff --git a/polygerrit-ui/app/styles/themes/app-theme.js b/polygerrit-ui/app/styles/themes/app-theme.js
new file mode 100644
index 0000000..5d5d9e3
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/app-theme.js
@@ -0,0 +1,220 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<custom-style><style is="custom-style">
+html {
+  /**
+   * When adding a new color variable make sure to also add it to the other
+   * theme files in the same directory.
+   *
+   * For colors prefer lower case hex colors.
+   *
+   * Note that plugins might be using these variables, so removing a variable
+   * can be a breaking change that should go into the release notes.
+   */
+
+  /* text colors */
+  --primary-text-color: black;
+  --link-color: #2a66d9;
+  --comment-text-color: black;
+  --deemphasized-text-color: #5F6368;
+  --default-button-text-color: #2a66d9;
+  --error-text-color: red;
+  --primary-button-text-color: white;
+    /* Used on text color for change list that doesn't need user's attention. */
+  --reviewed-text-color: black;
+  --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;
+  --edit-mode-background-color: #ebf5fb;
+  --emphasis-color: #fff9c4;
+  --hover-background-color: rgba(161, 194, 250, 0.2);
+  --disabled-button-background-color: #e8eaed;
+  --primary-button-background-color: #2a66d9;
+  --selection-background-color: rgba(161, 194, 250, 0.1);
+  --tooltip-background-color: #333;
+  /* 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;
+  --vote-color-recommended: #c9dfaf;
+  --vote-color-rejected: #f7a1ad;
+
+  /* misc colors */
+  --border-color: #e8e8e8;
+  --comment-separator-color: #dadce0;
+
+  /* fonts */
+  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
+  --font-size-code: 12px;     /* 12px mono */
+  --font-size-mono: .929rem;  /* 13px mono */
+  --font-size-small: .857rem; /* 12px */
+  --font-size-normal: 1rem;   /* 14px */
+  --font-size-h3: 1.143rem;   /* 16px */
+  --font-size-h2: 1.429rem;   /* 20px */
+  --font-size-h1: 1.714rem;   /* 24px */
+  --line-height-code: 1.334;      /* 16px */
+  --line-height-mono: 1.286rem;   /* 18px */
+  --line-height-small: 1.143rem;  /* 16px */
+  --line-height-normal: 1.429rem; /* 20px */
+  --line-height-h3: 1.714rem;     /* 24px */
+  --line-height-h2: 2rem;         /* 28px */
+  --line-height-h1: 2.286rem;     /* 32px */
+  --font-weight-normal: 400; /* 400 is the same as 'normal' */
+  --font-weight-bold: 500;
+  --font-weight-h1: 400;
+  --font-weight-h2: 400;
+  --font-weight-h3: 400;
+
+  /* spacing */
+  --spacing-xxs: 1px;
+  --spacing-xs: 2px;
+  --spacing-s: 4px;
+  --spacing-m: 8px;
+  --spacing-l: 12px;
+  --spacing-xl: 16px;
+  --spacing-xxl: 24px;
+
+  /* header and footer */
+  --footer-background-color: transparent;
+  --footer-border-top: none;
+  --header-background-color: var(--background-color-tertiary);
+  --header-border-bottom: 1px solid var(--border-color);
+  --header-border-image: '';
+  --header-box-shadow: none;
+  --header-padding: 0 var(--spacing-l);
+  --header-icon-size: 0em;
+  --header-icon: none;
+  --header-text-color: black;
+  --header-title-content: 'Gerrit';
+  --header-title-font-size: 1.75rem;
+
+  /* diff colors */
+  --dark-add-highlight-color: #aaf2aa;
+  --dark-rebased-add-highlight-color: #d7d7f9;
+  --dark-rebased-remove-highlight-color: #f7e8b7;
+  --dark-remove-highlight-color: #ffcdd2;
+  --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);
+  --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
+  --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+  --diff-selection-background-color: #c7dbf9;
+  --diff-tab-indicator-color: var(--deemphasized-text-color);
+  --diff-trailing-whitespace-indicator: #ff9ad2;
+  --light-add-highlight-color: #d8fed8;
+  --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;
+  --syntax-attribute-color: var(--primary-text-color);
+  --syntax-built_in-color: #30a;
+  --syntax-comment-color: #3f7f5f;
+  --syntax-default-color: var(--primary-text-color);
+  --syntax-doctag-weight: bold;
+  --syntax-function-color: var(--primary-text-color);
+  --syntax-keyword-color: #9e0069;
+  --syntax-link-color: #219;
+  --syntax-literal-color: #219;
+  --syntax-meta-color: #ff1717;
+  --syntax-meta-keyword-color: #219;
+  --syntax-number-color: #164;
+  --syntax-params-color: var(--primary-text-color);
+  --syntax-regexp-color: #fa8602;
+  --syntax-selector-attr-color: #fa8602;
+  --syntax-selector-class-color: #164;
+  --syntax-selector-id-color: #2a00ff;
+  --syntax-selector-pseudo-color: #fa8602;
+  --syntax-string-color: #2a00ff;
+  --syntax-tag-color: #170;
+  --syntax-template-tag-color: #fa8602;
+  --syntax-template-variable-color: #0000c0;
+  --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;
+  };
+}
+@media screen and (max-width: 50em) {
+  html {
+    --spacing-xxs: 1px;
+    --spacing-xs: 1px;
+    --spacing-s: 2px;
+    --spacing-m: 4px;
+    --spacing-l: 8px;
+    --spacing-xl: 12px;
+    --spacing-xxl: 16px;
+  }
+}
+</style></custom-style>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
+
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
index 957cc25..4248878 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -30,39 +30,37 @@
       --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);
+      --disabled-button-background-color: #484a4d;
       --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: #3c3f43;
+      --robot-comment-background-color: #1e3a5f;
+      --unresolved-comment-background-color: #614a19;
+      /* vote background colors */
       --vote-color-approved: #7fb66b;
       --vote-color-disliked: #bf6874;
       --vote-color-neutral: #597280;
@@ -71,16 +69,17 @@
 
       /* misc colors */
       --border-color: #5f6368;
+      --comment-separator-color: var(--border-color);
 
       /* fonts */
-      --font-weight-bold: 900;
+      --font-weight-bold: 700; /* 700 is the same as 'bold' */
 
       /* 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 +89,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 +102,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 +135,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
deleted file mode 100755
index 2782b65..0000000
--- a/polygerrit-ui/app/template_test.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/bash
-
-set -ex
-
-node_bin=$(which node) && true
-if [ -z "$node_bin" ]; then
-    echo "node must be on the path."
-    exit 1
-fi
-
-npm_bin=$(which npm) && true
-if [[ -z "$npm_bin" ]]; then
-    echo "NPM must be on the path. (https://www.npmjs.com/)"
-    exit 1
-fi
-
-# Have to find where node_modules are installed and set the NODE_PATH
-
-get_node_path() {
-    cd $(dirname $node_bin)
-    cd ../lib/node_modules
-    pwd
-}
-
-export NODE_PATH=$(get_node_path)
-
-unzip -o polygerrit-ui/polygerrit_components.bower_components.zip -d polygerrit-ui/app
-python $TEST_SRCDIR/gerrit/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
-# Pass a file name argument from the --test_args (example: --test_arg=gr-list-view)
-${node_bin} $TEST_SRCDIR/gerrit/polygerrit-ui/app/template_test_srcs/template_test.js $1 $2
diff --git a/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py b/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
deleted file mode 100644
index 579e783..0000000
--- a/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
+++ /dev/null
@@ -1,129 +0,0 @@
-import json
-import os
-import re
-
-polymerRegex = r"Polymer\({"
-polymerCompiledRegex = re.compile(polymerRegex)
-
-removeSelfInvokeRegex = r"\(function\(\) {\n(.+)}\)\(\);"
-fnCompiledRegex = re.compile(removeSelfInvokeRegex, re.DOTALL)
-
-regexBehavior = r"<script>(.+)<\/script>"
-behaviorCompiledRegex = re.compile(regexBehavior, re.DOTALL)
-
-
-def _open(filename, mode="r"):
-    try:
-        return open(filename, mode, encoding="utf-8")
-    except TypeError:
-        return open(filename, mode)
-
-
-def replaceBehaviorLikeHTML(fileIn, fileOut):
-    with _open(fileIn) as f:
-        file_str = f.read()
-        match = behaviorCompiledRegex.search(file_str)
-        if match:
-            with _open("polygerrit-ui/temp/behaviors/" +
-                       fileOut.replace("html", "js"), "w+") as f:
-                f.write(match.group(1))
-
-
-def replaceBehaviorLikeJS(fileIn, fileOut):
-    with _open(fileIn) as f:
-        file_str = f.read()
-        with _open("polygerrit-ui/temp/behaviors/" + fileOut, "w+") as f:
-            f.write(file_str)
-
-
-def generateStubBehavior(behaviorName):
-    with _open("polygerrit-ui/temp/behaviors/" +
-               behaviorName + ".js", "w+") as f:
-        f.write("/** @polymerBehavior **/\n" + behaviorName + "= {};")
-
-
-def replacePolymerElement(fileIn, fileOut, root):
-    with _open(fileIn) as f:
-        key = fileOut.split('.')[0]
-        # Removed self invoked function
-        file_str = f.read()
-        file_str_no_fn = fnCompiledRegex.search(file_str)
-
-        if file_str_no_fn:
-            package = root.replace("/", ".") + "." + fileOut
-
-            with _open("polygerrit-ui/temp/" + fileOut, "w+") as f:
-                mainFileContents = re.sub(
-                    polymerCompiledRegex,
-                    "exports = Polymer({",
-                    file_str_no_fn.group(1)).replace("'use strict';", "")
-                f.write("/** \n"
-                        "* @fileoverview \n"
-                        "* @suppress {missingProperties} \n"
-                        "*/ \n\n"
-                        "goog.module('polygerrit." + package + "')\n\n" +
-                        mainFileContents)
-
-            # Add package and javascript to files object.
-            elements[key]["js"] = "polygerrit-ui/temp/" + fileOut
-            elements[key]["package"] = package
-
-
-def writeTempFile(file, root):
-    # This is included in an extern because it is directly on the window object
-    # (for now at least).
-    if "gr-reporting" in file:
-        return
-    key = file.split('.')[0]
-    if key not in elements:
-        # gr-app doesn't have an additional level
-        elements[key] = {
-            "directory":
-                'gr-app' if len(root.split("/")) < 4 else root.split("/")[3]
-        }
-    if file.endswith(".html") and not file.endswith("_test.html"):
-        # gr-navigation is treated like a behavior rather than a standard
-        # element because of the way it added to the Gerrit object.
-        if file.endswith("gr-navigation.html"):
-            replaceBehaviorLikeHTML(os.path.join(root, file), file)
-        else:
-            elements[key]["html"] = os.path.join(root, file)
-    if file.endswith(".js"):
-        replacePolymerElement(os.path.join(root, file), file, root)
-
-
-if __name__ == "__main__":
-    # Create temp directory.
-    if not os.path.exists("polygerrit-ui/temp"):
-        os.makedirs("polygerrit-ui/temp")
-
-    # Within temp directory create behavior directory.
-    if not os.path.exists("polygerrit-ui/temp/behaviors"):
-        os.makedirs("polygerrit-ui/temp/behaviors")
-
-    elements = {}
-
-    # Go through every file in app/elements, and re-write accordingly to temp
-    # directory, and also added to elements object, which is used to generate a
-    # map of html files, package names, and javascript files.
-    for root, dirs, files in os.walk("polygerrit-ui/app/elements"):
-        for file in files:
-            writeTempFile(file, root)
-
-    # Special case for polymer behaviors we are using.
-    replaceBehaviorLikeHTML("polygerrit-ui/app/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html", "iron-a11y-keys-behavior.html")
-    generateStubBehavior("Polymer.IronOverlayBehavior")
-    generateStubBehavior("Polymer.IronFitBehavior")
-
-    # TODO figure out something to do with iron-overlay-behavior.
-    # it is hard-coded reformatted.
-
-    with _open("polygerrit-ui/temp/map.json", "w+") as f:
-        f.write(json.dumps(elements))
-
-    for root, dirs, files in os.walk("polygerrit-ui/app/behaviors"):
-        for file in files:
-            if file.endswith("behavior.html"):
-                replaceBehaviorLikeHTML(os.path.join(root, file), file)
-            elif file.endswith("behavior.js"):
-                replaceBehaviorLikeJS(os.path.join(root, file), file)
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
deleted file mode 100644
index d715d7d..0000000
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ /dev/null
@@ -1,86 +0,0 @@
-const fs = require('fs');
-const twinkie = require('fried-twinkie');
-
-fs.readdir('./polygerrit-ui/temp/behaviors/', (err, data) => {
-  if (err) {
-    console.log('error /polygerrit-ui/temp/behaviors/ directory');
-  }
-  const behaviors = data;
-  const additionalSources = [];
-  const externMap = {};
-
-  for (const behavior of behaviors) {
-    if (!externMap[behavior]) {
-      additionalSources.push({
-        path: `./polygerrit-ui/temp/behaviors/${behavior}`,
-        src: fs.readFileSync(
-            `./polygerrit-ui/temp/behaviors/${behavior}`, 'utf-8'),
-      });
-      externMap[behavior] = true;
-    }
-  }
-
-  let mappings = JSON.parse(fs.readFileSync(
-      `./polygerrit-ui/temp/map.json`, 'utf-8'));
-
-  // The directory is passed as arg2 by the test target.
-  const directory = process.argv[2];
-  if (directory) {
-    const mappingSpecificDirectory = {};
-
-    for (key of Object.keys(mappings)) {
-      if (directory === mappings[key].directory) {
-        mappingSpecificDirectory[key] = mappings[key];
-      }
-    }
-    mappings = mappingSpecificDirectory;
-  }
-
-  // If a particular file was passed by the user, don't test everything.
-  const file = process.argv[3];
-  if (file) {
-    const mappingSpecificFile = {};
-    for (key of Object.keys(mappings)) {
-      if (key.includes(file)) {
-        mappingSpecificFile[key] = mappings[key];
-      }
-    }
-    mappings = mappingSpecificFile;
-  }
-
-  /**
-   * Types in Gerrit.
-   * All types should be under `./polygerrit-ui/app/types` folder and end with `js`.
-   */
-  fs.readdir('./polygerrit-ui/app/types/', (err, typeFiles) => {
-    for (const typeFile of typeFiles) {
-      if (!typeFile.endsWith('.js')) continue;
-      additionalSources.push({
-        path: `./polygerrit-ui/app/types/${typeFile}`,
-        src: fs.readFileSync(
-            `./polygerrit-ui/app/types/${typeFile}`, 'utf-8'),
-      });
-    }
-
-    const toCheck = [];
-    for (key of Object.keys(mappings)) {
-      if (mappings[key].html && mappings[key].js) {
-        toCheck.push({
-          htmlSrcPath: mappings[key].html,
-          jsSrcPath: mappings[key].js,
-          jsModule: 'polygerrit.' + mappings[key].package,
-        });
-      }
-    }
-
-    twinkie.checkTemplate(toCheck, additionalSources)
-        .then(() => {}, joinedErrors => {
-          if (joinedErrors) {
-            process.exit(1);
-          }
-        }).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
deleted file mode 100644
index c1d8bbd..0000000
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ /dev/null
@@ -1,65 +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.
--->
-
-<link rel="import"
-    href="/bower_components/polymer-resin/standalone/polymer-resin.html" />
-<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
-<script>
-  security.polymer_resin.install({
-    allowedIdentifierPrefixes: [''],
-    reportHandler(isViolation, fmt, ...args) {
-      const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
-      log(isViolation, fmt, ...args);
-      if (isViolation) {
-        // This will cause the test to fail if there is a data binding
-        // violation.
-        throw new Error(
-            'polymer-resin violation: ' + fmt
-            + JSON.stringify(args));
-      }
-    },
-    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
-  });
-</script>
-<script>
-  /* eslint-disable no-unused-vars */
-  const mockPromise = () => {
-    let res;
-    const promise = new Promise(resolve => {
-      res = resolve;
-    });
-    promise.resolve = res;
-    return promise;
-  };
-  const isHidden = el => getComputedStyle(el).display === 'none';
-  /* eslint-enable no-unused-vars */
-</script>
-<script>
-  (function() {
-    setup(() => {
-      if (!window.Gerrit) { return; }
-      if (Gerrit._testOnly_resetPlugins) {
-        Gerrit._testOnly_resetPlugins();
-      }
-    });
-  })();
-</script>
-<link rel="import"
-    href="/bower_components/iron-test-helpers/iron-test-helpers.html" />
-<link rel="import" href="test-router.html" />
-<script src="/bower_components/moment/moment.js"></script>
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 7ceff7e..aea24da 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2019 The Android Open Source Project
+ * 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.
@@ -14,13 +14,92 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../scripts/bundled-polymer.js';
 
-/**
- * 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]');
-};
+import 'polymer-resin/standalone/polymer-resin.js';
+import '@polymer/iron-test-helpers/iron-test-helpers.js';
+import './test-router.js';
+import {SafeTypes} from '../behaviors/safe-types-behavior/safe-types-behavior.js';
+import {initAppContext} from '../services/app-context-init.js';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
+
+security.polymer_resin.install({
+  allowedIdentifierPrefixes: [''],
+  reportHandler(isViolation, fmt, ...args) {
+    const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
+    log(isViolation, fmt, ...args);
+    if (isViolation) {
+      // This will cause the test to fail if there is a data binding
+      // violation.
+      throw new Error(
+          'polymer-resin violation: ' + fmt +
+        JSON.stringify(args));
+    }
+  },
+  safeTypesBridge: SafeTypes.safeTypesBridge,
+});
+
+// Default implementations of 'fixture' and 'stub' methods in
+// web-component-tester are incorrect. Default methods calls mocha teardown
+// method to register cleanup actions. Each call to the teardown method adds
+// additional 'afterEach' hook to a suite.
+// As a result, if a suite's setup(..) method calls fixture(..) or stub(..)
+// method, then additional afterEach hook is registered before each test.
+// In overall, afterEach hook is called testCount^2 instead of testCount.
+// When tests runs with the wct test runner, the runner adds listener for
+// the 'afterEach' and tries to make some UI and log udpates. These updates
+// are quite heavy, and after about 40-50 tests each test waste 0.5-1seconds.
+//
+// Our implementation uses global teardown to clean up everything. mocha calls
+// global teardown after each test. The cleanups array stores all functions
+// which must be called after a test ends.
+//
+// Note, that fixture(...) and stub(..) methods are registered different by
+// WCT. This is why these methods implemented slightly different here.
+const cleanups = [];
+if (!window.fixture) {
+  window.fixture = function(fixtureId, model) {
+    // This method is inspired by WCT method
+    cleanups.push(() => document.getElementById(fixtureId).restore());
+    return document.getElementById(fixtureId).create(model);
+  };
+} else {
+  throw new Error('window.fixture must be set before wct sets it');
+}
+
+// On the first call to the setup, WCT installs window.fixture
+// and widnow.stub methods
+setup(() => {
+  // If the following asserts fails - then window.stub is
+  // overwritten by some other code.
+  assert.equal(cleanups.length, 0);
+
+  _testOnly_resetPluginLoader();
+  initAppContext();
+});
+
+if (window.stub) {
+  window.stub = function(tagName, implementation) {
+    // This method is inspired by WCT method
+    const proto = document.createElement(tagName).constructor.prototype;
+    const stubs = Object.keys(implementation)
+        .map(key => sinon.stub(proto, key, implementation[key]));
+    cleanups.push(() => {
+      stubs.forEach(stub => {
+        stub.restore();
+      });
+    });
+  };
+} else {
+  throw new Error('window.stub must be set after wct sets it');
+}
+
+teardown(() => {
+  // WCT incorrectly uses teardown method in the 'fixture' and 'stub'
+  // implementations. This leads to slowdown WCT tests after each tests.
+  // I.e. more tests in a file - longer it takes.
+  // For example, gr-file-list_test.html takes approx 40 second without
+  // a fix and 10 seconds with our implementation of fixture and stub.
+  cleanups.forEach(cleanup => cleanup());
+  cleanups.splice(0);
+});
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..63df0be 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -1,265 +1,34 @@
+<!-- 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. -->
+
 <!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>Elements 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>
-  const testFiles = [];
-  const scriptsPath = '../scripts/';
-  const elementsPath = '../elements/';
-  const behaviorsPath = '../behaviors/';
-
-  // Elements tests.
-  /* eslint-disable max-len */
-  const elements = [
-    // This seemed to be flakey when it was farther down the list. Keep at the
-    // beginning.
-    'gr-app_test.html',
-    'admin/gr-access-section/gr-access-section_test.html',
-    'admin/gr-admin-group-list/gr-admin-group-list_test.html',
-    'admin/gr-admin-view/gr-admin-view_test.html',
-    'admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html',
-    'admin/gr-create-change-dialog/gr-create-change-dialog_test.html',
-    'admin/gr-create-group-dialog/gr-create-group-dialog_test.html',
-    'admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html',
-    'admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html',
-    'admin/gr-group-audit-log/gr-group-audit-log_test.html',
-    'admin/gr-group-members/gr-group-members_test.html',
-    'admin/gr-group/gr-group_test.html',
-    'admin/gr-permission/gr-permission_test.html',
-    'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
-    'admin/gr-plugin-list/gr-plugin-list_test.html',
-    'admin/gr-repo-access/gr-repo-access_test.html',
-    'admin/gr-repo-command/gr-repo-command_test.html',
-    'admin/gr-repo-commands/gr-repo-commands_test.html',
-    'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
-    'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
-    'admin/gr-repo-list/gr-repo-list_test.html',
-    'admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html',
-    'admin/gr-repo/gr-repo_test.html',
-    'admin/gr-rule-editor/gr-rule-editor_test.html',
-    'change-list/gr-change-list-item/gr-change-list-item_test.html',
-    'change-list/gr-change-list-view/gr-change-list-view_test.html',
-    'change-list/gr-change-list/gr-change-list_test.html',
-    'change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html',
-    'change-list/gr-create-change-help/gr-create-change-help_test.html',
-    'change-list/gr-dashboard-view/gr-dashboard-view_test.html',
-    'change-list/gr-repo-header/gr-repo-header_test.html',
-    'change-list/gr-user-header/gr-user-header_test.html',
-    'change/gr-change-actions/gr-change-actions_test.html',
-    'change/gr-change-metadata/gr-change-metadata-it_test.html',
-    'change/gr-change-metadata/gr-change-metadata_test.html',
-    'change/gr-change-requirements/gr-change-requirements_test.html',
-    'change/gr-change-view/gr-change-view_test.html',
-    'change/gr-comment-list/gr-comment-list_test.html',
-    'change/gr-commit-info/gr-commit-info_test.html',
-    'change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html',
-    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
-    'change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html',
-    'change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html',
-    'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
-    'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
-    'change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html',
-    'change/gr-download-dialog/gr-download-dialog_test.html',
-    'change/gr-file-list-header/gr-file-list-header_test.html',
-    'change/gr-file-list/gr-file-list_test.html',
-    'change/gr-included-in-dialog/gr-included-in-dialog_test.html',
-    'change/gr-label-score-row/gr-label-score-row_test.html',
-    'change/gr-label-scores/gr-label-scores_test.html',
-    'change/gr-message/gr-message_test.html',
-    'change/gr-messages-list/gr-messages-list_test.html',
-    'change/gr-related-changes-list/gr-related-changes-list_test.html',
-    'change/gr-reply-dialog/gr-reply-dialog-it_test.html',
-    'change/gr-reply-dialog/gr-reply-dialog_test.html',
-    'change/gr-reviewer-list/gr-reviewer-list_test.html',
-    'change/gr-thread-list/gr-thread-list_test.html',
-    'change/gr-upload-help-dialog/gr-upload-help-dialog_test.html',
-    'core/gr-account-dropdown/gr-account-dropdown_test.html',
-    'core/gr-error-dialog/gr-error-dialog_test.html',
-    'core/gr-error-manager/gr-error-manager_test.html',
-    'core/gr-key-binding-display/gr-key-binding-display_test.html',
-    'core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html',
-    'core/gr-main-header/gr-main-header_test.html',
-    'core/gr-navigation/gr-navigation_test.html',
-    'core/gr-reporting/gr-reporting_test.html',
-    'core/gr-router/gr-router_test.html',
-    'core/gr-search-bar/gr-search-bar_test.html',
-    'core/gr-smart-search/gr-smart-search_test.html',
-    'diff/gr-comment-api/gr-comment-api_test.html',
-    'diff/gr-coverage-layer/gr-coverage-layer_test.html',
-    'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-builder/gr-diff-builder-unified_test.html',
-    'diff/gr-diff-cursor/gr-diff-cursor_test.html',
-    'diff/gr-diff-highlight/gr-annotation_test.html',
-    'diff/gr-diff-highlight/gr-diff-highlight_test.html',
-    'diff/gr-diff-host/gr-diff-host_test.html',
-    'diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html',
-    'diff/gr-diff-processor/gr-diff-processor_test.html',
-    'diff/gr-diff-selection/gr-diff-selection_test.html',
-    '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-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/gr-syntax-layer/gr-syntax-layer_test.html',
-    'documentation/gr-documentation-search/gr-documentation-search_test.html',
-    'edit/gr-default-editor/gr-default-editor_test.html',
-    'edit/gr-edit-controls/gr-edit-controls_test.html',
-    'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
-    'edit/gr-editor-view/gr-editor-view_test.html',
-    'plugins/gr-admin-api/gr-admin-api_test.html',
-    'plugins/gr-styles-api/gr-styles-api_test.html',
-    'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
-    'plugins/gr-dom-hooks/gr-dom-hooks_test.html',
-    'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
-    'plugins/gr-event-helper/gr-event-helper_test.html',
-    'plugins/gr-external-style/gr-external-style_test.html',
-    'plugins/gr-plugin-host/gr-plugin-host_test.html',
-    'plugins/gr-popup-interface/gr-plugin-popup_test.html',
-    'plugins/gr-popup-interface/gr-popup-interface_test.html',
-    'plugins/gr-repo-api/gr-repo-api_test.html',
-    'plugins/gr-settings-api/gr-settings-api_test.html',
-    'plugins/gr-theme-api/gr-theme-api_test.html',
-    'settings/gr-account-info/gr-account-info_test.html',
-    'settings/gr-agreements-list/gr-agreements-list_test.html',
-    'settings/gr-change-table-editor/gr-change-table-editor_test.html',
-    'settings/gr-cla-view/gr-cla-view_test.html',
-    'settings/gr-edit-preferences/gr-edit-preferences_test.html',
-    'settings/gr-email-editor/gr-email-editor_test.html',
-    'settings/gr-gpg-editor/gr-gpg-editor_test.html',
-    'settings/gr-group-list/gr-group-list_test.html',
-    'settings/gr-http-password/gr-http-password_test.html',
-    'settings/gr-identities/gr-identities_test.html',
-    'settings/gr-menu-editor/gr-menu-editor_test.html',
-    'settings/gr-registration-dialog/gr-registration-dialog_test.html',
-    'settings/gr-settings-view/gr-settings-view_test.html',
-    'settings/gr-ssh-editor/gr-ssh-editor_test.html',
-    'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
-    'shared/gr-event-interface/gr-event-interface_test.html',
-    'shared/gr-account-entry/gr-account-entry_test.html',
-    'shared/gr-account-label/gr-account-label_test.html',
-    'shared/gr-account-list/gr-account-list_test.html',
-    'shared/gr-account-link/gr-account-link_test.html',
-    'shared/gr-alert/gr-alert_test.html',
-    'shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html',
-    'shared/gr-autocomplete/gr-autocomplete_test.html',
-    'shared/gr-avatar/gr-avatar_test.html',
-    'shared/gr-button/gr-button_test.html',
-    'shared/gr-change-star/gr-change-star_test.html',
-    'shared/gr-change-status/gr-change-status_test.html',
-    'shared/gr-comment-thread/gr-comment-thread_test.html',
-    'shared/gr-comment/gr-comment_test.html',
-    'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
-    'shared/gr-count-string-formatter/gr-count-string-formatter_test.html',
-    'shared/gr-cursor-manager/gr-cursor-manager_test.html',
-    'shared/gr-date-formatter/gr-date-formatter_test.html',
-    'shared/gr-dialog/gr-dialog_test.html',
-    'shared/gr-diff-preferences/gr-diff-preferences_test.html',
-    'shared/gr-download-commands/gr-download-commands_test.html',
-    'shared/gr-dropdown/gr-dropdown_test.html',
-    'shared/gr-dropdown-list/gr-dropdown-list_test.html',
-    'shared/gr-editable-content/gr-editable-content_test.html',
-    'shared/gr-editable-label/gr-editable-label_test.html',
-    'shared/gr-formatted-text/gr-formatted-text_test.html',
-    'shared/gr-hovercard/gr-hovercard_test.html',
-    'shared/gr-js-api-interface/gr-annotation-actions-context_test.html',
-    'shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html',
-    'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
-    'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
-    'shared/gr-js-api-interface/gr-api-utils_test.html',
-    'shared/gr-js-api-interface/gr-js-api-interface_test.html',
-    'shared/gr-js-api-interface/gr-gerrit_test.html',
-    'shared/gr-js-api-interface/gr-plugin-action-context_test.html',
-    'shared/gr-js-api-interface/gr-plugin-loader_test.html',
-    'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
-    'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
-    'shared/gr-fixed-panel/gr-fixed-panel_test.html',
-    'shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html',
-    'shared/gr-label-info/gr-label-info_test.html',
-    'shared/gr-lib-loader/gr-lib-loader_test.html',
-    'shared/gr-limited-text/gr-limited-text_test.html',
-    'shared/gr-linked-chip/gr-linked-chip_test.html',
-    'shared/gr-linked-text/gr-linked-text_test.html',
-    'shared/gr-list-view/gr-list-view_test.html',
-    'shared/gr-overlay/gr-overlay_test.html',
-    'shared/gr-page-nav/gr-page-nav_test.html',
-    'shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html',
-    'shared/gr-rest-api-interface/gr-auth_test.html',
-    'shared/gr-rest-api-interface/gr-etag-decorator_test.html',
-    'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
-    'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
-    'shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html',
-    'shared/gr-select/gr-select_test.html',
-    'shared/gr-shell-command/gr-shell-command_test.html',
-    'shared/gr-storage/gr-storage_test.html',
-    'shared/gr-textarea/gr-textarea_test.html',
-    'shared/gr-tooltip-content/gr-tooltip-content_test.html',
-    'shared/gr-tooltip/gr-tooltip_test.html',
-    'shared/revision-info/revision-info_test.html',
-  ];
-  /* eslint-enable max-len */
-  for (let file of elements) {
-    file = elementsPath + file;
-    testFiles.push(file);
+<title>Elements Test Runner</title>
+<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/node_modules/web-component-tester/browser.js"></script>
+<style>
+  /* Prevent horizontal scrolling on page.
+   New version of web-component-tester creates very narrow iframe */
+  #subsuites {
+    width: 1500px !important;
   }
-
-  // Behaviors tests.
-  /* eslint-disable max-len */
-  const behaviors = [
-    'async-foreach-behavior/async-foreach-behavior_test.html',
-    'base-url-behavior/base-url-behavior_test.html',
-    'docs-url-behavior/docs-url-behavior_test.html',
-    'dom-util-behavior/dom-util-behavior_test.html',
-    'keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html',
-    'rest-client-behavior/rest-client-behavior_test.html',
-    'gr-access-behavior/gr-access-behavior_test.html',
-    'gr-admin-nav-behavior/gr-admin-nav-behavior_test.html',
-    'gr-change-table-behavior/gr-change-table-behavior_test.html',
-    'gr-list-view-behavior/gr-list-view-behavior_test.html',
-    'gr-display-name-behavior/gr-display-name-behavior_test.html',
-    'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
-    'gr-path-list-behavior/gr-path-list-behavior_test.html',
-    'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
-    'gr-url-encoding-behavior/gr-url-encoding-behavior_test.html',
-    'safe-types-behavior/safe-types-behavior_test.html',
-  ];
-  /* eslint-enable max-len */
-  for (let file of behaviors) {
-    // Behaviors do not utilize the DOM, so no shadow DOM test is necessary.
-    file = behaviorsPath + file;
-    testFiles.push(file);
-  }
-
-  const scripts = [
-    'gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html',
-    'gr-group-suggestions-provider/gr-group-suggestions-provider_test.html',
-    'gr-display-name-utils/gr-display-name-utils_test.html',
-    'gr-email-suggestions-provider/gr-email-suggestions-provider_test.html',
-  ];
-  /* eslint-enable max-len */
-  for (let file of scripts) {
-    file = scriptsPath + file;
-    testFiles.push(file);
-  }
-
-  WCT.loadSuites(testFiles);
+</style>
+<script type="module">
+    import {config, testsPerFileString} from './suite_conf.js';
+    import {getSuiteTests} from './tests.js';
+    WCT.loadSuites(
+        getSuiteTests(testsPerFileString, config.splitIndex, config.splitCount));
 </script>
diff --git a/polygerrit-ui/app/test/mock-diff-response.js b/polygerrit-ui/app/test/mock-diff-response.js
new file mode 100644
index 0000000..8ca44c2
--- /dev/null
+++ b/polygerrit-ui/app/test/mock-diff-response.js
@@ -0,0 +1,149 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export function getMockDiffResponse() {
+  // Return new response, so tests can't affect each other - if a test somehow
+  // modifies it, the future calls return original value
+  // Do not put it to a const outside of a method
+  return {
+    meta_a: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 45,
+    },
+    meta_b: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 48,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    diff_header: [
+      'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+      'index b2adcf4..554ae49 100644',
+      '--- a/lorem-ipsum.txt',
+      '+++ b/lorem-ipsum.txt',
+    ],
+    content: [
+      {
+        ab: [
+          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
+          'nulla phasellus.',
+          'Mattis lectus.',
+          'Sodales duis.',
+          'Orci a faucibus.',
+        ],
+      },
+      {
+        b: [
+          'Nullam neque, ligula ac, id blandit.',
+          'Sagittis tincidunt torquent, tempor nunc amet.',
+          'At rhoncus id.',
+        ],
+      },
+      {
+        ab: [
+          'Sem nascetur, erat ut, non in.',
+          'A donec, venenatis pellentesque dis.',
+          'Mauris mauris.',
+          'Quisque nisl duis, facilisis viverra.',
+          'Justo purus, semper eget et.',
+        ],
+      },
+      {
+        a: [
+          'Est amet, vestibulum pellentesque.',
+          'Erat ligula.',
+          'Justo eros.',
+          'Fringilla quisque.',
+        ],
+      },
+      {
+        ab: [
+          'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          'Eros suspendisse.',
+        ],
+      },
+      {
+        a: [
+          'Rhoncus tempor, ultricies aliquam ipsum.',
+        ],
+        b: [
+          'Rhoncus tempor, ultricies praesent ipsum.',
+        ],
+        edit_a: [
+          [
+            26,
+            7,
+          ],
+        ],
+        edit_b: [
+          [
+            26,
+            8,
+          ],
+        ],
+      },
+      {
+        ab: [
+          'Sollicitudin duis.',
+          'Blandit blandit, ante nisl fusce.',
+          'Felis ac at, tellus consectetuer.',
+          'Sociis ligula sapien, egestas leo.',
+          'Cum pulvinar, sed mauris, cursus neque velit.',
+          'Augue porta lobortis.',
+          'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
+          'Id quam ipsum, id urna et, massa suspendisse.',
+          'Ac nec, nibh praesent.',
+          'Rutrum vestibulum.',
+          'Est tellus, bibendum habitasse.',
+          'Justo facilisis, vel nulla.',
+          'Donec eu, vulputate neque aliquam, nulla dui.',
+          'Risus adipiscing in.',
+          'Lacus arcu arcu.',
+          'Urna velit.',
+          'Urna a dolor.',
+          'Lectus magna augue, convallis mattis tortor, sed tellus ' +
+          'consequat.',
+          'Etiam dui, blandit wisi.',
+          'Mi nec.',
+          'Vitae eget vestibulum.',
+          'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+          'Ac eget.',
+          'Vel fringilla, interdum pellentesque placerat, proin ante.',
+        ],
+      },
+      {
+        b: [
+          'Eu congue risus.',
+          'Enim ac, quis elementum.',
+          'Non et elit.',
+          'Etiam aliquam, diam vel nunc.',
+        ],
+      },
+      {
+        ab: [
+          'Nec at.',
+          'Arcu mauris, venenatis lacus fermentum, praesent duis.',
+          'Pellentesque amet et, tellus duis.',
+          'Ipsum arcu vitae, justo elit, sed libero tellus.',
+          'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
+        ],
+      },
+    ],
+  };
+}
diff --git a/polygerrit-ui/app/test/suite_conf.js b/polygerrit-ui/app/test/suite_conf.js
new file mode 100644
index 0000000..82870fe
--- /dev/null
+++ b/polygerrit-ui/app/test/suite_conf.js
@@ -0,0 +1,40 @@
+/**
+ * @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.
+ */
+
+/**
+ * This file is an example of the content.
+ * The real file is generated by the wct_test.sh script.
+ * Content of this file doesn't affect wct tests.
+ * Generated files contains all information to split test files between different tests
+ */
+
+export const config = {
+  splitIndex: 0, // Index for split (wct_suite creates several sh_test, each split has its own index)
+  splitCount: 1, // Defines the number of splits
+};
+
+/**
+ * testsPerFileString contains information about number of tests in each file
+ * This information is not precise. It is used to split test files between WCT suites more evenly.
+ */
+export const testsPerFileString = `
+./elements/change-list/gr-repo-header/gr-repo-header_test.html:1
+./elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html:25
+./elements/gr-app_test.html:4
+./behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html:13
+./behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html:19
+`;
diff --git a/polygerrit-ui/app/test/test-router.html b/polygerrit-ui/app/test/test-router.html
deleted file mode 100644
index 34ff374..0000000
--- a/polygerrit-ui/app/test/test-router.html
+++ /dev/null
@@ -1,22 +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.
--->
-
-<link rel="import" href="../elements/core/gr-navigation/gr-navigation.html">
-<script>
-  Gerrit.Nav.setup(url => { /* noop */ }, params => '', () => []);
-</script>
diff --git a/polygerrit-ui/app/test/test-router.js b/polygerrit-ui/app/test/test-router.js
new file mode 100644
index 0000000..9b89744
--- /dev/null
+++ b/polygerrit-ui/app/test/test-router.js
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {GerritNav} from '../elements/core/gr-navigation/gr-navigation.js';
+
+GerritNav.setup(url => { /* noop */ }, params => '', () => []);
diff --git a/polygerrit-ui/app/test/test-utils.js b/polygerrit-ui/app/test/test-utils.js
new file mode 100644
index 0000000..77d8e22
--- /dev/null
+++ b/polygerrit-ui/app/test/test-utils.js
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
+import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils.js';
+import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints.js';
+
+export const mockPromise = () => {
+  let res;
+  const promise = new Promise(resolve => {
+    res = resolve;
+  });
+  promise.resolve = res;
+  return promise;
+};
+export const isHidden = el => getComputedStyle(el).display === 'none';
+
+// Provide reset plugins function to clear installed plugins between tests.
+// No gr-app found (running tests)
+export const resetPlugins = () => {
+  testOnly_resetInternalState();
+  _testOnly_resetEndpoints();
+  _testOnly_resetPluginLoader();
+};
diff --git a/polygerrit-ui/app/test/tests.js b/polygerrit-ui/app/test/tests.js
new file mode 100644
index 0000000..795949d
--- /dev/null
+++ b/polygerrit-ui/app/test/tests.js
@@ -0,0 +1,339 @@
+/**
+ * @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.
+ */
+
+const testFiles = [];
+const scriptsPath = '../scripts/';
+const elementsPath = '../elements/';
+const behaviorsPath = '../behaviors/';
+const servicesPath = '../services/';
+
+// Elements tests.
+/* eslint-disable max-len */
+const elements = [
+  // This seemed to be flakey when it was farther down the list. Keep at the
+  // beginning.
+  'gr-app_test.html',
+  'admin/gr-access-section/gr-access-section_test.html',
+  'admin/gr-admin-group-list/gr-admin-group-list_test.html',
+  'admin/gr-admin-view/gr-admin-view_test.html',
+  'admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html',
+  'admin/gr-create-change-dialog/gr-create-change-dialog_test.html',
+  'admin/gr-create-group-dialog/gr-create-group-dialog_test.html',
+  'admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html',
+  'admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html',
+  'admin/gr-group-audit-log/gr-group-audit-log_test.html',
+  'admin/gr-group-members/gr-group-members_test.html',
+  'admin/gr-group/gr-group_test.html',
+  'admin/gr-permission/gr-permission_test.html',
+  'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
+  'admin/gr-plugin-list/gr-plugin-list_test.html',
+  'admin/gr-repo-access/gr-repo-access_test.html',
+  'admin/gr-repo-command/gr-repo-command_test.html',
+  'admin/gr-repo-commands/gr-repo-commands_test.html',
+  'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
+  'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
+  'admin/gr-repo-list/gr-repo-list_test.html',
+  'admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html',
+  'admin/gr-repo/gr-repo_test.html',
+  'admin/gr-rule-editor/gr-rule-editor_test.html',
+  'change-list/gr-change-list-item/gr-change-list-item_test.html',
+  'change-list/gr-change-list-view/gr-change-list-view_test.html',
+  'change-list/gr-change-list/gr-change-list_test.html',
+  'change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html',
+  'change-list/gr-create-change-help/gr-create-change-help_test.html',
+  'change-list/gr-dashboard-view/gr-dashboard-view_test.html',
+  'change-list/gr-repo-header/gr-repo-header_test.html',
+  'change-list/gr-user-header/gr-user-header_test.html',
+  'change/gr-change-actions/gr-change-actions_test.html',
+  'change/gr-change-metadata/gr-change-metadata-it_test.html',
+  'change/gr-change-metadata/gr-change-metadata_test.html',
+  'change/gr-change-requirements/gr-change-requirements_test.html',
+  'change/gr-change-view/gr-change-view_test.html',
+  'change/gr-comment-list/gr-comment-list_test.html',
+  'change/gr-commit-info/gr-commit-info_test.html',
+  'change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html',
+  'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
+  'change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html',
+  'change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html',
+  'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
+  'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
+  'change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html',
+  'change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html',
+  'change/gr-download-dialog/gr-download-dialog_test.html',
+  'change/gr-file-list-header/gr-file-list-header_test.html',
+  'change/gr-file-list/gr-file-list_test.html',
+  'change/gr-included-in-dialog/gr-included-in-dialog_test.html',
+  'change/gr-label-score-row/gr-label-score-row_test.html',
+  'change/gr-label-scores/gr-label-scores_test.html',
+  'change/gr-message/gr-message_test.html',
+  'change/gr-messages-list/gr-messages-list_test.html',
+  'change/gr-messages-list/gr-messages-list-experimental_test.html',
+  'change/gr-related-changes-list/gr-related-changes-list_test.html',
+  'change/gr-reply-dialog/gr-reply-dialog-it_test.html',
+  'change/gr-reply-dialog/gr-reply-dialog_test.html',
+  'change/gr-reviewer-list/gr-reviewer-list_test.html',
+  'change/gr-thread-list/gr-thread-list_test.html',
+  'change/gr-upload-help-dialog/gr-upload-help-dialog_test.html',
+  'core/gr-account-dropdown/gr-account-dropdown_test.html',
+  'core/gr-error-dialog/gr-error-dialog_test.html',
+  'core/gr-error-manager/gr-error-manager_test.html',
+  'core/gr-key-binding-display/gr-key-binding-display_test.html',
+  'core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html',
+  'core/gr-main-header/gr-main-header_test.html',
+  'core/gr-navigation/gr-navigation_test.html',
+  'core/gr-reporting/gr-reporting_test.html',
+  'core/gr-router/gr-router_test.html',
+  'core/gr-search-bar/gr-search-bar_test.html',
+  'core/gr-smart-search/gr-smart-search_test.html',
+  'diff/gr-comment-api/gr-comment-api_test.html',
+  'diff/gr-coverage-layer/gr-coverage-layer_test.html',
+  'diff/gr-diff-builder/gr-diff-builder-element_test.html',
+  'diff/gr-diff-builder/gr-diff-builder-unified_test.html',
+  'diff/gr-diff-cursor/gr-diff-cursor_test.html',
+  'diff/gr-diff-highlight/gr-annotation_test.html',
+  'diff/gr-diff-highlight/gr-diff-highlight_test.html',
+  'diff/gr-diff-host/gr-diff-host_test.html',
+  'diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html',
+  'diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html',
+  'diff/gr-diff-processor/gr-diff-processor_test.html',
+  'diff/gr-diff-selection/gr-diff-selection_test.html',
+  '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/gr-syntax-layer/gr-syntax-layer_test.html',
+  'documentation/gr-documentation-search/gr-documentation-search_test.html',
+  'edit/gr-default-editor/gr-default-editor_test.html',
+  'edit/gr-edit-controls/gr-edit-controls_test.html',
+  'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
+  'edit/gr-editor-view/gr-editor-view_test.html',
+  'plugins/gr-admin-api/gr-admin-api_test.html',
+  'plugins/gr-styles-api/gr-styles-api_test.html',
+  'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
+  'plugins/gr-dom-hooks/gr-dom-hooks_test.html',
+  'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
+  'plugins/gr-event-helper/gr-event-helper_test.html',
+  'plugins/gr-external-style/gr-external-style_test.html',
+  'plugins/gr-plugin-host/gr-plugin-host_test.html',
+  'plugins/gr-popup-interface/gr-plugin-popup_test.html',
+  'plugins/gr-popup-interface/gr-popup-interface_test.html',
+  'plugins/gr-repo-api/gr-repo-api_test.html',
+  'plugins/gr-settings-api/gr-settings-api_test.html',
+  'plugins/gr-theme-api/gr-theme-api_test.html',
+  'settings/gr-account-info/gr-account-info_test.html',
+  'settings/gr-agreements-list/gr-agreements-list_test.html',
+  'settings/gr-change-table-editor/gr-change-table-editor_test.html',
+  'settings/gr-cla-view/gr-cla-view_test.html',
+  'settings/gr-edit-preferences/gr-edit-preferences_test.html',
+  'settings/gr-email-editor/gr-email-editor_test.html',
+  'settings/gr-gpg-editor/gr-gpg-editor_test.html',
+  'settings/gr-group-list/gr-group-list_test.html',
+  'settings/gr-http-password/gr-http-password_test.html',
+  'settings/gr-identities/gr-identities_test.html',
+  'settings/gr-menu-editor/gr-menu-editor_test.html',
+  'settings/gr-registration-dialog/gr-registration-dialog_test.html',
+  'settings/gr-settings-view/gr-settings-view_test.html',
+  'settings/gr-ssh-editor/gr-ssh-editor_test.html',
+  'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
+  'shared/gr-event-interface/gr-event-interface_test.html',
+  'shared/gr-account-entry/gr-account-entry_test.html',
+  'shared/gr-account-label/gr-account-label_test.html',
+  'shared/gr-account-list/gr-account-list_test.html',
+  'shared/gr-account-link/gr-account-link_test.html',
+  'shared/gr-alert/gr-alert_test.html',
+  'shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html',
+  'shared/gr-autocomplete/gr-autocomplete_test.html',
+  'shared/gr-avatar/gr-avatar_test.html',
+  'shared/gr-button/gr-button_test.html',
+  'shared/gr-change-star/gr-change-star_test.html',
+  'shared/gr-change-status/gr-change-status_test.html',
+  'shared/gr-comment-thread/gr-comment-thread_test.html',
+  'shared/gr-comment/gr-comment_test.html',
+  'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
+  'shared/gr-count-string-formatter/gr-count-string-formatter_test.html',
+  'shared/gr-cursor-manager/gr-cursor-manager_test.html',
+  'shared/gr-date-formatter/gr-date-formatter_test.html',
+  'shared/gr-dialog/gr-dialog_test.html',
+  'shared/gr-diff-preferences/gr-diff-preferences_test.html',
+  'shared/gr-download-commands/gr-download-commands_test.html',
+  'shared/gr-dropdown/gr-dropdown_test.html',
+  'shared/gr-dropdown-list/gr-dropdown-list_test.html',
+  'shared/gr-editable-content/gr-editable-content_test.html',
+  'shared/gr-editable-label/gr-editable-label_test.html',
+  'shared/gr-formatted-text/gr-formatted-text_test.html',
+  'shared/gr-hovercard/gr-hovercard_test.html',
+  'shared/gr-hovercard-account/gr-hovercard-account_test.html',
+  'shared/gr-js-api-interface/gr-annotation-actions-context_test.html',
+  'shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html',
+  'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
+  'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
+  'shared/gr-js-api-interface/gr-api-utils_test.html',
+  'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+  'shared/gr-js-api-interface/gr-gerrit_test.html',
+  'shared/gr-js-api-interface/gr-plugin-action-context_test.html',
+  'shared/gr-js-api-interface/gr-plugin-loader_test.html',
+  'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
+  'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
+  'shared/gr-fixed-panel/gr-fixed-panel_test.html',
+  'shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html',
+  'shared/gr-label-info/gr-label-info_test.html',
+  'shared/gr-lib-loader/gr-lib-loader_test.html',
+  'shared/gr-limited-text/gr-limited-text_test.html',
+  'shared/gr-linked-chip/gr-linked-chip_test.html',
+  'shared/gr-linked-text/gr-linked-text_test.html',
+  'shared/gr-list-view/gr-list-view_test.html',
+  'shared/gr-overlay/gr-overlay_test.html',
+  'shared/gr-page-nav/gr-page-nav_test.html',
+  'shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html',
+  'shared/gr-rest-api-interface/gr-auth_test.html',
+  'shared/gr-rest-api-interface/gr-etag-decorator_test.html',
+  'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
+  'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
+  'shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html',
+  'shared/gr-select/gr-select_test.html',
+  'shared/gr-shell-command/gr-shell-command_test.html',
+  'shared/gr-storage/gr-storage_test.html',
+  'shared/gr-textarea/gr-textarea_test.html',
+  'shared/gr-tooltip-content/gr-tooltip-content_test.html',
+  'shared/gr-tooltip/gr-tooltip_test.html',
+  'shared/revision-info/revision-info_test.html',
+];
+/* eslint-enable max-len */
+for (let file of elements) {
+  file = elementsPath + file;
+  testFiles.push(file);
+}
+
+// Behaviors tests.
+/* eslint-disable max-len */
+const behaviors = [
+  'async-foreach-behavior/async-foreach-behavior_test.html',
+  'base-url-behavior/base-url-behavior_test.html',
+  'docs-url-behavior/docs-url-behavior_test.html',
+  'dom-util-behavior/dom-util-behavior_test.html',
+  'keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html',
+  'rest-client-behavior/rest-client-behavior_test.html',
+  'gr-access-behavior/gr-access-behavior_test.html',
+  'gr-admin-nav-behavior/gr-admin-nav-behavior_test.html',
+  'gr-change-table-behavior/gr-change-table-behavior_test.html',
+  'gr-list-view-behavior/gr-list-view-behavior_test.html',
+  'gr-display-name-behavior/gr-display-name-behavior_test.html',
+  'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
+  'gr-path-list-behavior/gr-path-list-behavior_test.html',
+  'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
+  'gr-url-encoding-behavior/gr-url-encoding-behavior_test.html',
+  'safe-types-behavior/safe-types-behavior_test.html',
+];
+/* eslint-enable max-len */
+for (let file of behaviors) {
+  // Behaviors do not utilize the DOM, so no shadow DOM test is necessary.
+  file = behaviorsPath + file;
+  testFiles.push(file);
+}
+
+const scripts = [
+  'gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html',
+  'gr-group-suggestions-provider/gr-group-suggestions-provider_test.html',
+  'gr-display-name-utils/gr-display-name-utils_test.html',
+  'gr-email-suggestions-provider/gr-email-suggestions-provider_test.html',
+  'util_test.html',
+];
+/* eslint-enable max-len */
+for (let file of scripts) {
+  file = scriptsPath + file;
+  testFiles.push(file);
+}
+
+const services = [
+  'flags_test.html',
+];
+for (let file of services) {
+  file = servicesPath + file;
+  testFiles.push(file);
+}
+
+/**
+ * Converts multiline string to a map<file_name, test_count>.
+ *
+ * @param {number} testsPerFileString - multiline input string in the following format:
+ *   fileName1:test_count1
+ *   fileName2:test_count2
+ *   ...
+ *   fileName3:test_count3
+ * @return Object<string, number> - key is the test file name, value is the number of tests
+ */
+function parseTestsPerFileString(testsPerFileString) {
+  return testsPerFileString.split('\n').map(s => s.trim().replace('./', '../'))
+      .reduce((acc, fileAndCount) => {
+        const [file, countStr] = fileAndCount.split(':');
+        acc[file] = parseInt(countStr);
+        return acc;
+      }, {});
+}
+
+const defaultTestsPerFile = [];
+
+function getBucketWithMinTests(buckets) {
+  let minBucket = buckets[0];
+  for (let i = 1; i < buckets.length; i++) {
+    if (buckets[i].count < minBucket.count) {
+      minBucket = buckets[i];
+    }
+  }
+  return minBucket;
+}
+
+/**
+ * Split testFiles among all buckets. The greedy algorithm is used,
+ * because we don't need accurate splitting
+ */
+function splitTestsByBuckets(buckets, testsPerFile) {
+  for (const testFile of testFiles) {
+    const testsInFile = testsPerFile[testFile] ?
+      testsPerFile[testFile] : defaultTestsPerFile;
+    const minBucket = getBucketWithMinTests(buckets);
+    minBucket.count += testsInFile;
+    minBucket.items.push(testFile);
+  }
+}
+
+/**
+ * Returns list of test files for specified splitIndex
+ *
+ * @param {string} testsPerFileString - information about number of tests in each file
+ *  (see suite_conf.js for exact format)
+ * @param {number} splitIndex - index of split to return (0<=splitIndex<splitCount)
+ * @param {number} splitCount - total number of splits
+ * @return Array<string> - list of test files
+ */
+export function getSuiteTests(testsPerFileString, splitIndex, splitCount) {
+  const testsPerFile = parseTestsPerFileString(testsPerFileString);
+  const buckets = [];
+  for (let i = 0; i < splitCount; i++) {
+    buckets.push({count: 0, items: []});
+  }
+  // TODO(dmfilippov): split tests by buckets only once
+  // This doesn't affect overall performance, so we can keep it
+  // while we have only small amounts of test files.
+  splitTestsByBuckets(buckets, testsPerFile);
+  console.log(buckets);
+  return buckets[splitIndex].items;
+}
+
diff --git a/polygerrit-ui/app/types/types.js b/polygerrit-ui/app/types/types.js
index e17bec8..5408eea 100644
--- a/polygerrit-ui/app/types/types.js
+++ b/polygerrit-ui/app/types/types.js
@@ -17,10 +17,8 @@
 
 // Type definitions used across multiple files in Gerrit
 
-window.Gerrit = window.Gerrit || {};
-
 /** @enum {string} */
-Gerrit.CoverageType = {
+export const CoverageType = {
   /**
    * start_character and end_character of the range will be ignored for this
    * type.
@@ -40,6 +38,8 @@
   NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
 };
 
+const Gerrit = window.Gerrit || {};
+
 /**
  * @typedef {{
  *   start_line: number,
@@ -87,6 +87,15 @@
 Gerrit.ChangeFetchRequest;
 
 /**
+ * @typedef {{
+ *   is_private: boolean,
+ *   subject: string,
+ *   unresolved_comment_count: number,
+ * }}
+ */
+Gerrit.Change;
+
+/**
  * Object to describe a request for passing into _send.
  * - method is the HTTP method to use in the request.
  * - url is the URL for the request
@@ -276,4 +285,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/app/wct.conf.js b/polygerrit-ui/app/wct.conf.js
new file mode 100644
index 0000000..1a9300e
--- /dev/null
+++ b/polygerrit-ui/app/wct.conf.js
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+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): ./polygerrit-ui/app/run_test.sh --test_arg=--plugin --test_arg=sauce
+*/
+
+const headless = 'WCT_HEADLESS_MODE' in process.env ?
+  process.env['WCT_HEADLESS_MODE'] === '1' : false;
+
+const headlessBrowserOptions = {
+  chrome: ['start-maximized', 'headless', 'disable-gpu', 'no-sandbox'],
+  firefox: ['-headless'],
+};
+
+const defaultBrowserOptions = {
+  chrome: ['start-maximized'],
+  firefox: [],
+};
+
+module.exports = {
+  suites: ['test'],
+  npm: true,
+  moduleResolution: 'node',
+  wctPackageName: 'wct-browser-legacy',
+  plugins: {
+    local: {
+      skipSeleniumInstall: true,
+      browserOptions: headless ? headlessBrowserOptions : defaultBrowserOptions,
+    },
+    sauce: {
+      disabled: true,
+      browsers: [
+        'OS X 10.12/chrome',
+        'Windows 10/chrome',
+        'Linux/firefox',
+        'OS X 10.12/safari',
+        'Windows 10/microsoftedge',
+      ],
+    },
+  },
+};
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
index f1b4666..42b98ab 100755
--- a/polygerrit-ui/app/wct_test.sh
+++ b/polygerrit-ui/app/wct_test.sh
@@ -1,67 +1,37 @@
 #!/bin/sh
 
 set -ex
+root_dir=$(pwd)
+t=$TEST_TMPDIR
+export JSON_CONFIG=$2
 
-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
+mkdir -p $t/node_modules
+# WCT doesn't implement node module resolution.
+# WCT uses only node_module/ directory from current directory when looking for a module
+# So, it is impossible to make hierarchical node_modules. Instead, we copy
+# all node_modules to one directory.
+cp -R -L ./external/ui_dev_npm/node_modules/* $t/node_modules
 
-echo $t
-unzip -qd $t $components
-unzip -qd $t $code
-mkdir -p $t/test
-cp $TEST_SRCDIR/gerrit/polygerrit-ui/app/test/index.html $t/test/
+# Copy ui_npm, so it will override ui_dev_npm modules (in case of conflicts)
+# Because browser always requests specific exact files (i.e. not a directory),
+# it always receives file from ui_npm. It can broke WCT itself but luckely it works.
+cp -R -L ./external/ui_npm/node_modules/* $t/node_modules
 
-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/run_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
+cp -R -L ./polygerrit-ui/app/* $t/
 
 export PATH="$(dirname $NPM):$PATH"
 
 cd $t
-test -n "${WCT}"
+echo "export const config=$JSON_CONFIG;" > ./test/suite_conf.js
+echo "export const testsPerFileString=\`" >> ./test/suite_conf.js
+# Count number of tests in each file.
+# We don't need accurate data, use simpliest method
+# TODO(dmfilippov): collect data only once
+# In the current implementation, the same data is collected for each split,
+# It takes less than a second which many times less than the overall wct test time
+grep -rnw '.' --include=\*_test.html -e "test(" -c >> ./test/suite_conf.js
+echo "\`;" >>./test/suite_conf.js
 
-${WCT} ${WCT_ARGS}
+# If wct doesn't receive any paramenters, it fails (can't find files)
+# Pass --config-file as a parameter to have some arguments in command line
+$root_dir/$1 --config-file wct.conf.js ${WCT_ARGS}
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
new file mode 100644
index 0000000..927c19b
--- /dev/null
+++ b/polygerrit-ui/app/yarn.lock
@@ -0,0 +1,379 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@polymer/font-roboto-local@^3.0.2":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/font-roboto-local/-/font-roboto-local-3.0.2.tgz#563cd6cabbcaef54999d654c0f3d476bcc49ce58"
+  integrity sha512-mCd9TcjwnCxU+7uVHCkbREGU+OmzStvYh3ru5DSaftOQDnMrLAzernEv/QCcfSPRgTMHij+pIUN4tcaGeDGcYg==
+
+"@polymer/font-roboto@^3.0.1":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/font-roboto/-/font-roboto-3.0.2.tgz#80cdaa7225db2359130dfb2c6d9a3be1820020c3"
+  integrity sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA==
+
+"@polymer/iron-a11y-announcer@^3.0.0-pre.26":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.0.2.tgz#730dd36ccb2e042ecd5160ba439c2bf2f8a97412"
+  integrity sha512-LqnMF39mXyxSSRbTHRzGbcJS8nU0NVTo2raBOgOlpxw5yfGJUVcwaTJ/qy5NtWCZLRfa4suycf0oAkuUjHTXHQ==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26", "@polymer/iron-a11y-keys-behavior@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-keys-behavior/-/iron-a11y-keys-behavior-3.0.1.tgz#2868ea72912d2007ffab4734684a91f5aac49b84"
+  integrity sha512-lnrjKq3ysbBPT/74l0Fj0U9H9C35Tpw2C/tpJ8a+5g8Y3YJs1WSZYnEl1yOkw6sEyaxOq/1DkzH0+60gGu5/PQ==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-autogrow-textarea@^3.0.0-pre.26", "@polymer/iron-autogrow-textarea@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-autogrow-textarea/-/iron-autogrow-textarea-3.0.1.tgz#0205d9c5ca16f3afd505f41e9037989707d59dce"
+  integrity sha512-FgSL7APrOSL9Vu812sBCFlQ17hvnJsBAV2C2e1UAiaHhB+dyfLq8gGdGUpqVWuGJ50q4Y/49QwCNnLf85AdVYA==
+  dependencies:
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-validatable-behavior" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-behaviors@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-behaviors/-/iron-behaviors-3.0.1.tgz#a3b6f876779a7f0a91a15e4423890968b6525901"
+  integrity sha512-IMEwcv1lhf1HSQxuyWOUIL0lOBwmeaoSTpgCJeP9IBYnuB1SPQngmfRuHKgK6/m9LQ9F9miC7p3HeQQUdKAE0w==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-checked-element-behavior@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-checked-element-behavior/-/iron-checked-element-behavior-3.0.1.tgz#7a4b49646603657ab2c5e5ca7bd97f34444fdaf5"
+  integrity sha512-aDr0cbCNVq49q+pOqa6CZutFh+wWpwPMLpEth9swx+GkAj+gCURhuQkaUYhIo5f2egDbEioR1aeHMnPlU9dQZA==
+  dependencies:
+    "@polymer/iron-form-element-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-validatable-behavior" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-dropdown@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-dropdown/-/iron-dropdown-3.0.1.tgz#c573faa1a08c179d201ae877c1c726018314bff3"
+  integrity sha512-22yLhepfcKjuQMfFmRHi/9MPKTqkzgRrmWWW0P5uqK++xle53k2QBO5VYUAYiCN3ZcxIi9lEhZ9YWGeQj2JBig==
+  dependencies:
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-overlay-behavior" "^3.0.0-pre.27"
+    "@polymer/neon-animation" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.0.1":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-fit-behavior/-/iron-fit-behavior-3.0.2.tgz#2ec460d8a6b0151394b55631a72a68b92e14e2e0"
+  integrity sha512-JndryJYbBR3gSN5IlST4rCHsd01+OyvYpRO6z5Zd3C6u5V/m07TwAtcf3aXwZ8WBNt2eLG28OcvdSO7XR2v2pg==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-flex-layout@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-flex-layout/-/iron-flex-layout-3.0.1.tgz#36f9e1a8eb792d279b2bc75d362628721ad37f0c"
+  integrity sha512-7gB869czArF+HZcPTVSgvA7tXYFze9EKckvM95NB7SqYF+NnsQyhoXgKnpFwGyo95lUjUW9TFDLUwDXnCYFtkw==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-form-element-behavior@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-form-element-behavior/-/iron-form-element-behavior-3.0.1.tgz#4c79e1981d7796ce659e997f3b8f5e14b4a075a4"
+  integrity sha512-G/e2KXyL5AY7mMjmomHkGpgS0uAf4ovNpKhkuUTRnMuMJuf589bKqE85KN4ovE1Tzhv2hJoh/igyD6ekHiYU1A==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-icon@^3.0.0-pre.26", "@polymer/iron-icon@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-icon/-/iron-icon-3.0.1.tgz#93211c39d8825fe4965a68419566036c1df291eb"
+  integrity sha512-QLPwirk+UPZNaLnMew9VludXA4CWUCenRewgEcGYwdzVgDPCDbXxy6vRJjmweZobMQv/oVLppT2JZtJFnPxX6g==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-meta" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-iconset-svg@^3.0.0-pre.26", "@polymer/iron-iconset-svg@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-iconset-svg/-/iron-iconset-svg-3.0.1.tgz#568d6e7dbc120299dae63be3600aeba0d30ddbea"
+  integrity sha512-XNwURbNHRw6u2fJe05O5fMYye6GSgDlDqCO+q6K1zAnKIrpgZwf2vTkBd5uCcZwsN0FyCB3mvNZx4jkh85dRDw==
+  dependencies:
+    "@polymer/iron-meta" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-input@^3.0.0-pre.26", "@polymer/iron-input@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-input/-/iron-input-3.0.1.tgz#dc866a25107f9b38d9ca4512dd9a3e51b78b4915"
+  integrity sha512-WLx13kEcbH9GKbj9+pWR6pbJkA5kxn3796ynx6eQd2rueMyUfVTR3GzOvadBKsciUuIuzrxpBWZ2+3UcueVUQQ==
+  dependencies:
+    "@polymer/iron-a11y-announcer" "^3.0.0-pre.26"
+    "@polymer/iron-validatable-behavior" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-menu-behavior@^3.0.0-pre.26":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-menu-behavior/-/iron-menu-behavior-3.0.2.tgz#f8fa2d59af472a4cb4fb0359c704b808bc2c105d"
+  integrity sha512-8dpASkFNBIkxAJWsFLWIO1M7tKM0+wKs3PqdeF/dDdBciwoaaFgC2K1XCZFZnbe2t9/nJgemXxVugGZAWpYCGg==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-selector" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-meta@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-meta/-/iron-meta-3.0.1.tgz#7f140628d127b0a284f882f1bb323a261bc125f5"
+  integrity sha512-pWguPugiLYmWFV9UWxLWzZ6gm4wBwQdDy4VULKwdHCqR7OP7u98h+XDdGZsSlDPv6qoryV/e3tGHlTIT0mbzJA==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.2":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-overlay-behavior/-/iron-overlay-behavior-3.0.3.tgz#29c198e19e05bb2bcf7d86d3c11848cb93301d00"
+  integrity sha512-Q/Fp0+uOQQ145ebZ7T8Cxl4m1tUKYjyymkjcL2rXUm+aDQGb1wA1M1LYxUF5YBqd+9lipE0PTIiYwA2ZL/sznA==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-fit-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-resizable-behavior" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-resizable-behavior@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-resizable-behavior/-/iron-resizable-behavior-3.0.1.tgz#e284348ed7c1c7e263f7039297532fa954025ea2"
+  integrity sha512-FyHxRxFspVoRaeZSWpT3y0C9awomb4tXXolIJcZ7RvXhMP632V5lez+ch5G5SwK0LpnAPkg35eB0LPMFv+YMMQ==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-selector@^3.0.0-pre.26", "@polymer/iron-selector@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-selector/-/iron-selector-3.0.1.tgz#e845bec58489c96b4e7609525532437869ad5a88"
+  integrity sha512-sBVk2uas6prW0glUe2xEJJYlvxmYzM40Au9OKbfDK2Qekou/fLKcBRyIYI39kuI8zWRaip8f3CI8qXcUHnKb1A==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/iron-validatable-behavior@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-validatable-behavior/-/iron-validatable-behavior-3.0.1.tgz#73538f005a07741c31b6fc1e981168c3d3e0d92b"
+  integrity sha512-wwpYh6wOa4fNI+jH5EYKC7TVPYQ2OfgQqocWat7GsNWcsblKYhLYbwsvEY5nO0n2xKqNfZzDLrUom5INJN7msQ==
+  dependencies:
+    "@polymer/iron-meta" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/neon-animation@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/neon-animation/-/neon-animation-3.0.1.tgz#6658e4b524abc057477772a7473292493d366c24"
+  integrity sha512-cDDc0llpVCe0ATbDS3clDthI54Bc8YwZIeTGGmBJleKOvbRTUC5+ssJmRL+VwVh+VM5FlnQlx760ppftY3uprg==
+  dependencies:
+    "@polymer/iron-resizable-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-selector" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-behaviors@^3.0.0-pre.27":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-behaviors/-/paper-behaviors-3.0.1.tgz#83f1cd06489f484c1b108a2967fb01952df722ad"
+  integrity sha512-6knhj69fPJejv8qR0kCSUY+Q0XjaUf0OSnkjRjmTJPAwSrRYtgqE+l6P1FfA+py1X/cUjgne9EF5rMZAKJIg1g==
+  dependencies:
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-checked-element-behavior" "^3.0.0-pre.26"
+    "@polymer/paper-ripple" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-button@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-button/-/paper-button-3.0.1.tgz#f13b019137e3f6ccc4d04d0b1f27f4830ea5774d"
+  integrity sha512-JRNBc+Oj9EWnmyLr7FcCr8T1KAnEHPh6mosln9BUdkM+qYaYsudSICh3cjTIbnj6AuF5OJidoLkM1dlyj0j6Zg==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-dialog-behavior@^3.0.0-pre.26", "@polymer/paper-dialog-behavior@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-dialog-behavior/-/paper-dialog-behavior-3.0.1.tgz#819b2fbb9444c1c318ddf55f02819bb29a85657b"
+  integrity sha512-wbI4kCK8le/9MHT+IXzvHjoatxf3kd3Yn0tgozAiAwfSZ7N4Ubpi5MHrK0m9S9PeIxKokAgBYdTUrezSE5378A==
+  dependencies:
+    "@polymer/iron-overlay-behavior" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-dialog-scrollable@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-dialog-scrollable/-/paper-dialog-scrollable-3.0.1.tgz#42fd30380320e6dd6d4d68b2ac4e45ee9e5e024f"
+  integrity sha512-1E8B9kNdL58jUrJ/BwqJeOoNVcxNrB559z//d1V0rVHWT5bWCCZegwS3G06iFK5MjxWFbIKzleVTLrT0opiZkA==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/paper-dialog-behavior" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-dialog@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-dialog/-/paper-dialog-3.0.1.tgz#728ebdbfc4d35ec1485e543434cef5dba476f15e"
+  integrity sha512-KvglYbEq7AWJvui2j6WKLnOvgVMeGjovAydGrPRj7kVzCiD49Eq/hpYFJTRV5iDcalWH+mORUpw+jrFnG9+Kgw==
+  dependencies:
+    "@polymer/iron-overlay-behavior" "^3.0.0-pre.27"
+    "@polymer/neon-animation" "^3.0.0-pre.26"
+    "@polymer/paper-dialog-behavior" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-icon-button@^3.0.0-pre.26":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-icon-button/-/paper-icon-button-3.0.2.tgz#a1254faadc2c8dd135ce1ae33bcc161a94c31f65"
+  integrity sha512-kOdxQgnKL097bggFF6PWvsBYuWg+MCcoHoTHX6bh/MuZoWFZNjrFntFqwuB4oEbpjCpfm4moA33muPJFj7CihQ==
+  dependencies:
+    "@polymer/iron-icon" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-input@^3.0.2":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-input/-/paper-input-3.2.0.tgz#a07dbc1b009bac97a5a86eccb57d99b17bd96285"
+  integrity sha512-vYEBxq6LDR+QGDrAO/il0JNhCd+31TwSnv58MVV+ijaGKz1qAuSJw4NSsgF3lrXCwomqnpME19vbp2ktrcluVA==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-autogrow-textarea" "^3.0.0-pre.26"
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-form-element-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-input" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-item@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-item/-/paper-item-3.0.1.tgz#05b3543483e556cd5532431cd1751a84343989b5"
+  integrity sha512-KTk2N+GsYiI/HuubL3sxebZ6tteQbBOAp4QVLAnbjSPmwl+mJSDWk+omuadesU0bpkCwaWVs3fHuQsmXxy4pkw==
+  dependencies:
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-listbox@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-listbox/-/paper-listbox-3.0.1.tgz#fe05094781b359e4afbc5bec89a07758a303a957"
+  integrity sha512-vMLWFpYcggAPmEDBmK+96fFefacOG3GLB1EguTn8+ZkqI+328hNfw1MzHjH68rgCIIUtjmm+9qgB1Sy/MN0a/A==
+  dependencies:
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-menu-behavior" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-ripple@^3.0.0-pre.26":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-ripple/-/paper-ripple-3.0.2.tgz#52566f5ee367942022ceaa991368105d21403de5"
+  integrity sha512-DnLNvYIMsiayeICroYxx6Q6Hg1cUU8HN2sbutXazlemAlGqdq80qz3TIaVdbpbt/pvjcFGX2HtntMlPstCge8Q==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-styles@^3.0.0-pre.26":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-styles/-/paper-styles-3.0.1.tgz#bd4962b83ab8432cd1cf197bb5222d3a08f135e1"
+  integrity sha512-y6hmObLqlCx602TQiSBKHqjwkE7xmDiFkoxdYGaNjtv4xcysOTdVJsDR/R9UHwIaxJ7gHlthMSykir1nv78++g==
+  dependencies:
+    "@polymer/font-roboto" "^3.0.1"
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-tabs@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-tabs/-/paper-tabs-3.1.0.tgz#a173839d20703fdd5fca97a9d878f7b0e6257150"
+  integrity sha512-t8G+3CiyI0R+wA077UNQXR/oG9GlsqRRO1KMsFHHjBSsYqWXghNsqxUG827wEj+PafI5u9tZ3vVt1S++Lg4B2g==
+  dependencies:
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-icon" "^3.0.0-pre.26"
+    "@polymer/iron-iconset-svg" "^3.0.0-pre.26"
+    "@polymer/iron-menu-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-resizable-behavior" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-icon-button" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-toggle-button@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-toggle-button/-/paper-toggle-button-3.0.1.tgz#7d855420f0df96e7f812a9f1bdcfbc5ab082e819"
+  integrity sha512-jadZB60fycT7YnSAH0H23LYo6/2HYmMZTtNr9LpdSIRFPLX6mqqxewex92cFz019bMKaRJgORn308hRlJo2u6A==
+  dependencies:
+    "@polymer/iron-checked-element-behavior" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.3.0":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.3.1.tgz#9ad48992d2a96775f80b0673f3a615d6df8a3dfc"
+  integrity sha512-8KaB48tzyMjdsHdxo5KvCAaqmTe7rYDzQAoj/pyEfq9Fp4YfUaS+/xqwYj0GbiDAUNzwkmEQ7dw9cgnRNdKO8A==
+  dependencies:
+    "@webcomponents/shadycss" "^1.9.1"
+
+"@webcomponents/shadycss@^1.9.1", "@webcomponents/shadycss@^1.9.2":
+  version "1.9.4"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
+  integrity sha512-tgNcVEaKssyeZPbUBjVQf4aryO5Fi7fxRvOxV982ZJuRVDcefmIblBh0SXAbcvAAlQ2zpNEP4SuQUnr8uApIpw==
+
+"@webcomponents/webcomponentsjs@^1.3.3":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
+  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
+
+"@webcomponents/webcomponentsjs@^2.0.3":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.4.1.tgz#7baadec56ed2fd79b94ddfd509132d8c0c295c5c"
+  integrity sha512-7jxBb+KoWncKb/JGFyTY40PjV4yRx2zd35ZLuvRP+6WndJDL7X32ZIZ7bN3sSQIl+NzJkCo7chfXJyzn+6WZaQ==
+
+"ba-linkify@file:../../lib/ba-linkify/src":
+  version "1.0.0"
+
+es6-promise@^3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
+  integrity sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=
+
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
+
+moment@^2.24.0:
+  version "2.24.0"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
+  integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
+
+page@^1.11.5:
+  version "1.11.5"
+  resolved "https://registry.yarnpkg.com/page/-/page-1.11.5.tgz#0cfc8608be337f26f4377f31df0787aef0ca1af7"
+  integrity sha512-0JXUHc7Y8p1cPJQbhZSwaKO3p+bU3Rgny+OM5gJMKHWHvJKan/fsE5RUzEjRQolv9DzPOSVWfSOHz0lLxK19eA==
+  dependencies:
+    path-to-regexp "~1.2.1"
+
+path-to-regexp@~1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.2.1.tgz#b33705c140234d873c8721c7b9fd8b541ed3aff9"
+  integrity sha1-szcFwUAjTYc8hyHHuf2LVB7Tr/k=
+  dependencies:
+    isarray "0.0.1"
+
+"polymer-bridges@file:../../polymer-bridges":
+  version "1.0.0"
+
+polymer-resin@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/polymer-resin/-/polymer-resin-2.0.1.tgz#08abcf0ea47bac746450cefa96caab1ebea199a8"
+  integrity sha512-Vak/4JiuToOKzvvjzcpISoFAlI7AwbOk79bZhqWNAZcqdzqEeFwos5hytjLV90d00TzVbff1LREaFpWR35zOjg==
+  dependencies:
+    "@polymer/polymer" "^3.0.2"
+    "@webcomponents/webcomponentsjs" "^2.0.3"
+
+shadow-selection-polyfill@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/shadow-selection-polyfill/-/shadow-selection-polyfill-1.1.0.tgz#87eee5c3cd9c7296f9fec083ba6f4910b1fa6686"
+  integrity sha512-ntz8P6DLEFpx7gikeXZ4gSi3APE2D+BP0rKnnaBzED+Lm8je8nkNcayy6kGWPEDWMFbtm+Yvd1ONFaXcsVWn2w==
+
+whatwg-fetch@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
+  integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
new file mode 100644
index 0000000..3d35e3e
--- /dev/null
+++ b/polygerrit-ui/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "polygerrit-ui-dev-dependencies",
+  "description": "Gerrit Code Review - Polygerrit dev dependencies",
+  "browser": true,
+  "dependencies": {},
+  "devDependencies": {
+    "@polymer/iron-test-helpers": "^3.0.1",
+    "chai": "^4.2.0",
+    "mocha": "^6.2.2",
+    "wct-browser-legacy": "^1.0.2",
+    "web-component-tester": "^6.9.2"
+  },
+  "license": "Apache-2.0",
+  "private": true
+}
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 1a2d299..120aff5 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.]*")
@@ -54,21 +54,20 @@
 		log.Fatal(err)
 	}
 
-	componentsArchive, err := openDataArchive("app/test_components.zip")
-	if err != nil {
-		log.Fatal(err)
-	}
-
 	workspace := os.Getenv("BUILD_WORKSPACE_DIRECTORY")
 	if err := os.Chdir(filepath.Join(workspace, "polygerrit-ui")); err != nil {
 		log.Fatal(err)
 	}
 
-	http.Handle("/", http.FileServer(http.Dir("app")))
-	http.Handle("/bower_components/",
-		http.FileServer(httpfs.New(zipfs.New(componentsArchive, "bower_components"))))
+	dirListingMux := http.NewServeMux()
+	dirListingMux.Handle("/styles/", http.StripPrefix("/styles/", http.FileServer(http.Dir("app/styles"))))
+	dirListingMux.Handle("/elements/", http.StripPrefix("/elements/", http.FileServer(http.Dir("app/elements"))))
+	dirListingMux.Handle("/behaviors/", http.StripPrefix("/behaviors/", http.FileServer(http.Dir("app/behaviors"))))
+
+	http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { handleSrcRequest(dirListingMux, w, req) })
+
 	http.Handle("/fonts/",
-		http.FileServer(httpfs.New(zipfs.New(fontsArchive, "fonts"))))
+		addDevHeadersMiddleware(http.FileServer(httpfs.New(zipfs.New(fontsArchive, "fonts")))))
 
 	http.HandleFunc("/index.html", handleIndex)
 	http.HandleFunc("/changes/", handleProxy)
@@ -84,11 +83,123 @@
 		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{}))
 }
 
+func addDevHeadersMiddleware(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
+		addDevHeaders(writer)
+		h.ServeHTTP(writer, req)
+	})
+}
+
+func addDevHeaders(writer http.ResponseWriter) {
+	writer.Header().Set("Access-Control-Allow-Origin", "*")
+	writer.Header().Set("Cache-Control", "public, max-age=10, must-revalidate")
+
+}
+
+func handleSrcRequest(dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
+	parsedUrl, err := url.Parse(originalRequest.RequestURI)
+	if err != nil {
+		writer.WriteHeader(500)
+		return
+	}
+	if parsedUrl.Path != "/" && strings.HasSuffix(parsedUrl.Path, "/") {
+		dirListingMux.ServeHTTP(writer, originalRequest)
+		return
+	}
+
+	normalizedContentPath := parsedUrl.Path
+
+	if !strings.HasPrefix(normalizedContentPath, "/") {
+		normalizedContentPath = "/" + normalizedContentPath
+	}
+
+	isJsFile := strings.HasSuffix(normalizedContentPath, ".js") || strings.HasSuffix(normalizedContentPath, ".mjs")
+	data, err := getContent(normalizedContentPath)
+	if err != nil {
+		data, err = getContent(normalizedContentPath + ".js")
+		if err != nil {
+			writer.WriteHeader(404)
+			return
+		}
+		isJsFile = true
+	}
+	if isJsFile {
+		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
+		writer.Header().Set("Content-Type", "application/javascript")
+	} else if strings.HasSuffix(normalizedContentPath, ".css") {
+		writer.Header().Set("Content-Type", "text/css")
+	} else if strings.HasSuffix(normalizedContentPath, "_test.html") {
+		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
+		writer.Header().Set("Content-Type", "text/html")
+	} else if strings.HasSuffix(normalizedContentPath, ".html") {
+		writer.Header().Set("Content-Type", "text/html")
+	}
+	writer.WriteHeader(200)
+	addDevHeaders(writer)
+	writer.Write(data)
+}
+
+func getContent(normalizedContentPath string) ([]byte, error) {
+	// normalizedContentPath must always starts with '/'
+
+	// gerrit loads gr-app.js as an ordinary script, without type="module" attribute.
+	// If server.go serves this file as is, browser shows the error:
+	// Uncaught SyntaxError: Cannot use import statement outside a module
+	//
+	// To load non-bundled gr-app.js as a module, we "virtually" renames original
+	// gr-app.js to gr-app.mjs and load it with dynamic import.
+	//
+	// Another option is to patch rewriteHostPage function and add type="module" attribute
+	// to <script src=".../elements/gr-app.js"> tag, but this solution is incompatible
+	// with --dev-cdn options. If you run local gerrit instance with --dev-cdn parameter,
+	// the server.go is used as cdn and it doesn't handle host page (i.e. rewriteHostPage
+	// method is not called).
+	if normalizedContentPath == "/elements/gr-app.js" {
+		return []byte("import('./gr-app.mjs')"), nil
+	}
+
+	if normalizedContentPath == "/elements/gr-app.mjs" {
+		normalizedContentPath = "/elements/gr-app.js"
+	}
+
+	pathsToTry := []string{"app" + normalizedContentPath}
+	bowerComponentsSuffix := "/bower_components/"
+	nodeModulesPrefix := "/node_modules/"
+	testComponentsPrefix := "/components/"
+
+	if strings.HasPrefix(normalizedContentPath, testComponentsPrefix) {
+		pathsToTry = append(pathsToTry, "node_modules/wct-browser-legacy/node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
+		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
+	}
+
+	if strings.HasPrefix(normalizedContentPath, bowerComponentsSuffix) {
+		pathsToTry = append(pathsToTry, "node_modules/@webcomponents/"+normalizedContentPath[len(bowerComponentsSuffix):])
+	}
+
+	if strings.HasPrefix(normalizedContentPath, nodeModulesPrefix) {
+		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(nodeModulesPrefix):])
+	}
+
+	for _, path := range pathsToTry {
+		data, err := ioutil.ReadFile(path)
+		if err == nil {
+			return data, nil
+		}
+	}
+
+	return nil, errors.New("File not found")
+}
+
 func openDataArchive(path string) (*zip.ReadCloser, error) {
 	absBinPath, err := resourceBasePath()
 	if err != nil {
@@ -315,6 +426,7 @@
 		return
 	}
 	w.Header().Set("Content-Encoding", "gzip")
+	addDevHeaders(w)
 	gzw := newGzipResponseWriter(w)
 	defer gzw.Close()
 	http.DefaultServeMux.ServeHTTP(gzw, r)
diff --git a/polygerrit-ui/wct.conf.js b/polygerrit-ui/wct.conf.js
index b6a6251..2096e60 100644
--- a/polygerrit-ui/wct.conf.js
+++ b/polygerrit-ui/wct.conf.js
@@ -1,3 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 var path = require('path');
 
 var ret = {
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
new file mode 100644
index 0000000..12d39aa
--- /dev/null
+++ b/polygerrit-ui/yarn.lock
@@ -0,0 +1,6828 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e"
+  integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==
+  dependencies:
+    "@babel/highlight" "^7.8.3"
+
+"@babel/core@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.3.tgz#30b0ebb4dd1585de6923a0b4d179e0b9f5d82941"
+  integrity sha512-4XFkf8AwyrEG7Ziu3L2L0Cv+WyY47Tcsp70JFmpftbAA1K7YL/sgE9jh9HyNj08Y/U50ItUchpN0w6HxAoX1rA==
+  dependencies:
+    "@babel/code-frame" "^7.8.3"
+    "@babel/generator" "^7.8.3"
+    "@babel/helpers" "^7.8.3"
+    "@babel/parser" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    convert-source-map "^1.7.0"
+    debug "^4.1.0"
+    gensync "^1.0.0-beta.1"
+    json5 "^2.1.0"
+    lodash "^4.17.13"
+    resolve "^1.3.2"
+    semver "^5.4.1"
+    source-map "^0.5.0"
+
+"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.3.tgz#0e22c005b0a94c1c74eafe19ef78ce53a4d45c03"
+  integrity sha512-WjoPk8hRpDRqqzRpvaR8/gDUPkrnOOeuT2m8cNICJtZH6mwaCo3v0OKMI7Y6SM1pBtyijnLtAL0HDi41pf41ug==
+  dependencies:
+    "@babel/types" "^7.8.3"
+    jsesc "^2.5.1"
+    lodash "^4.17.13"
+    source-map "^0.5.0"
+
+"@babel/helper-annotate-as-pure@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee"
+  integrity sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.8.3.tgz#c84097a427a061ac56a1c30ebf54b7b22d241503"
+  integrity sha512-5eFOm2SyFPK4Rh3XMMRDjN7lBH0orh3ss0g3rTYZnBQ+r6YPj7lgDyCvPphynHvUrobJmeMignBr6Acw9mAPlw==
+  dependencies:
+    "@babel/helper-explode-assignable-expression" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-call-delegate@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.8.3.tgz#de82619898aa605d409c42be6ffb8d7204579692"
+  integrity sha512-6Q05px0Eb+N4/GTyKPPvnkig7Lylw+QzihMpws9iiZQv7ZImf84ZsZpQH7QoWN4n4tm81SnSzPgHw2qtO0Zf3A==
+  dependencies:
+    "@babel/helper-hoist-variables" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-create-regexp-features-plugin@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.3.tgz#c774268c95ec07ee92476a3862b75cc2839beb79"
+  integrity sha512-Gcsm1OHCUr9o9TcJln57xhWHtdXbA2pgQ58S0Lxlks0WMGNXuki4+GLfX0p+L2ZkINUGZvfkz8rzoqJQSthI+Q==
+  dependencies:
+    "@babel/helper-regex" "^7.8.3"
+    regexpu-core "^4.6.0"
+
+"@babel/helper-define-map@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.8.3.tgz#a0655cad5451c3760b726eba875f1cd8faa02c15"
+  integrity sha512-PoeBYtxoZGtct3md6xZOCWPcKuMuk3IHhgxsRRNtnNShebf4C8YonTSblsK4tvDbm+eJAw2HAPOfCr+Q/YRG/g==
+  dependencies:
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    lodash "^4.17.13"
+
+"@babel/helper-explode-assignable-expression@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.8.3.tgz#a728dc5b4e89e30fc2dfc7d04fa28a930653f982"
+  integrity sha512-N+8eW86/Kj147bO9G2uclsg5pwfs/fqqY5rwgIL7eTBklgXjcOJ3btzS5iM6AitJcftnY7pm2lGsrJVYLGjzIw==
+  dependencies:
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-function-name@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz#eeeb665a01b1f11068e9fb86ad56a1cb1a824cca"
+  integrity sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==
+  dependencies:
+    "@babel/helper-get-function-arity" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-get-function-arity@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5"
+  integrity sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-hoist-variables@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.8.3.tgz#1dbe9b6b55d78c9b4183fc8cdc6e30ceb83b7134"
+  integrity sha512-ky1JLOjcDUtSc+xkt0xhYff7Z6ILTAHKmZLHPxAhOP0Nd77O+3nCsd6uSVYur6nJnCI029CrNbYlc0LoPfAPQg==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-member-expression-to-functions@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c"
+  integrity sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-module-imports@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498"
+  integrity sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-module-transforms@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.8.3.tgz#d305e35d02bee720fbc2c3c3623aa0c316c01590"
+  integrity sha512-C7NG6B7vfBa/pwCOshpMbOYUmrYQDfCpVL/JCRu0ek8B5p8kue1+BCXpg2vOYs7w5ACB9GTOBYQ5U6NwrMg+3Q==
+  dependencies:
+    "@babel/helper-module-imports" "^7.8.3"
+    "@babel/helper-simple-access" "^7.8.3"
+    "@babel/helper-split-export-declaration" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    lodash "^4.17.13"
+
+"@babel/helper-optimise-call-expression@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9"
+  integrity sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670"
+  integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==
+
+"@babel/helper-regex@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.8.3.tgz#139772607d51b93f23effe72105b319d2a4c6965"
+  integrity sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ==
+  dependencies:
+    lodash "^4.17.13"
+
+"@babel/helper-remap-async-to-generator@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz#273c600d8b9bf5006142c1e35887d555c12edd86"
+  integrity sha512-kgwDmw4fCg7AVgS4DukQR/roGp+jP+XluJE5hsRZwxCYGg+Rv9wSGErDWhlI90FODdYfd4xG4AQRiMDjjN0GzA==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.8.3"
+    "@babel/helper-wrap-function" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-replace-supers@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz#91192d25f6abbcd41da8a989d4492574fb1530bc"
+  integrity sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA==
+  dependencies:
+    "@babel/helper-member-expression-to-functions" "^7.8.3"
+    "@babel/helper-optimise-call-expression" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-simple-access@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae"
+  integrity sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==
+  dependencies:
+    "@babel/template" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-split-export-declaration@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9"
+  integrity sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-wrap-function@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610"
+  integrity sha512-LACJrbUET9cQDzb6kG7EeD7+7doC3JNvUgTEQOx2qaO1fKlzE/Bf05qs9w1oXQMmXlPO65lC3Tq9S6gZpTErEQ==
+  dependencies:
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helpers@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.8.3.tgz#382fbb0382ce7c4ce905945ab9641d688336ce85"
+  integrity sha512-LmU3q9Pah/XyZU89QvBgGt+BCsTPoQa+73RxAQh8fb8qkDyIfeQnmgs+hvzhTCKTzqOyk7JTkS3MS1S8Mq5yrQ==
+  dependencies:
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/highlight@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.8.3.tgz#28f173d04223eaaa59bc1d439a3836e6d1265797"
+  integrity sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==
+  dependencies:
+    chalk "^2.0.0"
+    esutils "^2.0.2"
+    js-tokens "^4.0.0"
+
+"@babel/parser@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.3.tgz#790874091d2001c9be6ec426c2eed47bc7679081"
+  integrity sha512-/V72F4Yp/qmHaTALizEm9Gf2eQHV3QyTL3K0cNfijwnMnb1L+LDlAubb/ZnSdGAVzVSWakujHYs1I26x66sMeQ==
+
+"@babel/plugin-external-helpers@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.8.3.tgz#5a94164d9af393b2820a3cdc407e28ebf237de4b"
+  integrity sha512-mx0WXDDiIl5DwzMtzWGRSPugXi9BxROS05GQrhLNbEamhBiicgn994ibwkyiBH+6png7bm/yA7AUsvHyCXi4Vw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-proposal-async-generator-functions@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f"
+  integrity sha512-NZ9zLv848JsV3hs8ryEh7Uaz/0KsmPLqv0+PdkDJL1cJy0K4kOCFa8zc1E3mp+RHPQcpdfb/6GovEsW4VDrOMw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-remap-async-to-generator" "^7.8.3"
+    "@babel/plugin-syntax-async-generators" "^7.8.0"
+
+"@babel/plugin-proposal-object-rest-spread@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.8.3.tgz#eb5ae366118ddca67bed583b53d7554cad9951bb"
+  integrity sha512-8qvuPwU/xxUCt78HocNlv0mXXo0wdh9VT1R04WU8HGOfaOob26pF+9P5/lYjN/q7DHOX1bvX60hnhOvuQUJdbA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+
+"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.0":
+  version "7.8.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
+  integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-dynamic-import@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
+  integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-import-meta@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.8.3.tgz#230afff79d3ccc215b5944b438e4e266daf3d84d"
+  integrity sha512-vYiGd4wQ9gx0Lngb7+bPCwQXGK/PR6FeTIJ+TIOlq+OfOKG/kCAOO2+IBac3oMM9qV7/fU76hfcqxUaLKZf1hQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
+  integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-transform-arrow-functions@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz#82776c2ed0cd9e1a49956daeb896024c9473b8b6"
+  integrity sha512-0MRF+KC8EqH4dbuITCWwPSzsyO3HIWWlm30v8BbbpOrS1B++isGxPnnuq/IZvOX5J2D/p7DQalQm+/2PnlKGxg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-async-to-generator@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.8.3.tgz#4308fad0d9409d71eafb9b1a6ee35f9d64b64086"
+  integrity sha512-imt9tFLD9ogt56Dd5CI/6XgpukMwd/fLGSrix2httihVe7LOGVPhyhMh1BU5kDM7iHD08i8uUtmV2sWaBFlHVQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-remap-async-to-generator" "^7.8.3"
+
+"@babel/plugin-transform-block-scoped-functions@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.8.3.tgz#437eec5b799b5852072084b3ae5ef66e8349e8a3"
+  integrity sha512-vo4F2OewqjbB1+yaJ7k2EJFHlTP3jR634Z9Cj9itpqNjuLXvhlVxgnjsHsdRgASR8xYDrx6onw4vW5H6We0Jmg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-block-scoping@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.8.3.tgz#97d35dab66857a437c166358b91d09050c868f3a"
+  integrity sha512-pGnYfm7RNRgYRi7bids5bHluENHqJhrV4bCZRwc5GamaWIIs07N4rZECcmJL6ZClwjDz1GbdMZFtPs27hTB06w==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    lodash "^4.17.13"
+
+"@babel/plugin-transform-classes@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.8.3.tgz#46fd7a9d2bb9ea89ce88720477979fe0d71b21b8"
+  integrity sha512-SjT0cwFJ+7Rbr1vQsvphAHwUHvSUPmMjMU/0P59G8U2HLFqSa082JO7zkbDNWs9kH/IUqpHI6xWNesGf8haF1w==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.8.3"
+    "@babel/helper-define-map" "^7.8.3"
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/helper-optimise-call-expression" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-replace-supers" "^7.8.3"
+    "@babel/helper-split-export-declaration" "^7.8.3"
+    globals "^11.1.0"
+
+"@babel/plugin-transform-computed-properties@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.8.3.tgz#96d0d28b7f7ce4eb5b120bb2e0e943343c86f81b"
+  integrity sha512-O5hiIpSyOGdrQZRQ2ccwtTVkgUDBBiCuK//4RJ6UfePllUTCENOzKxfh6ulckXKc0DixTFLCfb2HVkNA7aDpzA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-destructuring@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.8.3.tgz#20ddfbd9e4676906b1056ee60af88590cc7aaa0b"
+  integrity sha512-H4X646nCkiEcHZUZaRkhE2XVsoz0J/1x3VVujnn96pSoGCtKPA99ZZA+va+gK+92Zycd6OBKCD8tDb/731bhgQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-duplicate-keys@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.8.3.tgz#8d12df309aa537f272899c565ea1768e286e21f1"
+  integrity sha512-s8dHiBUbcbSgipS4SMFuWGqCvyge5V2ZeAWzR6INTVC3Ltjig/Vw1G2Gztv0vU/hRG9X8IvKvYdoksnUfgXOEQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-exponentiation-operator@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.8.3.tgz#581a6d7f56970e06bf51560cd64f5e947b70d7b7"
+  integrity sha512-zwIpuIymb3ACcInbksHaNcR12S++0MDLKkiqXHl3AzpgdKlFNhog+z/K0+TGW+b0w5pgTq4H6IwV/WhxbGYSjQ==
+  dependencies:
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-for-of@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.8.3.tgz#15f17bce2fc95c7d59a24b299e83e81cedc22e18"
+  integrity sha512-ZjXznLNTxhpf4Q5q3x1NsngzGA38t9naWH8Gt+0qYZEJAcvPI9waSStSh56u19Ofjr7QmD0wUsQ8hw8s/p1VnA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-function-name@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.8.3.tgz#279373cb27322aaad67c2683e776dfc47196ed8b"
+  integrity sha512-rO/OnDS78Eifbjn5Py9v8y0aR+aSYhDhqAwVfsTl0ERuMZyr05L1aFSCJnbv2mmsLkit/4ReeQ9N2BgLnOcPCQ==
+  dependencies:
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-instanceof@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.8.3.tgz#a44d7d71590da36be7429573300618aefd784c3d"
+  integrity sha512-c/jB6Ebe2u17hxo+rce6PDgbkuHyfcJOleqgHYttnvMrCsxVwUnYsMq7GhxXekzUQsv9IImhv6YICKihpen+Ag==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-literals@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.8.3.tgz#aef239823d91994ec7b68e55193525d76dbd5dc1"
+  integrity sha512-3Tqf8JJ/qB7TeldGl+TT55+uQei9JfYaregDcEAyBZ7akutriFrt6C/wLYIer6OYhleVQvH/ntEhjE/xMmy10A==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-modules-amd@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.8.3.tgz#65606d44616b50225e76f5578f33c568a0b876a5"
+  integrity sha512-MadJiU3rLKclzT5kBH4yxdry96odTUwuqrZM+GllFI/VhxfPz+k9MshJM+MwhfkCdxxclSbSBbUGciBngR+kEQ==
+  dependencies:
+    "@babel/helper-module-transforms" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+    babel-plugin-dynamic-import-node "^2.3.0"
+
+"@babel/plugin-transform-object-super@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.8.3.tgz#ebb6a1e7a86ffa96858bd6ac0102d65944261725"
+  integrity sha512-57FXk+gItG/GejofIyLIgBKTas4+pEU47IXKDBWFTxdPd7F80H8zybyAY7UoblVfBhBGs2EKM+bJUu2+iUYPDQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-replace-supers" "^7.8.3"
+
+"@babel/plugin-transform-parameters@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.8.3.tgz#7890576a13b17325d8b7d44cb37f21dc3bbdda59"
+  integrity sha512-/pqngtGb54JwMBZ6S/D3XYylQDFtGjWrnoCF4gXZOUpFV/ujbxnoNGNvDGu6doFWRPBveE72qTx/RRU44j5I/Q==
+  dependencies:
+    "@babel/helper-call-delegate" "^7.8.3"
+    "@babel/helper-get-function-arity" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-regenerator@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.8.3.tgz#b31031e8059c07495bf23614c97f3d9698bc6ec8"
+  integrity sha512-qt/kcur/FxrQrzFR432FGZznkVAjiyFtCOANjkAKwCbt465L6ZCiUQh2oMYGU3Wo8LRFJxNDFwWn106S5wVUNA==
+  dependencies:
+    regenerator-transform "^0.14.0"
+
+"@babel/plugin-transform-shorthand-properties@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.8.3.tgz#28545216e023a832d4d3a1185ed492bcfeac08c8"
+  integrity sha512-I9DI6Odg0JJwxCHzbzW08ggMdCezoWcuQRz3ptdudgwaHxTjxw5HgdFJmZIkIMlRymL6YiZcped4TTCB0JcC8w==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-spread@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.8.3.tgz#9c8ffe8170fdfb88b114ecb920b82fb6e95fe5e8"
+  integrity sha512-CkuTU9mbmAoFOI1tklFWYYbzX5qCIZVXPVy0jpXgGwkplCndQAa58s2jr66fTeQnA64bDox0HL4U56CFYoyC7g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-sticky-regex@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.8.3.tgz#be7a1290f81dae767475452199e1f76d6175b100"
+  integrity sha512-9Spq0vGCD5Bb4Z/ZXXSK5wbbLFMG085qd2vhL1JYu1WcQ5bXqZBAYRzU1d+p79GcHs2szYv5pVQCX13QgldaWw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-regex" "^7.8.3"
+
+"@babel/plugin-transform-template-literals@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.8.3.tgz#7bfa4732b455ea6a43130adc0ba767ec0e402a80"
+  integrity sha512-820QBtykIQOLFT8NZOcTRJ1UNuztIELe4p9DCgvj4NK+PwluSJ49we7s9FB1HIGNIYT7wFUJ0ar2QpCDj0escQ==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-typeof-symbol@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.8.3.tgz#5cffb216fb25c8c64ba6bf5f76ce49d3ab079f4d"
+  integrity sha512-3TrkKd4LPqm4jHs6nPtSDI/SV9Cm5PRJkHLUgTcqRQQTMAZ44ZaAdDZJtvWFSaRcvT0a1rTmJ5ZA5tDKjleF3g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-unicode-regex@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz#0cef36e3ba73e5c57273effb182f46b91a1ecaad"
+  integrity sha512-+ufgJjYdmWfSQ+6NS9VGUR2ns8cjJjYbrbi11mZBTaWm+Fui/ncTLFF28Ei1okavY+xkojGr1eJxNsWYeA5aZw==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/template@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8"
+  integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==
+  dependencies:
+    "@babel/code-frame" "^7.8.3"
+    "@babel/parser" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.3.tgz#a826215b011c9b4f73f3a893afbc05151358bf9a"
+  integrity sha512-we+a2lti+eEImHmEXp7bM9cTxGzxPmBiVJlLVD+FuuQMeeO7RaDbutbgeheDkw+Xe3mCfJHnGOWLswT74m2IPg==
+  dependencies:
+    "@babel/code-frame" "^7.8.3"
+    "@babel/generator" "^7.8.3"
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/helper-split-export-declaration" "^7.8.3"
+    "@babel/parser" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    debug "^4.1.0"
+    globals "^11.1.0"
+    lodash "^4.17.13"
+
+"@babel/types@^7.0.0-beta.42", "@babel/types@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
+  integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==
+  dependencies:
+    esutils "^2.0.2"
+    lodash "^4.17.13"
+    to-fast-properties "^2.0.0"
+
+"@polymer/esm-amd-loader@^1.0.0":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
+  integrity sha512-h+hqYkL+tQV/y2ESD5gFXMl5z4cC+XY1jTlBeGSBaTcj3VbB5OBEScbvRXm63NcEbBneQQYbHfBAXAkF9i9wIA==
+
+"@polymer/iron-test-helpers@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-test-helpers/-/iron-test-helpers-3.0.1.tgz#ec2b9c6567e2967a191b3d800a04b1167b2d1394"
+  integrity sha512-2R7dnGcW2eg95i7LhYWWUO4AlAk6qXsPnKoyeN2R1t0km0ECMx0jjwqeLwCo8/7LwuVPZSiarI4DK7jyU7fJLQ==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/polymer@^3.0.0":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.3.1.tgz#9ad48992d2a96775f80b0673f3a615d6df8a3dfc"
+  integrity sha512-8KaB48tzyMjdsHdxo5KvCAaqmTe7rYDzQAoj/pyEfq9Fp4YfUaS+/xqwYj0GbiDAUNzwkmEQ7dw9cgnRNdKO8A==
+  dependencies:
+    "@webcomponents/shadycss" "^1.9.1"
+
+"@polymer/sinonjs@^1.14.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@polymer/sinonjs/-/sinonjs-1.17.1.tgz#e47d3785b7d0e8c29feb97f7e924b0fc597e2e9b"
+  integrity sha512-/U8F/cOTrbF2iVVYgINYmvKbtbexs+89Q3v8AaHADRYabTg7aOZGOb0RyWpOI+sUJt04kj63U4FwMhzW5r4wZA==
+
+"@polymer/test-fixture@^0.0.3":
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-0.0.3.tgz#4443752697d4d9293bbc412ea0b5e4d341f149d9"
+  integrity sha1-REN1JpfU2Sk7vEEuoLXk00HxSdk=
+
+"@polymer/test-fixture@^3.0.0-pre.1":
+  version "3.0.0-pre.21"
+  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-3.0.0-pre.21.tgz#85152207cb0bf57caebc191c80bb0fdb6952614e"
+  integrity sha512-IxzUe6YzaORzUksafHAXHprV29YncOJgr0+1zNAifl0/f+cb5iAd4IWUrnsnVFHG5UGTLjvis5RgV6vvIZPDrA==
+
+"@types/babel-generator@^6.25.1":
+  version "6.25.3"
+  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.3.tgz#8f06caa12d0595a0538560abe771966d77d29286"
+  integrity sha512-pGgnuxVddKcYIc+VJkRDop7gxLhqclNKBdlsm/5Vp8d+37pQkkDK7fef8d9YYImRzw9xcojEPc18pUYnbxmjqA==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/babel-traverse@^6.25.2", "@types/babel-traverse@^6.25.3":
+  version "6.25.5"
+  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.5.tgz#6d293cf7523e48b524faa7b86dc3c488191484e5"
+  integrity sha512-WrMbwmu+MWf8FiUMbmVOGkc7bHPzndUafn1CivMaBHthBBoo0VNIcYk1KV71UovYguhsNOwf3UF5oRmkkGOU3w==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/babel-types@*":
+  version "7.0.7"
+  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.7.tgz#667eb1640e8039436028055737d2b9986ee336e3"
+  integrity sha512-dBtBbrc+qTHy1WdfHYjBwRln4+LWqASWakLHsWHR2NWHIFkv4W3O070IGoGLEBrJBvct3r0L1BUPuvURi7kYUQ==
+
+"@types/babel-types@^6.25.1":
+  version "6.25.2"
+  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-6.25.2.tgz#5c57f45973e4f13742dbc5273dd84cffe7373a9e"
+  integrity sha512-+3bMuktcY4a70a0KZc8aPJlEOArPuAKQYHU5ErjkOqGJdx8xuEEVK6nWogqigBOJ8nKPxRpyCUDTCPmZ3bUxGA==
+
+"@types/babylon@^6.16.2":
+  version "6.16.5"
+  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.5.tgz#1c5641db69eb8cdf378edd25b4be7754beeb48b4"
+  integrity sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/bluebird@*":
+  version "3.5.29"
+  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.29.tgz#7cd933c902c4fc83046517a1bef973886d00bdb6"
+  integrity sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==
+
+"@types/body-parser@*":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897"
+  integrity sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@types/chai-subset@^1.3.0":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
+  integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*":
+  version "4.2.7"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.7.tgz#1c8c25cbf6e59ffa7d6b9652c78e547d9a41692d"
+  integrity sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==
+
+"@types/chalk@^0.4.30":
+  version "0.4.31"
+  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-0.4.31.tgz#a31d74241a6b1edbb973cf36d97a2896834a51f9"
+  integrity sha1-ox10JBprHtu5c8822XooloNKUfk=
+
+"@types/clean-css@*":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.1.tgz#cb0134241ec5e6ede1b5344bc829668fd9871a8d"
+  integrity sha512-A1HQhQ0hkvqqByJMgg+Wiv9p9XdoYEzuwm11SVo1mX2/4PSdhjcrUlilJQoqLscIheC51t1D5g+EFWCXZ2VTQQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/clone@^0.1.30":
+  version "0.1.30"
+  resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
+  integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
+
+"@types/compression@^0.0.33":
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
+  integrity sha1-ldxzOiM5qoRjgdfxN3eS0lU9wn0=
+  dependencies:
+    "@types/express" "*"
+
+"@types/connect@*":
+  version "3.4.33"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546"
+  integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-type@^1.1.0":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.3.tgz#3688bd77fc12f935548eef102a4e34c512b03a07"
+  integrity sha512-pv8VcFrZ3fN93L4rTNIbbUzdkzjEyVMp5mPVjsFfOYTDOZMZiZ8P1dhu+kEv3faYyKzZgLlSvnyQNFg+p/v5ug==
+
+"@types/cssbeautify@^0.3.1":
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.1.tgz#8e0bee8f7decb952250da0caebe05e30591c17ef"
+  integrity sha1-jgvuj33suVIlDaDK6+BeMFkcF+8=
+
+"@types/doctrine@^0.0.1":
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
+  integrity sha1-uZny2fe0PKvgoaLzm8IDvH3K2p0=
+
+"@types/escape-html@0.0.20":
+  version "0.0.20"
+  resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
+  integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
+
+"@types/estree@*":
+  version "0.0.42"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.42.tgz#8d0c1f480339efedb3e46070e22dd63e0430dd11"
+  integrity sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==
+
+"@types/events@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
+  integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
+
+"@types/expect@^1.20.4":
+  version "1.20.4"
+  resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
+  integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==
+
+"@types/express-serve-static-core@*":
+  version "4.17.2"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.2.tgz#f6f41fa35d42e79dbf6610eccbb2637e6008a0cf"
+  integrity sha512-El9yMpctM6tORDAiBwZVLMcxoTMcqqRO9dVyYcn7ycLWbvR8klrDn8CAOwRfZujZtWD7yS/mshTdz43jMOejbg==
+  dependencies:
+    "@types/node" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
+  version "4.17.2"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
+  integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "*"
+    "@types/serve-static" "*"
+
+"@types/freeport@^1.0.19":
+  version "1.0.21"
+  resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.21.tgz#73f6543ed67d3ca3fff97b985591598b7092066f"
+  integrity sha1-c/ZUPtZ9PKP/+XuYVZFZi3CSBm8=
+
+"@types/glob-stream@*":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc"
+  integrity sha512-RHv6ZQjcTncXo3thYZrsbAVwoy4vSKosSWhuhuQxLOTv74OJuFQxXkmUuZCr3q9uNBEVCvIzmZL/FeRNbHZGUg==
+  dependencies:
+    "@types/glob" "*"
+    "@types/node" "*"
+
+"@types/glob@*":
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"
+  integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==
+  dependencies:
+    "@types/events" "*"
+    "@types/minimatch" "*"
+    "@types/node" "*"
+
+"@types/gulp-if@0.0.33":
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/@types/gulp-if/-/gulp-if-0.0.33.tgz#edece22b7925d9a6db5f9c8c0d7882aa776fb678"
+  integrity sha512-J5lzff21X7r1x/4hSzn02GgIUEyjCqYIXZ9GgGBLhbsD3RiBdqwnkFWgF16/0jO5rcVZ52Zp+6MQMQdvIsWuKg==
+  dependencies:
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/html-minifier@^3.5.1":
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f"
+  integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==
+  dependencies:
+    "@types/clean-css" "*"
+    "@types/relateurl" "*"
+    "@types/uglify-js" "*"
+
+"@types/is-windows@^0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
+  integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
+
+"@types/launchpad@^0.6.0":
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
+  integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
+
+"@types/mime@*", "@types/mime@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
+  integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
+
+"@types/minimatch@*", "@types/minimatch@^3.0.1":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
+  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+
+"@types/mz@0.0.29":
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
+  integrity sha1-vCRyjGSZc/HHhR6QM/nOUlZowns=
+  dependencies:
+    "@types/bluebird" "*"
+    "@types/node" "*"
+
+"@types/mz@0.0.31":
+  version "0.0.31"
+  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52"
+  integrity sha1-pNgMCC/v5x5Ap8DwfR5lVbu8e1I=
+  dependencies:
+    "@types/node" "*"
+
+"@types/node@*":
+  version "13.1.8"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-13.1.8.tgz#1d590429fe8187a02707720ecf38a6fe46ce294b"
+  integrity sha512-6XzyyNM9EKQW4HKuzbo/CkOIjn/evtCmsU+MUM1xDfJ+3/rNjBttM1NgN7AOQvN6tP1Sl1D1PIKMreTArnxM9A==
+
+"@types/node@^4.0.30":
+  version "4.9.4"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.4.tgz#75ef91733afaa856b01e12da6ecf48aa9d5e221f"
+  integrity sha512-nKoiCZ87x6+fs26bNHjy07AQt6f46nFEitGH0P9JmWbY6tEyum6LLfLf7SIsKFh4DnBWsyUM2gYhaQAt+aA0Sw==
+
+"@types/opn@^3.0.28":
+  version "3.0.28"
+  resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
+  integrity sha1-CX0NHJtXSVc6XZbfEyOHu20CEYo=
+  dependencies:
+    "@types/node" "*"
+
+"@types/parse5@^2.2.34":
+  version "2.2.34"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
+  integrity sha1-44cKEOgnNacg9i1x3NGDunjvOp0=
+  dependencies:
+    "@types/node" "*"
+
+"@types/path-is-inside@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
+  integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
+
+"@types/pem@^1.8.1":
+  version "1.9.5"
+  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.5.tgz#cd5548b5e0acb4b41a9e21067e9fcd8c57089c99"
+  integrity sha512-C0txxEw8B7DCoD85Ko7SEvzUogNd5VDJ5/YBG8XUcacsOGqxr5Oo4g3OUAfdEDUbhXanwUoVh/ZkMFw77FGPQQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/range-parser@*":
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
+  integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
+
+"@types/relateurl@*":
+  version "0.2.28"
+  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
+  integrity sha1-a9p9uGU/piZD9e5p6facEaOS46Y=
+
+"@types/resolve@0.0.6":
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
+  integrity sha512-g+Rg8uMWY76oYTyaL+m7ZcblqF/oj7pE6uEUyACluJx4zcop1Lk14qQiocdEkEVMDFm6DmKpxJhsER+ZuTwG3g==
+  dependencies:
+    "@types/node" "*"
+
+"@types/resolve@0.0.7":
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.7.tgz#b299c13be8d712b1b502fb14a084252acef84f4d"
+  integrity sha512-GPewdjkb0Q76o459qgp6pBLzJj/bD3oveS2kfLhIkZ9U3t3AFKtl5DlFB6lGTw0iZmcmxoGC8lpLW3NNJKrN9A==
+  dependencies:
+    "@types/node" "*"
+
+"@types/serve-static@*", "@types/serve-static@^1.7.31":
+  version "1.13.3"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
+  integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==
+  dependencies:
+    "@types/express-serve-static-core" "*"
+    "@types/mime" "*"
+
+"@types/spdy@^3.4.1":
+  version "3.4.4"
+  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.4.tgz#3282fd4ad8c4603aa49f7017dd520a08a345b2bc"
+  integrity sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/ua-parser-js@^0.7.31":
+  version "0.7.33"
+  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.33.tgz#4a92089511574e12928a7cb6b99a01831acd1dd7"
+  integrity sha512-ngUKcHnytUodUCL7C6EZ+lVXUjTMQb+9p/e1JjV5tN9TVzS98lHozWEFRPY1QcCdwFeMsmVWfZ3DPPT/udCyIw==
+
+"@types/uglify-js@*":
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
+  integrity sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==
+  dependencies:
+    source-map "^0.6.1"
+
+"@types/uuid@^3.4.3":
+  version "3.4.6"
+  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.6.tgz#d2c4c48eb85a757bf2927f75f939942d521e3016"
+  integrity sha512-cCdlC/1kGEZdEglzOieLDYBxHsvEOIg7kp/2FYyVR9Pxakq+Qf/inL3RKQ+PA8gOlI/NnL+fXmQH12nwcGzsHw==
+  dependencies:
+    "@types/node" "*"
+
+"@types/vinyl-fs@^2.4.8":
+  version "2.4.11"
+  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.11.tgz#b98119b8bb2494141eaf649b09fbfeb311161206"
+  integrity sha512-2OzQSfIr9CqqWMGqmcERE6Hnd2KY3eBVtFaulVo3sJghplUcaeMdL9ZjEiljcQQeHjheWY9RlNmumjIAvsBNaA==
+  dependencies:
+    "@types/glob-stream" "*"
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/vinyl@*", "@types/vinyl@^2.0.0":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.4.tgz#9a7a8071c8d14d3a95d41ebe7135babe4ad5995a"
+  integrity sha512-2o6a2ixaVI2EbwBPg1QYLGQoHK56p/8X/sGfKbFC8N6sY9lfjsMf/GprtkQkSya0D4uRiutRZ2BWj7k3JvLsAQ==
+  dependencies:
+    "@types/expect" "^1.20.4"
+    "@types/node" "*"
+
+"@types/whatwg-url@^6.4.0":
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
+  integrity sha512-tonhlcbQ2eho09am6RHnHOgvtDfDYINd5rgxD+2YSkKENooVCFsWizJz139MQW/PV8FfClyKrNe9ZbdHrSCxGg==
+  dependencies:
+    "@types/node" "*"
+
+"@types/which@^1.3.1":
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
+  integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
+
+"@webcomponents/shadycss@^1.9.1":
+  version "1.9.4"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
+  integrity sha512-tgNcVEaKssyeZPbUBjVQf4aryO5Fi7fxRvOxV982ZJuRVDcefmIblBh0SXAbcvAAlQ2zpNEP4SuQUnr8uApIpw==
+
+"@webcomponents/webcomponentsjs@^1.0.7":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
+  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
+
+"@webcomponents/webcomponentsjs@^2.0.0":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.4.1.tgz#7baadec56ed2fd79b94ddfd509132d8c0c295c5c"
+  integrity sha512-7jxBb+KoWncKb/JGFyTY40PjV4yRx2zd35ZLuvRP+6WndJDL7X32ZIZ7bN3sSQIl+NzJkCo7chfXJyzn+6WZaQ==
+
+accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
+  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
+  dependencies:
+    mime-types "~2.1.24"
+    negotiator "0.6.2"
+
+accessibility-developer-tools@^2.12.0:
+  version "2.12.0"
+  resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
+  integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
+
+acorn-jsx@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
+  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
+  dependencies:
+    acorn "^3.0.4"
+
+acorn@^3.0.4:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
+  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
+
+acorn@^5.5.0:
+  version "5.7.3"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
+  integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==
+
+acorn@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
+  integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
+
+adm-zip@~0.4.3:
+  version "0.4.13"
+  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
+  integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
+
+after@0.8.2:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
+  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
+
+agent-base@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
+ajv@^6.5.5:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.11.0.tgz#c3607cbc8ae392d8a5a536f25b21f8e5f3f87fe9"
+  integrity sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ansi-align@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
+  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
+  dependencies:
+    string-width "^2.0.0"
+
+ansi-colors@3.2.3:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813"
+  integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==
+
+ansi-regex@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+
+ansi-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+
+ansi-regex@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
+  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
+
+ansi-styles@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
+  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
+
+ansi-styles@^3.2.0, ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+ansi-styles@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
+  integrity sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=
+
+any-promise@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
+  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
+
+append-field@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
+  integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
+
+archiver-utils@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
+  integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==
+  dependencies:
+    glob "^7.1.4"
+    graceful-fs "^4.2.0"
+    lazystream "^1.0.0"
+    lodash.defaults "^4.2.0"
+    lodash.difference "^4.5.0"
+    lodash.flatten "^4.4.0"
+    lodash.isplainobject "^4.0.6"
+    lodash.union "^4.6.0"
+    normalize-path "^3.0.0"
+    readable-stream "^2.0.0"
+
+archiver@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
+  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
+  dependencies:
+    archiver-utils "^2.1.0"
+    async "^2.6.3"
+    buffer-crc32 "^0.2.1"
+    glob "^7.1.4"
+    readable-stream "^3.4.0"
+    tar-stream "^2.1.0"
+    zip-stream "^2.1.2"
+
+argparse@^1.0.7:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
+  integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
+  dependencies:
+    sprintf-js "~1.0.2"
+
+arr-diff@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
+  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
+  dependencies:
+    arr-flatten "^1.0.1"
+
+arr-diff@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
+  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
+
+arr-flatten@^1.0.1, arr-flatten@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
+
+arr-union@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
+  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
+
+array-back@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022"
+  integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==
+  dependencies:
+    typical "^2.6.1"
+
+array-back@^3.0.1:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-find-index@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
+  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
+
+array-flatten@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
+
+array-unique@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
+  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
+
+array-unique@^0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
+  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
+
+arraybuffer.slice@~0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
+  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
+
+asn1@~0.2.3:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
+  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
+  dependencies:
+    safer-buffer "~2.1.0"
+
+assert-plus@1.0.0, assert-plus@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
+  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
+
+assertion-error@^1.0.1, assertion-error@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
+  integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
+
+assign-symbols@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
+  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
+
+async-limiter@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
+  integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
+
+async@^1.5.2:
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
+  integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=
+
+async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.1, async@^2.6.2, async@^2.6.3:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
+  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+  dependencies:
+    lodash "^4.17.14"
+
+async@~0.2.9:
+  version "0.2.10"
+  resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
+  integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
+
+asynckit@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+
+atob@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
+  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
+
+aws-sign2@~0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+
+aws4@^1.8.0:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"
+  integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==
+
+babel-code-frame@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
+  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
+  dependencies:
+    chalk "^1.1.3"
+    esutils "^2.0.2"
+    js-tokens "^3.0.2"
+
+babel-generator@^6.26.1:
+  version "6.26.1"
+  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
+  integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==
+  dependencies:
+    babel-messages "^6.23.0"
+    babel-runtime "^6.26.0"
+    babel-types "^6.26.0"
+    detect-indent "^4.0.0"
+    jsesc "^1.3.0"
+    lodash "^4.17.4"
+    source-map "^0.5.7"
+    trim-right "^1.0.1"
+
+babel-helper-evaluate-path@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.5.0.tgz#a62fa9c4e64ff7ea5cea9353174ef023a900a67c"
+  integrity sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==
+
+babel-helper-flip-expressions@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.4.3.tgz#3696736a128ac18bc25254b5f40a22ceb3c1d3fd"
+  integrity sha1-NpZzahKKwYvCUlS19AoizrPB0/0=
+
+babel-helper-is-nodes-equiv@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
+  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
+
+babel-helper-is-void-0@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.4.3.tgz#7d9c01b4561e7b95dbda0f6eee48f5b60e67313e"
+  integrity sha1-fZwBtFYee5Xb2g9u7kj1tg5nMT4=
+
+babel-helper-mark-eval-scopes@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.4.3.tgz#d244a3bef9844872603ffb46e22ce8acdf551562"
+  integrity sha1-0kSjvvmESHJgP/tG4izorN9VFWI=
+
+babel-helper-remove-or-void@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.4.3.tgz#a4f03b40077a0ffe88e45d07010dee241ff5ae60"
+  integrity sha1-pPA7QAd6D/6I5F0HAQ3uJB/1rmA=
+
+babel-helper-to-multiple-sequence-expressions@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d"
+  integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==
+
+babel-messages@^6.23.0:
+  version "6.23.0"
+  resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
+  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
+  dependencies:
+    babel-runtime "^6.22.0"
+
+babel-plugin-dynamic-import-node@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f"
+  integrity sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==
+  dependencies:
+    object.assign "^4.1.0"
+
+babel-plugin-minify-builtins@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.5.0.tgz#31eb82ed1a0d0efdc31312f93b6e4741ce82c36b"
+  integrity sha512-wpqbN7Ov5hsNwGdzuzvFcjgRlzbIeVv1gMIlICbPj0xkexnfoIDe7q+AZHMkQmAE/F9R5jkrB6TLfTegImlXag==
+
+babel-plugin-minify-constant-folding@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.5.0.tgz#f84bc8dbf6a561e5e350ff95ae216b0ad5515b6e"
+  integrity sha512-Vj97CTn/lE9hR1D+jKUeHfNy+m1baNiJ1wJvoGyOBUx7F7kJqDZxr9nCHjO/Ad+irbR3HzR6jABpSSA29QsrXQ==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+
+babel-plugin-minify-dead-code-elimination@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.1.tgz#1a0c68e44be30de4976ca69ffc535e08be13683f"
+  integrity sha512-x8OJOZIrRmQBcSqxBcLbMIK8uPmTvNWPXH2bh5MDCW1latEqYiRMuUkPImKcfpo59pTUB2FT7HfcgtG8ZlR5Qg==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+    babel-helper-mark-eval-scopes "^0.4.3"
+    babel-helper-remove-or-void "^0.4.3"
+    lodash "^4.17.11"
+
+babel-plugin-minify-flip-comparisons@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.4.3.tgz#00ca870cb8f13b45c038b3c1ebc0f227293c965a"
+  integrity sha1-AMqHDLjxO0XAOLPB68DyJyk8llo=
+  dependencies:
+    babel-helper-is-void-0 "^0.4.3"
+
+babel-plugin-minify-guarded-expressions@^0.4.3, babel-plugin-minify-guarded-expressions@^0.4.4:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.4.tgz#818960f64cc08aee9d6c75bec6da974c4d621135"
+  integrity sha512-RMv0tM72YuPPfLT9QLr3ix9nwUIq+sHT6z8Iu3sLbqldzC1Dls8DPCywzUIzkTx9Zh1hWX4q/m9BPoPed9GOfA==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+    babel-helper-flip-expressions "^0.4.3"
+
+babel-plugin-minify-infinity@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.4.3.tgz#dfb876a1b08a06576384ef3f92e653ba607b39ca"
+  integrity sha1-37h2obCKBldjhO8/kuZTumB7Oco=
+
+babel-plugin-minify-mangle-names@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.5.0.tgz#bcddb507c91d2c99e138bd6b17a19c3c271e3fd3"
+  integrity sha512-3jdNv6hCAw6fsX1p2wBGPfWuK69sfOjfd3zjUXkbq8McbohWy23tpXfy5RnToYWggvqzuMOwlId1PhyHOfgnGw==
+  dependencies:
+    babel-helper-mark-eval-scopes "^0.4.3"
+
+babel-plugin-minify-numeric-literals@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.4.3.tgz#8e4fd561c79f7801286ff60e8c5fd9deee93c0bc"
+  integrity sha1-jk/VYcefeAEob/YOjF/Z3u6TwLw=
+
+babel-plugin-minify-replace@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.5.0.tgz#d3e2c9946c9096c070efc96761ce288ec5c3f71c"
+  integrity sha512-aXZiaqWDNUbyNNNpWs/8NyST+oU7QTpK7J9zFEFSA0eOmtUNMU3fczlTTTlnCxHmq/jYNFEmkkSG3DDBtW3Y4Q==
+
+babel-plugin-minify-simplify@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.1.tgz#f21613c8b95af3450a2ca71502fdbd91793c8d6a"
+  integrity sha512-OSYDSnoCxP2cYDMk9gxNAed6uJDiDz65zgL6h8d3tm8qXIagWGMLWhqysT6DY3Vs7Fgq7YUDcjOomhVUb+xX6A==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+    babel-helper-flip-expressions "^0.4.3"
+    babel-helper-is-nodes-equiv "^0.0.1"
+    babel-helper-to-multiple-sequence-expressions "^0.5.0"
+
+babel-plugin-minify-type-constructors@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.4.3.tgz#1bc6f15b87f7ab1085d42b330b717657a2156500"
+  integrity sha1-G8bxW4f3qxCF1CszC3F2V6IVZQA=
+  dependencies:
+    babel-helper-is-void-0 "^0.4.3"
+
+babel-plugin-transform-inline-consecutive-adds@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.4.3.tgz#323d47a3ea63a83a7ac3c811ae8e6941faf2b0d1"
+  integrity sha1-Mj1Ho+pjqDp6w8gRro5pQfrysNE=
+
+babel-plugin-transform-member-expression-literals@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
+  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
+
+babel-plugin-transform-merge-sibling-variables@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
+  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
+
+babel-plugin-transform-minify-booleans@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
+  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
+
+babel-plugin-transform-property-literals@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
+  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
+  dependencies:
+    esutils "^2.0.2"
+
+babel-plugin-transform-regexp-constructors@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.4.3.tgz#58b7775b63afcf33328fae9a5f88fbd4fb0b4965"
+  integrity sha1-WLd3W2OvzzMyj66aX4j71PsLSWU=
+
+babel-plugin-transform-remove-console@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
+  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
+
+babel-plugin-transform-remove-debugger@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
+  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
+
+babel-plugin-transform-remove-undefined@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.5.0.tgz#80208b31225766c630c97fa2d288952056ea22dd"
+  integrity sha512-+M7fJYFaEE/M9CXa0/IRkDbiV3wRELzA1kKQFCJ4ifhrzLKn/9VCCgj9OFmYWwBd8IB48YdgPkHYtbYq+4vtHQ==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+
+babel-plugin-transform-simplify-comparison-operators@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
+  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
+
+babel-plugin-transform-undefined-to-void@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
+  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
+
+babel-preset-minify@^0.5.0:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.1.tgz#25f5d0bce36ec818be80338d0e594106e21eaa9f"
+  integrity sha512-1IajDumYOAPYImkHbrKeiN5AKKP9iOmRoO2IPbIuVp0j2iuCcj0n7P260z38siKMZZ+85d3mJZdtW8IgOv+Tzg==
+  dependencies:
+    babel-plugin-minify-builtins "^0.5.0"
+    babel-plugin-minify-constant-folding "^0.5.0"
+    babel-plugin-minify-dead-code-elimination "^0.5.1"
+    babel-plugin-minify-flip-comparisons "^0.4.3"
+    babel-plugin-minify-guarded-expressions "^0.4.4"
+    babel-plugin-minify-infinity "^0.4.3"
+    babel-plugin-minify-mangle-names "^0.5.0"
+    babel-plugin-minify-numeric-literals "^0.4.3"
+    babel-plugin-minify-replace "^0.5.0"
+    babel-plugin-minify-simplify "^0.5.1"
+    babel-plugin-minify-type-constructors "^0.4.3"
+    babel-plugin-transform-inline-consecutive-adds "^0.4.3"
+    babel-plugin-transform-member-expression-literals "^6.9.4"
+    babel-plugin-transform-merge-sibling-variables "^6.9.4"
+    babel-plugin-transform-minify-booleans "^6.9.4"
+    babel-plugin-transform-property-literals "^6.9.4"
+    babel-plugin-transform-regexp-constructors "^0.4.3"
+    babel-plugin-transform-remove-console "^6.9.4"
+    babel-plugin-transform-remove-debugger "^6.9.4"
+    babel-plugin-transform-remove-undefined "^0.5.0"
+    babel-plugin-transform-simplify-comparison-operators "^6.9.4"
+    babel-plugin-transform-undefined-to-void "^6.9.4"
+    lodash "^4.17.11"
+
+babel-runtime@^6.22.0, babel-runtime@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
+  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
+  dependencies:
+    core-js "^2.4.0"
+    regenerator-runtime "^0.11.0"
+
+babel-traverse@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
+  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
+  dependencies:
+    babel-code-frame "^6.26.0"
+    babel-messages "^6.23.0"
+    babel-runtime "^6.26.0"
+    babel-types "^6.26.0"
+    babylon "^6.18.0"
+    debug "^2.6.8"
+    globals "^9.18.0"
+    invariant "^2.2.2"
+    lodash "^4.17.4"
+
+babel-types@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
+  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
+  dependencies:
+    babel-runtime "^6.26.0"
+    esutils "^2.0.2"
+    lodash "^4.17.4"
+    to-fast-properties "^1.0.3"
+
+babylon@^6.18.0:
+  version "6.18.0"
+  resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
+  integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
+
+babylon@^7.0.0-beta.42:
+  version "7.0.0-beta.47"
+  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
+  integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
+
+backo2@1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
+  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
+
+balanced-match@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+
+base64-arraybuffer@0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
+  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+
+base64-js@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
+  integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
+
+base64-js@^1.0.2:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
+  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
+
+base64id@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
+  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
+
+base@^0.11.1:
+  version "0.11.2"
+  resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
+  integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==
+  dependencies:
+    cache-base "^1.0.1"
+    class-utils "^0.3.5"
+    component-emitter "^1.2.1"
+    define-property "^1.0.0"
+    isobject "^3.0.1"
+    mixin-deep "^1.2.0"
+    pascalcase "^0.1.1"
+
+bcrypt-pbkdf@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
+  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
+  dependencies:
+    tweetnacl "^0.14.3"
+
+better-assert@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
+  integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
+  dependencies:
+    callsite "1.0.0"
+
+bl@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.0.tgz#e1a574cdf528e4053019bb800b041c0ac88da493"
+  integrity sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==
+  dependencies:
+    readable-stream "^2.3.5"
+    safe-buffer "^5.1.1"
+
+bl@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
+  integrity sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==
+  dependencies:
+    readable-stream "^3.0.1"
+
+blob@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
+  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
+
+body-parser@1.19.0, body-parser@^1.17.2:
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
+  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+  dependencies:
+    bytes "3.1.0"
+    content-type "~1.0.4"
+    debug "2.6.9"
+    depd "~1.1.2"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    on-finished "~2.3.0"
+    qs "6.7.0"
+    raw-body "2.4.0"
+    type-is "~1.6.17"
+
+bower-config@^1.4.0, bower-config@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.1.tgz#85fd9df367c2b8dbbd0caa4c5f2bad40cd84c2cc"
+  integrity sha1-hf2d82fCuNu9DKpMXyutQM2Ewsw=
+  dependencies:
+    graceful-fs "^4.1.3"
+    mout "^1.0.0"
+    optimist "^0.6.1"
+    osenv "^0.1.3"
+    untildify "^2.1.0"
+
+boxen@^1.2.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
+  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
+  dependencies:
+    ansi-align "^2.0.0"
+    camelcase "^4.0.0"
+    chalk "^2.0.1"
+    cli-boxes "^1.0.0"
+    string-width "^2.0.0"
+    term-size "^1.2.0"
+    widest-line "^2.0.0"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+braces@^1.8.2:
+  version "1.8.5"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
+  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
+  dependencies:
+    expand-range "^1.8.1"
+    preserve "^0.2.0"
+    repeat-element "^1.1.2"
+
+braces@^2.3.1:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
+  integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==
+  dependencies:
+    arr-flatten "^1.1.0"
+    array-unique "^0.3.2"
+    extend-shallow "^2.0.1"
+    fill-range "^4.0.0"
+    isobject "^3.0.1"
+    repeat-element "^1.1.2"
+    snapdragon "^0.8.1"
+    snapdragon-node "^2.0.1"
+    split-string "^3.0.2"
+    to-regex "^3.0.1"
+
+browser-capabilities@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/browser-capabilities/-/browser-capabilities-1.1.4.tgz#a6bd657a07a134532ad66c722b8949904478b973"
+  integrity sha512-BezMQhbQklxjRQpZZQ8tnbzEo6AldUwMh8/PeWt5/CTBSwByQRXZEAK2fbnEahQ4poeeaI0suAYRq25A1YGOmw==
+  dependencies:
+    "@types/ua-parser-js" "^0.7.31"
+    ua-parser-js "^0.7.15"
+
+browser-stdout@1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f"
+  integrity sha1-81HTKWnTL6XXpVZxVCY9korjvR8=
+
+browser-stdout@1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
+  integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
+
+browserstack@^1.2.0:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.5.3.tgz#93ab48799a12ef99dbd074dd595410ddb196a7ac"
+  integrity sha512-AO+mECXsW4QcqC9bxwM29O7qWa7bJT94uBFzeb5brylIQwawuEziwq20dPYbins95GlWzOawgyDNdjYAo32EKg==
+  dependencies:
+    https-proxy-agent "^2.2.1"
+
+buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
+
+buffer-from@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+
+buffer@^5.1.0:
+  version "5.4.3"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
+  integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
+  dependencies:
+    base64-js "^1.0.2"
+    ieee754 "^1.1.4"
+
+busboy@^0.2.11:
+  version "0.2.14"
+  resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
+  integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
+  dependencies:
+    dicer "0.2.5"
+    readable-stream "1.1.x"
+
+bytes@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
+  integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
+
+bytes@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
+  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
+
+cache-base@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
+  integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==
+  dependencies:
+    collection-visit "^1.0.0"
+    component-emitter "^1.2.1"
+    get-value "^2.0.6"
+    has-value "^1.0.0"
+    isobject "^3.0.1"
+    set-value "^2.0.0"
+    to-object-path "^0.3.0"
+    union-value "^1.0.0"
+    unset-value "^1.0.0"
+
+callsite@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
+  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
+
+camel-case@3.0.x:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
+  integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
+  dependencies:
+    no-case "^2.2.0"
+    upper-case "^1.1.1"
+
+camelcase-keys@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
+  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
+  dependencies:
+    camelcase "^2.0.0"
+    map-obj "^1.0.0"
+
+camelcase@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
+  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
+
+camelcase@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
+  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
+
+camelcase@^5.0.0:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
+  integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
+
+cancel-token@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
+  integrity sha1-wYGXZ0uxyEwdaTPr8V2NWlznm08=
+  dependencies:
+    "@types/node" "^4.0.30"
+
+capture-stack-trace@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
+  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
+
+caseless@~0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
+  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
+
+chai@^3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247"
+  integrity sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=
+  dependencies:
+    assertion-error "^1.0.1"
+    deep-eql "^0.1.3"
+    type-detect "^1.0.0"
+
+chai@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5"
+  integrity sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==
+  dependencies:
+    assertion-error "^1.1.0"
+    check-error "^1.0.2"
+    deep-eql "^3.0.1"
+    get-func-name "^2.0.0"
+    pathval "^1.1.0"
+    type-detect "^4.0.5"
+
+chalk@^1.1.1, chalk@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
+  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
+  dependencies:
+    ansi-styles "^2.2.1"
+    escape-string-regexp "^1.0.2"
+    has-ansi "^2.0.0"
+    strip-ansi "^3.0.0"
+    supports-color "^2.0.0"
+
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+chalk@~0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
+  integrity sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=
+  dependencies:
+    ansi-styles "~1.0.0"
+    has-color "~0.1.0"
+    strip-ansi "~0.1.0"
+
+charenc@~0.0.1:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
+  integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
+
+check-error@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
+  integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
+
+ci-info@^1.5.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
+  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
+
+class-utils@^0.3.5:
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
+  integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==
+  dependencies:
+    arr-union "^3.1.0"
+    define-property "^0.2.5"
+    isobject "^3.0.0"
+    static-extend "^0.1.1"
+
+clean-css@4.2.x:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17"
+  integrity sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==
+  dependencies:
+    source-map "~0.6.0"
+
+cleankill@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/cleankill/-/cleankill-2.0.0.tgz#59830dfc8b411d53dc72ad09d45a78ea33161a91"
+  integrity sha1-WYMN/ItBHVPccq0J1Fp46jMWGpE=
+
+cli-boxes@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
+  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
+
+cliui@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
+  integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
+  dependencies:
+    string-width "^3.1.0"
+    strip-ansi "^5.2.0"
+    wrap-ansi "^5.1.0"
+
+clone-stats@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
+  integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=
+
+clone@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
+  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
+
+clone@^2.0.0, clone@^2.1.0:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
+
+collection-visit@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
+  integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=
+  dependencies:
+    map-visit "^1.0.0"
+    object-visit "^1.0.0"
+
+color-convert@^1.9.0, color-convert@^1.9.1:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-name@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
+
+color-name@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+color-string@^1.5.2:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
+  integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
+  dependencies:
+    color-name "^1.0.0"
+    simple-swizzle "^0.2.2"
+
+color@3.0.x:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
+  integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==
+  dependencies:
+    color-convert "^1.9.1"
+    color-string "^1.5.2"
+
+colornames@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96"
+  integrity sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=
+
+colors@^1.2.1:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
+  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
+
+colorspace@1.1.x:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5"
+  integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==
+  dependencies:
+    color "3.0.x"
+    text-hex "1.0.x"
+
+combined-stream@^1.0.6, combined-stream@~1.0.6:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+  dependencies:
+    delayed-stream "~1.0.0"
+
+command-line-args@^5.0.2:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.1.1.tgz#88e793e5bb3ceb30754a86863f0401ac92fd369a"
+  integrity sha512-hL/eG8lrll1Qy1ezvkant+trihbGnaKaeEjj6Scyr3DN+RC7iQ5Rz84IeLERfAWDGo0HBSNAakczwgCilDXnWg==
+  dependencies:
+    array-back "^3.0.1"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-usage@^5.0.5:
+  version "5.0.5"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-5.0.5.tgz#5f25933ffe6dedd983c635d38a21d7e623fda357"
+  integrity sha512-d8NrGylA5oCXSbGoKz05FkehDAzSmIm4K03S5VDh4d5lZAtTWfc3D1RuETtuQCn8129nYfJfDdF7P/lwcz1BlA==
+  dependencies:
+    array-back "^2.0.0"
+    chalk "^2.4.1"
+    table-layout "^0.4.3"
+    typical "^2.6.1"
+
+commander@2.17.x:
+  version "2.17.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
+  integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
+
+commander@2.9.0:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4"
+  integrity sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=
+  dependencies:
+    graceful-readlink ">= 1.0.0"
+
+commander@^2.19.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+commander@~2.19.0:
+  version "2.19.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
+  integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
+
+component-bind@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
+  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
+
+component-emitter@1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
+  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
+
+component-emitter@^1.2.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
+  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
+
+component-inherit@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
+  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
+
+compress-commons@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
+  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
+  dependencies:
+    buffer-crc32 "^0.2.13"
+    crc32-stream "^3.0.1"
+    normalize-path "^3.0.0"
+    readable-stream "^2.3.6"
+
+compressible@~2.0.16:
+  version "2.0.18"
+  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
+  integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
+  dependencies:
+    mime-db ">= 1.43.0 < 2"
+
+compression@^1.6.2:
+  version "1.7.4"
+  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
+  integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
+  dependencies:
+    accepts "~1.3.5"
+    bytes "3.0.0"
+    compressible "~2.0.16"
+    debug "2.6.9"
+    on-headers "~1.0.2"
+    safe-buffer "5.1.2"
+    vary "~1.1.2"
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+concat-stream@^1.5.2:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
+  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
+  dependencies:
+    buffer-from "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^2.2.2"
+    typedarray "^0.0.6"
+
+configstore@^3.0.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
+  integrity sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==
+  dependencies:
+    dot-prop "^4.1.0"
+    graceful-fs "^4.1.2"
+    make-dir "^1.0.0"
+    unique-string "^1.0.0"
+    write-file-atomic "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+content-disposition@0.5.3:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
+  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
+  dependencies:
+    safe-buffer "5.1.2"
+
+content-type@^1.0.2, content-type@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+convert-source-map@^1.1.1, convert-source-map@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
+  integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
+  dependencies:
+    safe-buffer "~5.1.1"
+
+cookie-signature@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
+  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
+
+cookie@0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
+  integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
+
+cookie@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
+  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
+
+copy-descriptor@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
+  integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
+
+core-js@^2.4.0:
+  version "2.6.11"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
+  integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
+
+core-util-is@1.0.2, core-util-is@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+
+cors@^2.8.4:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
+  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
+  dependencies:
+    object-assign "^4"
+    vary "^1"
+
+crc32-stream@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
+  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
+  dependencies:
+    crc "^3.4.4"
+    readable-stream "^3.4.0"
+
+crc@^3.4.4:
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
+  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
+  dependencies:
+    buffer "^5.1.0"
+
+create-error-class@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
+  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
+  dependencies:
+    capture-stack-trace "^1.0.0"
+
+cross-spawn@^5.0.1:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
+  dependencies:
+    lru-cache "^4.0.1"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+cross-spawn@^6.0.5:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
+  dependencies:
+    nice-try "^1.0.4"
+    path-key "^2.0.1"
+    semver "^5.5.0"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+crypt@~0.0.1:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
+  integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
+
+crypto-random-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
+  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
+
+css-slam@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
+  integrity sha512-cObrY+mhFEmepWpua6EpMrgRNTQ0eeym+kvR0lukI6hDEzK7F8himEDS4cJ9+fPHCoArTzVrrR0d+oAUbTR1NA==
+  dependencies:
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    dom5 "^3.0.0"
+    parse5 "^4.0.0"
+    shady-css-parser "^0.1.0"
+
+cssbeautify@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
+  integrity sha1-Et0fc0A1wub6ymfcvc73TkKBE5c=
+
+currently-unhandled@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
+  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
+  dependencies:
+    array-find-index "^1.0.1"
+
+dashdash@^1.12.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
+  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
+  dependencies:
+    assert-plus "^1.0.0"
+
+debug@2.6.8:
+  version "2.6.8"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc"
+  integrity sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=
+  dependencies:
+    ms "2.0.0"
+
+debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@3.2.6, debug@^3.0.0, debug@^3.1.0:
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+  dependencies:
+    ms "^2.1.1"
+
+debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+  dependencies:
+    ms "^2.1.1"
+
+debug@~3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+  dependencies:
+    ms "2.0.0"
+
+decamelize@^1.1.2, decamelize@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+  integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
+
+decode-uri-component@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
+  integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
+
+deep-eql@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2"
+  integrity sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=
+  dependencies:
+    type-detect "0.1.1"
+
+deep-eql@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
+  integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==
+  dependencies:
+    type-detect "^4.0.0"
+
+deep-extend@^0.6.0, deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+define-properties@^1.1.2, define-properties@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
+  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
+  dependencies:
+    object-keys "^1.0.12"
+
+define-property@^0.2.5:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
+  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
+  dependencies:
+    is-descriptor "^0.1.0"
+
+define-property@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
+  integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY=
+  dependencies:
+    is-descriptor "^1.0.0"
+
+define-property@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
+  integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==
+  dependencies:
+    is-descriptor "^1.0.2"
+    isobject "^3.0.1"
+
+delayed-stream@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
+
+destroy@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
+  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
+
+detect-file@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
+  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
+
+detect-indent@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
+  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
+  dependencies:
+    repeating "^2.0.0"
+
+detect-node@^2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
+  integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
+
+diagnostics@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a"
+  integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==
+  dependencies:
+    colorspace "1.1.x"
+    enabled "1.0.x"
+    kuler "1.0.x"
+
+dicer@0.2.5:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
+  integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
+  dependencies:
+    readable-stream "1.1.x"
+    streamsearch "0.1.2"
+
+diff@3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.2.0.tgz#c9ce393a4b7cbd0b058a725c93df299027868ff9"
+  integrity sha1-yc45Okt8vQsFinJck98pkCeGj/k=
+
+diff@3.5.0, diff@^3.1.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
+  integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
+
+doctrine@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
+  integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
+  dependencies:
+    esutils "^2.0.2"
+
+dom-urls@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
+  integrity sha1-AB3fgWKM0ecGElxxdvU8zsVdkY4=
+  dependencies:
+    urijs "^1.16.1"
+
+dom5@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dom5/-/dom5-3.0.1.tgz#cdfc7331f376e284bf379e6ea054afc136702944"
+  integrity sha512-JPFiouQIr16VQ4dX6i0+Hpbg3H2bMKPmZ+WZgBOSSvOPx9QHwwY8sPzeM2baUtViESYto6wC2nuZOMC/6gulcA==
+  dependencies:
+    "@types/parse5" "^2.2.34"
+    clone "^2.1.0"
+    parse5 "^4.0.0"
+
+dot-prop@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
+  integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==
+  dependencies:
+    is-obj "^1.0.0"
+
+duplexer2@^0.1.2:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
+  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
+  dependencies:
+    readable-stream "^2.0.2"
+
+duplexer3@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
+
+duplexify@^3.2.0, duplexify@^3.5.0:
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
+  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
+  dependencies:
+    end-of-stream "^1.0.0"
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
+    stream-shift "^1.0.0"
+
+ecc-jsbn@~0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
+  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+  dependencies:
+    jsbn "~0.1.0"
+    safer-buffer "^2.1.0"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
+
+emitter-component@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
+  integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
+
+emoji-regex@^7.0.1:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
+  integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
+
+enabled@1.0.x:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93"
+  integrity sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=
+  dependencies:
+    env-variable "0.0.x"
+
+encodeurl@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
+
+end-of-stream@^1.0.0, end-of-stream@^1.4.1:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+  dependencies:
+    once "^1.4.0"
+
+engine.io-client@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700"
+  integrity sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==
+  dependencies:
+    component-emitter "1.2.1"
+    component-inherit "0.0.3"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    ws "~6.1.0"
+    xmlhttprequest-ssl "~1.5.4"
+    yeast "0.1.2"
+
+engine.io-parser@~2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.0.tgz#312c4894f57d52a02b420868da7b5c1c84af80ed"
+  integrity sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==
+  dependencies:
+    after "0.8.2"
+    arraybuffer.slice "~0.0.7"
+    base64-arraybuffer "0.1.5"
+    blob "0.0.5"
+    has-binary2 "~1.0.2"
+
+engine.io@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.0.tgz#3a962cc4535928c252759a00f98519cb46c53ff3"
+  integrity sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==
+  dependencies:
+    accepts "~1.3.4"
+    base64id "2.0.0"
+    cookie "0.3.1"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
+    ws "^7.1.2"
+
+env-variable@0.0.x:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
+  integrity sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==
+
+error-ex@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
+  dependencies:
+    is-arrayish "^0.2.1"
+
+es-abstract@^1.17.0-next.1:
+  version "1.17.4"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.4.tgz#e3aedf19706b20e7c2594c35fc0d57605a79e184"
+  integrity sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ==
+  dependencies:
+    es-to-primitive "^1.2.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.1"
+    is-callable "^1.1.5"
+    is-regex "^1.0.5"
+    object-inspect "^1.7.0"
+    object-keys "^1.1.1"
+    object.assign "^4.1.0"
+    string.prototype.trimleft "^2.1.1"
+    string.prototype.trimright "^2.1.1"
+
+es-to-primitive@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
+  integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
+  dependencies:
+    is-callable "^1.1.4"
+    is-date-object "^1.0.1"
+    is-symbol "^1.0.2"
+
+es6-promise@^4.0.3, es6-promise@^4.0.5:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  dependencies:
+    es6-promise "^4.0.3"
+
+es6-promisify@^6.0.0:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.0.2.tgz#525c23725b8510f5f1f2feb5a1fbad93a93e29b4"
+  integrity sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==
+
+escape-html@^1.0.3, escape-html@~1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
+
+escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
+
+espree@^3.5.2:
+  version "3.5.4"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
+  integrity sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==
+  dependencies:
+    acorn "^5.5.0"
+    acorn-jsx "^3.0.0"
+
+esprima@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
+  integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
+
+esutils@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
+  integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
+
+etag@~1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
+
+eventemitter3@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
+  integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
+
+execa@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
+  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
+  dependencies:
+    cross-spawn "^5.0.1"
+    get-stream "^3.0.0"
+    is-stream "^1.1.0"
+    npm-run-path "^2.0.0"
+    p-finally "^1.0.0"
+    signal-exit "^3.0.0"
+    strip-eof "^1.0.0"
+
+expand-brackets@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
+  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
+  dependencies:
+    is-posix-bracket "^0.1.0"
+
+expand-brackets@^2.1.4:
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
+  integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI=
+  dependencies:
+    debug "^2.3.3"
+    define-property "^0.2.5"
+    extend-shallow "^2.0.1"
+    posix-character-classes "^0.1.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+expand-range@^1.8.1:
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
+  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
+  dependencies:
+    fill-range "^2.1.0"
+
+expand-tilde@^2.0.0, expand-tilde@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
+  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
+  dependencies:
+    homedir-polyfill "^1.0.1"
+
+express@^4.15.3, express@^4.8.5:
+  version "4.17.1"
+  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
+  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
+  dependencies:
+    accepts "~1.3.7"
+    array-flatten "1.1.1"
+    body-parser "1.19.0"
+    content-disposition "0.5.3"
+    content-type "~1.0.4"
+    cookie "0.4.0"
+    cookie-signature "1.0.6"
+    debug "2.6.9"
+    depd "~1.1.2"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    finalhandler "~1.1.2"
+    fresh "0.5.2"
+    merge-descriptors "1.0.1"
+    methods "~1.1.2"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    path-to-regexp "0.1.7"
+    proxy-addr "~2.0.5"
+    qs "6.7.0"
+    range-parser "~1.2.1"
+    safe-buffer "5.1.2"
+    send "0.17.1"
+    serve-static "1.14.1"
+    setprototypeof "1.1.1"
+    statuses "~1.5.0"
+    type-is "~1.6.18"
+    utils-merge "1.0.1"
+    vary "~1.1.2"
+
+extend-shallow@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
+  integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=
+  dependencies:
+    is-extendable "^0.1.0"
+
+extend-shallow@^3.0.0, extend-shallow@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
+  integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=
+  dependencies:
+    assign-symbols "^1.0.0"
+    is-extendable "^1.0.1"
+
+extend@^3.0.0, extend@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
+extglob@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
+  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
+  dependencies:
+    is-extglob "^1.0.0"
+
+extglob@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
+  integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==
+  dependencies:
+    array-unique "^0.3.2"
+    define-property "^1.0.0"
+    expand-brackets "^2.1.4"
+    extend-shallow "^2.0.1"
+    fragment-cache "^0.2.1"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+extsprintf@1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
+
+extsprintf@^1.2.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
+  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
+
+fast-deep-equal@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4"
+  integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==
+
+fast-json-stable-stringify@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fast-safe-stringify@^2.0.4:
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
+  integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==
+
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+  dependencies:
+    pend "~1.2.0"
+
+fecha@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
+  integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
+
+filename-regex@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
+  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
+
+fill-range@^2.1.0:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
+  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
+  dependencies:
+    is-number "^2.1.0"
+    isobject "^2.0.0"
+    randomatic "^3.0.0"
+    repeat-element "^1.1.2"
+    repeat-string "^1.5.2"
+
+fill-range@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
+  integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=
+  dependencies:
+    extend-shallow "^2.0.1"
+    is-number "^3.0.0"
+    repeat-string "^1.6.1"
+    to-regex-range "^2.1.0"
+
+finalhandler@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
+  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
+  dependencies:
+    debug "2.6.9"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    statuses "~1.5.0"
+    unpipe "~1.0.0"
+
+find-port@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/find-port/-/find-port-1.0.1.tgz#db084a6cbf99564d99869ae79fbdecf66e8a185c"
+  integrity sha1-2whKbL+ZVk2Zhprnn73s9m6KGFw=
+  dependencies:
+    async "~0.2.9"
+
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@3.0.0, find-up@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
+  integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
+  dependencies:
+    locate-path "^3.0.0"
+
+find-up@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
+  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
+  dependencies:
+    path-exists "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+findup-sync@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
+  integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=
+  dependencies:
+    detect-file "^1.0.0"
+    is-glob "^3.1.0"
+    micromatch "^3.0.4"
+    resolve-dir "^1.0.1"
+
+first-chunk-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
+  integrity sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=
+
+flat@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.0.tgz#090bec8b05e39cba309747f1d588f04dbaf98db2"
+  integrity sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw==
+  dependencies:
+    is-buffer "~2.0.3"
+
+follow-redirects@^1.0.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
+  integrity sha512-CRcPzsSIbXyVDl0QI01muNDu69S8trU4jArW9LpOt2WtC6LyUJetcIrmfHsRBx7/Jb6GHJUiuqyYxPooFfNt6A==
+  dependencies:
+    debug "^3.0.0"
+
+for-in@^1.0.1, for-in@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
+  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
+
+for-own@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
+  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
+  dependencies:
+    for-in "^1.0.1"
+
+forever-agent@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+
+fork-stream@^0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70"
+  integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
+
+form-data@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
+  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.6"
+    mime-types "^2.1.12"
+
+formatio@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.1.1.tgz#5ed3ccd636551097383465d996199100e86161e9"
+  integrity sha1-XtPM1jZVEJc4NGXZlhmRAOhhYek=
+  dependencies:
+    samsam "~1.1"
+
+formatio@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
+  integrity sha1-87IWfZBoxGmKjVH092CjmlTYGOs=
+  dependencies:
+    samsam "1.x"
+
+forwarded@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
+
+fragment-cache@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
+  integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=
+  dependencies:
+    map-cache "^0.2.2"
+
+freeport@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
+  integrity sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=
+
+fresh@0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
+
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
+function-bind@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+gensync@^1.0.0-beta.1:
+  version "1.0.0-beta.1"
+  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
+  integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==
+
+get-caller-file@^2.0.1:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+  integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+
+get-func-name@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
+  integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=
+
+get-stdin@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
+  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
+
+get-stream@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
+
+get-value@^2.0.3, get-value@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
+  integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
+
+getpass@^0.1.1:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
+  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
+  dependencies:
+    assert-plus "^1.0.0"
+
+glob-base@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
+  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
+  dependencies:
+    glob-parent "^2.0.0"
+    is-glob "^2.0.0"
+
+glob-parent@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
+  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
+  dependencies:
+    is-glob "^2.0.0"
+
+glob-parent@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
+  integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=
+  dependencies:
+    is-glob "^3.1.0"
+    path-dirname "^1.0.0"
+
+glob-stream@^5.3.2:
+  version "5.3.5"
+  resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-5.3.5.tgz#a55665a9a8ccdc41915a87c701e32d4e016fad22"
+  integrity sha1-pVZlqajM3EGRWofHAeMtTgFvrSI=
+  dependencies:
+    extend "^3.0.0"
+    glob "^5.0.3"
+    glob-parent "^3.0.0"
+    micromatch "^2.3.7"
+    ordered-read-streams "^0.3.0"
+    through2 "^0.6.0"
+    to-absolute-glob "^0.1.1"
+    unique-stream "^2.0.2"
+
+glob@7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8"
+  integrity sha1-gFIR3wT6rxxjo2ADBs31reULLsg=
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.2"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@7.1.3:
+  version "7.1.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
+  integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^5.0.3:
+  version "5.0.15"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
+  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
+  version "7.1.6"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
+  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+global-dirs@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
+  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
+  dependencies:
+    ini "^1.3.4"
+
+global-modules@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
+  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
+  dependencies:
+    global-prefix "^1.0.1"
+    is-windows "^1.0.1"
+    resolve-dir "^1.0.0"
+
+global-prefix@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
+  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
+  dependencies:
+    expand-tilde "^2.0.2"
+    homedir-polyfill "^1.0.1"
+    ini "^1.3.4"
+    is-windows "^1.0.1"
+    which "^1.2.14"
+
+globals@^11.1.0:
+  version "11.12.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
+  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
+
+globals@^9.18.0:
+  version "9.18.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
+  integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
+
+got@^6.7.1:
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
+  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
+  dependencies:
+    create-error-class "^3.0.0"
+    duplexer3 "^0.1.4"
+    get-stream "^3.0.0"
+    is-redirect "^1.0.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    lowercase-keys "^1.0.0"
+    safe-buffer "^5.0.1"
+    timed-out "^4.0.0"
+    unzip-response "^2.0.1"
+    url-parse-lax "^1.0.0"
+
+graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.2.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
+  integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==
+
+"graceful-readlink@>= 1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
+  integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=
+
+growl@1.10.5:
+  version "1.10.5"
+  resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e"
+  integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==
+
+growl@1.9.2:
+  version "1.9.2"
+  resolved "https://registry.yarnpkg.com/growl/-/growl-1.9.2.tgz#0ea7743715db8d8de2c5ede1775e1b45ac85c02f"
+  integrity sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=
+
+gulp-if@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
+  integrity sha1-pJe351cwBQQcqivIt92jyARE1ik=
+  dependencies:
+    gulp-match "^1.0.3"
+    ternary-stream "^2.0.1"
+    through2 "^2.0.1"
+
+gulp-match@^1.0.3:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.1.0.tgz#552b7080fc006ee752c90563f9fec9d61aafdf4f"
+  integrity sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==
+  dependencies:
+    minimatch "^3.0.3"
+
+gulp-sourcemaps@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz#b86ff349d801ceb56e1d9e7dc7bbcb4b7dee600c"
+  integrity sha1-uG/zSdgBzrVuHZ59x7vLS33uYAw=
+  dependencies:
+    convert-source-map "^1.1.1"
+    graceful-fs "^4.1.2"
+    strip-bom "^2.0.0"
+    through2 "^2.0.0"
+    vinyl "^1.0.0"
+
+handle-thing@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
+  integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=
+
+har-schema@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+
+har-validator@~5.1.0:
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
+  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+  dependencies:
+    ajv "^6.5.5"
+    har-schema "^2.0.0"
+
+has-ansi@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
+  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+has-binary2@~1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
+  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
+  dependencies:
+    isarray "2.0.1"
+
+has-color@~0.1.0:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f"
+  integrity sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=
+
+has-cors@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
+  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
+
+has-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
+  integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
+
+has-symbols@^1.0.0, has-symbols@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
+  integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
+
+has-value@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
+  integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=
+  dependencies:
+    get-value "^2.0.3"
+    has-values "^0.1.4"
+    isobject "^2.0.0"
+
+has-value@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
+  integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=
+  dependencies:
+    get-value "^2.0.6"
+    has-values "^1.0.0"
+    isobject "^3.0.0"
+
+has-values@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
+  integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E=
+
+has-values@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
+  integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=
+  dependencies:
+    is-number "^3.0.0"
+    kind-of "^4.0.0"
+
+has@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+  dependencies:
+    function-bind "^1.1.1"
+
+he@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
+  integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0=
+
+he@1.2.0, he@1.2.x:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
+homedir-polyfill@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
+  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
+  dependencies:
+    parse-passwd "^1.0.0"
+
+hosted-git-info@^2.1.4:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c"
+  integrity sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==
+
+hpack.js@^2.1.6:
+  version "2.1.6"
+  resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
+  integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
+  dependencies:
+    inherits "^2.0.1"
+    obuf "^1.0.0"
+    readable-stream "^2.0.1"
+    wbuf "^1.1.0"
+
+html-minifier@^3.5.10:
+  version "3.5.21"
+  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c"
+  integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==
+  dependencies:
+    camel-case "3.0.x"
+    clean-css "4.2.x"
+    commander "2.17.x"
+    he "1.2.x"
+    param-case "2.1.x"
+    relateurl "0.2.x"
+    uglify-js "3.4.x"
+
+http-deceiver@^1.2.7:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
+  integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
+
+http-errors@1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
+  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
+http-errors@~1.7.2:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
+  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
+http-proxy-middleware@^0.17.2:
+  version "0.17.4"
+  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
+  integrity sha1-ZC6ISIUdZvCdTxJJEoRtuutBuDM=
+  dependencies:
+    http-proxy "^1.16.2"
+    is-glob "^3.1.0"
+    lodash "^4.17.2"
+    micromatch "^2.3.11"
+
+http-proxy@^1.16.2:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
+  integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
+  dependencies:
+    eventemitter3 "^4.0.0"
+    follow-redirects "^1.0.0"
+    requires-port "^1.0.0"
+
+http-signature@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
+  dependencies:
+    assert-plus "^1.0.0"
+    jsprim "^1.2.2"
+    sshpk "^1.7.0"
+
+https-proxy-agent@^2.2.1:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
+  integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+https-proxy-agent@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
+  integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+iconv-lite@0.4.24:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+ieee754@^1.1.4:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
+  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
+
+import-lazy@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
+  integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
+
+imurmurhash@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+  integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
+
+indent-string@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
+  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
+  dependencies:
+    repeating "^2.0.0"
+
+indent@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
+  integrity sha1-jHnwgBkFWbaHA0uEx676l9WpEdk=
+
+indexof@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
+  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+
+ini@^1.3.4, ini@~1.3.0:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+
+invariant@^2.2.2:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
+  dependencies:
+    loose-envify "^1.0.0"
+
+ipaddr.js@1.9.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
+  integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
+
+is-accessor-descriptor@^0.1.6:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
+  integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-accessor-descriptor@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
+  integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==
+  dependencies:
+    kind-of "^6.0.0"
+
+is-arguments@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
+  integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
+
+is-arrayish@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+  integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
+
+is-arrayish@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
+  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
+
+is-buffer@^1.1.5, is-buffer@~1.1.1:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+  integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
+
+is-buffer@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
+  integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
+
+is-callable@^1.1.4, is-callable@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab"
+  integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==
+
+is-ci@^1.0.10:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
+  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
+  dependencies:
+    ci-info "^1.5.0"
+
+is-data-descriptor@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
+  integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-data-descriptor@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
+  integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==
+  dependencies:
+    kind-of "^6.0.0"
+
+is-date-object@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
+  integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
+
+is-descriptor@^0.1.0:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
+  integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==
+  dependencies:
+    is-accessor-descriptor "^0.1.6"
+    is-data-descriptor "^0.1.4"
+    kind-of "^5.0.0"
+
+is-descriptor@^1.0.0, is-descriptor@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
+  integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==
+  dependencies:
+    is-accessor-descriptor "^1.0.0"
+    is-data-descriptor "^1.0.0"
+    kind-of "^6.0.2"
+
+is-dotfile@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
+  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
+
+is-equal-shallow@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
+  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
+  dependencies:
+    is-primitive "^2.0.0"
+
+is-extendable@^0.1.0, is-extendable@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
+  integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=
+
+is-extendable@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
+  integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==
+  dependencies:
+    is-plain-object "^2.0.4"
+
+is-extglob@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
+  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
+
+is-extglob@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
+
+is-finite@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
+  integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
+
+is-generator-function@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.7.tgz#d2132e529bb0000a7f80794d4bdf5cd5e5813522"
+  integrity sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==
+
+is-glob@^2.0.0, is-glob@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
+  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
+  dependencies:
+    is-extglob "^1.0.0"
+
+is-glob@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
+  integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=
+  dependencies:
+    is-extglob "^2.1.0"
+
+is-installed-globally@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
+  dependencies:
+    global-dirs "^0.1.0"
+    is-path-inside "^1.0.0"
+
+is-npm@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
+  integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
+
+is-number@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
+  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-number@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
+  integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-number@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+  integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
+
+is-obj@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
+  integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
+
+is-path-inside@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
+  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
+  dependencies:
+    path-is-inside "^1.0.1"
+
+is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
+  dependencies:
+    isobject "^3.0.1"
+
+is-posix-bracket@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
+  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
+
+is-primitive@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
+
+is-redirect@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
+  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
+
+is-regex@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
+  integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==
+  dependencies:
+    has "^1.0.3"
+
+is-retry-allowed@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
+  integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
+
+is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
+  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+
+is-symbol@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
+  integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
+  dependencies:
+    has-symbols "^1.0.1"
+
+is-typedarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
+
+is-utf8@^0.2.0:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
+  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
+
+is-valid-glob@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-0.3.0.tgz#d4b55c69f51886f9b65c70d6c2622d37e29f48fe"
+  integrity sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=
+
+is-windows@^1.0.1, is-windows@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
+  integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
+
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
+
+isarray@1.0.0, isarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+
+isarray@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
+  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
+
+isobject@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
+  integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=
+  dependencies:
+    isarray "1.0.0"
+
+isobject@^3.0.0, isobject@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
+
+isstream@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
+  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+js-tokens@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
+  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
+
+js-yaml@3.13.1:
+  version "3.13.1"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
+  integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
+  dependencies:
+    argparse "^1.0.7"
+    esprima "^4.0.0"
+
+jsbn@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
+  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+
+jsesc@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
+  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
+
+jsesc@^2.5.1:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
+  integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
+
+jsesc@~0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
+  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
+
+json-schema-traverse@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+json-schema@0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
+  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+
+json-stable-stringify-without-jsonify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+  integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
+
+json-stringify-safe@~5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+
+json3@3.3.2:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1"
+  integrity sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=
+
+json5@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.1.tgz#81b6cb04e9ba496f1c7005d07b4368a2638f90b6"
+  integrity sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==
+  dependencies:
+    minimist "^1.2.0"
+
+jsonschema@^1.1.0, jsonschema@^1.1.1:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.5.tgz#bab69d97fa28946aec0a56a9cc266d23fe80ae61"
+  integrity sha512-kVTF+08x25PQ0CjuVc0gRM9EUPb0Fe9Ln/utFOgcdxEIOHuU7ooBk/UPTd7t1M91pP35m0MU1T8M5P7vP1bRRw==
+
+jsprim@^1.2.2:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
+  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+  dependencies:
+    assert-plus "1.0.0"
+    extsprintf "1.3.0"
+    json-schema "0.2.3"
+    verror "1.10.0"
+
+kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
+  integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=
+  dependencies:
+    is-buffer "^1.1.5"
+
+kind-of@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
+  integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc=
+  dependencies:
+    is-buffer "^1.1.5"
+
+kind-of@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
+  integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
+
+kind-of@^6.0.0, kind-of@^6.0.2:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
+  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
+
+kuler@1.0.x:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6"
+  integrity sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==
+  dependencies:
+    colornames "^1.1.1"
+
+latest-version@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
+  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
+  dependencies:
+    package-json "^4.0.0"
+
+launchpad@^0.7.0:
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.5.tgz#a16950c937572f10ef01c9be945a96f7aef8e427"
+  integrity sha512-gsYFgT8XKL3X2XZHPPPrgwM0JqeQwGpSWnzg7EYadBY3MirbQrTVq6L4fm6l7UE2T+7gnfuhiGkKr/xxuU/fdw==
+  dependencies:
+    async "^2.0.1"
+    browserstack "^1.2.0"
+    debug "^2.2.0"
+    mkdirp "^0.5.1"
+    plist "^2.0.1"
+    q "^1.4.1"
+    rimraf "^3.0.0"
+    underscore "^1.8.3"
+
+lazystream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
+  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
+  dependencies:
+    readable-stream "^2.0.5"
+
+load-json-file@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
+  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
+  dependencies:
+    graceful-fs "^4.1.2"
+    parse-json "^2.2.0"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+    strip-bom "^2.0.0"
+
+locate-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
+  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
+  dependencies:
+    p-locate "^3.0.0"
+    path-exists "^3.0.0"
+
+lodash._baseassign@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e"
+  integrity sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=
+  dependencies:
+    lodash._basecopy "^3.0.0"
+    lodash.keys "^3.0.0"
+
+lodash._basecopy@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36"
+  integrity sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=
+
+lodash._basecreate@^3.0.0:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz#1bc661614daa7fc311b7d03bf16806a0213cf821"
+  integrity sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=
+
+lodash._getnative@^3.0.0:
+  version "3.9.1"
+  resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
+  integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=
+
+lodash._isiterateecall@^3.0.0:
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
+  integrity sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=
+
+lodash._reinterpolate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
+  integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
+
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
+
+lodash.create@3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/lodash.create/-/lodash.create-3.1.1.tgz#d7f2849f0dbda7e04682bb8cd72ab022461debe7"
+  integrity sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=
+  dependencies:
+    lodash._baseassign "^3.0.0"
+    lodash._basecreate "^3.0.0"
+    lodash._isiterateecall "^3.0.0"
+
+lodash.defaults@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
+  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
+
+lodash.difference@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
+  integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
+
+lodash.flatten@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
+  integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
+
+lodash.isarguments@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
+  integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=
+
+lodash.isarray@^3.0.0:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
+  integrity sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=
+
+lodash.isequal@^4.0.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
+
+lodash.isplainobject@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+
+lodash.keys@^3.0.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
+  integrity sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=
+  dependencies:
+    lodash._getnative "^3.0.0"
+    lodash.isarguments "^3.0.0"
+    lodash.isarray "^3.0.0"
+
+lodash.padend@^4.6.1:
+  version "4.6.1"
+  resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
+  integrity sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=
+
+lodash.sortby@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
+  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
+
+lodash.template@^4.4.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
+  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
+  dependencies:
+    lodash._reinterpolate "^3.0.0"
+    lodash.templatesettings "^4.0.0"
+
+lodash.templatesettings@^4.0.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
+  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
+  dependencies:
+    lodash._reinterpolate "^3.0.0"
+
+lodash.union@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
+  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
+
+lodash@^3.0.0, lodash@^3.10.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
+  integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
+
+lodash@^4.0.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.2, lodash@^4.17.4:
+  version "4.17.15"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
+log-symbols@2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
+  integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
+  dependencies:
+    chalk "^2.0.1"
+
+logform@^1.9.1:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/logform/-/logform-1.10.0.tgz#c9d5598714c92b546e23f4e78147c40f1e02012e"
+  integrity sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==
+  dependencies:
+    colors "^1.2.1"
+    fast-safe-stringify "^2.0.4"
+    fecha "^2.3.3"
+    ms "^2.1.1"
+    triple-beam "^1.2.0"
+
+logform@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360"
+  integrity sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==
+  dependencies:
+    colors "^1.2.1"
+    fast-safe-stringify "^2.0.4"
+    fecha "^2.3.3"
+    ms "^2.1.1"
+    triple-beam "^1.3.0"
+
+lolex@1.3.2:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31"
+  integrity sha1-fD2mL/yzDw9agKJWbKJORdigHzE=
+
+lolex@^1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
+  integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=
+
+loose-envify@^1.0.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+  integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+  dependencies:
+    js-tokens "^3.0.0 || ^4.0.0"
+
+loud-rejection@^1.0.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
+  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
+  dependencies:
+    currently-unhandled "^0.4.1"
+    signal-exit "^3.0.0"
+
+lower-case@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
+  integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
+
+lowercase-keys@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
+  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
+
+lru-cache@^4.0.1, lru-cache@^4.0.2:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
+  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
+  dependencies:
+    pseudomap "^1.0.2"
+    yallist "^2.1.2"
+
+magic-string@^0.22.4:
+  version "0.22.5"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
+  integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==
+  dependencies:
+    vlq "^0.2.2"
+
+make-dir@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
+  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
+  dependencies:
+    pify "^3.0.0"
+
+map-cache@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
+  integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
+
+map-obj@^1.0.0, map-obj@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
+  integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
+
+map-visit@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
+  integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=
+  dependencies:
+    object-visit "^1.0.0"
+
+matcher@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
+  integrity sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==
+  dependencies:
+    escape-string-regexp "^1.0.4"
+
+math-random@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
+  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
+
+md5@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
+  integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=
+  dependencies:
+    charenc "~0.0.1"
+    crypt "~0.0.1"
+    is-buffer "~1.1.1"
+
+media-typer@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+  integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
+
+meow@^3.7.0:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
+  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
+  dependencies:
+    camelcase-keys "^2.0.0"
+    decamelize "^1.1.2"
+    loud-rejection "^1.0.0"
+    map-obj "^1.0.1"
+    minimist "^1.1.3"
+    normalize-package-data "^2.3.4"
+    object-assign "^4.0.1"
+    read-pkg-up "^1.0.1"
+    redent "^1.0.0"
+    trim-newlines "^1.0.0"
+
+merge-descriptors@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
+  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
+
+merge-stream@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
+  integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=
+  dependencies:
+    readable-stream "^2.0.1"
+
+methods@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
+  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
+
+micromatch@^2.3.11, micromatch@^2.3.7:
+  version "2.3.11"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
+  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
+  dependencies:
+    arr-diff "^2.0.0"
+    array-unique "^0.2.1"
+    braces "^1.8.2"
+    expand-brackets "^0.1.4"
+    extglob "^0.3.1"
+    filename-regex "^2.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.1"
+    kind-of "^3.0.2"
+    normalize-path "^2.0.1"
+    object.omit "^2.0.0"
+    parse-glob "^3.0.4"
+    regex-cache "^0.4.2"
+
+micromatch@^3.0.4:
+  version "3.1.10"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
+  integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
+  dependencies:
+    arr-diff "^4.0.0"
+    array-unique "^0.3.2"
+    braces "^2.3.1"
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    extglob "^2.0.4"
+    fragment-cache "^0.2.1"
+    kind-of "^6.0.2"
+    nanomatch "^1.2.9"
+    object.pick "^1.3.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.2"
+
+mime-db@1.43.0, "mime-db@>= 1.43.0 < 2":
+  version "1.43.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
+  integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
+
+mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
+  version "2.1.26"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06"
+  integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==
+  dependencies:
+    mime-db "1.43.0"
+
+mime@1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
+  integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==
+
+mime@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
+
+mime@^2.3.1:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
+  integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
+
+minimalistic-assert@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
+  integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
+
+minimatch-all@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/minimatch-all/-/minimatch-all-1.1.0.tgz#40c496a27a2e128d19bf758e76bb01a0c7145787"
+  integrity sha1-QMSWonouEo0Zv3WOdrsBoMcUV4c=
+  dependencies:
+    minimatch "^3.0.2"
+
+"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@0.0.8:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
+
+minimist@^1.1.3, minimist@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
+  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
+
+minimist@~0.0.1:
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
+  integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
+
+mixin-deep@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
+  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
+  dependencies:
+    for-in "^1.0.2"
+    is-extendable "^1.0.1"
+
+mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
+  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
+  dependencies:
+    minimist "0.0.8"
+
+mocha@^3.4.2:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-3.5.3.tgz#1e0480fe36d2da5858d1eb6acc38418b26eaa20d"
+  integrity sha512-/6na001MJWEtYxHOV1WLfsmR4YIynkUEhBwzsb+fk2qmQ3iqsi258l/Q2MWHJMImAcNpZ8DEdYAK72NHoIQ9Eg==
+  dependencies:
+    browser-stdout "1.3.0"
+    commander "2.9.0"
+    debug "2.6.8"
+    diff "3.2.0"
+    escape-string-regexp "1.0.5"
+    glob "7.1.1"
+    growl "1.9.2"
+    he "1.1.1"
+    json3 "3.3.2"
+    lodash.create "3.1.1"
+    mkdirp "0.5.1"
+    supports-color "3.1.2"
+
+mocha@^6.2.2:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.2.2.tgz#5d8987e28940caf8957a7d7664b910dc5b2fea20"
+  integrity sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A==
+  dependencies:
+    ansi-colors "3.2.3"
+    browser-stdout "1.3.1"
+    debug "3.2.6"
+    diff "3.5.0"
+    escape-string-regexp "1.0.5"
+    find-up "3.0.0"
+    glob "7.1.3"
+    growl "1.10.5"
+    he "1.2.0"
+    js-yaml "3.13.1"
+    log-symbols "2.2.0"
+    minimatch "3.0.4"
+    mkdirp "0.5.1"
+    ms "2.1.1"
+    node-environment-flags "1.0.5"
+    object.assign "4.1.0"
+    strip-json-comments "2.0.1"
+    supports-color "6.0.0"
+    which "1.3.1"
+    wide-align "1.1.3"
+    yargs "13.3.0"
+    yargs-parser "13.1.1"
+    yargs-unparser "1.6.0"
+
+mout@^1.0.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/mout/-/mout-1.2.2.tgz#c9b718a499806a0632cede178e80f436259e777d"
+  integrity sha512-w0OUxFEla6z3d7sVpMZGBCpQvYh8PHS1wZ6Wu9GNKHMpAHWJ0if0LsQZh3DlOqw55HlhJEOMLpFnwtxp99Y5GA==
+
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
+
+ms@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
+  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
+
+ms@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+multer@^1.3.0:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
+  integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==
+  dependencies:
+    append-field "^1.0.0"
+    busboy "^0.2.11"
+    concat-stream "^1.5.2"
+    mkdirp "^0.5.1"
+    object-assign "^4.1.1"
+    on-finished "^2.3.0"
+    type-is "^1.6.4"
+    xtend "^4.0.0"
+
+multipipe@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
+  integrity sha1-zBPv2DPJzamfIk+GhGG44aP9k50=
+  dependencies:
+    duplexer2 "^0.1.2"
+    object-assign "^4.1.0"
+
+mz@^2.4.0, mz@^2.6.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
+  integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
+  dependencies:
+    any-promise "^1.0.0"
+    object-assign "^4.0.1"
+    thenify-all "^1.0.0"
+
+nanomatch@^1.2.9:
+  version "1.2.13"
+  resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
+  integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==
+  dependencies:
+    arr-diff "^4.0.0"
+    array-unique "^0.3.2"
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    fragment-cache "^0.2.1"
+    is-windows "^1.0.2"
+    kind-of "^6.0.2"
+    object.pick "^1.3.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+native-promise-only@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
+  integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=
+
+negotiator@0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
+  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
+
+nice-try@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+
+no-case@^2.2.0:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
+  integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==
+  dependencies:
+    lower-case "^1.1.1"
+
+node-environment-flags@1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a"
+  integrity sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ==
+  dependencies:
+    object.getownpropertydescriptors "^2.0.3"
+    semver "^5.7.0"
+
+nomnom@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"
+  integrity sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=
+  dependencies:
+    chalk "~0.4.0"
+    underscore "~1.6.0"
+
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
+  integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
+  dependencies:
+    hosted-git-info "^2.1.4"
+    resolve "^1.10.0"
+    semver "2 || 3 || 4 || 5"
+    validate-npm-package-license "^3.0.1"
+
+normalize-path@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
+  integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
+  dependencies:
+    remove-trailing-separator "^1.0.1"
+
+normalize-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+npm-run-path@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
+  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
+  dependencies:
+    path-key "^2.0.0"
+
+number-is-nan@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
+
+oauth-sign@~0.9.0:
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
+  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
+
+object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+
+object-component@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
+  integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
+
+object-copy@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
+  integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw=
+  dependencies:
+    copy-descriptor "^0.1.0"
+    define-property "^0.2.5"
+    kind-of "^3.0.3"
+
+object-inspect@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
+  integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
+
+object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
+  integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
+
+object-visit@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
+  integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=
+  dependencies:
+    isobject "^3.0.0"
+
+object.assign@4.1.0, object.assign@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
+  integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
+  dependencies:
+    define-properties "^1.1.2"
+    function-bind "^1.1.1"
+    has-symbols "^1.0.0"
+    object-keys "^1.0.11"
+
+object.entries@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b"
+  integrity sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0-next.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+
+object.getownpropertydescriptors@^2.0.3:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649"
+  integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0-next.1"
+
+object.omit@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
+  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
+  dependencies:
+    for-own "^0.1.4"
+    is-extendable "^0.1.1"
+
+object.pick@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
+  integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=
+  dependencies:
+    isobject "^3.0.1"
+
+obuf@^1.0.0, obuf@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
+  integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
+
+on-finished@^2.3.0, on-finished@~2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
+  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
+  dependencies:
+    ee-first "1.1.1"
+
+on-headers@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
+  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
+
+once@^1.3.0, once@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  dependencies:
+    wrappy "1"
+
+one-time@0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e"
+  integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=
+
+opn@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
+  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
+  dependencies:
+    object-assign "^4.0.1"
+
+optimist@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
+  integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY=
+  dependencies:
+    minimist "~0.0.1"
+    wordwrap "~0.0.2"
+
+ordered-read-streams@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
+  integrity sha1-cTfmmzKYuzQiR6G77jiByA4v14s=
+  dependencies:
+    is-stream "^1.0.1"
+    readable-stream "^2.0.1"
+
+os-homedir@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
+
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
+
+osenv@^0.1.3:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
+  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
+  dependencies:
+    os-homedir "^1.0.0"
+    os-tmpdir "^1.0.0"
+
+p-finally@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
+
+p-limit@^2.0.0:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e"
+  integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==
+  dependencies:
+    p-try "^2.0.0"
+
+p-locate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
+  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
+  dependencies:
+    p-limit "^2.0.0"
+
+p-try@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
+package-json@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
+  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
+  dependencies:
+    got "^6.7.1"
+    registry-auth-token "^3.0.1"
+    registry-url "^3.0.3"
+    semver "^5.1.0"
+
+param-case@2.1.x:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
+  integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
+  dependencies:
+    no-case "^2.2.0"
+
+parse-glob@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
+  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
+  dependencies:
+    glob-base "^0.3.0"
+    is-dotfile "^1.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.0"
+
+parse-json@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
+  integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
+  dependencies:
+    error-ex "^1.2.0"
+
+parse-passwd@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
+  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
+
+parse5@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
+  integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
+
+parseqs@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
+  integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseuri@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
+  integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseurl@~1.3.3:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+pascalcase@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
+  integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
+
+path-dirname@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
+  integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
+
+path-exists@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
+  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
+  dependencies:
+    pinkie-promise "^2.0.0"
+
+path-exists@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
+  integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
+
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
+path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
+  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+
+path-key@^2.0.0, path-key@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
+
+path-parse@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
+  integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
+
+path-to-regexp@0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
+  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
+
+path-to-regexp@^1.0.1, path-to-regexp@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+  dependencies:
+    isarray "0.0.1"
+
+path-type@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
+  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
+  dependencies:
+    graceful-fs "^4.1.2"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+pathval@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
+  integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA=
+
+pem@^1.8.3:
+  version "1.14.3"
+  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.3.tgz#347e5a5c194a5f7612b88083e45042fcc4fb4901"
+  integrity sha512-Q+AMVMD3fzeVvZs5PHeI+pVt0hgZY2fjhkliBW43qyONLgCXPVk1ryim43F9eupHlNGLJNT5T/NNrzhUdiC5Zg==
+  dependencies:
+    es6-promisify "^6.0.0"
+    md5 "^2.2.1"
+    os-tmpdir "^1.0.1"
+    which "^1.3.1"
+
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
+
+performance-now@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
+
+pify@^2.0.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+  integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
+
+pify@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
+  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
+
+pinkie-promise@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
+  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
+  dependencies:
+    pinkie "^2.0.0"
+
+pinkie@^2.0.0:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
+  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
+
+plist@^2.0.1:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
+  integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
+  dependencies:
+    base64-js "1.2.0"
+    xmlbuilder "8.2.2"
+    xmldom "0.1.x"
+
+plylog@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/plylog/-/plylog-1.1.0.tgz#f6f354e2ae0b01f6db4ed111f4b3855da9c37417"
+  integrity sha512-/QnY5aSVaP54va6hruzNtAj02HpsLlAt7V5EndMrtq6ZUTZJKUja43rgiUtGXqm95yrSJjbZoPW0yQQQwLpoJA==
+  dependencies:
+    logform "^1.9.1"
+    winston "^3.0.0"
+    winston-transport "^4.2.0"
+
+polymer-analyzer@^3.1.3, polymer-analyzer@^3.2.2:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
+  integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
+  dependencies:
+    "@babel/generator" "^7.0.0-beta.42"
+    "@babel/traverse" "^7.0.0-beta.42"
+    "@babel/types" "^7.0.0-beta.42"
+    "@types/babel-generator" "^6.25.1"
+    "@types/babel-traverse" "^6.25.2"
+    "@types/babel-types" "^6.25.1"
+    "@types/babylon" "^6.16.2"
+    "@types/chai-subset" "^1.3.0"
+    "@types/chalk" "^0.4.30"
+    "@types/clone" "^0.1.30"
+    "@types/cssbeautify" "^0.3.1"
+    "@types/doctrine" "^0.0.1"
+    "@types/is-windows" "^0.2.0"
+    "@types/minimatch" "^3.0.1"
+    "@types/parse5" "^2.2.34"
+    "@types/path-is-inside" "^1.0.0"
+    "@types/resolve" "0.0.6"
+    "@types/whatwg-url" "^6.4.0"
+    babylon "^7.0.0-beta.42"
+    cancel-token "^0.1.1"
+    chalk "^1.1.3"
+    clone "^2.0.0"
+    cssbeautify "^0.3.1"
+    doctrine "^2.0.2"
+    dom5 "^3.0.0"
+    indent "0.0.2"
+    is-windows "^1.0.2"
+    jsonschema "^1.1.0"
+    minimatch "^3.0.4"
+    parse5 "^4.0.0"
+    path-is-inside "^1.0.2"
+    resolve "^1.5.0"
+    shady-css-parser "^0.1.0"
+    stable "^0.1.6"
+    strip-indent "^2.0.0"
+    vscode-uri "=1.0.6"
+    whatwg-url "^6.4.0"
+
+polymer-build@^3.1.0:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/polymer-build/-/polymer-build-3.1.4.tgz#ab539f1a13d803518b13b73ffd09198431d98142"
+  integrity sha512-OhTOPG5Y/tK2HqGZ5XA/CVDh+TuOaDv7wTZWXDCg6hxeMgNKuljDMn2coyGU5NLM0pLbS+gwFAc2ZJ5cWHCHNg==
+  dependencies:
+    "@babel/core" "^7.0.0"
+    "@babel/plugin-external-helpers" "^7.0.0"
+    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
+    "@babel/plugin-proposal-object-rest-spread" "^7.0.0"
+    "@babel/plugin-syntax-async-generators" "^7.0.0"
+    "@babel/plugin-syntax-dynamic-import" "^7.0.0"
+    "@babel/plugin-syntax-import-meta" "^7.0.0"
+    "@babel/plugin-syntax-object-rest-spread" "^7.0.0"
+    "@babel/plugin-transform-arrow-functions" "^7.0.0"
+    "@babel/plugin-transform-async-to-generator" "^7.0.0"
+    "@babel/plugin-transform-block-scoped-functions" "^7.0.0"
+    "@babel/plugin-transform-block-scoping" "^7.0.0"
+    "@babel/plugin-transform-classes" "^7.0.0"
+    "@babel/plugin-transform-computed-properties" "^7.0.0"
+    "@babel/plugin-transform-destructuring" "^7.0.0"
+    "@babel/plugin-transform-duplicate-keys" "^7.0.0"
+    "@babel/plugin-transform-exponentiation-operator" "^7.0.0"
+    "@babel/plugin-transform-for-of" "^7.0.0"
+    "@babel/plugin-transform-function-name" "^7.0.0"
+    "@babel/plugin-transform-instanceof" "^7.0.0"
+    "@babel/plugin-transform-literals" "^7.0.0"
+    "@babel/plugin-transform-modules-amd" "^7.0.0"
+    "@babel/plugin-transform-object-super" "^7.0.0"
+    "@babel/plugin-transform-parameters" "^7.0.0"
+    "@babel/plugin-transform-regenerator" "^7.0.0"
+    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
+    "@babel/plugin-transform-spread" "^7.0.0"
+    "@babel/plugin-transform-sticky-regex" "^7.0.0"
+    "@babel/plugin-transform-template-literals" "^7.0.0"
+    "@babel/plugin-transform-typeof-symbol" "^7.0.0"
+    "@babel/plugin-transform-unicode-regex" "^7.0.0"
+    "@babel/traverse" "^7.0.0"
+    "@polymer/esm-amd-loader" "^1.0.0"
+    "@types/babel-types" "^6.25.1"
+    "@types/babylon" "^6.16.2"
+    "@types/gulp-if" "0.0.33"
+    "@types/html-minifier" "^3.5.1"
+    "@types/is-windows" "^0.2.0"
+    "@types/mz" "0.0.31"
+    "@types/parse5" "^2.2.34"
+    "@types/resolve" "0.0.7"
+    "@types/uuid" "^3.4.3"
+    "@types/vinyl" "^2.0.0"
+    "@types/vinyl-fs" "^2.4.8"
+    babel-plugin-minify-guarded-expressions "^0.4.3"
+    babel-preset-minify "^0.5.0"
+    babylon "^7.0.0-beta.42"
+    css-slam "^2.1.2"
+    dom5 "^3.0.0"
+    gulp-if "^2.0.2"
+    html-minifier "^3.5.10"
+    matcher "^1.1.0"
+    multipipe "^1.0.2"
+    mz "^2.6.0"
+    parse5 "^4.0.0"
+    plylog "^1.0.0"
+    polymer-analyzer "^3.1.3"
+    polymer-bundler "^4.0.9"
+    polymer-project-config "^4.0.3"
+    regenerator-runtime "^0.11.1"
+    stream "0.0.2"
+    sw-precache "^5.1.1"
+    uuid "^3.2.1"
+    vinyl "^1.2.0"
+    vinyl-fs "^2.4.4"
+
+polymer-bundler@^4.0.9:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/polymer-bundler/-/polymer-bundler-4.0.10.tgz#abc8d33977652f031068d034c8104841e80d4cbb"
+  integrity sha512-nwlN3LQlQDqbZ2sUH3394C/dHZUDHq8tpdS5HARvPDb0Q9IXWD+znOR1cr7wSjF0EZN4LiUH5hWyUoV4QSjhpQ==
+  dependencies:
+    "@types/babel-generator" "^6.25.1"
+    "@types/babel-traverse" "^6.25.3"
+    babel-generator "^6.26.1"
+    babel-traverse "^6.26.0"
+    babel-types "^6.26.0"
+    clone "^2.1.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    dom5 "^3.0.0"
+    espree "^3.5.2"
+    magic-string "^0.22.4"
+    mkdirp "^0.5.1"
+    parse5 "^4.0.0"
+    polymer-analyzer "^3.2.2"
+    rollup "^1.3.0"
+    source-map "^0.5.6"
+    vscode-uri "=1.0.6"
+
+polymer-project-config@^4.0.0, polymer-project-config@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/polymer-project-config/-/polymer-project-config-4.0.3.tgz#ef0c1a676ce4809907986c8e910745660de8024f"
+  integrity sha512-Drr+Imq+znhBC8XSt9pMlmPixoGnIOmleV5SD6mto1zOGC5oCDbSNsQL2v89DWOk+9aSUO79vnWwOmEPDSvYfw==
+  dependencies:
+    "@types/parse5" "^2.2.34"
+    browser-capabilities "^1.0.0"
+    jsonschema "^1.1.1"
+    minimatch-all "^1.1.0"
+    plylog "^1.0.0"
+    winston "^3.0.0"
+
+polyserve@^0.27.13:
+  version "0.27.15"
+  resolved "https://registry.yarnpkg.com/polyserve/-/polyserve-0.27.15.tgz#261fa5a0873c8d95fd7068598f44c9dac20cf9c4"
+  integrity sha512-AaFgANt+tUUVgHLw+BnaVYcn649JiwL1ru0TOZUKj1gGGn/Bq2S16gxql+1muGpRaAsgFu13Zu7k5XkwatwwSg==
+  dependencies:
+    "@types/compression" "^0.0.33"
+    "@types/content-type" "^1.1.0"
+    "@types/escape-html" "0.0.20"
+    "@types/express" "^4.0.36"
+    "@types/mime" "^2.0.0"
+    "@types/mz" "0.0.29"
+    "@types/opn" "^3.0.28"
+    "@types/parse5" "^2.2.34"
+    "@types/pem" "^1.8.1"
+    "@types/resolve" "0.0.6"
+    "@types/serve-static" "^1.7.31"
+    "@types/spdy" "^3.4.1"
+    bower-config "^1.4.1"
+    browser-capabilities "^1.0.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    compression "^1.6.2"
+    content-type "^1.0.2"
+    cors "^2.8.4"
+    escape-html "^1.0.3"
+    express "^4.8.5"
+    find-port "^1.0.1"
+    http-proxy-middleware "^0.17.2"
+    lru-cache "^4.0.2"
+    mime "^2.3.1"
+    mz "^2.4.0"
+    opn "^3.0.2"
+    pem "^1.8.3"
+    polymer-build "^3.1.0"
+    polymer-project-config "^4.0.0"
+    requirejs "^2.3.4"
+    resolve "^1.5.0"
+    send "^0.16.2"
+    spdy "^3.3.3"
+
+posix-character-classes@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
+  integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
+
+prepend-http@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
+  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
+
+preserve@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
+
+pretty-bytes@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
+  integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=
+
+private@^0.1.6:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
+  integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
+
+process-nextick-args@~2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
+
+progress@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+proxy-addr@~2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
+  integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
+  dependencies:
+    forwarded "~0.1.2"
+    ipaddr.js "1.9.0"
+
+pseudomap@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
+
+psl@^1.1.24:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c"
+  integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==
+
+punycode@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
+  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
+
+punycode@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+q@^1.4.1, q@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
+  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
+
+qs@6.7.0:
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
+  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
+
+qs@~6.5.2:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+
+randomatic@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
+  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
+  dependencies:
+    is-number "^4.0.0"
+    kind-of "^6.0.0"
+    math-random "^1.0.1"
+
+range-parser@~1.2.0, range-parser@~1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
+  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+
+raw-body@2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
+  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+  dependencies:
+    bytes "3.1.0"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+rc@^1.0.1, rc@^1.1.6:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+  dependencies:
+    deep-extend "^0.6.0"
+    ini "~1.3.0"
+    minimist "^1.2.0"
+    strip-json-comments "~2.0.1"
+
+read-pkg-up@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
+  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
+  dependencies:
+    find-up "^1.0.0"
+    read-pkg "^1.0.0"
+
+read-pkg@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
+  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
+  dependencies:
+    load-json-file "^1.0.0"
+    normalize-package-data "^2.3.2"
+    path-type "^1.0.0"
+
+readable-stream@1.1.x:
+  version "1.1.14"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
+  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+"readable-stream@>=1.0.33-1 <1.1.0-0":
+  version "1.0.34"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
+  integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+readable-stream@^3.0.1, readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.5.0.tgz#465d70e6d1087f6162d079cd0b5db7fbebfd1606"
+  integrity sha512-gSz026xs2LfxBPudDuI41V1lka8cxg64E66SGe78zJlsUofOg/yqwezdIcdfwik6B4h8LFmWPA9ef9X3FiNFLA==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
+redent@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
+  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
+  dependencies:
+    indent-string "^2.1.0"
+    strip-indent "^1.0.1"
+
+reduce-flatten@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
+  integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
+
+regenerate-unicode-properties@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e"
+  integrity sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==
+  dependencies:
+    regenerate "^1.4.0"
+
+regenerate@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
+  integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
+
+regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
+  version "0.11.1"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
+  integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
+
+regenerator-transform@^0.14.0:
+  version "0.14.1"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb"
+  integrity sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ==
+  dependencies:
+    private "^0.1.6"
+
+regex-cache@^0.4.2:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
+  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
+  dependencies:
+    is-equal-shallow "^0.1.3"
+
+regex-not@^1.0.0, regex-not@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
+  integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==
+  dependencies:
+    extend-shallow "^3.0.2"
+    safe-regex "^1.1.0"
+
+regexpu-core@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.6.0.tgz#2037c18b327cfce8a6fea2a4ec441f2432afb8b6"
+  integrity sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg==
+  dependencies:
+    regenerate "^1.4.0"
+    regenerate-unicode-properties "^8.1.0"
+    regjsgen "^0.5.0"
+    regjsparser "^0.6.0"
+    unicode-match-property-ecmascript "^1.0.4"
+    unicode-match-property-value-ecmascript "^1.1.0"
+
+registry-auth-token@^3.0.1:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
+  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
+  dependencies:
+    rc "^1.1.6"
+    safe-buffer "^5.0.1"
+
+registry-url@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
+  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
+  dependencies:
+    rc "^1.0.1"
+
+regjsgen@^0.5.0:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.1.tgz#48f0bf1a5ea205196929c0d9798b42d1ed98443c"
+  integrity sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==
+
+regjsparser@^0.6.0:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.2.tgz#fd62c753991467d9d1ffe0a9f67f27a529024b96"
+  integrity sha512-E9ghzUtoLwDekPT0DYCp+c4h+bvuUpe6rRHCTYn6eGoqj1LgKXxT6I0Il4WbjhQkOghzi/V+y03bPKvbllL93Q==
+  dependencies:
+    jsesc "~0.5.0"
+
+relateurl@0.2.x:
+  version "0.2.7"
+  resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
+  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
+
+remove-trailing-separator@^1.0.1:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
+  integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
+
+repeat-element@^1.1.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
+  integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==
+
+repeat-string@^1.5.2, repeat-string@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
+  integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
+
+repeating@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
+  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
+  dependencies:
+    is-finite "^1.0.0"
+
+replace-ext@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
+  integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
+
+request@2.88.0, request@^2.85.0:
+  version "2.88.0"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
+  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
+  dependencies:
+    aws-sign2 "~0.7.0"
+    aws4 "^1.8.0"
+    caseless "~0.12.0"
+    combined-stream "~1.0.6"
+    extend "~3.0.2"
+    forever-agent "~0.6.1"
+    form-data "~2.3.2"
+    har-validator "~5.1.0"
+    http-signature "~1.2.0"
+    is-typedarray "~1.0.0"
+    isstream "~0.1.2"
+    json-stringify-safe "~5.0.1"
+    mime-types "~2.1.19"
+    oauth-sign "~0.9.0"
+    performance-now "^2.1.0"
+    qs "~6.5.2"
+    safe-buffer "^5.1.2"
+    tough-cookie "~2.4.3"
+    tunnel-agent "^0.6.0"
+    uuid "^3.3.2"
+
+require-directory@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+  integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
+
+require-main-filename@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
+  integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
+
+requirejs@^2.3.4:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
+  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
+
+requires-port@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
+  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
+
+resolve-dir@^1.0.0, resolve-dir@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
+  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
+  dependencies:
+    expand-tilde "^2.0.0"
+    global-modules "^1.0.0"
+
+resolve-url@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
+  integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
+
+resolve@^1.10.0, resolve@^1.3.2, resolve@^1.5.0:
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5"
+  integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==
+  dependencies:
+    path-parse "^1.0.6"
+
+ret@~0.1.10:
+  version "0.1.15"
+  resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+  integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+
+rimraf@^2.5.4:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
+  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b"
+  integrity sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@~2.6.2:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
+  integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
+  dependencies:
+    glob "^7.1.3"
+
+rollup@^1.3.0:
+  version "1.29.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.29.1.tgz#8715d0a4ca439be3079f8095989ec8aa60f637bc"
+  integrity sha512-dGQ+b9d1FOX/gluiggTAVnTvzQZUEkCi/TwZcax7ujugVRHs0nkYJlV9U4hsifGEMojnO+jvEML2CJQ6qXgbHA==
+  dependencies:
+    "@types/estree" "*"
+    "@types/node" "*"
+    acorn "^7.1.0"
+
+safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
+  integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
+
+safe-regex@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
+  integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4=
+  dependencies:
+    ret "~0.1.10"
+
+"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+samsam@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567"
+  integrity sha1-vsEf3IOp/aBjQBIQ5AF2wwJNFWc=
+
+samsam@1.x, samsam@^1.1.3:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
+  integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
+
+samsam@~1.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.3.tgz#9f5087419b4d091f232571e7fa52e90b0f552621"
+  integrity sha1-n1CHQZtNCR8jJXHn+lLpCw9VJiE=
+
+sauce-connect-launcher@^1.0.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.1.tgz#31137f57b0f7176e1c0525b7fb09c6da746647cf"
+  integrity sha512-vIf9qDol3q2FlYzrKt0dr3kvec6LSjX2WS+/mVnAJIhqh1evSkPKCR2AzcJrnSmx9Xt9PtV0tLY7jYh0wsQi8A==
+  dependencies:
+    adm-zip "~0.4.3"
+    async "^2.1.2"
+    https-proxy-agent "^3.0.0"
+    lodash "^4.16.6"
+    rimraf "^2.5.4"
+
+select-hose@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
+  integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
+
+selenium-standalone@^6.7.0:
+  version "6.17.0"
+  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.17.0.tgz#0f24b691836205ee9bc3d7a6f207ebcb28170cd9"
+  integrity sha512-5PSnDHwMiq+OCiAGlhwQ8BM9xuwFfvBOZ7Tfbw+ifkTnOy0PWbZmI1B9gPGuyGHpbQ/3J3CzIK7BYwrQ7EjtWQ==
+  dependencies:
+    async "^2.6.2"
+    commander "^2.19.0"
+    cross-spawn "^6.0.5"
+    debug "^4.1.1"
+    lodash "^4.17.11"
+    minimist "^1.2.0"
+    mkdirp "^0.5.1"
+    progress "2.0.3"
+    request "2.88.0"
+    tar-stream "2.0.0"
+    urijs "^1.19.1"
+    which "^1.3.1"
+    yauzl "^2.10.0"
+
+semver-diff@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
+  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
+  dependencies:
+    semver "^5.0.3"
+
+"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.7.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
+  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
+
+send@0.17.1:
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
+  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.2"
+    destroy "~1.0.4"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.7.2"
+    mime "1.6.0"
+    ms "2.1.1"
+    on-finished "~2.3.0"
+    range-parser "~1.2.1"
+    statuses "~1.5.0"
+
+send@^0.16.1, send@^0.16.2:
+  version "0.16.2"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
+  integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.2"
+    destroy "~1.0.4"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.6.2"
+    mime "1.4.1"
+    ms "2.0.0"
+    on-finished "~2.3.0"
+    range-parser "~1.2.0"
+    statuses "~1.4.0"
+
+serve-static@1.14.1:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
+  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
+  dependencies:
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    parseurl "~1.3.3"
+    send "0.17.1"
+
+server-destroy@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
+  integrity sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0=
+
+serviceworker-cache-polyfill@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
+  integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
+
+set-blocking@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+
+set-value@^2.0.0, set-value@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
+  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
+  dependencies:
+    extend-shallow "^2.0.1"
+    is-extendable "^0.1.1"
+    is-plain-object "^2.0.3"
+    split-string "^3.0.1"
+
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
+  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
+
+shady-css-parser@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
+  integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
+
+shebang-command@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
+  dependencies:
+    shebang-regex "^1.0.0"
+
+shebang-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
+
+signal-exit@^3.0.0, signal-exit@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
+
+simple-swizzle@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
+  integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
+  dependencies:
+    is-arrayish "^0.3.1"
+
+sinon-chai@^2.10.0:
+  version "2.14.0"
+  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.14.0.tgz#da7dd4cc83cd6a260b67cca0f7a9fdae26a1205d"
+  integrity sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==
+
+sinon@^1.17.1:
+  version "1.17.7"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf"
+  integrity sha1-RUKk9JugxFwF6y6d2dID4rjv4L8=
+  dependencies:
+    formatio "1.1.1"
+    lolex "1.3.2"
+    samsam "1.1.2"
+    util ">=0.10.3 <1"
+
+sinon@^2.3.5:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36"
+  integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==
+  dependencies:
+    diff "^3.1.0"
+    formatio "1.2.0"
+    lolex "^1.6.0"
+    native-promise-only "^0.8.1"
+    path-to-regexp "^1.7.0"
+    samsam "^1.1.3"
+    text-encoding "0.6.4"
+    type-detect "^4.0.0"
+
+snapdragon-node@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
+  integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==
+  dependencies:
+    define-property "^1.0.0"
+    isobject "^3.0.0"
+    snapdragon-util "^3.0.1"
+
+snapdragon-util@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
+  integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==
+  dependencies:
+    kind-of "^3.2.0"
+
+snapdragon@^0.8.1:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
+  integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==
+  dependencies:
+    base "^0.11.1"
+    debug "^2.2.0"
+    define-property "^0.2.5"
+    extend-shallow "^2.0.1"
+    map-cache "^0.2.2"
+    source-map "^0.5.6"
+    source-map-resolve "^0.5.0"
+    use "^3.1.0"
+
+socket.io-adapter@~1.1.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
+  integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
+
+socket.io-client@2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4"
+  integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==
+  dependencies:
+    backo2 "1.0.2"
+    base64-arraybuffer "0.1.5"
+    component-bind "1.0.0"
+    component-emitter "1.2.1"
+    debug "~4.1.0"
+    engine.io-client "~3.4.0"
+    has-binary2 "~1.0.2"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    object-component "0.0.3"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    socket.io-parser "~3.3.0"
+    to-array "0.1.4"
+
+socket.io-parser@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
+  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~3.1.0"
+    isarray "2.0.1"
+
+socket.io-parser@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.0.tgz#370bb4a151df2f77ce3345ff55a7072cc6e9565a"
+  integrity sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~4.1.0"
+    isarray "2.0.1"
+
+socket.io@^2.0.3:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb"
+  integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==
+  dependencies:
+    debug "~4.1.0"
+    engine.io "~3.4.0"
+    has-binary2 "~1.0.2"
+    socket.io-adapter "~1.1.0"
+    socket.io-client "2.3.0"
+    socket.io-parser "~3.4.0"
+
+source-map-resolve@^0.5.0:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
+  integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==
+  dependencies:
+    atob "^2.1.2"
+    decode-uri-component "^0.2.0"
+    resolve-url "^0.2.1"
+    source-map-url "^0.4.0"
+    urix "^0.1.0"
+
+source-map-url@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
+  integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=
+
+source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
+  version "0.5.7"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+  integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
+
+source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+spdx-correct@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4"
+  integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==
+  dependencies:
+    spdx-expression-parse "^3.0.0"
+    spdx-license-ids "^3.0.0"
+
+spdx-exceptions@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977"
+  integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==
+
+spdx-expression-parse@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
+  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  dependencies:
+    spdx-exceptions "^2.1.0"
+    spdx-license-ids "^3.0.0"
+
+spdx-license-ids@^3.0.0:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
+  integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
+
+spdy-transport@^2.0.18:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.1.tgz#c54815d73858aadd06ce63001e7d25fa6441623b"
+  integrity sha512-q7D8c148escoB3Z7ySCASadkegMmUZW8Wb/Q1u0/XBgDKMO880rLQDj8Twiew/tYi7ghemKUi/whSYOwE17f5Q==
+  dependencies:
+    debug "^2.6.8"
+    detect-node "^2.0.3"
+    hpack.js "^2.1.6"
+    obuf "^1.1.1"
+    readable-stream "^2.2.9"
+    safe-buffer "^5.0.1"
+    wbuf "^1.7.2"
+
+spdy@^3.3.3:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
+  integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=
+  dependencies:
+    debug "^2.6.8"
+    handle-thing "^1.2.5"
+    http-deceiver "^1.2.7"
+    safe-buffer "^5.0.1"
+    select-hose "^2.0.0"
+    spdy-transport "^2.0.18"
+
+split-string@^3.0.1, split-string@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
+  integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==
+  dependencies:
+    extend-shallow "^3.0.0"
+
+sprintf-js@~1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
+  integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
+
+sshpk@^1.7.0:
+  version "1.16.1"
+  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
+  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
+  dependencies:
+    asn1 "~0.2.3"
+    assert-plus "^1.0.0"
+    bcrypt-pbkdf "^1.0.0"
+    dashdash "^1.12.0"
+    ecc-jsbn "~0.1.1"
+    getpass "^0.1.1"
+    jsbn "~0.1.0"
+    safer-buffer "^2.0.2"
+    tweetnacl "~0.14.0"
+
+stable@^0.1.6:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
+  integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
+
+stack-trace@0.0.x:
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
+  integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
+
+stacky@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/stacky/-/stacky-1.3.1.tgz#3f117e5187b9a73d23f876d69f05c85b11804a12"
+  integrity sha1-PxF+UYe5pz0j+HbWnwXIWxGAShI=
+  dependencies:
+    chalk "^1.1.1"
+    lodash "^3.0.0"
+
+static-extend@^0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
+  integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=
+  dependencies:
+    define-property "^0.2.5"
+    object-copy "^0.1.0"
+
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
+
+statuses@~1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
+  integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
+
+stream-shift@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
+  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
+
+stream@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef"
+  integrity sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=
+  dependencies:
+    emitter-component "^1.1.1"
+
+streamsearch@0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
+  integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
+
+"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
+  dependencies:
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^4.0.0"
+
+string-width@^3.0.0, string-width@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
+  integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
+  dependencies:
+    emoji-regex "^7.0.1"
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^5.1.0"
+
+string.prototype.trimleft@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74"
+  integrity sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==
+  dependencies:
+    define-properties "^1.1.3"
+    function-bind "^1.1.1"
+
+string.prototype.trimright@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz#440314b15996c866ce8a0341894d45186200c5d9"
+  integrity sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==
+  dependencies:
+    define-properties "^1.1.3"
+    function-bind "^1.1.1"
+
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
+string_decoder@~0.10.x:
+  version "0.10.31"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
+  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
+
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+strip-ansi@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  dependencies:
+    ansi-regex "^3.0.0"
+
+strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
+  integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
+  dependencies:
+    ansi-regex "^4.1.0"
+
+strip-ansi@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
+  integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
+
+strip-bom-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
+  integrity sha1-5xRDmFd9Uaa+0PoZlPoF9D/ZiO4=
+  dependencies:
+    first-chunk-stream "^1.0.0"
+    strip-bom "^2.0.0"
+
+strip-bom@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
+  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
+  dependencies:
+    is-utf8 "^0.2.0"
+
+strip-eof@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
+  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
+
+strip-indent@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
+  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
+  dependencies:
+    get-stdin "^4.0.1"
+
+strip-indent@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
+  integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
+
+strip-json-comments@2.0.1, strip-json-comments@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+
+supports-color@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5"
+  integrity sha1-cqJiiU2dQIuVbKBf83su2KbiotU=
+  dependencies:
+    has-flag "^1.0.0"
+
+supports-color@6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.0.0.tgz#76cfe742cf1f41bb9b1c29ad03068c05b4c0e40a"
+  integrity sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==
+  dependencies:
+    has-flag "^3.0.0"
+
+supports-color@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
+  integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+sw-precache@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
+  integrity sha512-8FAy+BP/FXE+ILfiVTt+GQJ6UEf4CVHD9OfhzH0JX+3zoy2uFk7Vn9EfXASOtVmmIVbL3jE/W8Z66VgPSZcMhw==
+  dependencies:
+    dom-urls "^1.1.0"
+    es6-promise "^4.0.5"
+    glob "^7.1.1"
+    lodash.defaults "^4.2.0"
+    lodash.template "^4.4.0"
+    meow "^3.7.0"
+    mkdirp "^0.5.1"
+    pretty-bytes "^4.0.2"
+    sw-toolbox "^3.4.0"
+    update-notifier "^2.3.0"
+
+sw-toolbox@^3.4.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/sw-toolbox/-/sw-toolbox-3.6.0.tgz#26df1d1c70348658e4dea2884319149b7b3183b5"
+  integrity sha1-Jt8dHHA0hljk3qKIQxkUm3sxg7U=
+  dependencies:
+    path-to-regexp "^1.0.1"
+    serviceworker-cache-polyfill "^4.0.0"
+
+table-layout@^0.4.3:
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.4.5.tgz#d906de6a25fa09c0c90d1d08ecd833ecedcb7378"
+  integrity sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==
+  dependencies:
+    array-back "^2.0.0"
+    deep-extend "~0.6.0"
+    lodash.padend "^4.6.1"
+    typical "^2.6.1"
+    wordwrapjs "^3.0.0"
+
+tar-stream@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.0.0.tgz#8829bbf83067bc0288a9089db49c56be395b6aea"
+  integrity sha512-n2vtsWshZOVr/SY4KtslPoUlyNh06I2SGgAOCZmquCEjlbV/LjY2CY80rDtdQRHFOYXNlgBDo6Fr3ww2CWPOtA==
+  dependencies:
+    bl "^2.2.0"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+tar-stream@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3"
+  integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==
+  dependencies:
+    bl "^3.0.0"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+temp@^0.8.1:
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
+  integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==
+  dependencies:
+    rimraf "~2.6.2"
+
+term-size@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
+  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
+  dependencies:
+    execa "^0.7.0"
+
+ternary-stream@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.1.1.tgz#4ad64b98668d796a085af2c493885a435a8a8bfc"
+  integrity sha512-j6ei9hxSoyGlqTmoMjOm+QNvUKDOIY6bNl4Uh1lhBvl6yjPW2iLqxDUYyfDPZknQ4KdRziFl+ec99iT4l7g0cw==
+  dependencies:
+    duplexify "^3.5.0"
+    fork-stream "^0.0.4"
+    merge-stream "^1.0.0"
+    through2 "^2.0.1"
+
+text-encoding@0.6.4:
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
+  integrity sha1-45mpgiV6J22uQou5KEXLcb3CbRk=
+
+text-hex@1.0.x:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
+  integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
+
+thenify-all@^1.0.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
+  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
+  dependencies:
+    thenify ">= 3.1.0 < 4"
+
+"thenify@>= 3.1.0 < 4":
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839"
+  integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=
+  dependencies:
+    any-promise "^1.0.0"
+
+through2-filter@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec"
+  integrity sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=
+  dependencies:
+    through2 "~2.0.0"
+    xtend "~4.0.0"
+
+through2-filter@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
+  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
+  dependencies:
+    through2 "~2.0.0"
+    xtend "~4.0.0"
+
+through2@^0.6.0:
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
+  integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=
+  dependencies:
+    readable-stream ">=1.0.33-1 <1.1.0-0"
+    xtend ">=4.0.0 <4.1.0-0"
+
+through2@^2.0.0, through2@^2.0.1, through2@~2.0.0:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
+  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
+  dependencies:
+    readable-stream "~2.3.6"
+    xtend "~4.0.1"
+
+timed-out@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
+  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
+
+to-absolute-glob@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f"
+  integrity sha1-HN+kcqnvUMI57maZm2YsoOs5k38=
+  dependencies:
+    extend-shallow "^2.0.1"
+
+to-array@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
+  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
+
+to-fast-properties@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
+  integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=
+
+to-fast-properties@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
+  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
+
+to-object-path@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
+  integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=
+  dependencies:
+    kind-of "^3.0.2"
+
+to-regex-range@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
+  integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=
+  dependencies:
+    is-number "^3.0.0"
+    repeat-string "^1.6.1"
+
+to-regex@^3.0.1, to-regex@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
+  integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==
+  dependencies:
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    regex-not "^1.0.2"
+    safe-regex "^1.1.0"
+
+toidentifier@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
+  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
+
+tough-cookie@~2.4.3:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
+  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
+  dependencies:
+    psl "^1.1.24"
+    punycode "^1.4.1"
+
+tr46@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
+  integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
+  dependencies:
+    punycode "^2.1.0"
+
+trim-newlines@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
+  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
+
+trim-right@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
+  integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
+
+triple-beam@^1.2.0, triple-beam@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
+  integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
+
+tunnel-agent@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+  dependencies:
+    safe-buffer "^5.0.1"
+
+tweetnacl@^0.14.3, tweetnacl@~0.14.0:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
+  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+
+type-detect@0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822"
+  integrity sha1-C6XsKohWQORw6k6FBZcZANrFiCI=
+
+type-detect@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2"
+  integrity sha1-diIXzAbbJY7EiQihKY6LlRIejqI=
+
+type-detect@^4.0.0, type-detect@^4.0.5:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+  integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
+
+type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+typedarray@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
+  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+
+typical@^2.6.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d"
+  integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0=
+
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+ua-parser-js@^0.7.15:
+  version "0.7.21"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"
+  integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==
+
+uglify-js@3.4.x:
+  version "3.4.10"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
+  integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==
+  dependencies:
+    commander "~2.19.0"
+    source-map "~0.6.1"
+
+underscore@^1.8.3:
+  version "1.9.2"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.2.tgz#0c8d6f536d6f378a5af264a72f7bec50feb7cf2f"
+  integrity sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==
+
+underscore@~1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"
+  integrity sha1-izixDKze9jM3uLJOT/htRa6lKag=
+
+unicode-canonical-property-names-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
+  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
+
+unicode-match-property-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
+  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
+  dependencies:
+    unicode-canonical-property-names-ecmascript "^1.0.4"
+    unicode-property-aliases-ecmascript "^1.0.4"
+
+unicode-match-property-value-ecmascript@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277"
+  integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==
+
+unicode-property-aliases-ecmascript@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57"
+  integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==
+
+union-value@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
+  integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==
+  dependencies:
+    arr-union "^3.1.0"
+    get-value "^2.0.6"
+    is-extendable "^0.1.1"
+    set-value "^2.0.1"
+
+unique-stream@^2.0.2:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
+  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
+  dependencies:
+    json-stable-stringify-without-jsonify "^1.0.1"
+    through2-filter "^3.0.0"
+
+unique-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
+  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
+  dependencies:
+    crypto-random-string "^1.0.0"
+
+unpipe@1.0.0, unpipe@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
+
+unset-value@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
+  integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=
+  dependencies:
+    has-value "^0.3.1"
+    isobject "^3.0.0"
+
+untildify@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0"
+  integrity sha1-F+soB5h/dpUunASF/DEdBqgmouA=
+  dependencies:
+    os-homedir "^1.0.0"
+
+unzip-response@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
+  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
+
+update-notifier@^2.2.0, update-notifier@^2.3.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
+  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
+  dependencies:
+    boxen "^1.2.1"
+    chalk "^2.0.1"
+    configstore "^3.0.0"
+    import-lazy "^2.1.0"
+    is-ci "^1.0.10"
+    is-installed-globally "^0.1.0"
+    is-npm "^1.0.0"
+    latest-version "^3.0.0"
+    semver-diff "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+upper-case@^1.1.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
+  integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
+
+uri-js@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
+  integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
+  dependencies:
+    punycode "^2.1.0"
+
+urijs@^1.16.1, urijs@^1.19.1:
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.2.tgz#f9be09f00c4c5134b7cb3cf475c1dd394526265a"
+  integrity sha512-s/UIq9ap4JPZ7H1EB5ULo/aOUbWqfDi7FKzMC2Nz+0Si8GiT1rIEaprt8hy3Vy2Ex2aJPpOQv4P4DuOZ+K1c6w==
+
+urix@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
+  integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
+
+url-parse-lax@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
+  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
+  dependencies:
+    prepend-http "^1.0.1"
+
+use@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
+  integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
+
+util-deprecate@^1.0.1, util-deprecate@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+"util@>=0.10.3 <1":
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/util/-/util-0.12.1.tgz#f908e7b633e7396c764e694dd14e716256ce8ade"
+  integrity sha512-MREAtYOp+GTt9/+kwf00IYoHZyjM8VU4aVrkzUlejyqaIjd2GztVl5V9hGXKlvBKE3gENn/FMfHE5v6hElXGcQ==
+  dependencies:
+    inherits "^2.0.3"
+    is-arguments "^1.0.4"
+    is-generator-function "^1.0.7"
+    object.entries "^1.1.0"
+    safe-buffer "^5.1.2"
+
+utils-merge@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+
+uuid@^3.2.1, uuid@^3.3.2:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
+  integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
+
+vali-date@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/vali-date/-/vali-date-1.0.0.tgz#1b904a59609fb328ef078138420934f6b86709a6"
+  integrity sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=
+
+validate-npm-package-license@^3.0.1:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
+  integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
+  dependencies:
+    spdx-correct "^3.0.0"
+    spdx-expression-parse "^3.0.0"
+
+vargs@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
+  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
+
+vary@^1, vary@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
+
+verror@1.10.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
+  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+  dependencies:
+    assert-plus "^1.0.0"
+    core-util-is "1.0.2"
+    extsprintf "^1.2.0"
+
+vinyl-fs@^2.4.4:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-2.4.4.tgz#be6ff3270cb55dfd7d3063640de81f25d7532239"
+  integrity sha1-vm/zJwy1Xf19MGNkDegfJddTIjk=
+  dependencies:
+    duplexify "^3.2.0"
+    glob-stream "^5.3.2"
+    graceful-fs "^4.0.0"
+    gulp-sourcemaps "1.6.0"
+    is-valid-glob "^0.3.0"
+    lazystream "^1.0.0"
+    lodash.isequal "^4.0.0"
+    merge-stream "^1.0.0"
+    mkdirp "^0.5.0"
+    object-assign "^4.0.0"
+    readable-stream "^2.0.4"
+    strip-bom "^2.0.0"
+    strip-bom-stream "^1.0.0"
+    through2 "^2.0.0"
+    through2-filter "^2.0.0"
+    vali-date "^1.0.0"
+    vinyl "^1.0.0"
+
+vinyl@^1.0.0, vinyl@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
+  integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
+  dependencies:
+    clone "^1.0.0"
+    clone-stats "^0.0.1"
+    replace-ext "0.0.1"
+
+vlq@^0.2.2:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
+  integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
+
+vscode-uri@=1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
+  integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
+
+wbuf@^1.1.0, wbuf@^1.7.2:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
+  integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
+  dependencies:
+    minimalistic-assert "^1.0.0"
+
+wct-browser-legacy@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wct-browser-legacy/-/wct-browser-legacy-1.0.2.tgz#6be39174bd37e2903028d3dbd2292f9c4ec59767"
+  integrity sha512-23rbZwBh/DxWU36htJN9lsyBq3NxgVbuyMUq7fgFP6ZVTel+uFWO6LPXPoZQ6VyvXvlUYLE5PxY+ZdJ88a4COw==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+    "@polymer/sinonjs" "^1.14.1"
+    "@polymer/test-fixture" "^3.0.0-pre.1"
+    "@webcomponents/webcomponentsjs" "^2.0.0"
+    accessibility-developer-tools "^2.12.0"
+    async "^1.5.2"
+    chai "^3.5.0"
+    lodash "^3.10.1"
+    mocha "^3.4.2"
+    sinon "^1.17.1"
+    sinon-chai "^2.10.0"
+    stacky "^1.3.1"
+
+wct-local@^2.1.1:
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.5.tgz#f7986753e3ad9a35d39178a9989350523561fff1"
+  integrity sha512-eqoZhjGy4Xq2tY0uB46Grkw/ztq+/rC0ImbYKl62unFHXtOgal+kkvnxR3SLRFNM8ty9+ItgycPeH0IpTqVL+w==
+  dependencies:
+    "@types/express" "^4.0.30"
+    "@types/freeport" "^1.0.19"
+    "@types/launchpad" "^0.6.0"
+    "@types/which" "^1.3.1"
+    chalk "^2.3.0"
+    cleankill "^2.0.0"
+    freeport "^1.0.4"
+    launchpad "^0.7.0"
+    selenium-standalone "^6.7.0"
+    which "^1.0.8"
+
+wct-sauce@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/wct-sauce/-/wct-sauce-2.1.0.tgz#67d0be346aabbbc28384e8d143b8d3ca7ba774c0"
+  integrity sha512-c3R4PJcbpS7Gxv2vZ4HDAqpXV6cT9peslAWMU7hHH9PMhKDPbn8RNa6E4DVL0tOmZznB+3cRmtZ6+vJ/aDwu1A==
+  dependencies:
+    chalk "^2.4.1"
+    cleankill "^2.0.0"
+    lodash "^4.17.10"
+    request "^2.85.0"
+    sauce-connect-launcher "^1.0.0"
+    temp "^0.8.1"
+    uuid "^3.2.1"
+
+wd@^1.2.0:
+  version "1.12.1"
+  resolved "https://registry.yarnpkg.com/wd/-/wd-1.12.1.tgz#067eb3674db00eeb9e506701f9314657c44d5a89"
+  integrity sha512-O99X8OnOgkqfmsPyLIRzG9LmZ+rjmdGFBCyhGpnsSL4MB4xzHoeWmSVcumDiQ5QqPZcwGkszTgeJvjk2VjtiNw==
+  dependencies:
+    archiver "^3.0.0"
+    async "^2.0.0"
+    lodash "^4.0.0"
+    mkdirp "^0.5.1"
+    q "^1.5.1"
+    request "2.88.0"
+    vargs "^0.1.0"
+
+web-component-tester@^6.9.2:
+  version "6.9.2"
+  resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
+  integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
+  dependencies:
+    "@polymer/sinonjs" "^1.14.1"
+    "@polymer/test-fixture" "^0.0.3"
+    "@webcomponents/webcomponentsjs" "^1.0.7"
+    accessibility-developer-tools "^2.12.0"
+    async "^2.4.1"
+    body-parser "^1.17.2"
+    bower-config "^1.4.0"
+    chalk "^1.1.3"
+    cleankill "^2.0.0"
+    express "^4.15.3"
+    findup-sync "^2.0.0"
+    glob "^7.1.2"
+    lodash "^3.10.1"
+    multer "^1.3.0"
+    nomnom "^1.8.1"
+    polyserve "^0.27.13"
+    resolve "^1.5.0"
+    semver "^5.3.0"
+    send "^0.16.1"
+    server-destroy "^1.0.1"
+    sinon "^2.3.5"
+    sinon-chai "^2.10.0"
+    socket.io "^2.0.3"
+    stacky "^1.3.1"
+    wd "^1.2.0"
+  optionalDependencies:
+    update-notifier "^2.2.0"
+    wct-local "^2.1.1"
+    wct-sauce "^2.0.2"
+
+webidl-conversions@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
+  integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
+
+whatwg-url@^6.4.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
+  integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==
+  dependencies:
+    lodash.sortby "^4.7.0"
+    tr46 "^1.0.1"
+    webidl-conversions "^4.0.2"
+
+which-module@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
+  integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
+
+which@1.3.1, which@^1.0.8, which@^1.2.14, which@^1.2.9, which@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
+  dependencies:
+    isexe "^2.0.0"
+
+wide-align@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+  dependencies:
+    string-width "^1.0.2 || 2"
+
+widest-line@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
+  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
+  dependencies:
+    string-width "^2.1.1"
+
+winston-transport@^4.2.0, winston-transport@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.3.0.tgz#df68c0c202482c448d9b47313c07304c2d7c2c66"
+  integrity sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==
+  dependencies:
+    readable-stream "^2.3.6"
+    triple-beam "^1.2.0"
+
+winston@^3.0.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/winston/-/winston-3.2.1.tgz#63061377976c73584028be2490a1846055f77f07"
+  integrity sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==
+  dependencies:
+    async "^2.6.1"
+    diagnostics "^1.1.1"
+    is-stream "^1.1.0"
+    logform "^2.1.1"
+    one-time "0.0.4"
+    readable-stream "^3.1.1"
+    stack-trace "0.0.x"
+    triple-beam "^1.3.0"
+    winston-transport "^4.3.0"
+
+wordwrap@~0.0.2:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
+  integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
+
+wordwrapjs@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-3.0.0.tgz#c94c372894cadc6feb1a66bff64e1d9af92c5d1e"
+  integrity sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==
+  dependencies:
+    reduce-flatten "^1.0.1"
+    typical "^2.6.1"
+
+wrap-ansi@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
+  integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
+  dependencies:
+    ansi-styles "^3.2.0"
+    string-width "^3.0.0"
+    strip-ansi "^5.0.0"
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+
+write-file-atomic@^2.0.0:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
+  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
+  dependencies:
+    graceful-fs "^4.1.11"
+    imurmurhash "^0.1.4"
+    signal-exit "^3.0.2"
+
+ws@^7.1.2:
+  version "7.2.1"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
+  integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==
+
+ws@~6.1.0:
+  version "6.1.4"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
+  integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
+  dependencies:
+    async-limiter "~1.0.0"
+
+xdg-basedir@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
+  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
+
+xmlbuilder@8.2.2:
+  version "8.2.2"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
+  integrity sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=
+
+xmldom@0.1.x:
+  version "0.1.31"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
+  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
+
+xmlhttprequest-ssl@~1.5.4:
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
+  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+
+"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
+  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
+
+y18n@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
+  integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
+
+yallist@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
+  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+
+yargs-parser@13.1.1, yargs-parser@^13.1.1:
+  version "13.1.1"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0"
+  integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==
+  dependencies:
+    camelcase "^5.0.0"
+    decamelize "^1.2.0"
+
+yargs-unparser@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.0.tgz#ef25c2c769ff6bd09e4b0f9d7c605fb27846ea9f"
+  integrity sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==
+  dependencies:
+    flat "^4.1.0"
+    lodash "^4.17.15"
+    yargs "^13.3.0"
+
+yargs@13.3.0, yargs@^13.3.0:
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83"
+  integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==
+  dependencies:
+    cliui "^5.0.0"
+    find-up "^3.0.0"
+    get-caller-file "^2.0.1"
+    require-directory "^2.1.1"
+    require-main-filename "^2.0.0"
+    set-blocking "^2.0.0"
+    string-width "^3.0.0"
+    which-module "^2.0.0"
+    y18n "^4.0.0"
+    yargs-parser "^13.1.1"
+
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
+yeast@0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
+  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
+
+zip-stream@^2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
+  integrity sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==
+  dependencies:
+    archiver-utils "^2.1.0"
+    compress-commons "^2.1.1"
+    readable-stream "^3.4.0"
diff --git a/polymer-bridges b/polymer-bridges
new file mode 160000
index 0000000..855f478
--- /dev/null
+++ b/polymer-bridges
@@ -0,0 +1 @@
+Subproject commit 855f4781b702de120953a64da5c277ea4908deaa
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..e157608 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -42,7 +42,8 @@
   string token = 1;
   string secret = 2;
   string raw = 3;
-  int64 expires_at = 4;
+  // Epoch millis.
+  int64 expires_at_millis = 4;
   string provider_id = 5;
 }
 
@@ -75,7 +76,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 23
+// Next ID: 24
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -84,13 +85,15 @@
 
   int32 change_id = 2;
 
-  // Next ID: 24
+  // Next ID: 26
   message ChangeColumnsProto {
     string change_key = 1;
 
-    int64 created_on = 2;
+    // Epoch millis.
+    int64 created_on_millis = 2;
 
-    int64 last_updated_on = 3;
+    // Epoch millis.
+    int64 last_updated_on_millis = 3;
 
     int32 owner = 4;
 
@@ -124,13 +127,16 @@
 
     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
   // which case attempting to use the ChangeNotesCache is programmer error.
   ChangeColumnsProto columns = 3;
 
-  reserved  4; // past_assignee
+  reserved 4; // past_assignee
 
   repeated string hashtag = 5;
 
@@ -144,7 +150,8 @@
   message ReviewerSetEntryProto {
     string state = 1;
     int32 account_id = 2;
-    int64 timestamp = 3;
+    // Epoch millis.
+    int64 timestamp_millis = 3;
   }
   repeated ReviewerSetEntryProto reviewer = 8;
 
@@ -152,7 +159,8 @@
   message ReviewerByEmailSetEntryProto {
     string state = 1;
     string address = 2;
-    int64 timestamp = 3;
+    // Epoch millis.
+    int64 timestamp_millis = 3;
   }
   repeated ReviewerByEmailSetEntryProto reviewer_by_email = 9;
 
@@ -164,7 +172,8 @@
 
   // Next ID: 5
   message ReviewerStatusUpdateProto {
-    int64 date = 1;
+    // Epoch millis.
+    int64 timestamp_millis = 1;
     int32 updated_by = 2;
     int32 reviewer = 3;
     string state = 4;
@@ -191,14 +200,26 @@
   bool has_server_id = 21;
 
   message AssigneeStatusUpdateProto {
-    int64 date = 1;
+    // Epoch millis.
+    int64 timestamp_millis = 1;
     int32 updated_by = 2;
     int32 current_assignee = 3;
     bool has_current_assignee = 4;
   }
   repeated AssigneeStatusUpdateProto assignee_update = 22;
-}
 
+  // An update to the attention set of the change. See class AttentionSetUpdate
+  // for context.
+  message AttentionSetUpdateProto {
+    // Epoch millis.
+    int64 timestamp_millis = 1;
+    int32 account = 2;
+    // Maps to enum AttentionSetUpdate.Operation
+    string operation = 3;
+    string reason = 4;
+  }
+  repeated AttentionSetUpdateProto attention_set_update = 23;
+}
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
 message ConflictKeyProto {
@@ -249,6 +270,15 @@
   repeated ExternalIdProto external_id = 1;
 }
 
+// Serialized form of a list of com.google.gerrit.entities.AccountGroup.UUID
+// Next ID: 2
+message AllExternalGroupsProto {
+  message ExternalGroupProto {
+    string groupUuid = 1;
+  }
+  repeated ExternalGroupProto external_group = 1;
+}
+
 // Key for com.google.gerrit.server.git.PureRevertCache.
 // Next ID: 4
 message PureRevertKeyProto {
@@ -256,3 +286,40 @@
   bytes claimed_original = 2;
   bytes claimed_revert = 3;
 }
+
+// Key for com.google.gerrit.server.account.ProjectWatches.
+// Next ID: 4
+message ProjectWatchProto {
+  string project = 1;
+  string filter = 2;
+  repeated string notify_type = 3;
+}
+
+// Serialized form of
+// com.google.gerrit.entities.Account.
+// Next ID: 9
+message AccountProto {
+  int32 id = 1;
+  int64 registered_on = 2;
+  string full_name = 3;
+  string display_name = 4;
+  string preferred_email = 5;
+  bool inactive = 6;
+  string status = 7;
+  string meta_id = 8;
+}
+
+// Serialized form of com.google.gerrit.server.account.CachedAccountDetails.Key.
+// Next ID: 3
+message AccountKeyProto {
+  int32 account_id = 1;
+  bytes id = 2;
+}
+
+// Serialized form of com.google.gerrit.server.account.CachedAccountDetails.
+// Next ID: 4
+message AccountDetailsProto {
+  AccountProto account = 1;
+  repeated ProjectWatchProto project_watch_proto = 2;
+  string user_preferences = 3;
+}
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..d162714 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -20,13 +20,19 @@
   {@param canonicalPath: ?}
   {@param staticResourcePath: ?}
   {@param gerritInitialData: /** {string} map of REST endpoint to response for startup. */ ?}
+  {@param? enabledExperiments: /** A list of enabled experiments for current user. */ ?}
   {@param? assetsPath: ?}  /** {string} URL to static assets root, if served from CDN. */
   {@param? assetsBundle: ?}  /** {string} Assets bundle .html file, served from $assetsPath. */
   {@param? faviconPath: ?}
   {@param? versionInfo: ?}
   {@param? polyfillCE: ?}
-  {@param? polyfillSD: ?}
-  {@param? polyfillSC: ?}
+  {@param? useGoogleFonts: ?}
+  {@param? changeRequestsPath: ?}
+  {@param? defaultChangeDetailHex: ?}
+  {@param? defaultDiffDetailHex: ?}
+  {@param? preloadChangePage: ?}
+  {@param? preloadDiffPage: ?}
+  {@param? userIsAuthenticated: ?}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
@@ -42,13 +48,19 @@
     // Disable extra font load from paper-styles
     window.polymerSkipLoadingFontRoboto = true;
     window.CLOSURE_NO_DEPS = true;
+    window.DEFAULT_DETAIL_HEXES = {lb}
+      {if $defaultChangeDetailHex}
+        changePage: '{$defaultChangeDetailHex}',
+      {/if}
+      {if $defaultDiffDetailHex}
+        diffPage: '{$defaultDiffDetailHex}',
+      {/if}
+    {rb};
     {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
     {if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
     {if $assetsPath}window.ASSETS_PATH = '{$assetsPath}';{/if}
     {if $polyfillCE}if (window.customElements) window.customElements.forcePolyfill = true;{/if}
-    {if $polyfillSD}{literal}ShadyDOM = { force: true };{/literal}{/if}
-    {if $polyfillSC}{literal}ShadyCSS = { shimcssproperties: true};{/literal}{/if}
     {if $gerritInitialData}
       // INITIAL_DATA is a string that represents a JSON map. It's inlined here so that we can
       // spare calls to the API when starting up the app.
@@ -60,6 +72,11 @@
       // '/accounts/self/detail' => { 'username' : 'gerrit-user' }
       window.INITIAL_DATA = JSON.parse({$gerritInitialData});
     {/if}
+    {if $enabledExperiments}
+      // ENABLED_EXPERIMENTS is a list of string that contains all enabled experiments
+      // for the given user.
+      window.ENABLED_EXPERIMENTS = JSON.parse({$enabledExperiments});
+    {/if}
   </script>{\n}
 
   {if $faviconPath}
@@ -67,17 +84,55 @@
   {else}
     <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
   {/if}
+  {if $changeRequestsPath}
+    {if $preloadChangePage and $defaultChangeDetailHex}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultChangeDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+      {if $userIsAuthenticated}
+        <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+      {/if}
+    {/if}
+    {if $preloadDiffPage and $defaultDiffDetailHex}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultDiffDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+      {if $userIsAuthenticated}
+        <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+      {/if}
+    {/if}
+    <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/robotcomments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    {if $userIsAuthenticated}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    {/if}
+  {/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}
-  <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
-  <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
+  {if $useGoogleFonts}
+    <link rel="preload" as="style" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">
+  {else}
+    // $useGoogleFonts only exists so that hosts can opt-out of loading fonts from fonts.googleapis.com.
+    // fonts.css and the woff2 files in the fonts/ directory are only relevant, if $useGoogleFonts is false.
+
+    // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
+    <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-400.woff2"        as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-600.woff2"        as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-700.woff2"        as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-ext-400.woff2"    as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-ext-600.woff2"    as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-ext-700.woff2"    as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-latin-400.woff2"          as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-latin-500.woff2"          as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-latin-700.woff2"          as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-latin-ext-400.woff2"      as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-latin-ext-500.woff2"      as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-latin-ext-700.woff2"      as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-400.woff2"     as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-500.woff2"     as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-700.woff2"     as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-400.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-500.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-700.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+
+    <link rel="preload" as="style" href="{$staticResourcePath}/styles/fonts.css">{\n}
+  {/if}
+  <link rel="preload" as="style" href="{$staticResourcePath}/styles/main.css">{\n}
 
   <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
 
@@ -91,8 +146,18 @@
     <link rel="import" href="{$assetsPath}/{$assetsBundle}">{\n}
   {/if}
 
-  <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
+  // Now use preloaded resources
+  {if $useGoogleFonts}
+    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">{\n}
+  {else}
+    <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
+  {/if}
+  <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
 
   <body unresolved>{\n}
   <gr-app id="app"></gr-app>{\n}
+
+  // Load gr-app.js after <gr-app ...> tag because gr-router expects that
+  // <gr-app ...> already exists in the document when script is executed.
+  <script src="{$staticResourcePath}/elements/gr-app.js"></script>{\n}
 {/template}
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/rules_nodejs-1.5.patch b/rules_nodejs-1.5.patch
new file mode 100644
index 0000000..baa566b3
--- /dev/null
+++ b/rules_nodejs-1.5.patch
@@ -0,0 +1,12 @@
+--- build_bazel_rules_nodejs/internal/node/node.bzl	2021-02-07 16:09:38.099484740 +0100
++++ build_bazel_rules_nodejs/internal/node/node_fixed.bzl	2021-02-07 16:10:33.583847582 +0100
+@@ -87,6 +87,6 @@
+     for d in ctx.attr.data:
+         if hasattr(d, "runfiles_module_mappings"):
+             for [mn, mr] in d.runfiles_module_mappings.items():
+-                escaped = mn.replace("/", "\/").replace(".", "\.")
++                escaped = mn.replace("/", "\\/").replace(".", "\\.")
+                 mapping = "{module_name: /^%s\\b/, module_root: '%s'}" % (escaped, mr)
+                 module_mappings.append(mapping)
+
+
diff --git a/tools/BUILD b/tools/BUILD
index f5da450..5159177 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/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/js.bzl b/tools/bzl/js.bzl
index b428a2d..5440b88 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -506,6 +506,16 @@
             cmd = "sed 's/<script src=\"" + name + "_combined.js\"/<script src=\"" + plugin_name + ".js\"/g' $(SRCS) > $(OUTS)",
             output_to_bindir = True,
         )
+    else:
+        # For polymer 3 migration, we will only have js plugins, in case server side
+        # is still asking for *.html, we still want to create a html placeholder just to load the js
+        # TODO(taoalpha): this should be cleaned up once polymer 3 plugins are the only ones gerrit supports
+        native.genrule(
+            name = name + "_rename_html",
+            outs = [plugin_name + ".html"],
+            cmd = "echo \"<script src='" + plugin_name + ".js'></script>\" > $(OUTS)",
+            output_to_bindir = True,
+        )
 
     native.genrule(
         name = name + "_rename_js",
@@ -515,10 +525,7 @@
         output_to_bindir = True,
     )
 
-    if html_plugin:
-        static_files = [plugin_name + ".js", plugin_name + ".html"]
-    else:
-        static_files = [plugin_name + ".js"]
+    static_files = [plugin_name + ".js", plugin_name + ".html"]
 
     if assets:
         nested, direct = [], []
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/license-map.py b/tools/bzl/license-map.py
index 2779130..c32579c 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -3,56 +3,138 @@
 # reads bazel query XML files, to join target names with their licenses.
 
 from __future__ import print_function
+from collections import namedtuple
 
 import argparse
+import json
 from collections import defaultdict
-from shutil import copyfileobj
 from sys import stdout, stderr
 import xml.etree.ElementTree as ET
 
-
 DO_NOT_DISTRIBUTE = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
 
 LICENSE_PREFIX = "//lib:LICENSE-"
 
 parser = argparse.ArgumentParser()
 parser.add_argument("--asciidoctor", action="store_true")
+parser.add_argument("--json-map", action="append", dest="json_maps")
 parser.add_argument("xmls", nargs="+")
 args = parser.parse_args()
 
-entries = defaultdict(list)
-graph = defaultdict(list)
-handled_rules = []
 
-for xml in args.xmls:
-    tree = ET.parse(xml)
-    root = tree.getroot()
+def read_file(filename):
+    "Reads file and returns its content"
+    with open(filename) as fd:
+        return fd.read()
 
-    for child in root:
-        rule_name = child.attrib["name"]
-        if rule_name in handled_rules:
-            # already handled in other xml files
-            continue
+# List of files in package to which license is applied.
+# kind - enum, one of
+#   AllFiles - license applies to all files in package
+#   OnlySpecificFiles - license applies to all files from "files" list
+#   AllFilesExceptSpecific - license applies to all files in package
+#      except several files. "files" contains list of exceptions
+# files - defines list of files for the following kinds:
+#   OnlySpecificFiles, AllFilesExceptSpecific.
+#   Each item is a string, but not necessary a real name of a file.
+#   It can be any string, understandable by human (like directory name)
+LicensedFiles = namedtuple("LicensedFiles", ["kind", "files"])
 
-        handled_rules.append(rule_name)
-        for c in list(child):
-            if c.tag != "rule-input":
+# PackageInfo - contains information about pacakge/files in packages to
+#   which license is applied.
+# name - name of the package, as specified in package.json file
+# version - optional package version. Exists only if different versions
+#   of the same package have different licenses
+# licensed_files - instance of LicensedFiles
+PackageInfo = namedtuple("PackageInfo", ["name", "version", "licensed_files"])
+
+# LicenseMapItem - describe one type of license and a list of packages
+#   under this license
+# name - name of the license
+# safename - name which is safe to use as an asciidoc bookmark name
+# packages - list of PackageInfo
+# license_text - license text as string
+LicenseMapItem = namedtuple("LicenseMapItem",
+                            ["name", "safename", "packages", "license_text"])
+
+
+def load_xmls(xml_filenames):
+    """Load xml files produced by bazel query
+     and converts them to a list of LicenseMapItem
+
+    Args:
+         xml_filenames: list of string; each string is a filename
+    Returns:
+        list of LicenseMapItem
+    """
+    entries = defaultdict(list)
+    graph = defaultdict(list)
+    handled_rules = set()
+    for xml in xml_filenames:
+        tree = ET.parse(xml)
+        root = tree.getroot()
+
+        for child in root:
+            rule_name = child.attrib["name"]
+            if rule_name in handled_rules:
+                # already handled in other xml files
                 continue
 
-            license_name = c.attrib["name"]
-            if LICENSE_PREFIX in license_name:
-                entries[rule_name].append(license_name)
-                graph[license_name].append(rule_name)
+            handled_rules.add(rule_name)
+            for c in list(child):
+                if c.tag != "rule-input":
+                    continue
 
-if len(graph[DO_NOT_DISTRIBUTE]):
-    print("DO_NOT_DISTRIBUTE license found in:", file=stderr)
-    for target in graph[DO_NOT_DISTRIBUTE]:
-        print(target, file=stderr)
-    exit(1)
+                license_name = c.attrib["name"]
+                if LICENSE_PREFIX in license_name:
+                    entries[rule_name].append(license_name)
+                    graph[license_name].append(rule_name)
 
-if args.asciidoctor:
-    # We don't want any blank line before "= Gerrit Code Review - Licenses"
-    print("""= Gerrit Code Review - Licenses
+    if len(graph[DO_NOT_DISTRIBUTE]):
+        print("DO_NOT_DISTRIBUTE license found in:", file=stderr)
+        for target in graph[DO_NOT_DISTRIBUTE]:
+            print(target, file=stderr)
+        exit(1)
+
+    result = []
+    for n in sorted(graph.keys()):
+        if len(graph[n]) == 0:
+            continue
+
+        name = n[len(LICENSE_PREFIX):]
+        safename = name.replace(".", "_")
+        packages_names = []
+        for d in sorted(graph[n]):
+            if d.startswith("//lib:") or d.startswith("//lib/"):
+                p = d[len("//lib:"):]
+            else:
+                p = d[d.index(":") + 1:].lower()
+            if "__" in p:
+                p = p[:p.index("__")]
+            packages_names.append(p)
+
+        filename = n[2:].replace(":", "/")
+        content = read_file(filename)
+        result.append(LicenseMapItem(
+            name=name,
+            safename=safename,
+            license_text=content,
+            packages=[PackageInfo(name=name, version=None,
+                                  licensed_files=LicensedFiles(kind="All",
+                                                               files=[])) for
+                      name
+                      in packages_names]
+        )
+        )
+
+    return result
+
+def main():
+    xml_data = load_xmls(args.xmls)
+    json_map_data = load_jsons(args.json_maps)
+
+    if args.asciidoctor:
+        # We don't want any blank line before "= Gerrit Code Review - Licenses"
+        print("""= Gerrit Code Review - Licenses
 
 // DO NOT EDIT - GENERATED AUTOMATICALLY.
 
@@ -93,41 +175,134 @@
 == Licenses
 """)
 
-for n in sorted(graph.keys()):
-    if len(graph[n]) == 0:
-        continue
+    for data in xml_data + json_map_data:
+        name = data.name
+        safename = data.safename
+        print()
+        print("[[%s]]" % safename)
+        print(name)
+        print()
+        for p in data.packages:
+            package_notice = ""
+            if p.licensed_files.kind == "OnlySpecificFiles":
+                package_notice = " - only the following file(s):"
+            elif p.licensed_files.kind == "AllFilesExceptSpecific":
+                package_notice = " - except the following file(s):"
 
-    name = n[len(LICENSE_PREFIX):]
-    safename = name.replace(".", "_")
-    print()
-    print("[[%s]]" % safename)
-    print(name)
-    print()
-    for d in sorted(graph[n]):
-        if d.startswith("//lib:") or d.startswith("//lib/"):
-            p = d[len("//lib:"):]
-        else:
-            p = d[d.index(":")+1:].lower()
-        if "__" in p:
-            p = p[:p.index("__")]
-        print("* " + p)
-    print()
-    print("[[%s_license]]" % safename)
-    print("----")
-    filename = n[2:].replace(":", "/")
-    try:
-        with open(filename, errors='ignore') as fd:
-            copyfileobj(fd, stdout)
-    except TypeError:
-        with open(filename) as fd:
-            copyfileobj(fd, stdout)
-    print()
-    print("----")
-    print()
+            print("* " + get_package_display_name(p) + package_notice)
+            for file in p.licensed_files.files:
+                print("** " + file)
+        print()
+        print("[[%s_license]]" % safename)
+        print("----")
+        license_text = data.license_text
+        print(data.license_text.rstrip("\r\n"))
+        print()
+        print("----")
+        print()
 
-if args.asciidoctor:
-    print("""
+    if args.asciidoctor:
+        print("""
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
 """)
+
+def load_jsons(json_filenames):
+    """Loads information about licenses from jsons files.
+    The json files are generated by license-map-generator.ts tool
+
+    Args:
+         json_filenames: list of string; each string is a filename
+    Returns:
+        list of LicenseMapItem
+    """
+    result = []
+    for json_map in json_filenames:
+        with open(json_map, 'r') as f:
+            licenses_list = json.load(f)
+        for license_id, license in licenses_list.items():
+            name = license["licenseName"]
+            safename = name.replace(".", "_")
+            packages = []
+            for p in license["packages"]:
+                package = PackageInfo(name=p["name"], version=p["version"],
+                                      licensed_files=get_licensed_files(
+                                          p["licensedFiles"]))
+                packages.append(package)
+            result.append(LicenseMapItem(
+                name=name,
+                safename=safename,
+                license_text=license["licenseText"],
+                packages=sorted(remove_duplicated_packages(packages),
+                                key=lambda package: get_package_display_name(
+                                    package)),
+            ))
+    return result
+
+def get_licensed_files(json_licensed_file_dict):
+    """Convert json dictionary to LicensedFiles"""
+    kind = json_licensed_file_dict["kind"]
+    if kind == "AllFiles":
+        return LicensedFiles(kind="All", files=[])
+    if kind == "OnlySpecificFiles" or kind == "AllFilesExceptSpecific":
+        return LicensedFiles(kind=kind, files=sorted(json_licensed_file_dict["files"]))
+    raise Exception("Invalid licensed files kind: %s".format(kind))
+
+def get_package_display_name(package):
+    """Returns a human-readable name of package with optional version"""
+    if package.version:
+        return package.name + " - " + package.version
+    else:
+        return package.name
+
+
+def can_merge_packages(package_info_list):
+    """Returns true if all versions of a package can be replaced with
+    a package name
+
+    Args:
+        package_info_list: list of PackageInfo. Method assumes,
+        that all items in package_info_list have the same package name,
+        but different package version.
+
+    Returns:
+        True if it is safe to print only a package name (without versions)
+        False otherwise
+    """
+    first = package_info_list[0]
+    for package in package_info_list:
+        if package.licensed_files != first.licensed_files:
+            return False
+    return True
+
+
+def remove_duplicated_packages(package_info_list):
+    """ Keep only the name of a package if all versions of the package
+    have the same licensed files.
+
+    Args:
+        package_info_list: list of PackageInfo. All items in the list
+           have the same license.
+
+    Returns:
+        list of PackageInfo with removed/replaced items.
+
+    Keep single version of package if all versions have the same
+    license files."""
+    name_to_package = defaultdict(list)
+    for package in package_info_list:
+        name_to_package[package.name].append(package)
+
+    result = []
+    for package_name, packages in name_to_package.items():
+        if can_merge_packages(packages):
+            package = packages[0]
+            result.append(PackageInfo(name=package.name, version=None,
+                                      licensed_files=package.licensed_files))
+        else:
+            result.extend(packages)
+    return result
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index 5a6bf7f..cdb13d0 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -1,8 +1,26 @@
+"""This file contains rules to generate and test the license map"""
+
 def normalize_target_name(target):
     return target.replace("//", "").replace("/", "__").replace(":", "___")
 
-def license_map(name, targets = [], opts = [], **kwargs):
-    """Generate XML for all targets that depend directly on a LICENSE file"""
+def license_map(name, targets = [], opts = [], json_maps = [], **kwargs):
+    """Generate text represantation for pacakges' and libs' licenses
+
+    Args:
+        name: of the rule
+        targets: list of targets for which licenses should be added the output file.
+            The list must not include targets, for which json_map is passed in json_maps parameter
+        opts: command line options for license-map.py tool
+        json_maps: list of json files. Such files can be produced by node_modules_licenses rule
+            for node_modules licenses.
+        **kwargs: Args passed through to genrule
+
+    Generate: text file with the name
+        gen_license_txt_{name}
+
+    """
+
+    # Generate XML for all targets that depend directly on a LICENSE file
     xmls = []
     tools = ["//tools/bzl:license-map.py", "//lib:all-licenses"]
     for target in targets:
@@ -22,10 +40,17 @@
             opts = ["--output=xml"],
         )
 
+    # Add all files from the json_maps list to license-map.py command-line arguments
+    json_maps_locations = []
+
+    for json_map in json_maps:
+        json_maps_locations.append("--json-map=$(location %s)" % json_map)
+        tools.append(json_map)
+
     # post process the XML into our favorite format.
     native.genrule(
         name = "gen_license_txt_" + name,
-        cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
+        cmd = "python $(location //tools/bzl:license-map.py) %s %s %s > $@" % (" ".join(opts), " ".join(json_maps_locations), " ".join(xmls)),
         outs = [name + ".gen.txt"],
         tools = tools,
         **kwargs
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 5bc181b..9908ee8 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/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index c9ac0fe..2b473bc 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -23,6 +23,7 @@
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
     "//lib/log:impl-log4j",
+    "//lib:jgit-ssh-jsch",
     "//prolog:gerrit-prolog-common",
     "//resources:log4j-config",
 ]
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/dev-hooks/pre-commit b/tools/dev-hooks/pre-commit
index b77f382..af87b7e 100755
--- a/tools/dev-hooks/pre-commit
+++ b/tools/dev-hooks/pre-commit
@@ -31,7 +31,7 @@
 eslint=${gitroot}/node_modules/eslint/bin/eslint.js
 
 # Run eslint over changed frontend code
-CHANGED_UI_FILES=$(git diff --cached --name-only -- '*.js' '*.html' | grep 'polygerrit-ui') && true
+CHANGED_UI_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.js' '*.html' | grep 'polygerrit-ui') && true
 if [ "${CHANGED_UI_FILES}" ]; then
   if $eslint --fix ${CHANGED_UI_FILES}; then
     # Add again in case lint fix modified some files
diff --git a/tools/eclipse/gerrit_daemon.launch b/tools/eclipse/gerrit_daemon.launch
index d00f7bf..4f6c599 100644
--- a/tools/eclipse/gerrit_daemon.launch
+++ b/tools/eclipse/gerrit_daemon.launch
@@ -11,7 +11,7 @@
 </listAttribute>
 <booleanAttribute key="org.eclipse.jdt.launching.ATTR_USE_START_ON_FIRST_THREAD" value="true"/>
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="Main"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="daemon --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="daemon --console-log --show-stack-trace --dev-cdn http://localhost:8081 -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
 <stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Dgerrit.plugin-classes=${resource_loc:/gerrit/eclipse-out}/plugins"/>
 </launchConfiguration>
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 9915a6e..f360fa5 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -181,6 +181,8 @@
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/resources')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.junit/src')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/src')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/resources')
 
     def classpathentry(kind, path, src=None, out=None, exported=None, excluding=None):
         e = doc.createElement('classpathentry')
diff --git a/tools/js/bower2bazel.py b/tools/js/bower2bazel.py
deleted file mode 100755
index a896dba..0000000
--- a/tools/js/bower2bazel.py
+++ /dev/null
@@ -1,262 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2015 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-"""
-Suggested call sequence:
-
-python tools/js/bower2bazel.py -w lib/js/bower_archives.bzl \
-  -b lib/js/bower_components.bzl
-"""
-
-from __future__ import print_function
-
-import argparse
-import collections
-import json
-import hashlib
-import os
-import subprocess
-import sys
-import tempfile
-import glob
-import bowerutil
-
-# list of licenses for packages that don't specify one in their bower.json file
-package_licenses = {
-    "codemirror-minified": "codemirror-minified",
-    "es6-promise": "es6-promise",
-    "fetch": "fetch",
-    "font-roboto-local": "polymer",
-    "iron-a11y-announcer": "polymer",
-    "iron-a11y-keys-behavior": "polymer",
-    "iron-autogrow-textarea": "polymer",
-    "iron-behaviors": "polymer",
-    "iron-dropdown": "polymer",
-    "iron-fit-behavior": "polymer",
-    "iron-flex-layout": "polymer",
-    "iron-form-element-behavior": "polymer",
-    "iron-icon": "polymer",
-    "iron-iconset-svg": "polymer",
-    "iron-input": "polymer",
-    "iron-menu-behavior": "polymer",
-    "iron-meta": "polymer",
-    "iron-overlay-behavior": "polymer",
-    "iron-resizable-behavior": "polymer",
-    "iron-selector": "polymer",
-    "iron-validatable-behavior": "polymer",
-    "moment": "moment",
-    "neon-animation": "polymer",
-    "page": "page.js",
-    "paper-button": "polymer",
-    "paper-icon-button": "polymer",
-    "paper-input": "polymer",
-    "paper-item": "polymer",
-    "paper-listbox": "polymer",
-    "paper-toggle-button": "polymer",
-    "paper-styles": "polymer",
-    "paper-tabs": "polymer",
-    "polymer": "polymer",
-    "polymer-resin": "polymer",
-    "promise-polyfill": "promise-polyfill",
-    "web-animations-js": "Apache2.0",
-    "webcomponentsjs": "polymer",
-    "paper-material": "polymer",
-    "paper-styles": "polymer",
-    "paper-behaviors": "polymer",
-    "paper-ripple": "polymer",
-    "iron-checked-element-behavior": "polymer",
-    "font-roboto-local": "polymer",
-}
-
-
-def build_bower_json(version_targets, seeds):
-    """Generate bower JSON file, return its path.
-
-    Args:
-      version_targets: bazel target names of the versions.json file.
-      seeds: an iterable of bower package names of the seed packages, ie.
-        the packages whose versions we control manually.
-    """
-    bower_json = collections.OrderedDict()
-    bower_json['name'] = 'bower2bazel-output'
-    bower_json['version'] = '0.0.0'
-    bower_json['description'] = 'Auto-generated bower.json for dependency ' + \
-                                'management'
-    bower_json['private'] = True
-    bower_json['dependencies'] = {}
-
-    seeds = set(seeds)
-    for v in version_targets:
-        path = os.path.join("bazel-out/*-fastbuild/bin",
-                            v.lstrip("/").replace(":", "/"))
-        fs = glob.glob(path)
-        err_msg = '%s: file not found or multiple files found: %s' % (path, fs)
-        assert len(fs) == 1, err_msg
-        with open(fs[0]) as f:
-            j = json.load(f)
-            if "" in j:
-                # drop dummy entries.
-                del j[""]
-
-            trimmed = {}
-            for k, v in j.items():
-                if k in seeds:
-                    trimmed[k] = v
-
-            bower_json['dependencies'].update(trimmed)
-
-    tmpdir = tempfile.mkdtemp()
-    ret = os.path.join(tmpdir, 'bower.json')
-    with open(ret, 'w') as f:
-        json.dump(bower_json, f, indent=2)
-    return ret
-
-
-def decode(input):
-    try:
-        return input.decode("utf-8")
-    except TypeError:
-        return input
-
-
-def bower_command(args):
-    base = subprocess.check_output(["bazel", "info", "output_base"]).strip()
-    exp = os.path.join(decode(base), "external", "bower", "*npm_binary.tgz")
-    fs = sorted(glob.glob(exp))
-    err_msg = "bower tarball not found or have multiple versions %s" % fs
-    assert len(fs) == 1, err_msg
-    return ["python",
-            os.getcwd() + "/tools/js/run_npm_binary.py", sorted(fs)[0]] + args
-
-
-def main():
-    parser = argparse.ArgumentParser()
-    parser.add_argument('-w', help='.bzl output for WORKSPACE')
-    parser.add_argument('-b', help='.bzl output for //lib:BUILD')
-    args = parser.parse_args()
-
-    target_str = subprocess.check_output([
-        "bazel", "query", "kind(bower_component_bundle, //polygerrit-ui/...)"])
-    seed_str = subprocess.check_output(
-        ["bazel", "query",
-         "attr(seed, 1, kind(bower_component, deps(//polygerrit-ui/...)))"])
-    targets = [s for s in decode(target_str).split('\n') if s]
-    seeds = [s for s in decode(seed_str).split('\n') if s]
-    prefix = "//lib/js:"
-    non_seeds = [s for s in seeds if not s.startswith(prefix)]
-    assert not non_seeds, non_seeds
-    seeds = set([s[len(prefix):] for s in seeds])
-
-    version_targets = [t + "-versions.json" for t in targets]
-    subprocess.check_call(['bazel', 'build'] + version_targets)
-    bower_json_path = build_bower_json(version_targets, seeds)
-    dir = os.path.dirname(bower_json_path)
-    cmd = bower_command(["install"])
-
-    build_out = sys.stdout
-    if args.b:
-        build_out = open(args.b + ".tmp", 'w')
-
-    ws_out = sys.stdout
-    if args.b:
-        ws_out = open(args.w + ".tmp", 'w')
-
-    header = """# DO NOT EDIT
-# generated with the following command:
-#
-#   %s
-#
-
-""" % ' '.join(sys.argv)
-
-    ws_out.write(header)
-    build_out.write(header)
-
-    oldwd = os.getcwd()
-    os.chdir(dir)
-    subprocess.check_call(cmd)
-
-    interpret_bower_json(seeds, ws_out, build_out)
-    ws_out.close()
-    build_out.close()
-
-    os.chdir(oldwd)
-    os.rename(args.w + ".tmp", args.w)
-    os.rename(args.b + ".tmp", args.b)
-
-
-def dump_workspace(data, seeds, out):
-    out.write('load("//tools/bzl:js.bzl", "bower_archive")\n\n')
-    out.write('def load_bower_archives():\n')
-
-    for d in data:
-        if d["name"] in seeds:
-            continue
-        out.write("""    bower_archive(
-        name = "%(name)s",
-        package = "%(normalized-name)s",
-        version = "%(version)s",
-        sha1 = "%(bazel-sha1)s",
-    )
-""" % d)
-
-
-def dump_build(data, seeds, out):
-    out.write('load("//tools/bzl:js.bzl", "bower_component")\n\n')
-    out.write('def define_bower_components():\n')
-    for d in data:
-        out.write("    bower_component(\n")
-        out.write("        name = \"%s\",\n" % d["name"])
-        out.write("        license = \"//lib:LICENSE-%s\",\n" % d["bazel-license"])
-        deps = sorted(d.get("dependencies", {}).keys())
-        if deps:
-            if len(deps) == 1:
-                out.write("        deps = [\":%s\"],\n" % deps[0])
-            else:
-                out.write("        deps = [\n")
-                for dep in deps:
-                    out.write("            \":%s\",\n" % dep)
-                out.write("        ],\n")
-        if d["name"] in seeds:
-            out.write("        seed = True,\n")
-        out.write("    )\n")
-    # done
-
-
-def interpret_bower_json(seeds, ws_out, build_out):
-    out = subprocess.check_output(["find", "bower_components/", "-name",
-                                   ".bower.json"])
-
-    data = []
-    for f in sorted(decode(out).split('\n')):
-        if not f:
-            continue
-        pkg = json.load(open(f))
-        pkg_name = pkg["name"]
-
-        pkg["bazel-sha1"] = bowerutil.hash_bower_component(
-            hashlib.sha1(), os.path.dirname(f)).hexdigest()
-        license = package_licenses.get(pkg_name, "DO_NOT_DISTRIBUTE")
-
-        pkg["bazel-license"] = license
-        pkg["normalized-name"] = pkg["_originalSource"]
-        data.append(pkg)
-
-    dump_workspace(data, seeds, ws_out)
-    dump_build(data, seeds, build_out)
-
-
-if __name__ == '__main__':
-    main()
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
new file mode 100644
index 0000000..bd2bc32
--- /dev/null
+++ b/tools/js/eslint.bzl
@@ -0,0 +1,93 @@
+# 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.
+
+"""This file contains macro to run eslint and define a eslint test rule."""
+
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test")
+
+def eslint(name, plugins, srcs, config, ignore, extensions = [".js"], data = []):
+    """ Macro to define eslint rules for files.
+
+    Args:
+        name: name of the rule
+        plugins: list of npm dependencies with plugins, for example "@npm//eslint-config-google"
+        srcs: list of files to be checked (ignored in {name}_bin rule)
+        config: eslint config file
+        ignore: eslint ignore file
+        extensions: list of file extensions to be checked. This is an additional filter for
+            srcs list. Each extension must start with '.' character.
+            Default: [".js"].
+        data: list of additional dependencies. For example if a config file extends an another
+            file, this other file must be added to data.
+
+    Generate: 2 rules:
+        {name}_test rule - runs eslint tests. You can run this rule with
+            'bazel test {name}_test' command. The rule tests all files from srcs with specified
+            extensions inside the package where eslint macro is called.
+        {name}_bin rule - runs eslint with specified settings; ignores srcs. To use this rule
+            you must pass a folder to check, for example:
+            bazel run {name}_test -- --fix $(pwd)/polygerrit-ui/app
+    """
+    entry_point = "@npm//:node_modules/eslint/bin/eslint.js"
+    bin_data = [
+        "@npm//eslint:eslint",
+        config,
+        ignore,
+    ] + plugins + data
+    common_templated_args = [
+        "--ext",
+        ",".join(extensions),
+        "-c",
+        # Use rlocation/rootpath instead of location.
+        # See note and example here:
+        # https://bazelbuild.github.io/rules_nodejs/Built-ins.html#nodejs_binary
+        "$$(rlocation $(rootpath {}))".format(config),
+        "--ignore-path",
+        "$$(rlocation $(rootpath {}))".format(ignore),
+    ]
+    nodejs_test(
+        name = name + "_test",
+        entry_point = entry_point,
+        data = bin_data + srcs,
+        # Bazel generates 2 .js files, where names of the files are generated from the name
+        # of the rule: {name}_test_require_patch.js and {name}_test_loader.js
+        # Ignore these 2 files, for simplicity do not use {name} in the patterns.
+        templated_args = common_templated_args + [
+            "--ignore-pattern",
+            "*_test_require_patch.js",
+            "--ignore-pattern",
+            "*_test_loader.js",
+            native.package_name(),
+        ],
+        # Should not run sandboxed.
+        tags = [
+            "local",
+            "manual",
+        ],
+    )
+
+    nodejs_binary(
+        name = name + "_bin",
+        entry_point = "@npm//:node_modules/eslint/bin/eslint.js",
+        data = bin_data,
+        # Bazel generates 2 .js files, where names of the files are generated from the name
+        # of the rule: {name}_bin_require_patch.js and {name}_bin_loader.js
+        # Ignore these 2 files, for simplicity do not use {name} in the patterns.
+        templated_args = common_templated_args + [
+            "--ignore-pattern",
+            "*_bin_require_patch.js",
+            "--ignore-pattern",
+            "*_bin_loader.js",
+        ],
+    )
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 4a94ee3..8360c26 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.13-SNAPSHOT</version>
+  <version>3.2.8-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 099c2e6..8efe17d 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.13-SNAPSHOT</version>
+  <version>3.2.8-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 a4948a0..586a78a 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.13-SNAPSHOT</version>
+  <version>3.2.8-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 948056b..30eaeba 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.13-SNAPSHOT</version>
+  <version>3.2.8-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/node_tools/.gitignore b/tools/node_tools/.gitignore
new file mode 100644
index 0000000..2ccbe46
--- /dev/null
+++ b/tools/node_tools/.gitignore
@@ -0,0 +1 @@
+/node_modules/
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
new file mode 100644
index 0000000..4019542
--- /dev/null
+++ b/tools/node_tools/BUILD
@@ -0,0 +1,38 @@
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
+
+package(default_visibility = ["//visibility:public"])
+
+# By default, rollup_bundle rule uses rollup from @npm workspace
+# and it expects that all plugins are installed in the same workspace.
+# This rule defines another rollup-bin from @tools_npm workspace.
+# Usage: rollup_bundle(rollup_bin = "//tools/node_tools:rollup-bin, ...)
+nodejs_binary(
+    name = "rollup-bin",
+    # Define only minimal required dependencies.
+    # Otherwise remote build execution fails with the too many
+    # files error when it builds :release target.
+    data = [
+        "@tools_npm//rollup",
+        "@tools_npm//rollup-plugin-terser",
+    ],
+    # The entry point must be "@tools_npm:node_modules/rollup/dist/bin/rollup",
+    # But bazel doesn't run it correctly with the following command line:
+    # bazel test --test_env=GERRIT_NOTEDB=ON --spawn_strategy=standalone \
+    #    --genrule_strategy=standalone --test_output errors --test_summary detailed \
+    #    --flaky_test_attempts 3 --test_verbose_timeout_warnings --build_tests_only \
+    #    --subcommands //...
+    # This command line appears in Gerrit CI.
+    # For details, see comment in rollup-runner.js file
+    entry_point = "//tools/node_tools:rollup-runner.js",
+)
+
+# Create a tsc_wrapped compiler rule to use in the ts_library
+# compiler attribute when using self-managed dependencies
+nodejs_binary(
+    name = "tsc_wrapped-bin",
+    # Point bazel to your node_modules to find the entry point
+    data = ["@tools_npm//:node_modules"],
+    # It seems, bazel uses different approaches to compile ts files (it runs some
+    # ts service in background). It works without any workaround.
+    entry_point = "@tools_npm//:node_modules/@bazel/typescript/internal/tsc_wrapped/tsc_wrapped.js",
+)
diff --git a/tools/node_tools/legacy/BUILD b/tools/node_tools/legacy/BUILD
new file mode 100644
index 0000000..ed0946e
--- /dev/null
+++ b/tools/node_tools/legacy/BUILD
@@ -0,0 +1,15 @@
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
+
+package(default_visibility = ["//visibility:public"])
+
+nodejs_binary(
+    name = "polymer-bundler-bin",
+    data = ["@tools_npm//:node_modules"],
+    entry_point = "@tools_npm//:node_modules/polymer-bundler/lib/bin/polymer-bundler.js",
+)
+
+nodejs_binary(
+    name = "crisper-bin",
+    data = ["@tools_npm//:node_modules"],
+    entry_point = "@tools_npm//:node_modules/crisper/bin/crisper",
+)
diff --git a/tools/node_tools/legacy/index.bzl b/tools/node_tools/legacy/index.bzl
new file mode 100644
index 0000000..fe66bf8
--- /dev/null
+++ b/tools/node_tools/legacy/index.bzl
@@ -0,0 +1,66 @@
+""" File contains a wrapper for legacy polymer-bundler and crisper tools. """
+
+# File must be removed after get rid of HTML imports
+
+def _polymer_bundler_tool_impl(ctx):
+    """Wrapper for the polymer-bundler and crisper command-line tools"""
+
+    html_bundled_file = ctx.actions.declare_file(ctx.label.name + "_tmp.html")
+    ctx.actions.run(
+        executable = ctx.executable._bundler,
+        outputs = [html_bundled_file],
+        inputs = ctx.files.srcs,
+        arguments = [
+            "--inline-css",
+            "--sourcemaps",
+            "--strip-comments",
+            "--root",
+            ctx.file.entry_point.dirname,
+            "--out-file",
+            html_bundled_file.path,
+            "--in-file",
+            ctx.file.entry_point.basename,
+        ],
+    )
+
+    output_js_file = ctx.outputs.js
+    if ctx.attr.script_src_value:
+        output_js_file = ctx.actions.declare_file(ctx.attr.script_src_value, sibling = ctx.outputs.html)
+    script_src_value = ctx.attr.script_src_value if ctx.attr.script_src_value else ctx.outputs.js.path
+
+    ctx.actions.run(
+        executable = ctx.executable._crisper,
+        outputs = [ctx.outputs.html, output_js_file],
+        inputs = [html_bundled_file],
+        arguments = ["-s", html_bundled_file.path, "-h", ctx.outputs.html.path, "-j", output_js_file.path, "--always-write-script", "--script-in-head=false"],
+    )
+
+    if ctx.attr.script_src_value:
+        ctx.actions.expand_template(
+            template = output_js_file,
+            output = ctx.outputs.js,
+            substitutions = {},
+        )
+
+polymer_bundler_tool = rule(
+    implementation = _polymer_bundler_tool_impl,
+    attrs = {
+        "entry_point": attr.label(allow_single_file = True, mandatory = True),
+        "srcs": attr.label_list(allow_files = True),
+        "script_src_value": attr.string(),
+        "_bundler": attr.label(
+            default = ":polymer-bundler-bin",
+            executable = True,
+            cfg = "host",
+        ),
+        "_crisper": attr.label(
+            default = ":crisper-bin",
+            executable = True,
+            cfg = "host",
+        ),
+    },
+    outputs = {
+        "html": "%{name}.html",
+        "js": "%{name}.js",
+    },
+)
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
new file mode 100644
index 0000000..bd7e854
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -0,0 +1,61 @@
+load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
+load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+    name = "licenses-map",
+    srcs = glob(["*.ts"]),
+    compiler = "//tools/node_tools:tsc_wrapped-bin",
+    node_modules = "@tools_npm//:node_modules",
+    tsconfig = "tsconfig.json",
+    deps = [
+        "@tools_npm//@types/node",
+    ],
+)
+
+# rollup_bundle - workaround for https://github.com/bazelbuild/rules_nodejs/issues/1522
+# The ts_library rule ("license-map") transpiles each .ts file to .js file.
+# The "license-map" rule includes multiple .ts files and produces multiple output files.
+# The nodejs_binary requires only one file as an entry_point. It is expected, that other
+# .js files from ts_library are also available, but because of the bug they are not available.
+# As a workaround we are using rollup_bundle to group all files together.
+rollup_bundle(
+    name = "license-map-generator-bundle",
+    config_file = "rollup.config.js",
+    entry_point = "license-map-generator.ts",
+    format = "cjs",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    deps = [
+        ":licenses-map",
+        "@tools_npm//rollup",
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
+)
+
+nodejs_binary(
+    name = "license-map-generator-bin",
+    entry_point = "license-map-generator-bundle.js",
+)
+
+# (TODO)dmfilippov Find a better way to fix it (another workaround or submit a bug to
+# plugin's authors or to a ts_config rule author).
+# The following genrule is a workaround for a bazel intellij plugin's bug.
+# According to the documentation, the ts_config_rules section should be added
+# to a .bazelproject file if a project uses typescript
+# (https://ij.bazel.build/docs/dynamic-languages-typescript.html)
+# Unfortunately, this doesn't work. It seems, that the plugin expects some output from
+# the ts_config rule, but the rule doesn't produce any output.
+# To workaround the issue, the tsconfig_editor genrule was added. The genrule only copies
+# input file to the output file, but this is enough to make bazel plugins works.
+# So, if you have any problem a typescript editor (import errors, types not found, etc...) -
+# try to build this rule from the command line
+# (bazel build tools/node_tools/node_modules/licenses:tsconfig_editor) and then sync bazel project
+# in intellij.
+genrule(
+    name = "tsconfig_editor",
+    srcs = ["tsconfig.json"],
+    outs = ["tsconfig_editor.json"],
+    cmd = "cp $< $@",
+)
diff --git a/tools/node_tools/node_modules_licenses/README.md b/tools/node_tools/node_modules_licenses/README.md
new file mode 100644
index 0000000..b3258ee
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/README.md
@@ -0,0 +1,14 @@
+This directory contains a tool to produce json file with information about licenses for npm
+workspace.
+
+The tool consists of:
+* Command-line tool (entry point license-map-generator.ts) which gets as input a list of files in
+    a node_modules folder, licenses config and produces a json file with packages grouped by their
+    licenses.
+  If the tool finds a file with an inappropriate license, then it returns error.
+
+  Exact format for input and output data of the tool can be found in the source code.
+
+* Bazel rule node_modules_licenses (in the node_modules_licenses.bzl file), which wraps
+  command-line tool and allows to call it
+    during the build
diff --git a/tools/node_tools/node_modules_licenses/base-types.ts b/tools/node_tools/node_modules_licenses/base-types.ts
new file mode 100644
index 0000000..3c067d1
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/base-types.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Aliases to use in the code*/
+export type PackageName = string;
+export type PackageVersion = string;
+export type FilePath = string;
+export type DirPath = string;
+export type LicenseName = string;
+export type LicenseTypeName = string;
diff --git a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
new file mode 100644
index 0000000..3f4955e
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { PackageName, PackageVersion, DirPath, FilePath } from "./base-types";
+import {fail} from "./utils";
+import * as path from "path";
+import * as fs from "fs";
+
+/**
+ * Describe one installed package from node_modules
+ */
+export interface InstalledPackage {
+  /** Package name (it is calculated based on path to module) */
+  name: PackageName;
+  /** Package version from package.json */
+  version: PackageVersion;
+  /**
+   * Path to the top-level package directory, where package.json is placed
+   * This path is relative to process working directory (because bazel pass all paths relative to it)
+   */
+  rootPath: DirPath;
+  /** All files in package. Path is relative to rootPath */
+  files: FilePath[];
+}
+
+/**
+ * Calculates all installed packages from a list of files
+ * It is expected, that the addPackageJson method is called first for
+ * all package.json files first and then the addFile method is called for all files (including package.json)
+ */
+export class InsalledPackagesBuilder {
+  private readonly rootPathToPackageMap: Map<DirPath, InstalledPackage> = new Map();
+
+  public addPackageJson(packageJsonPath: string) {
+    const pack = this.createInstalledPackage(packageJsonPath);
+    this.rootPathToPackageMap.set(pack.rootPath, pack)
+  }
+  public addFile(file: string) {
+    const pack = this.findPackageForFile(file)!;
+    pack.files.push(path.relative(pack.rootPath, file));
+  }
+
+  /**
+   * Create new InstalledPackage.
+   *  The name of a package is a relative path to the closest node_modules parent.
+   * For example for the packageJsonFile='/a/node_modules/b/node_modules/d/e/package.json'
+   * the package name is 'd/e'
+   */
+  private createInstalledPackage(packageJsonFile: string): InstalledPackage {
+    const nameParts: Array<string> = [];
+    const rootPath = path.dirname(packageJsonFile);
+    let currentDir = rootPath;
+    while(currentDir != "") {
+      const partName = path.basename(currentDir);
+      if(partName === "node_modules") {
+        const version = JSON.parse(fs.readFileSync(packageJsonFile, {encoding: 'utf-8'}))["version"];
+        if(!version) {
+          fail(`Can't get version for ${packageJsonFile}`)
+        }
+        return {
+          name: nameParts.reverse().join("/"),
+          rootPath: rootPath,
+          version: version,
+          files: []
+        };
+      }
+      nameParts.push(partName);
+      currentDir = path.dirname(currentDir);
+    }
+    fail(`Can't create package info for '${packageJsonFile}'`)
+  }
+
+  private findPackageForFile(filePath: FilePath): InstalledPackage {
+    let currentDir = path.dirname(filePath);
+    while(currentDir != "") {
+      if(this.rootPathToPackageMap.has(currentDir)) {
+        return this.rootPathToPackageMap.get(currentDir)!;
+      }
+      currentDir = path.dirname(currentDir);
+    }
+    fail(`Can't find package for '${filePath}'`);
+  }
+
+  public build(): InstalledPackage[] {
+    return [...this.rootPathToPackageMap.values()];
+  }
+}
diff --git a/tools/node_tools/node_modules_licenses/license-map-generator.ts b/tools/node_tools/node_modules_licenses/license-map-generator.ts
new file mode 100644
index 0000000..c0c2315
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/license-map-generator.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This is a command-line tool to calculate licenses for files in npm packages.
+ * The tool expected the following inputs:
+ *
+ * config.js - information about licenses for packages. The js file must be a module
+ *   and must have array of {@link PackageInfo} as a default export.
+ *
+ * node-modules-files.txt - list of files (one per line) to calculate license.
+ *   Each line must be a path to a file inside node_module directory
+ *   (full path or path relative to current work dir)
+ *
+ * shared-licenses.txt - list of files (one per line) with licenses texts. Each line is a path
+ *   to a file with license text. The files can be referenced by {@link SharedFileLicenseInfo}
+ *
+ * json-output.json - output file name. The {@link LicensesMap} defines the schema of this file.
+ *
+ * Note: It is expected, that config is compiled from .ts file end has default export.
+ * Typescript compiler checks that .ts file matches PackageInfo interface, so no additional
+ * validations are needed.
+ */
+
+import * as path from "path";
+import * as fs from "fs";
+import {PackageInfo} from "./package-license-info";
+import {fail, readMultilineParamFile} from "./utils";
+import {LicenseMapGenerator} from "./licenses-map";
+import {SharedLicensesProvider} from "./shared-licenses-provider";
+
+interface LicenseMapCommandLineArgs {
+  generatorParams: LicenseMapGeneratorParameters;
+  outputJsonPath: string;
+}
+
+export interface LicenseMapGeneratorParameters {
+  sharedLicensesFiles: string[];
+  nodeModulesFiles: string[];
+  packages: PackageInfo[];
+}
+
+function parseArguments(argv: string[]): LicenseMapCommandLineArgs {
+  if(argv.length < 6) {
+    fail("Invalid command line parameters\n" +
+        "\tUsage:\n\tnode license-map-generator config.js node-modules-files.txt shared-licenses.txt json-output.json");
+  }
+  const packages: PackageInfo[] = require(path.join(process.cwd(), argv[2])).default;
+  const nodeModulesFiles = readMultilineParamFile(argv[3]);
+  const sharedLicensesFiles = readMultilineParamFile(argv[4]);
+
+  return {
+    generatorParams: {
+      packages,
+      nodeModulesFiles,
+      sharedLicensesFiles,
+    },
+    outputJsonPath: argv[5]
+  }
+}
+
+function main() {
+  const args = parseArguments(process.argv);
+  const generator = new LicenseMapGenerator(args.generatorParams.packages, new SharedLicensesProvider(args.generatorParams.sharedLicensesFiles));
+  const licenseMap = generator.generateMap(args.generatorParams.nodeModulesFiles);
+  // JSON is quite small, so there are no reasons to minify it.
+  // Write it as multiline file with tabs (spaces).
+  fs.writeFileSync(args.outputJsonPath, JSON.stringify(licenseMap, null, 2), "utf-8");
+}
+
+main();
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
new file mode 100644
index 0000000..9f277e5
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -0,0 +1,279 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as path from "path";
+import * as fs from "fs";
+import {isSharedFileLicenseInfo, LicenseInfo, PackageInfo} from "./package-license-info";
+import {LicenseName, PackageName, FilePath, PackageVersion} from "./base-types";
+import { InstalledPackage, InsalledPackagesBuilder } from "./installed-node-modules-map";
+import {SharedLicensesProvider} from "./shared-licenses-provider";
+import {fail} from "./utils";
+
+interface FilteredFiles {
+  installedPackage: InstalledPackage;
+  /** Path relative to installedPackage */
+  includedFiles: FilePath[];
+  /** Path relative to installedPackage */
+  excludedFiles: FilePath[];
+}
+interface PackageLicensedFiles {
+  packageInfo: PackageInfo;
+  filteredFiles: FilteredFiles;
+}
+
+enum LicensedFilesDisplayInfoType {
+  AllFiles = "AllFiles",
+  OnlySpecificFiles = "OnlySpecificFiles",
+  AllFilesExceptSpecific = "AllFilesExceptSpecific",
+}
+
+interface AllFiles {
+  kind: LicensedFilesDisplayInfoType.AllFiles;
+}
+
+interface AllFilesExceptSpecificFiles {
+  kind: LicensedFilesDisplayInfoType.AllFilesExceptSpecific;
+  /** Path relative to installedPackage */
+  files: FilePath[]
+}
+
+interface OnlySpecificFiles {
+  kind: LicensedFilesDisplayInfoType.OnlySpecificFiles;
+  /** Path relative to installedPackage */
+  files: FilePath[];
+}
+
+export type FilteredFilesDisplayInfo = AllFiles | AllFilesExceptSpecificFiles | OnlySpecificFiles;
+
+export interface LicenseMapPackageInfo {
+  name: PackageName;
+  version: PackageVersion;
+  licensedFiles: FilteredFilesDisplayInfo;
+}
+
+export interface LicenseMapItem {
+  licenseName: LicenseName,
+  licenseText: string;
+  packages: LicenseMapPackageInfo[];
+}
+
+export type LicensesMap = { [licenseName: string]:  LicenseMapItem }
+
+/** LicenseMapGenerator calculates license map for nodeModulesFiles.
+ */
+export class LicenseMapGenerator {
+  /**
+   *  packages - information about licenses for packages
+   *  sharedLicensesProvider - returns text for shared licenses by name
+   */
+  public constructor(private readonly packages: ReadonlyArray<PackageInfo>, private readonly sharedLicensesProvider: SharedLicensesProvider) {
+  }
+
+  /** generateMap calculates LicenseMap for nodeModulesFiles
+   * Each (key, value) pair in this map has the following information:
+   * The key is a license name.
+   * The value contains information about packages and files for which this license
+   * is applied.
+   *
+   * The method tries to provide information in a compact way:
+   * 1) If all files in the package have the same license, then it stores a name of the package
+   *    without list of files
+   * 2) If different files in the package has different licenses, then the method calculates
+   *    which list is shorter - list of included files or list of excluded files.
+   */
+  public generateMap(nodeModulesFiles: ReadonlyArray<string>): LicensesMap {
+    const installedPackages = this.getInstalledPackages(nodeModulesFiles);
+    const licensedFilesGroupedByLicense = this.getLicensedFilesGroupedByLicense(installedPackages);
+
+    const result: LicensesMap = {};
+    licensedFilesGroupedByLicense.forEach((packageLicensedFiles, licenseName) => {
+      result[licenseName] = this.getLicenseMapItem(licenseName, packageLicensedFiles);
+    });
+    return result;
+  }
+
+  private getLicenseMapItem(licenseName: string, packagesLicensedFiles: PackageLicensedFiles[]): LicenseMapItem {
+    const packages: LicenseMapPackageInfo[] = [];
+    let licenseText: string = "";
+    packagesLicensedFiles.forEach((packageLicensedFiles) => {
+      const packageLicenseText = this.getLicenseText(packageLicensedFiles);
+      if (licenseText.length !== 0 && packageLicenseText !== licenseText) {
+        fail(`There are different license texts for license '${licenseName}'.\n` +
+            "Probably, you have multiple version of the same package.\n" +
+            "In this case you must define different license name for each version"
+        );
+      }
+      licenseText = packageLicenseText;
+      packages.push({
+        name: packageLicensedFiles.packageInfo.name,
+        version: packageLicensedFiles.filteredFiles.installedPackage.version,
+        licensedFiles: this.getFilteredFilesDisplayInfo(packageLicensedFiles.filteredFiles)
+      });
+    });
+    return {
+      licenseName,
+      licenseText,
+      packages
+    };
+  }
+
+  /** getFilteredFilesDisplayInfo calculates the best method to display information about
+   * filteredFiles in filteredFiles.installedPackage
+   * In the current implementation - the best method is a method with a shorter list of files
+   *
+   * Each {@link PackageInfo} has a filter for files (by default all files
+   * are included). Applying filter to files from an installedPackage returns 2 lists of files:
+   * includedFiles (i.e. files with {@link PackageInfo.license} license) and excludedFiles
+   * (i.e. files which have different license(s)).
+   *
+   * A text representaion of license-map must have full information about per-file licenses if
+   * needed, but we want to produce files lists there as short as possible
+   */
+  private getFilteredFilesDisplayInfo(filteredFiles: FilteredFiles): FilteredFilesDisplayInfo {
+    if (filteredFiles.includedFiles.length > 0 && filteredFiles.excludedFiles.length === 0) {
+      /** All files from package are included (i.e. all files have the same license).
+       * It is enough to print only installedPackage name.
+       * */
+      return {
+        kind: LicensedFilesDisplayInfoType.AllFiles
+      }
+    } else if (filteredFiles.includedFiles.length <= filteredFiles.excludedFiles.length) {
+      /** After applying filter, the number of files with filteredFiles.license is less than
+       * the number of excluded files.
+       * It is more convenient to print information about license in the format:
+       * GIT license - fontPackage (only files A,B,C)*/
+      return {
+        kind: LicensedFilesDisplayInfoType.OnlySpecificFiles,
+        files: filteredFiles.includedFiles
+      };
+    } else {
+      /** Only few files from filteredFiles.installedPackage has filteredFiles.license.
+       * It is more convenient to print information about license in the format:
+       * Apache license - fontPackage (except files A,B,C)
+       */
+      return {
+        kind: LicensedFilesDisplayInfoType.AllFilesExceptSpecific,
+        files: filteredFiles.excludedFiles
+      }
+    }
+  }
+
+  private getLicensedFilesGroupedByLicense(installedPackages: InstalledPackage[]): Map<LicenseName, PackageLicensedFiles[]> {
+    const result: Map<LicenseName, PackageLicensedFiles[]> = new Map();
+    installedPackages.forEach(installedPackage => {
+      // It is possible that different files in package have different licenses.
+      // See the getPackageInfosForInstalledPackage method for details.
+      const packageInfos = this.findPackageInfosForInstalledPackage(installedPackage);
+      if(packageInfos.length === 0) {
+        fail(`License for package '${installedPackage.name}-${installedPackage.version}' was not found`);
+      }
+
+      const allPackageLicensedFiles: Set<string> = new Set();
+      packageInfos.forEach(packInfo => {
+          const licensedFiles = this.filterFilesByPackageInfo(installedPackage, packInfo);
+          if(licensedFiles.includedFiles.length === 0) {
+            return;
+          }
+          const license = packInfo.license;
+          if (!license.type.allowed) {
+            fail(`Polygerrit bundle use files with invalid licence ${license.name} from` +
+                ` the package ${installedPackage.name}`);
+          }
+          if(!result.has(license.name)) {
+            result.set(license.name, []);
+          }
+          result.get(license.name)!.push({
+            packageInfo: packInfo,
+            filteredFiles: licensedFiles
+          });
+          licensedFiles.includedFiles.forEach((fileName) => {
+            if(allPackageLicensedFiles.has(fileName)) {
+              fail(`File '${fileName}' from '${installedPackage.name}' has multiple licenses.`)
+            }
+            allPackageLicensedFiles.add(fileName);
+          });
+        });
+      if(allPackageLicensedFiles.size !== installedPackage.files.length) {
+        fail(`Some files from '${installedPackage.name}' doesn't have a license, but they are used during build`);
+      }
+      });
+    return result;
+  }
+
+  /** getInstalledPackages Collects information about all installed packages */
+  private getInstalledPackages(nodeModulesFiles: ReadonlyArray<string>): InstalledPackage[] {
+    const builder = new InsalledPackagesBuilder();
+    // Register all package.json files - such files exists in the root folder of each module
+    nodeModulesFiles.filter(f => path.basename(f) === "package.json")
+      .forEach(packageJsonFile => builder.addPackageJson(packageJsonFile));
+    // Iterate through all files. builder adds each file to appropriate package
+    nodeModulesFiles.forEach(f => builder.addFile(f));
+    return builder.build();
+  }
+
+  /**
+   * findPackageInfosForInstalledPackage finds all possible licenses for the installedPackage.
+   * It is possible, that different files in package have different licenses.
+   * For example, @polymer/font-roboto-local package has files with 2 different licenses
+   * - .js files - Polymer license (i.e. BSD),
+   * - font files - Apache 2.0 license
+   * In this case, the package is defined several times with different filesFilter
+   */
+  private findPackageInfosForInstalledPackage(installedPackage: InstalledPackage): PackageInfo[] {
+    return this.packages.filter(packInfo => {
+      if(packInfo.name !== installedPackage.name) {
+        return false;
+      }
+      return !packInfo.versions || packInfo.versions.indexOf(installedPackage.version) >= 0;
+    });
+  }
+
+  /** Returns list of files which have license defined in packageInfo */
+  private filterFilesByPackageInfo(installedPackage: InstalledPackage, packageInfo: PackageInfo): FilteredFiles {
+    const filter = packageInfo.filesFilter;
+    if(!filter) {
+      return {
+        installedPackage,
+        includedFiles: installedPackage.files,
+        excludedFiles: [],
+      }
+    }
+    return installedPackage.files.reduce((result: FilteredFiles, f) => {
+      if(filter(f)) {
+        result.includedFiles.push(f);
+      } else {
+        result.excludedFiles.push(f);
+      }
+      return result;
+    }, {
+      installedPackage,
+      includedFiles: [],
+      excludedFiles: []
+    });
+  }
+
+  private getLicenseText(packageInfo: PackageLicensedFiles) {
+    if(isSharedFileLicenseInfo(packageInfo.packageInfo.license)) {
+      return this.sharedLicensesProvider.getText(packageInfo.packageInfo.license.sharedLicenseFile);
+    } else {
+      const filePath = path.join(packageInfo.filteredFiles.installedPackage.rootPath,
+          packageInfo.packageInfo.license.packageLicenseFile)
+      return fs.readFileSync(filePath, {encoding: 'utf-8'});
+    }
+  }
+}
+
diff --git a/tools/node_tools/node_modules_licenses/node_modules_licenses.bzl b/tools/node_tools/node_modules_licenses/node_modules_licenses.bzl
new file mode 100644
index 0000000..64c8b79
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/node_modules_licenses.bzl
@@ -0,0 +1,42 @@
+"""This file contains the rule to generate a license map for node modules"""
+
+def _node_modules_licenses_impl(ctx):
+    """Wrapper for the license-map-generator command-line tool"""
+
+    node_modules_args = ctx.actions.args()
+    node_modules_args.add_all(ctx.files.node_modules)
+    node_modules_args.use_param_file("%s", use_always = True)
+
+    licenses_texts_args = ctx.actions.args()
+    licenses_texts_args.add_all(ctx.files.licenses_texts)
+    licenses_texts_args.use_param_file("%s", use_always = True)
+
+    ctx.actions.run(
+        executable = ctx.executable._license_map_generator,
+        arguments = [ctx.file.licenses_config.path, node_modules_args, licenses_texts_args, ctx.outputs.json.path],
+        outputs = [ctx.outputs.json],
+        inputs = depset([ctx.file.licenses_config] + ctx.files.licenses_texts, transitive = [ctx.attr.node_modules.files]),
+    )
+
+# Rule to run license-map-generator.ts
+# node_modules - label of npm workspace in the format @npm//:node_modules
+# The output contains information about licenses for the workspace.
+# licenses_texts is a list of shared licenses
+# For details - see comments in the
+# tools/node_tools/node_modules_licenses/license-map-generator.ts file
+node_modules_licenses = rule(
+    implementation = _node_modules_licenses_impl,
+    attrs = {
+        "node_modules": attr.label(mandatory = True),
+        "licenses_texts": attr.label_list(allow_files = True, mandatory = True),
+        "licenses_config": attr.label(allow_single_file = True, mandatory = True),
+        "_license_map_generator": attr.label(
+            default = Label("//tools/node_tools/node_modules_licenses:license-map-generator-bin"),
+            executable = True,
+            cfg = "host",
+        ),
+    },
+    outputs = {
+        "json": "%{name}.json",
+    },
+)
diff --git a/tools/node_tools/node_modules_licenses/package-license-info.ts b/tools/node_tools/node_modules_licenses/package-license-info.ts
new file mode 100644
index 0000000..c5cdb0f
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/package-license-info.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {LicenseName, LicenseTypeName, PackageName, FilePath} from "./base-types";
+
+export interface LicenseType {
+  /** Name of license type (GPL, MIT, etc...) - can appear in Documentation*/
+  name: LicenseTypeName;
+  /** If true, the file with this license type can be used.
+   *  If false, build shouldn't use this file */
+  allowed: boolean;
+}
+
+/** Type to describe a license*/
+export type LicenseInfo = SharedFileLicenseInfo | PackageFileLicenseInfo;
+
+interface LicenseInfoBase {
+  /** Name of license - can appear in Documentation*/
+  name: LicenseName;
+  /** Type of license */
+  type: LicenseType;
+}
+
+/** Use SharedFileLicenseInfo if license text must be obtained from a list of predefined texts */
+export interface SharedFileLicenseInfo extends LicenseInfoBase {
+  /** Name of the file with license text. The path to the file is not important, only the filename is used */
+  sharedLicenseFile: string;
+}
+
+/** Use PackageFileLicenseInfo if license text must be obtained from package's file */
+export interface PackageFileLicenseInfo extends LicenseInfoBase {
+  /** Relative path to a file inside package*/
+  packageLicenseFile: string;
+}
+
+export function isSharedFileLicenseInfo(licenseInfo: LicenseInfo): licenseInfo is SharedFileLicenseInfo {
+  return (licenseInfo as SharedFileLicenseInfo).sharedLicenseFile !== undefined;
+}
+
+export function isPackageFileLicenseInfo(licenseInfo: LicenseInfo): licenseInfo is PackageFileLicenseInfo {
+  return (licenseInfo as PackageFileLicenseInfo).packageLicenseFile !== undefined;
+}
+
+export type FilesFilter = (fileName: FilePath) => boolean;
+
+/** Describes license for a whole package or to some files inside package */
+export interface PackageInfo {
+  /** Package name, as it appears in dependencies proprty of package.json */
+  name: PackageName;
+  /** Information about license*/
+  license: LicenseInfo;
+  /** If versions are set, then license applies only to a specific versions */
+  versions?: string[];
+  /** Predicate to select files to apply license. */
+  filesFilter?: FilesFilter;
+}
diff --git a/tools/node_tools/node_modules_licenses/rollup.config.js b/tools/node_tools/node_modules_licenses/rollup.config.js
new file mode 100644
index 0000000..5b4a848
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/rollup.config.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export default {
+  external: ['fs', 'path']
+};
diff --git a/tools/node_tools/node_modules_licenses/shared-licenses-provider.ts b/tools/node_tools/node_modules_licenses/shared-licenses-provider.ts
new file mode 100644
index 0000000..3be85281
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/shared-licenses-provider.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as path from "path";
+import * as fs from "fs";
+import { fail } from "./utils";
+
+/** SharedLicensesProvider uses to get license's text
+ * when package itself doesn't have LICENSE file.
+ */
+export class SharedLicensesProvider {
+  private licenseNameToText: Map<string, string>;
+
+  /** constructor takes list of file paths with licenses texts.
+   * The path is used to load a license text,
+   * the filename (without dirname) is used as a license name.
+   * */
+  public constructor(sharedLicensesFiles: string[]) {
+    this.licenseNameToText = new Map();
+    sharedLicensesFiles.forEach(f => {
+      const licenseName = path.basename(f);
+      if(this.licenseNameToText.has(licenseName)) {
+        fail(`There are multiple files for the license's text '${licenseName}'`);
+      }
+      this.licenseNameToText.set(licenseName, fs.readFileSync(f, {encoding: 'utf-8'}));
+    });
+  }
+
+  public getText(licenseName: string): string {
+    if(!this.licenseNameToText.has(licenseName)) {
+      fail(`There are no text for license ${licenseName}`);
+    }
+    return this.licenseNameToText.get(licenseName)!;
+  }
+
+}
diff --git a/tools/node_tools/node_modules_licenses/tsconfig.json b/tools/node_tools/node_modules_licenses/tsconfig.json
new file mode 100644
index 0000000..2854857
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "target": "es6",
+    "module": "commonjs",
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "strict": true,
+    "moduleResolution": "node",
+    "outDir": "out",
+    "types": ["node"]
+  },
+  "include": ["*.ts"]
+}
diff --git a/tools/node_tools/node_modules_licenses/utils.ts b/tools/node_tools/node_modules_licenses/utils.ts
new file mode 100644
index 0000000..5f8e7b3
--- /dev/null
+++ b/tools/node_tools/node_modules_licenses/utils.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as fs from "fs";
+
+export function fail(message: string): never {
+  console.error(message);
+  process.exit(1);
+}
+
+export function readMultilineParamFile(path: string): string[] {
+  return fs.readFileSync(path, {encoding: 'utf-8'}).split(/\r?\n/).filter(f => f.length > 0);
+}
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
new file mode 100644
index 0000000..2af06b4
--- /dev/null
+++ b/tools/node_tools/package.json
@@ -0,0 +1,24 @@
+{
+  "name": "gerrit-build-tools",
+  "description": "Gerrit Build Tools",
+  "browser": false,
+  "dependencies": {
+    "@bazel/rollup": "^0.41.0",
+    "@bazel/typescript": "^1.0.1",
+    "@types/node": "^10.17.12",
+    "@types/parse5": "^4.0.0",
+    "@types/parse5-html-rewriting-stream": "^5.1.2",
+    "crisper": "^2.1.1",
+    "dom5": "^3.0.1",
+    "parse5-html-rewriting-stream": "^5.1.1",
+    "polymer-bundler": "^4.0.10",
+    "polymer-cli": "^1.9.11",
+    "rollup": "^1.27.5",
+    "rollup-plugin-node-resolve": "^5.2.0",
+    "rollup-plugin-terser": "^5.1.3",
+    "typescript": "^3.7.4"
+  },
+  "devDependencies": {},
+  "license": "Apache-2.0",
+  "private": true
+}
diff --git a/tools/node_tools/polygerrit_app_preprocessor/.gitignore b/tools/node_tools/polygerrit_app_preprocessor/.gitignore
new file mode 100644
index 0000000..6a3417b
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/.gitignore
@@ -0,0 +1 @@
+/out/
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
new file mode 100644
index 0000000..b031293
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/BUILD
@@ -0,0 +1,74 @@
+load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
+load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+    name = "preprocessor",
+    srcs = glob(["*.ts"]),
+    node_modules = "@tools_npm//:node_modules",
+    tsconfig = "tsconfig.json",
+    deps = [
+        "//tools/node_tools/utils",
+        "@tools_npm//:node_modules",
+    ],
+)
+
+#rollup_bundle - workaround for https://github.com/bazelbuild/rules_nodejs/issues/1522
+rollup_bundle(
+    name = "preprocessor-bundle",
+    config_file = "rollup.config.js",
+    entry_point = "preprocessor.ts",
+    format = "cjs",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    deps = [
+        ":preprocessor",
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
+)
+
+rollup_bundle(
+    name = "links-updater-bundle",
+    config_file = "rollup.config.js",
+    entry_point = "links-updater.ts",
+    format = "cjs",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    deps = [
+        ":preprocessor",
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
+)
+
+nodejs_binary(
+    name = "preprocessor-bin",
+    data = ["@tools_npm//:node_modules"],
+    entry_point = "preprocessor-bundle.js",
+)
+
+nodejs_binary(
+    name = "links-updater-bin",
+    data = ["@tools_npm//:node_modules"],
+    entry_point = "links-updater-bundle.js",
+)
+
+# TODO(dmfilippov): Find a better way to fix it (another workaround or submit a bug to
+# Bazel IJ plugin's) authors or to a ts_config rule author).
+# The following genrule is a workaround for a bazel intellij plugin's bug.
+# According to the documentation, the ts_config_rules section should be added
+# to a .bazelproject file if a project uses typescript
+# (https://ij.bazel.build/docs/dynamic-languages-typescript.html)
+# Unfortunately, this doesn't work. It seems, that the plugin expects some output from
+# the ts_config rule, but the rule doesn't produce any output.
+# To workaround the issue, the tsconfig_editor genrule was added. The genrule only copies
+# input file to the output file, but this is enough to make bazel IJ plugins works.
+# So, if you have any problem a typescript editor (import errors, types not found, etc...) -
+# try to build this rule from the command line
+# (bazel build tools/node_tools/node_modules/licenses:tsconfig_editor) and then sync bazel project
+# in intellij.
+genrule(
+    name = "tsconfig_editor",
+    srcs = ["tsconfig.json"],
+    outs = ["tsconfig_editor.json"],
+    cmd = "cp $< $@",
+)
diff --git a/tools/node_tools/polygerrit_app_preprocessor/README.md b/tools/node_tools/polygerrit_app_preprocessor/README.md
new file mode 100644
index 0000000..91f2a2b
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/README.md
@@ -0,0 +1,9 @@
+This directory contains bazel rules and CLI tools to preprocess HTML and JS files before bundling.
+
+There are 2 different tools here:
+* links-updater (and update_links rule) - updates link in HTML files.
+ Receives list of input and output files as well as a redirect.json file with information
+ about redirects.
+* preprocessor (and prepare_for_bundling rule) - split each HTML files to a pair of one HTML
+ and one JS files. The output HTML doesn't contain `<script>` tags and JS file contains
+  all scripts and imports from HTML file. For more details see source code.
diff --git a/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts b/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
new file mode 100644
index 0000000..24e445d
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as fs from "fs";
+import RewritingStream from "parse5-html-rewriting-stream";
+import * as dom5 from "dom5";
+import {HtmlFileUtils, RedirectsResolver} from "./utils";
+import {Node} from 'dom5';
+import {readMultilineParamFile} from "../utils/command-line";
+import { fail } from "../utils/common";
+import {JSONRedirects} from "./redirects";
+
+/** Update links in HTML file
+ * input_output_param_files - is a list of paths; each path is placed on a separate line
+ *   The first line is the path to a first input file (relative to process working directory)
+ *   The second line is the path to the output file  (relative to process working directory)
+ *   The next 2 lines describe the second file and so on.
+ * redirectFile.json describes how to update links (see {@link JSONRedirects} for exact format)
+ * Additionaly, update some test links (related to web-component-tester)
+ */
+
+async function main() {
+  if (process.argv.length < 4) {
+    console.info("Usage:\n\tnode links_updater.js input_output_param_files redirectFile.json\n");
+    process.exit(1);
+  }
+
+  const jsonRedirects: JSONRedirects = JSON.parse(fs.readFileSync(process.argv[3], {encoding: "utf-8"}));
+  const redirectsResolver = new RedirectsResolver(jsonRedirects.redirects);
+
+  const input = readMultilineParamFile(process.argv[2]);
+  const updater = new HtmlFileUpdater(redirectsResolver);
+  for(let i = 0; i < input.length; i += 2) {
+    const srcFile = input[i];
+    const targetFile = input[i + 1];
+    await updater.updateFile(srcFile, targetFile);
+  }
+}
+
+/** Update all links in HTML file based on redirects.
+ * Additionally, update references to web-component-tester */
+class HtmlFileUpdater {
+  private static readonly Predicates = {
+    isScriptWithSrcTag: (node: Node) => node.tagName === "script" && dom5.hasAttribute(node, "src"),
+
+    isWebComponentTesterImport: (node: Node) => HtmlFileUpdater.Predicates.isScriptWithSrcTag(node) &&
+        dom5.getAttribute(node, "src")!.endsWith("/bower_components/web-component-tester/browser.js"),
+
+    isHtmlImport: (node: Node) => node.tagName === "link" && dom5.getAttribute(node, "rel") === "import" &&
+        dom5.hasAttribute(node, "href")
+  };
+
+  public constructor(private readonly redirectsResolver: RedirectsResolver) {
+  }
+
+  public async updateFile(srcFile: string, targetFile: string) {
+    const html = fs.readFileSync(srcFile, "utf-8");
+    const readStream = fs.createReadStream(srcFile, {encoding: "utf-8"});
+    const rewriterOutput = srcFile === targetFile ? targetFile + ".tmp" : targetFile;
+    const writeStream = fs.createWriteStream(rewriterOutput, {encoding: "utf-8"});
+    const rewriter = new RewritingStream();
+    (rewriter as any).tokenizer.preprocessor.bufferWaterline = Infinity;
+    rewriter.on("startTag", (tag: any) => {
+      if (HtmlFileUpdater.Predicates.isWebComponentTesterImport(tag)) {
+        dom5.setAttribute(tag, "src", "/components/wct-browser-legacy/browser.js");
+      } else if (HtmlFileUpdater.Predicates.isHtmlImport(tag)) {
+        this.updateRefAttribute(tag, srcFile, "href");
+      } else if (HtmlFileUpdater.Predicates.isScriptWithSrcTag(tag)) {
+        this.updateRefAttribute(tag, srcFile, "src");
+      } else {
+        const location = tag.sourceCodeLocation;
+        const raw = html.substring(location.startOffset, location.endOffset);
+        rewriter.emitRaw(raw);
+        return;
+      }
+      rewriter.emitStartTag(tag);
+    });
+    return new Promise<void>((resolve, reject) => {
+      writeStream.on("close", () => {
+        writeStream.close();
+        if (rewriterOutput !== targetFile) {
+          fs.renameSync(rewriterOutput, targetFile);
+        }
+        resolve();
+      });
+      readStream.pipe(rewriter).pipe(writeStream);
+    });
+  }
+
+  private getResolvedPath(parentHtml: string, href: string) {
+    const originalPath = '/' + HtmlFileUtils.getPathRelativeToRoot(parentHtml, href);
+
+    const resolvedInfo = this.redirectsResolver.resolve(originalPath, true);
+    if (!resolvedInfo.insideNodeModules && resolvedInfo.target === originalPath) {
+      return href;
+    }
+    if (resolvedInfo.insideNodeModules) {
+      return '/node_modules/' + resolvedInfo.target;
+    }
+    if (href.startsWith('/')) {
+      return resolvedInfo.target;
+    }
+    return HtmlFileUtils.getPathRelativeToRoot(parentHtml, resolvedInfo.target);
+  }
+
+  private updateRefAttribute(node: Node, parentHtml: string, attributeName: string) {
+    const ref = dom5.getAttribute(node, attributeName);
+    if (!ref) {
+      fail(`Internal error - ${node} in ${parentHtml} doesn't have attribute ${attributeName}`);
+    }
+    const newRef = this.getResolvedPath(parentHtml, ref);
+    if (newRef === ref) {
+      return;
+    }
+    dom5.setAttribute(node, attributeName, newRef);
+  }
+}
+
+main();
diff --git a/tools/node_tools/polygerrit_app_preprocessor/preprocessor.ts b/tools/node_tools/polygerrit_app_preprocessor/preprocessor.ts
new file mode 100644
index 0000000..a886373
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/preprocessor.ts
@@ -0,0 +1,352 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as fs from "fs";
+import * as parse5 from "parse5";
+import * as dom5 from "dom5";
+import * as path from "path";
+import {Node} from 'dom5';
+import {fail, unexpectedSwitchValue} from "../utils/common";
+import {readMultilineParamFile} from "../utils/command-line";
+import {
+  HtmlSrcFilePath,
+  JsSrcFilePath,
+  HtmlTargetFilePath,
+  JsTargetFilePath,
+  FileUtils,
+  FilePath
+} from "../utils/file-utils";
+import {
+  AbsoluteWebPath,
+  getRelativeImport,
+  NodeModuleImportPath,
+  SrcWebSite
+} from "../utils/web-site-utils";
+
+/**
+ * Update source code by moving all scripts out of HTML files.
+ * Input:
+ *   input_output_html_param_file - list of file paths, each file path on a separate line
+ *      The first 3 line contains the path to the first input HTML file and 2 output paths
+ *         (for HTML and JS files)
+ *      The second 3 line contains paths for the second HTML file, and so on.
+ *
+ *   input_output_js_param_file - similar to input_output_html_param_file, but has only 2 lines
+ *      per file (input JS file and output JS file)
+ *
+ *   input_web_root_path - path (in filesystem) which should be treated as a web-site root path.
+
+ *    For each HTML file it creates 2 output files - HTML and JS file.
+ *      HTML file contains everything from HTML input file, except <script> tags.
+ *      JS file contains (in the same order, as in original HTML):
+ *      - inline javascript code from HTML file
+ *      - each <script src = "path/to/file.js" > from HTML is converted to
+ *           import 'path/to/output/file.js'
+ *        statement. Such import statement run all side-effects in file.js (i.e. it run all    #
+ *        global code).
+ *      - each <link rel="import" href = "path/to/file.html"> adds to .js file as
+ *           import 'path/to/output/file.html.js
+ *        i.e. instead of html, the .js script imports
+ *    Because output JS keeps the order of imports, all global variables are
+ *    initialized in a correct order (this is important for gerrit; it is impossible to use
+ *    AMD modules here).
+ */
+
+enum RefType {
+  Html,
+  InlineJS,
+  JSFile
+}
+
+type LinkOrScript = HtmlFileRef | HtmlFileNodeModuleRef | JsFileReference | JsFileNodeModuleReference | InlineJS;
+
+interface HtmlFileRef {
+  type: RefType.Html,
+  path: HtmlSrcFilePath;
+  isNodeModule: false;
+}
+
+interface HtmlFileNodeModuleRef {
+  type: RefType.Html,
+  path: NodeModuleImportPath;
+  isNodeModule: true;
+}
+
+
+function isHtmlFileRef(ref: LinkOrScript): ref is HtmlFileRef {
+  return ref.type === RefType.Html;
+}
+
+interface JsFileReference {
+  type: RefType.JSFile,
+  path: JsSrcFilePath;
+  isModule: boolean;
+  isNodeModule: false;
+}
+
+interface JsFileNodeModuleReference {
+  type: RefType.JSFile,
+  path: NodeModuleImportPath;
+  isModule: boolean;
+  isNodeModule: true;
+}
+
+interface InlineJS {
+  type: RefType.InlineJS,
+  isModule: boolean;
+  content: string;
+}
+
+interface HtmlOutputs {
+  html: HtmlTargetFilePath;
+  js: JsTargetFilePath;
+}
+
+interface JsOutputs {
+  js: JsTargetFilePath;
+}
+
+type HtmlSrcToOutputMap = Map<HtmlSrcFilePath, HtmlOutputs>;
+type JsSrcToOutputMap = Map<JsSrcFilePath, JsOutputs>;
+
+interface HtmlFileInfo {
+  src: HtmlSrcFilePath;
+  ast: parse5.AST.Document;
+  linksAndScripts: LinkOrScript[]
+}
+
+/** HtmlScriptAndLinksCollector walks through HTML file and collect
+ * all links and inline scripts.
+ */
+class HtmlScriptAndLinksCollector {
+  public constructor(private readonly webSite: SrcWebSite) {
+  }
+  public collect(src: HtmlSrcFilePath): HtmlFileInfo {
+    const ast = HtmlScriptAndLinksCollector.getAst(src);
+    const isHtmlImport = (node: Node) => node.tagName == "link" &&
+        dom5.getAttribute(node, "rel") == "import";
+    const isScriptTag = (node: Node) => node.tagName == "script";
+
+    const linksAndScripts: LinkOrScript[] = dom5
+      .nodeWalkAll(ast as Node, (node) => isHtmlImport(node) || isScriptTag(node))
+      .map((node) => {
+        if (isHtmlImport(node)) {
+          const href = dom5.getAttribute(node, "href");
+          if (!href) {
+            fail(`Tag <link rel="import...> in the file '${src}' doesn't have href attribute`);
+          }
+          if(this.webSite.isNodeModuleReference(href)) {
+            return {
+              type: RefType.Html,
+              path: this.webSite.getNodeModuleImport(href),
+              isNodeModule: true,
+            }
+          } else {
+            return {
+              type: RefType.Html,
+              path: this.webSite.resolveHtmlImport(src, href),
+              isNodeModule: false,
+            }
+          }
+        } else {
+          const isModule = dom5.getAttribute(node, "type") === "module";
+          if (dom5.hasAttribute(node, "src")) {
+            let srcPath = dom5.getAttribute(node, "src")!;
+            if(this.webSite.isNodeModuleReference(srcPath)) {
+              return {
+                type: RefType.JSFile,
+                isModule: isModule,
+                path: this.webSite.getNodeModuleImport(srcPath),
+                isNodeModule: true
+              };
+            } else {
+              return {
+                type: RefType.JSFile,
+                isModule: isModule,
+                path: this.webSite.resolveScriptSrc(src, srcPath),
+                isNodeModule: false
+              };
+            }
+          }
+          return {
+            type: RefType.InlineJS,
+            isModule: isModule,
+            content: dom5.getTextContent(node)
+          };
+        }
+      });
+    return {
+      src,
+      ast,
+      linksAndScripts
+    };
+  };
+
+  private static getAst(file: string): parse5.AST.Document {
+    const html = fs.readFileSync(file, "utf-8");
+    return parse5.parse(html, {locationInfo: true});
+  }
+
+}
+
+/** Generate js files */
+class ScriptGenerator {
+  public constructor(private readonly pathMapper: SrcToTargetPathMapper) {
+  }
+  public generateFromJs(src: JsSrcFilePath) {
+    FileUtils.copyFile(src, this.pathMapper.getJsTargetForJs(src));
+  }
+
+  public generateFromHtml(html: HtmlFileInfo) {
+    const content: string[] = [];
+    const src = html.src;
+    const targetJsFile: JsTargetFilePath = this.pathMapper.getJsTargetForHtml(src);
+    html.linksAndScripts.forEach((linkOrScript) => {
+      switch (linkOrScript.type) {
+        case RefType.Html:
+          if(linkOrScript.isNodeModule) {
+            const importPath = this.pathMapper.getJsTargetForHtmlInNodeModule(linkOrScript.path)
+            content.push(`import '${importPath}';`);
+          } else {
+            const importPath = this.pathMapper.getJsTargetForHtml(linkOrScript.path);
+            const htmlRelativePath = getRelativeImport(targetJsFile, importPath);
+            content.push(`import '${htmlRelativePath}';`);
+          }
+          break;
+        case RefType.JSFile:
+          if(linkOrScript.isNodeModule) {
+            content.push(`import '${linkOrScript.path}'`);
+          } else {
+            const importFromJs = this.pathMapper.getJsTargetForJs(linkOrScript.path);
+            const scriptRelativePath = getRelativeImport(targetJsFile, importFromJs);
+            content.push(`import '${scriptRelativePath}';`);
+          }
+          break;
+        case RefType.InlineJS:
+          content.push(linkOrScript.content);
+          break;
+        default:
+          unexpectedSwitchValue(linkOrScript);
+      }
+    });
+    FileUtils.writeContent(targetJsFile, content.join("\n"));
+  }
+}
+
+/** Generate html files*/
+class HtmlGenerator {
+  constructor(private readonly pathMapper: SrcToTargetPathMapper) {
+  }
+  public generateFromHtml(html: HtmlFileInfo) {
+    const ast = html.ast;
+    dom5.nodeWalkAll(ast as Node, (node) => node.tagName === "script")
+      .forEach((scriptNode) => dom5.remove(scriptNode));
+    const newContent = parse5.serialize(ast);
+    if(newContent.indexOf("<script") >= 0) {
+      fail(`Has content ${html.src}`);
+    }
+    FileUtils.writeContent(this.pathMapper.getHtmlTargetForHtml(html.src), newContent);
+  }
+}
+
+function readHtmlSrcToTargetMap(paramFile: string): HtmlSrcToOutputMap {
+  const htmlSrcToTarget: HtmlSrcToOutputMap = new Map();
+  const input = readMultilineParamFile(paramFile);
+  for(let i = 0; i < input.length; i += 3) {
+    const srcHtmlFile = path.resolve(input[i]) as HtmlSrcFilePath;
+    const targetHtmlFile = path.resolve(input[i + 1]) as HtmlTargetFilePath;
+    const targetJsFile = path.resolve(input[i + 2]) as JsTargetFilePath;
+    htmlSrcToTarget.set(srcHtmlFile, {
+      html: targetHtmlFile,
+      js: targetJsFile
+    });
+  }
+  return htmlSrcToTarget;
+}
+
+function readJsSrcToTargetMap(paramFile: string): JsSrcToOutputMap {
+  const jsSrcToTarget: JsSrcToOutputMap = new Map();
+  const input = readMultilineParamFile(paramFile);
+  for(let i = 0; i < input.length; i += 2) {
+    const srcJsFile = path.resolve(input[i]) as JsSrcFilePath;
+    const targetJsFile = path.resolve(input[i + 1]) as JsTargetFilePath;
+    jsSrcToTarget.set(srcJsFile as JsSrcFilePath, {
+      js: targetJsFile as JsTargetFilePath
+    });
+  }
+  return jsSrcToTarget;
+}
+
+class SrcToTargetPathMapper {
+  public constructor(
+      private readonly htmlSrcToTarget: HtmlSrcToOutputMap,
+      private readonly jsSrcToTarget: JsSrcToOutputMap) {
+  }
+  public getJsTargetForHtmlInNodeModule(file: NodeModuleImportPath): JsTargetFilePath {
+    return `${file}_gen.js` as JsTargetFilePath;
+  }
+
+  public getJsTargetForHtml(html: HtmlSrcFilePath): JsTargetFilePath {
+    return this.getHtmlOutputs(html).js;
+  }
+  public getHtmlTargetForHtml(html: HtmlSrcFilePath): HtmlTargetFilePath {
+    return this.getHtmlOutputs(html).html;
+  }
+  public getJsTargetForJs(js: JsSrcFilePath): JsTargetFilePath {
+    return this.getJsOutputs(js).js;
+  }
+
+  private getHtmlOutputs(html: HtmlSrcFilePath): HtmlOutputs {
+    if(!this.htmlSrcToTarget.has(html)) {
+      fail(`There are no outputs for the file '${html}'`);
+    }
+    return this.htmlSrcToTarget.get(html)!;
+  }
+  private getJsOutputs(js: JsSrcFilePath): JsOutputs {
+    if(!this.jsSrcToTarget.has(js)) {
+      fail(`There are no outputs for the file '${js}'`);
+    }
+    return this.jsSrcToTarget.get(js)!;
+  }
+}
+
+function main() {
+  if(process.argv.length < 5) {
+    const execFileName = path.basename(__filename);
+    fail(`Usage:\nnode ${execFileName} input_web_root_path input_output_html_param_file input_output_js_param_file\n`);
+  }
+
+  const srcWebSite = new SrcWebSite(path.resolve(process.argv[2]) as FilePath);
+  const htmlSrcToTarget: HtmlSrcToOutputMap = readHtmlSrcToTargetMap(process.argv[3]);
+  const jsSrcToTarget: JsSrcToOutputMap = readJsSrcToTargetMap(process.argv[4]);
+  const pathMapper = new SrcToTargetPathMapper(htmlSrcToTarget, jsSrcToTarget);
+
+  const scriptGenerator = new ScriptGenerator(pathMapper);
+  const htmlGenerator = new HtmlGenerator(pathMapper);
+  const scriptAndLinksCollector = new HtmlScriptAndLinksCollector(srcWebSite);
+
+  htmlSrcToTarget.forEach((targets, src) => {
+    const htmlFileInfo = scriptAndLinksCollector.collect(src);
+    scriptGenerator.generateFromHtml(htmlFileInfo);
+    htmlGenerator.generateFromHtml(htmlFileInfo);
+  });
+  jsSrcToTarget.forEach((targets, src) => {
+    scriptGenerator.generateFromJs(src);
+  });
+}
+
+main();
diff --git a/tools/node_tools/polygerrit_app_preprocessor/redirects.ts b/tools/node_tools/polygerrit_app_preprocessor/redirects.ts
new file mode 100644
index 0000000..0ccd78f
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/redirects.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** redirects.json schema*/
+export interface JSONRedirects {
+  /** Short text description. Do not used anywhere*/
+  description?: string;
+  /** List of redirects, from the highest to lower priority. */
+  redirects: Redirect[];
+}
+
+/** Redirect - describes one redirect.
+ * Each link in the html file is converted to a path relative to site root
+ * Redirect is applied, if converted link started with 'from'
+ * */
+export interface Redirect {
+  /** from - path prefix. The '/' is added to the end of string if not present */
+  from: string;
+  /** New location - can be either other directory or node module*/
+  to: PathRedirect;
+}
+
+export type PathRedirect = RedirectToDir | RedirectToNodeModule;
+
+/** RedirectToDir - use another dir instead of original one*/
+export interface RedirectToDir {
+  /** New dir (relative to site root)*/
+  dir: string;
+  /** Redirects for files inside directory
+   * Key is the original relative path, value is the new relative path (relative to new dir) */
+  files?: { [name: string]: string }
+}
+
+export interface RedirectToNodeModule {
+  /** Import from this node module instead of directory*/
+  npm_module: string;
+  /** Redirects for files inside node module
+   * Key is the original relative path, value is the new relative path (relative to npm_module) */
+  files?: { [name: string]: string }
+}
+
+export function isRedirectToNodeModule(redirect: PathRedirect): redirect is RedirectToNodeModule {
+  return (redirect as RedirectToNodeModule).npm_module !== undefined;
+}
+
+export function isRedirectToDir(redirect: PathRedirect): redirect is RedirectToDir {
+  return (redirect as RedirectToDir).dir !== undefined;
+}
diff --git a/tools/node_tools/polygerrit_app_preprocessor/rollup.config.js b/tools/node_tools/polygerrit_app_preprocessor/rollup.config.js
new file mode 100644
index 0000000..7109bde
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/rollup.config.js
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+export default {
+  external: ['fs', 'path', 'parse5', 'dom5', 'parse5-html-rewriting-stream'],
+  onwarn: warn => {
+    // Typescript adds helper methods for await and promise which look like
+    // var __awaiter = (this && this.__awaiter)
+    // It is safe to ignore this warning
+    if(warn.code === 'THIS_IS_UNDEFINED') { return; }
+    throw new Error(warn.message);
+  }
+};
diff --git a/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json b/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json
new file mode 100644
index 0000000..34ffb2f
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "compilerOptions": {
+    "target": "es6",
+    "module": "commonjs",
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "strict": true,
+    "moduleResolution": "node",
+    "outDir": "out"
+  },
+  "include": ["*.ts"]
+}
diff --git a/tools/node_tools/polygerrit_app_preprocessor/utils.ts b/tools/node_tools/polygerrit_app_preprocessor/utils.ts
new file mode 100644
index 0000000..4163d26
--- /dev/null
+++ b/tools/node_tools/polygerrit_app_preprocessor/utils.ts
@@ -0,0 +1,111 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as fs from "fs";
+import * as path from "path";
+import {FileUtils} from "../utils/file-utils";
+import {
+  Redirect,
+  isRedirectToNodeModule,
+  isRedirectToDir,
+  RedirectToNodeModule,
+  PathRedirect
+} from "./redirects";
+
+export class HtmlFileUtils {
+  public static getPathRelativeToRoot(parentHtml: string, fileHref: string): string {
+    if (fileHref.startsWith('/')) {
+      return fileHref.substring(1);
+    }
+    return path.join(path.dirname(parentHtml), fileHref);
+  }
+
+  public static getImportPathRelativeToParent(rootDir: string, parentFile: string, importPath: string) {
+    if (importPath.startsWith('/')) {
+      importPath = importPath.substr(1);
+    }
+    const parentDir = path.dirname(
+        path.resolve(path.join(rootDir, parentFile)));
+    const fullImportPath = path.resolve(path.join(rootDir, importPath));
+    const relativePath = path.relative(parentDir, fullImportPath);
+    return relativePath.startsWith('../') ?
+        relativePath : "./" + relativePath;
+  }
+}
+interface RedirectForFile {
+  to: PathRedirect;
+  pathToFile: string;
+}
+
+interface ResolvedPath {
+  target: string;
+  insideNodeModules: boolean;
+}
+
+/** RedirectsResolver based on the list of redirects, calculates
+ *  new import path
+ */
+export class RedirectsResolver {
+  public constructor(private readonly redirects: Redirect[]) {
+  }
+
+  /** resolve returns new path instead of pathRelativeToRoot; */
+  public resolve(pathRelativeToRoot: string, resolveNodeModules: boolean): ResolvedPath {
+    const redirect = this.findRedirect(pathRelativeToRoot);
+    if (!redirect) {
+      return {target: pathRelativeToRoot, insideNodeModules: false};
+    }
+    if (isRedirectToNodeModule(redirect.to)) {
+      return {
+        target: resolveNodeModules ? RedirectsResolver.resolveNodeModuleFile(redirect.to,
+            redirect.pathToFile) : pathRelativeToRoot,
+        insideNodeModules: resolveNodeModules
+      };
+    }
+    if (isRedirectToDir(redirect.to)) {
+      let newDir = redirect.to.dir;
+      if (!newDir.endsWith('/')) {
+        newDir = newDir + '/';
+      }
+      return {target: `${newDir}${redirect.pathToFile}`, insideNodeModules: false}
+    }
+    throw new Error(`Invalid redirect for path: ${pathRelativeToRoot}`);
+  }
+
+  private static resolveNodeModuleFile(npmRedirect: RedirectToNodeModule, pathToFile: string): string {
+    if(npmRedirect.files && npmRedirect.files[pathToFile]) {
+      pathToFile = npmRedirect.files[pathToFile];
+    }
+    return `${npmRedirect.npm_module}/${pathToFile}`;
+  }
+
+  private findRedirect(relativePathToRoot: string): RedirectForFile | undefined {
+    if(!relativePathToRoot.startsWith('/')) {
+      relativePathToRoot = '/' + relativePathToRoot;
+    }
+    for(const redirect of this.redirects) {
+      const normalizedFrom = redirect.from + (redirect.from.endsWith('/') ? '' : '/');
+      if(relativePathToRoot.startsWith(normalizedFrom)) {
+        return {
+          to: redirect.to,
+          pathToFile: relativePathToRoot.substring(normalizedFrom.length)
+        };
+      }
+    }
+    return undefined;
+  }
+}
diff --git a/tools/node_tools/rollup-runner.js b/tools/node_tools/rollup-runner.js
new file mode 100644
index 0000000..a8a09f9
--- /dev/null
+++ b/tools/node_tools/rollup-runner.js
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// This file is a part of workaround for
+// https://github.com/bazelbuild/rules_nodejs/issues/1575
+// rollup_bundle doesn't run when it is placed
+// in nested node_modules folder and bazel runs with
+// --spawn_strategy local flag. The error happens
+// because npm_package_bin generates incorrect path to
+// rollup bin.
+// But npm_package_bin works correctly if entry point
+// is an ordinary .js file which depends on some node module.
+// This script is a proxy script, which run rollup bin from
+// node_modules folder and pass all parameters to it.
+const {spawnSync} = require('child_process');
+const path = require('path');
+
+const nodePath = process.argv[0];
+const scriptArgs = process.argv.slice(2);
+const nodeArgs = process.execArgv;
+
+const pathToBin = require.resolve("rollup/dist/bin/rollup");
+
+const options = {
+  stdio: 'inherit'
+};
+
+const spawnResult = spawnSync(nodePath, [...nodeArgs, pathToBin, ...scriptArgs], options);
+
+if(spawnResult.status !== null) {
+  process.exit(spawnResult.status);
+}
+
+if(spawnResult.error) {
+  console.error(spawnResult.error);
+  process.exit(1);
+}
diff --git a/tools/node_tools/utils/BUILD b/tools/node_tools/utils/BUILD
new file mode 100644
index 0000000..fca3c12
--- /dev/null
+++ b/tools/node_tools/utils/BUILD
@@ -0,0 +1,13 @@
+load("@npm_bazel_typescript//:index.bzl", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+    name = "utils",
+    srcs = glob(["*.ts"]),
+    node_modules = "@tools_npm//:node_modules",
+    tsconfig = "tsconfig.json",
+    deps = [
+        "@tools_npm//:node_modules",
+    ],
+)
diff --git a/tools/node_tools/utils/command-line.ts b/tools/node_tools/utils/command-line.ts
new file mode 100644
index 0000000..48e3c87
--- /dev/null
+++ b/tools/node_tools/utils/command-line.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as fs from "fs";
+
+export function readMultilineParamFile(path: string): string[] {
+  return fs.readFileSync(path, {encoding: 'utf-8'}).split(/\r?\n/).filter(f => f.length > 0);
+}
diff --git a/tools/node_tools/utils/common.ts b/tools/node_tools/utils/common.ts
new file mode 100644
index 0000000..9b976ba
--- /dev/null
+++ b/tools/node_tools/utils/common.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export function fail(message: string): never {
+  console.error(message);
+  process.exit(1);
+}
+
+export function unexpectedSwitchValue(_: never): never {
+  fail(`Internal error - unexpected switch value`);
+}
diff --git a/tools/node_tools/utils/file-utils.ts b/tools/node_tools/utils/file-utils.ts
new file mode 100644
index 0000000..d8e1581
--- /dev/null
+++ b/tools/node_tools/utils/file-utils.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as path from "path";
+import * as fs from "fs";
+
+export type FilePath = string & {__filePath: undefined};
+export type TypedFilePath<T> = FilePath & { __type?: T, __typedFilePath: undefined };
+
+export enum FileType{
+  HtmlSrc,
+  HtmlTarget,
+  JsSrc,
+  JsTarget
+}
+
+export type HtmlSrcFilePath = TypedFilePath<FileType.HtmlSrc>;
+export type HtmlTargetFilePath = TypedFilePath<FileType.HtmlTarget>;
+export type JsSrcFilePath = TypedFilePath<FileType.JsSrc>;
+export type JsTargetFilePath = TypedFilePath<FileType.JsTarget>;
+
+export class FileUtils {
+  public static ensureDirExistsForFile(filePath: string) {
+    const dirName = path.dirname(filePath);
+    if (!fs.existsSync(dirName)) {
+      fs.mkdirSync(dirName, {recursive: true, mode: 0o744});
+    }
+  }
+
+  public static writeContent(file: string, content: string) {
+    if(fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) {
+      throw new Error(`Output file '${file}' is a symbolic link. Inplace update for links are not supported.`);
+    }
+    FileUtils.ensureDirExistsForFile(file);
+    fs.writeFileSync(file, content);
+  }
+
+  public static copyFile(src: string, dst: string) {
+    FileUtils.ensureDirExistsForFile(dst);
+    fs.copyFileSync(src, dst);
+  }
+}
diff --git a/tools/node_tools/utils/tsconfig.json b/tools/node_tools/utils/tsconfig.json
new file mode 100644
index 0000000..34ffb2f
--- /dev/null
+++ b/tools/node_tools/utils/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "compilerOptions": {
+    "target": "es6",
+    "module": "commonjs",
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "strict": true,
+    "moduleResolution": "node",
+    "outDir": "out"
+  },
+  "include": ["*.ts"]
+}
diff --git a/tools/node_tools/utils/web-site-utils.ts b/tools/node_tools/utils/web-site-utils.ts
new file mode 100644
index 0000000..eb30ce4
--- /dev/null
+++ b/tools/node_tools/utils/web-site-utils.ts
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as path from "path";
+import {fail} from "./common";
+import {FilePath, HtmlSrcFilePath, JsSrcFilePath} from "./file-utils";
+
+export type AbsoluteWebPath = string & { __absoluteWebPath: undefined };
+export type RelativeWebPath = string & { __relativeWebPath: undefined };
+export type WebPath = AbsoluteWebPath | RelativeWebPath;
+
+export type NodeModuleImportPath = string & {__nodeModuleImportPath: undefined};
+
+export type AbsoluteTypedWebPath<T> = AbsoluteWebPath & { __type?: T, __absoluteTypedFilePath: undefined };
+export type RelativeTypedWebPath<T> = RelativeWebPath & { __type?: T, __relativeTypedFilePath: undefined };
+
+export type TypedWebPath<T> = AbsoluteTypedWebPath<T> | RelativeTypedWebPath<T>;
+
+export function isAbsoluteWebPath(path: WebPath): path is AbsoluteWebPath {
+  return path.startsWith("/");
+}
+
+export function isRelativeWebPath(path: WebPath): path is RelativeWebPath {
+  return !isAbsoluteWebPath(path);
+}
+const node_modules_path_prefix = "/node_modules/";
+
+/** Contains method to resolve absolute and relative paths */
+export class SrcWebSite {
+  public constructor(private readonly webSiteRoot: FilePath) {
+  }
+
+  public getFilePath(webPath: AbsoluteWebPath): FilePath {
+    return path.resolve(this.webSiteRoot, webPath.substr(1)) as FilePath;
+  }
+
+  public getAbsoluteWebPathToFile(file: FilePath): AbsoluteWebPath {
+    const relativePath = path.relative(this.webSiteRoot, file);
+    if(relativePath.startsWith("..")) {
+      fail(`The file ${file} is not under webSiteRoot`);
+    }
+    return ("/" + relativePath) as AbsoluteWebPath;
+  }
+
+  public static resolveReference(from: AbsoluteWebPath, to: WebPath): AbsoluteWebPath {
+    return isAbsoluteWebPath(to) ? to : path.resolve(from, to) as AbsoluteWebPath;
+  }
+
+  public static getRelativePath(from: AbsoluteWebPath, to: AbsoluteWebPath): RelativeWebPath {
+    return path.relative(from, to) as RelativeWebPath;
+  }
+
+  public resolveHtmlImport(from: HtmlSrcFilePath, href: string): HtmlSrcFilePath {
+    return this.resolveReferenceToAbsPath(from, href) as HtmlSrcFilePath;
+
+  }
+  public resolveScriptSrc(from: HtmlSrcFilePath, src: string): JsSrcFilePath {
+    return this.resolveReferenceToAbsPath(from, src) as JsSrcFilePath;
+  }
+
+  public isNodeModuleReference(ref: string): boolean {
+    return ref.startsWith(node_modules_path_prefix);
+  }
+
+  public getNodeModuleImport(ref: string): NodeModuleImportPath {
+    if(!this.isNodeModuleReference(ref)) {
+      fail(`Internal error! ${ref} must be inside node modules`);
+    }
+    return ref.substr(node_modules_path_prefix.length) as NodeModuleImportPath;
+  }
+
+  private resolveReferenceToAbsPath(from: string, ref: string): string {
+    if(ref.startsWith("/")) {
+      const relativeToRootPath = ref.substr(1);
+      return path.resolve(this.webSiteRoot, relativeToRootPath);
+    }
+    return path.resolve(path.dirname(from), ref);
+  }
+}
+
+export function getRelativeImport(from: FilePath, ref: FilePath) {
+  const relativePath = path.relative(path.dirname(from), ref);
+  if(relativePath.startsWith("../")) {
+    return relativePath
+  } else {
+    return "./" + relativePath;
+  }
+}
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
new file mode 100644
index 0000000..0648c8d
--- /dev/null
+++ b/tools/node_tools/yarn.lock
@@ -0,0 +1,8548 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e"
+  integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==
+  dependencies:
+    "@babel/highlight" "^7.8.3"
+
+"@babel/core@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.3.tgz#30b0ebb4dd1585de6923a0b4d179e0b9f5d82941"
+  integrity sha512-4XFkf8AwyrEG7Ziu3L2L0Cv+WyY47Tcsp70JFmpftbAA1K7YL/sgE9jh9HyNj08Y/U50ItUchpN0w6HxAoX1rA==
+  dependencies:
+    "@babel/code-frame" "^7.8.3"
+    "@babel/generator" "^7.8.3"
+    "@babel/helpers" "^7.8.3"
+    "@babel/parser" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    convert-source-map "^1.7.0"
+    debug "^4.1.0"
+    gensync "^1.0.0-beta.1"
+    json5 "^2.1.0"
+    lodash "^4.17.13"
+    resolve "^1.3.2"
+    semver "^5.4.1"
+    source-map "^0.5.0"
+
+"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.3.tgz#0e22c005b0a94c1c74eafe19ef78ce53a4d45c03"
+  integrity sha512-WjoPk8hRpDRqqzRpvaR8/gDUPkrnOOeuT2m8cNICJtZH6mwaCo3v0OKMI7Y6SM1pBtyijnLtAL0HDi41pf41ug==
+  dependencies:
+    "@babel/types" "^7.8.3"
+    jsesc "^2.5.1"
+    lodash "^4.17.13"
+    source-map "^0.5.0"
+
+"@babel/helper-annotate-as-pure@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee"
+  integrity sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.8.3.tgz#c84097a427a061ac56a1c30ebf54b7b22d241503"
+  integrity sha512-5eFOm2SyFPK4Rh3XMMRDjN7lBH0orh3ss0g3rTYZnBQ+r6YPj7lgDyCvPphynHvUrobJmeMignBr6Acw9mAPlw==
+  dependencies:
+    "@babel/helper-explode-assignable-expression" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-call-delegate@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.8.3.tgz#de82619898aa605d409c42be6ffb8d7204579692"
+  integrity sha512-6Q05px0Eb+N4/GTyKPPvnkig7Lylw+QzihMpws9iiZQv7ZImf84ZsZpQH7QoWN4n4tm81SnSzPgHw2qtO0Zf3A==
+  dependencies:
+    "@babel/helper-hoist-variables" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-create-regexp-features-plugin@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.3.tgz#c774268c95ec07ee92476a3862b75cc2839beb79"
+  integrity sha512-Gcsm1OHCUr9o9TcJln57xhWHtdXbA2pgQ58S0Lxlks0WMGNXuki4+GLfX0p+L2ZkINUGZvfkz8rzoqJQSthI+Q==
+  dependencies:
+    "@babel/helper-regex" "^7.8.3"
+    regexpu-core "^4.6.0"
+
+"@babel/helper-define-map@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.8.3.tgz#a0655cad5451c3760b726eba875f1cd8faa02c15"
+  integrity sha512-PoeBYtxoZGtct3md6xZOCWPcKuMuk3IHhgxsRRNtnNShebf4C8YonTSblsK4tvDbm+eJAw2HAPOfCr+Q/YRG/g==
+  dependencies:
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    lodash "^4.17.13"
+
+"@babel/helper-explode-assignable-expression@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.8.3.tgz#a728dc5b4e89e30fc2dfc7d04fa28a930653f982"
+  integrity sha512-N+8eW86/Kj147bO9G2uclsg5pwfs/fqqY5rwgIL7eTBklgXjcOJ3btzS5iM6AitJcftnY7pm2lGsrJVYLGjzIw==
+  dependencies:
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-function-name@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz#eeeb665a01b1f11068e9fb86ad56a1cb1a824cca"
+  integrity sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==
+  dependencies:
+    "@babel/helper-get-function-arity" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-get-function-arity@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5"
+  integrity sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-hoist-variables@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.8.3.tgz#1dbe9b6b55d78c9b4183fc8cdc6e30ceb83b7134"
+  integrity sha512-ky1JLOjcDUtSc+xkt0xhYff7Z6ILTAHKmZLHPxAhOP0Nd77O+3nCsd6uSVYur6nJnCI029CrNbYlc0LoPfAPQg==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-member-expression-to-functions@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c"
+  integrity sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-module-imports@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498"
+  integrity sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-module-transforms@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.8.3.tgz#d305e35d02bee720fbc2c3c3623aa0c316c01590"
+  integrity sha512-C7NG6B7vfBa/pwCOshpMbOYUmrYQDfCpVL/JCRu0ek8B5p8kue1+BCXpg2vOYs7w5ACB9GTOBYQ5U6NwrMg+3Q==
+  dependencies:
+    "@babel/helper-module-imports" "^7.8.3"
+    "@babel/helper-simple-access" "^7.8.3"
+    "@babel/helper-split-export-declaration" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    lodash "^4.17.13"
+
+"@babel/helper-optimise-call-expression@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9"
+  integrity sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670"
+  integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==
+
+"@babel/helper-regex@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.8.3.tgz#139772607d51b93f23effe72105b319d2a4c6965"
+  integrity sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ==
+  dependencies:
+    lodash "^4.17.13"
+
+"@babel/helper-remap-async-to-generator@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz#273c600d8b9bf5006142c1e35887d555c12edd86"
+  integrity sha512-kgwDmw4fCg7AVgS4DukQR/roGp+jP+XluJE5hsRZwxCYGg+Rv9wSGErDWhlI90FODdYfd4xG4AQRiMDjjN0GzA==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.8.3"
+    "@babel/helper-wrap-function" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-replace-supers@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz#91192d25f6abbcd41da8a989d4492574fb1530bc"
+  integrity sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA==
+  dependencies:
+    "@babel/helper-member-expression-to-functions" "^7.8.3"
+    "@babel/helper-optimise-call-expression" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-simple-access@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae"
+  integrity sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==
+  dependencies:
+    "@babel/template" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-split-export-declaration@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9"
+  integrity sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
+"@babel/helper-wrap-function@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610"
+  integrity sha512-LACJrbUET9cQDzb6kG7EeD7+7doC3JNvUgTEQOx2qaO1fKlzE/Bf05qs9w1oXQMmXlPO65lC3Tq9S6gZpTErEQ==
+  dependencies:
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/helpers@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.8.3.tgz#382fbb0382ce7c4ce905945ab9641d688336ce85"
+  integrity sha512-LmU3q9Pah/XyZU89QvBgGt+BCsTPoQa+73RxAQh8fb8qkDyIfeQnmgs+hvzhTCKTzqOyk7JTkS3MS1S8Mq5yrQ==
+  dependencies:
+    "@babel/template" "^7.8.3"
+    "@babel/traverse" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/highlight@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.8.3.tgz#28f173d04223eaaa59bc1d439a3836e6d1265797"
+  integrity sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==
+  dependencies:
+    chalk "^2.0.0"
+    esutils "^2.0.2"
+    js-tokens "^4.0.0"
+
+"@babel/parser@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.3.tgz#790874091d2001c9be6ec426c2eed47bc7679081"
+  integrity sha512-/V72F4Yp/qmHaTALizEm9Gf2eQHV3QyTL3K0cNfijwnMnb1L+LDlAubb/ZnSdGAVzVSWakujHYs1I26x66sMeQ==
+
+"@babel/plugin-external-helpers@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.8.3.tgz#5a94164d9af393b2820a3cdc407e28ebf237de4b"
+  integrity sha512-mx0WXDDiIl5DwzMtzWGRSPugXi9BxROS05GQrhLNbEamhBiicgn994ibwkyiBH+6png7bm/yA7AUsvHyCXi4Vw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-proposal-async-generator-functions@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f"
+  integrity sha512-NZ9zLv848JsV3hs8ryEh7Uaz/0KsmPLqv0+PdkDJL1cJy0K4kOCFa8zc1E3mp+RHPQcpdfb/6GovEsW4VDrOMw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-remap-async-to-generator" "^7.8.3"
+    "@babel/plugin-syntax-async-generators" "^7.8.0"
+
+"@babel/plugin-proposal-object-rest-spread@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.8.3.tgz#eb5ae366118ddca67bed583b53d7554cad9951bb"
+  integrity sha512-8qvuPwU/xxUCt78HocNlv0mXXo0wdh9VT1R04WU8HGOfaOob26pF+9P5/lYjN/q7DHOX1bvX60hnhOvuQUJdbA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+
+"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.0":
+  version "7.8.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
+  integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-dynamic-import@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
+  integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-import-meta@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.8.3.tgz#230afff79d3ccc215b5944b438e4e266daf3d84d"
+  integrity sha512-vYiGd4wQ9gx0Lngb7+bPCwQXGK/PR6FeTIJ+TIOlq+OfOKG/kCAOO2+IBac3oMM9qV7/fU76hfcqxUaLKZf1hQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
+  integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-transform-arrow-functions@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz#82776c2ed0cd9e1a49956daeb896024c9473b8b6"
+  integrity sha512-0MRF+KC8EqH4dbuITCWwPSzsyO3HIWWlm30v8BbbpOrS1B++isGxPnnuq/IZvOX5J2D/p7DQalQm+/2PnlKGxg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-async-to-generator@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.8.3.tgz#4308fad0d9409d71eafb9b1a6ee35f9d64b64086"
+  integrity sha512-imt9tFLD9ogt56Dd5CI/6XgpukMwd/fLGSrix2httihVe7LOGVPhyhMh1BU5kDM7iHD08i8uUtmV2sWaBFlHVQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-remap-async-to-generator" "^7.8.3"
+
+"@babel/plugin-transform-block-scoped-functions@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.8.3.tgz#437eec5b799b5852072084b3ae5ef66e8349e8a3"
+  integrity sha512-vo4F2OewqjbB1+yaJ7k2EJFHlTP3jR634Z9Cj9itpqNjuLXvhlVxgnjsHsdRgASR8xYDrx6onw4vW5H6We0Jmg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-block-scoping@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.8.3.tgz#97d35dab66857a437c166358b91d09050c868f3a"
+  integrity sha512-pGnYfm7RNRgYRi7bids5bHluENHqJhrV4bCZRwc5GamaWIIs07N4rZECcmJL6ZClwjDz1GbdMZFtPs27hTB06w==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    lodash "^4.17.13"
+
+"@babel/plugin-transform-classes@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.8.3.tgz#46fd7a9d2bb9ea89ce88720477979fe0d71b21b8"
+  integrity sha512-SjT0cwFJ+7Rbr1vQsvphAHwUHvSUPmMjMU/0P59G8U2HLFqSa082JO7zkbDNWs9kH/IUqpHI6xWNesGf8haF1w==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.8.3"
+    "@babel/helper-define-map" "^7.8.3"
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/helper-optimise-call-expression" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-replace-supers" "^7.8.3"
+    "@babel/helper-split-export-declaration" "^7.8.3"
+    globals "^11.1.0"
+
+"@babel/plugin-transform-computed-properties@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.8.3.tgz#96d0d28b7f7ce4eb5b120bb2e0e943343c86f81b"
+  integrity sha512-O5hiIpSyOGdrQZRQ2ccwtTVkgUDBBiCuK//4RJ6UfePllUTCENOzKxfh6ulckXKc0DixTFLCfb2HVkNA7aDpzA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-destructuring@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.8.3.tgz#20ddfbd9e4676906b1056ee60af88590cc7aaa0b"
+  integrity sha512-H4X646nCkiEcHZUZaRkhE2XVsoz0J/1x3VVujnn96pSoGCtKPA99ZZA+va+gK+92Zycd6OBKCD8tDb/731bhgQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-duplicate-keys@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.8.3.tgz#8d12df309aa537f272899c565ea1768e286e21f1"
+  integrity sha512-s8dHiBUbcbSgipS4SMFuWGqCvyge5V2ZeAWzR6INTVC3Ltjig/Vw1G2Gztv0vU/hRG9X8IvKvYdoksnUfgXOEQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-exponentiation-operator@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.8.3.tgz#581a6d7f56970e06bf51560cd64f5e947b70d7b7"
+  integrity sha512-zwIpuIymb3ACcInbksHaNcR12S++0MDLKkiqXHl3AzpgdKlFNhog+z/K0+TGW+b0w5pgTq4H6IwV/WhxbGYSjQ==
+  dependencies:
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-for-of@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.8.3.tgz#15f17bce2fc95c7d59a24b299e83e81cedc22e18"
+  integrity sha512-ZjXznLNTxhpf4Q5q3x1NsngzGA38t9naWH8Gt+0qYZEJAcvPI9waSStSh56u19Ofjr7QmD0wUsQ8hw8s/p1VnA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-function-name@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.8.3.tgz#279373cb27322aaad67c2683e776dfc47196ed8b"
+  integrity sha512-rO/OnDS78Eifbjn5Py9v8y0aR+aSYhDhqAwVfsTl0ERuMZyr05L1aFSCJnbv2mmsLkit/4ReeQ9N2BgLnOcPCQ==
+  dependencies:
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-instanceof@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.8.3.tgz#a44d7d71590da36be7429573300618aefd784c3d"
+  integrity sha512-c/jB6Ebe2u17hxo+rce6PDgbkuHyfcJOleqgHYttnvMrCsxVwUnYsMq7GhxXekzUQsv9IImhv6YICKihpen+Ag==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-literals@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.8.3.tgz#aef239823d91994ec7b68e55193525d76dbd5dc1"
+  integrity sha512-3Tqf8JJ/qB7TeldGl+TT55+uQei9JfYaregDcEAyBZ7akutriFrt6C/wLYIer6OYhleVQvH/ntEhjE/xMmy10A==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-modules-amd@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.8.3.tgz#65606d44616b50225e76f5578f33c568a0b876a5"
+  integrity sha512-MadJiU3rLKclzT5kBH4yxdry96odTUwuqrZM+GllFI/VhxfPz+k9MshJM+MwhfkCdxxclSbSBbUGciBngR+kEQ==
+  dependencies:
+    "@babel/helper-module-transforms" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+    babel-plugin-dynamic-import-node "^2.3.0"
+
+"@babel/plugin-transform-object-super@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.8.3.tgz#ebb6a1e7a86ffa96858bd6ac0102d65944261725"
+  integrity sha512-57FXk+gItG/GejofIyLIgBKTas4+pEU47IXKDBWFTxdPd7F80H8zybyAY7UoblVfBhBGs2EKM+bJUu2+iUYPDQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-replace-supers" "^7.8.3"
+
+"@babel/plugin-transform-parameters@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.8.3.tgz#7890576a13b17325d8b7d44cb37f21dc3bbdda59"
+  integrity sha512-/pqngtGb54JwMBZ6S/D3XYylQDFtGjWrnoCF4gXZOUpFV/ujbxnoNGNvDGu6doFWRPBveE72qTx/RRU44j5I/Q==
+  dependencies:
+    "@babel/helper-call-delegate" "^7.8.3"
+    "@babel/helper-get-function-arity" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-regenerator@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.8.3.tgz#b31031e8059c07495bf23614c97f3d9698bc6ec8"
+  integrity sha512-qt/kcur/FxrQrzFR432FGZznkVAjiyFtCOANjkAKwCbt465L6ZCiUQh2oMYGU3Wo8LRFJxNDFwWn106S5wVUNA==
+  dependencies:
+    regenerator-transform "^0.14.0"
+
+"@babel/plugin-transform-shorthand-properties@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.8.3.tgz#28545216e023a832d4d3a1185ed492bcfeac08c8"
+  integrity sha512-I9DI6Odg0JJwxCHzbzW08ggMdCezoWcuQRz3ptdudgwaHxTjxw5HgdFJmZIkIMlRymL6YiZcped4TTCB0JcC8w==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-spread@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.8.3.tgz#9c8ffe8170fdfb88b114ecb920b82fb6e95fe5e8"
+  integrity sha512-CkuTU9mbmAoFOI1tklFWYYbzX5qCIZVXPVy0jpXgGwkplCndQAa58s2jr66fTeQnA64bDox0HL4U56CFYoyC7g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-sticky-regex@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.8.3.tgz#be7a1290f81dae767475452199e1f76d6175b100"
+  integrity sha512-9Spq0vGCD5Bb4Z/ZXXSK5wbbLFMG085qd2vhL1JYu1WcQ5bXqZBAYRzU1d+p79GcHs2szYv5pVQCX13QgldaWw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-regex" "^7.8.3"
+
+"@babel/plugin-transform-template-literals@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.8.3.tgz#7bfa4732b455ea6a43130adc0ba767ec0e402a80"
+  integrity sha512-820QBtykIQOLFT8NZOcTRJ1UNuztIELe4p9DCgvj4NK+PwluSJ49we7s9FB1HIGNIYT7wFUJ0ar2QpCDj0escQ==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-typeof-symbol@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.8.3.tgz#5cffb216fb25c8c64ba6bf5f76ce49d3ab079f4d"
+  integrity sha512-3TrkKd4LPqm4jHs6nPtSDI/SV9Cm5PRJkHLUgTcqRQQTMAZ44ZaAdDZJtvWFSaRcvT0a1rTmJ5ZA5tDKjleF3g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-transform-unicode-regex@^7.0.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz#0cef36e3ba73e5c57273effb182f46b91a1ecaad"
+  integrity sha512-+ufgJjYdmWfSQ+6NS9VGUR2ns8cjJjYbrbi11mZBTaWm+Fui/ncTLFF28Ei1okavY+xkojGr1eJxNsWYeA5aZw==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/template@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8"
+  integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==
+  dependencies:
+    "@babel/code-frame" "^7.8.3"
+    "@babel/parser" "^7.8.3"
+    "@babel/types" "^7.8.3"
+
+"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.3.tgz#a826215b011c9b4f73f3a893afbc05151358bf9a"
+  integrity sha512-we+a2lti+eEImHmEXp7bM9cTxGzxPmBiVJlLVD+FuuQMeeO7RaDbutbgeheDkw+Xe3mCfJHnGOWLswT74m2IPg==
+  dependencies:
+    "@babel/code-frame" "^7.8.3"
+    "@babel/generator" "^7.8.3"
+    "@babel/helper-function-name" "^7.8.3"
+    "@babel/helper-split-export-declaration" "^7.8.3"
+    "@babel/parser" "^7.8.3"
+    "@babel/types" "^7.8.3"
+    debug "^4.1.0"
+    globals "^11.1.0"
+    lodash "^4.17.13"
+
+"@babel/types@^7.0.0-beta.42", "@babel/types@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
+  integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==
+  dependencies:
+    esutils "^2.0.2"
+    lodash "^4.17.13"
+    to-fast-properties "^2.0.0"
+
+"@bazel/rollup@^0.41.0":
+  version "0.41.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-0.41.0.tgz#8dfaccc239f3efbae1c816b0ce2aeb6069d23582"
+  integrity sha512-M+ybGfcxTXnAS1QiaijLEfUznNYLA0cqeGXnYHSRrOhq2U7yesfavxbBtfLSKtg32ktmlHts5te8Zg82BS4DPQ==
+
+"@bazel/typescript@^1.0.1":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.1.0.tgz#b57ac6c6d627577f394a60fb540fbbdf53bcff0d"
+  integrity sha512-QnTdb6rwZUR+KfUuAdyazpkA7BOvrWRe7tkPDdyIZHJdBPYdpJW+AapnFSfxvXEIP0Nwesl5KP6Saau0GPiBLg==
+  dependencies:
+    protobufjs "6.8.8"
+    semver "5.6.0"
+    source-map-support "0.5.9"
+    tsutils "2.27.2"
+
+"@mrmlnc/readdir-enhanced@^2.2.1":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
+  integrity sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==
+  dependencies:
+    call-me-maybe "^1.0.1"
+    glob-to-regexp "^0.3.0"
+
+"@nodelib/fs.stat@^1.1.2":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
+  integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
+
+"@octokit/auth-token@^2.4.0":
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.0.tgz#b64178975218b99e4dfe948253f0673cbbb59d9f"
+  integrity sha512-eoOVMjILna7FVQf96iWc3+ZtE/ZT6y8ob8ZzcqKY1ibSQCnu4O/B7pJvzMx5cyZ/RjAff6DAdEb0O0Cjcxidkg==
+  dependencies:
+    "@octokit/types" "^2.0.0"
+
+"@octokit/endpoint@^5.5.0":
+  version "5.5.1"
+  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-5.5.1.tgz#2eea81e110ca754ff2de11c79154ccab4ae16b3f"
+  integrity sha512-nBFhRUb5YzVTCX/iAK1MgQ4uWo89Gu0TH00qQHoYRCsE12dWcG1OiLd7v2EIo2+tpUKPMOQ62QFy9hy9Vg2ULg==
+  dependencies:
+    "@octokit/types" "^2.0.0"
+    is-plain-object "^3.0.0"
+    universal-user-agent "^4.0.0"
+
+"@octokit/request-error@^1.0.1", "@octokit/request-error@^1.0.2":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.2.0.tgz#a64d2a9d7a13555570cd79722de4a4d76371baaa"
+  integrity sha512-DNBhROBYjjV/I9n7A8kVkmQNkqFAMem90dSxqvPq57e2hBr7mNTX98y3R2zDpqMQHVRpBDjsvsfIGgBzy+4PAg==
+  dependencies:
+    "@octokit/types" "^2.0.0"
+    deprecation "^2.0.0"
+    once "^1.4.0"
+
+"@octokit/request@^5.2.0":
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.3.1.tgz#3a1ace45e6f88b1be4749c5da963b3a3b4a2f120"
+  integrity sha512-5/X0AL1ZgoU32fAepTfEoggFinO3rxsMLtzhlUX+RctLrusn/CApJuGFCd0v7GMFhF+8UiCsTTfsu7Fh1HnEJg==
+  dependencies:
+    "@octokit/endpoint" "^5.5.0"
+    "@octokit/request-error" "^1.0.1"
+    "@octokit/types" "^2.0.0"
+    deprecation "^2.0.0"
+    is-plain-object "^3.0.0"
+    node-fetch "^2.3.0"
+    once "^1.4.0"
+    universal-user-agent "^4.0.0"
+
+"@octokit/rest@^16.2.0":
+  version "16.38.3"
+  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.38.3.tgz#d5d200f88962392f71e048e12833ea36f4e0d192"
+  integrity sha512-Ui5W4Gzv0YHe9P3KDZAuU/BkRrT88PCuuATfWBMBf4fux4nB8th8LlyVAVnHKba1s/q4umci+sNHzoFYFujPEg==
+  dependencies:
+    "@octokit/auth-token" "^2.4.0"
+    "@octokit/request" "^5.2.0"
+    "@octokit/request-error" "^1.0.2"
+    atob-lite "^2.0.0"
+    before-after-hook "^2.0.0"
+    btoa-lite "^1.0.0"
+    deprecation "^2.0.0"
+    lodash.get "^4.4.2"
+    lodash.set "^4.3.2"
+    lodash.uniq "^4.5.0"
+    octokit-pagination-methods "^1.1.0"
+    once "^1.4.0"
+    universal-user-agent "^4.0.0"
+
+"@octokit/types@^2.0.0":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.1.1.tgz#77e80d1b663c5f1f829e5377b728fa3c4fe5a97d"
+  integrity sha512-89LOYH+d/vsbDX785NOfLxTW88GjNd0lWRz1DVPVsZgg9Yett5O+3MOvwo7iHgvUwbFz0mf/yPIjBkUbs4kxoQ==
+  dependencies:
+    "@types/node" ">= 8"
+
+"@polymer/esm-amd-loader@^1.0.0":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
+  integrity sha512-h+hqYkL+tQV/y2ESD5gFXMl5z4cC+XY1jTlBeGSBaTcj3VbB5OBEScbvRXm63NcEbBneQQYbHfBAXAkF9i9wIA==
+
+"@polymer/sinonjs@^1.14.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@polymer/sinonjs/-/sinonjs-1.17.1.tgz#e47d3785b7d0e8c29feb97f7e924b0fc597e2e9b"
+  integrity sha512-/U8F/cOTrbF2iVVYgINYmvKbtbexs+89Q3v8AaHADRYabTg7aOZGOb0RyWpOI+sUJt04kj63U4FwMhzW5r4wZA==
+
+"@polymer/test-fixture@^0.0.3":
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-0.0.3.tgz#4443752697d4d9293bbc412ea0b5e4d341f149d9"
+  integrity sha1-REN1JpfU2Sk7vEEuoLXk00HxSdk=
+
+"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
+  integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78=
+
+"@protobufjs/base64@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
+  integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
+
+"@protobufjs/codegen@^2.0.4":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
+  integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==
+
+"@protobufjs/eventemitter@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
+  integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A=
+
+"@protobufjs/fetch@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
+  integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=
+  dependencies:
+    "@protobufjs/aspromise" "^1.1.1"
+    "@protobufjs/inquire" "^1.1.0"
+
+"@protobufjs/float@^1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
+  integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=
+
+"@protobufjs/inquire@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
+  integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=
+
+"@protobufjs/path@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
+  integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=
+
+"@protobufjs/pool@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
+  integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=
+
+"@protobufjs/utf8@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
+  integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
+
+"@types/babel-generator@^6.25.1":
+  version "6.25.3"
+  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.3.tgz#8f06caa12d0595a0538560abe771966d77d29286"
+  integrity sha512-pGgnuxVddKcYIc+VJkRDop7gxLhqclNKBdlsm/5Vp8d+37pQkkDK7fef8d9YYImRzw9xcojEPc18pUYnbxmjqA==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/babel-traverse@^6.25.2", "@types/babel-traverse@^6.25.3":
+  version "6.25.5"
+  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.5.tgz#6d293cf7523e48b524faa7b86dc3c488191484e5"
+  integrity sha512-WrMbwmu+MWf8FiUMbmVOGkc7bHPzndUafn1CivMaBHthBBoo0VNIcYk1KV71UovYguhsNOwf3UF5oRmkkGOU3w==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/babel-types@*":
+  version "7.0.7"
+  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.7.tgz#667eb1640e8039436028055737d2b9986ee336e3"
+  integrity sha512-dBtBbrc+qTHy1WdfHYjBwRln4+LWqASWakLHsWHR2NWHIFkv4W3O070IGoGLEBrJBvct3r0L1BUPuvURi7kYUQ==
+
+"@types/babel-types@^6.25.1":
+  version "6.25.2"
+  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-6.25.2.tgz#5c57f45973e4f13742dbc5273dd84cffe7373a9e"
+  integrity sha512-+3bMuktcY4a70a0KZc8aPJlEOArPuAKQYHU5ErjkOqGJdx8xuEEVK6nWogqigBOJ8nKPxRpyCUDTCPmZ3bUxGA==
+
+"@types/babylon@^6.16.2":
+  version "6.16.5"
+  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.5.tgz#1c5641db69eb8cdf378edd25b4be7754beeb48b4"
+  integrity sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/bluebird@*":
+  version "3.5.29"
+  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.29.tgz#7cd933c902c4fc83046517a1bef973886d00bdb6"
+  integrity sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==
+
+"@types/body-parser@*":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897"
+  integrity sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@types/chai-subset@^1.3.0":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
+  integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*":
+  version "4.2.7"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.7.tgz#1c8c25cbf6e59ffa7d6b9652c78e547d9a41692d"
+  integrity sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==
+
+"@types/chalk@^0.4.30":
+  version "0.4.31"
+  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-0.4.31.tgz#a31d74241a6b1edbb973cf36d97a2896834a51f9"
+  integrity sha1-ox10JBprHtu5c8822XooloNKUfk=
+
+"@types/chalk@^2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-2.2.0.tgz#b7f6e446f4511029ee8e3f43075fb5b73fbaa0ba"
+  integrity sha512-1zzPV9FDe1I/WHhRkf9SNgqtRJWZqrBWgu7JGveuHmmyR9CnAPCie2N/x+iHrgnpYBIcCJWHBoMRv2TRWktsvw==
+  dependencies:
+    chalk "*"
+
+"@types/clean-css@*":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.1.tgz#cb0134241ec5e6ede1b5344bc829668fd9871a8d"
+  integrity sha512-A1HQhQ0hkvqqByJMgg+Wiv9p9XdoYEzuwm11SVo1mX2/4PSdhjcrUlilJQoqLscIheC51t1D5g+EFWCXZ2VTQQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/clone@^0.1.29", "@types/clone@^0.1.30":
+  version "0.1.30"
+  resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
+  integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
+
+"@types/color-name@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
+  integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
+
+"@types/compression@^0.0.33":
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
+  integrity sha1-ldxzOiM5qoRjgdfxN3eS0lU9wn0=
+  dependencies:
+    "@types/express" "*"
+
+"@types/connect@*":
+  version "3.4.33"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546"
+  integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-type@^1.1.0":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.3.tgz#3688bd77fc12f935548eef102a4e34c512b03a07"
+  integrity sha512-pv8VcFrZ3fN93L4rTNIbbUzdkzjEyVMp5mPVjsFfOYTDOZMZiZ8P1dhu+kEv3faYyKzZgLlSvnyQNFg+p/v5ug==
+
+"@types/cssbeautify@^0.3.1":
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.1.tgz#8e0bee8f7decb952250da0caebe05e30591c17ef"
+  integrity sha1-jgvuj33suVIlDaDK6+BeMFkcF+8=
+
+"@types/del@^3.0.0":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/del/-/del-3.0.1.tgz#4712da8c119873cbbf533ad8dbf1baac5940ac5d"
+  integrity sha512-y6qRq6raBuu965clKgx6FHuiPu3oHdtmzMPXi8Uahsjdq1L6DL5fS/aY5/s71YwM7k6K1QIWvem5vNwlnNGIkQ==
+  dependencies:
+    "@types/glob" "*"
+
+"@types/doctrine@^0.0.1":
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
+  integrity sha1-uZny2fe0PKvgoaLzm8IDvH3K2p0=
+
+"@types/escape-html@0.0.20":
+  version "0.0.20"
+  resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
+  integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
+
+"@types/estree@*":
+  version "0.0.42"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.42.tgz#8d0c1f480339efedb3e46070e22dd63e0430dd11"
+  integrity sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==
+
+"@types/events@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
+  integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
+
+"@types/expect@^1.20.4":
+  version "1.20.4"
+  resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
+  integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==
+
+"@types/express-serve-static-core@*":
+  version "4.17.2"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.2.tgz#f6f41fa35d42e79dbf6610eccbb2637e6008a0cf"
+  integrity sha512-El9yMpctM6tORDAiBwZVLMcxoTMcqqRO9dVyYcn7ycLWbvR8klrDn8CAOwRfZujZtWD7yS/mshTdz43jMOejbg==
+  dependencies:
+    "@types/node" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
+  version "4.17.2"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
+  integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "*"
+    "@types/serve-static" "*"
+
+"@types/fast-levenshtein@0.0.1":
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/@types/fast-levenshtein/-/fast-levenshtein-0.0.1.tgz#3a3615cf173645c8fca58d051e4e32824e4bd286"
+  integrity sha1-OjYVzxc2Rcj8pY0FHk4ygk5L0oY=
+
+"@types/findup-sync@^0.3.29":
+  version "0.3.30"
+  resolved "https://registry.yarnpkg.com/@types/findup-sync/-/findup-sync-0.3.30.tgz#8ab7bdbd6ba7cbf4f33b6596fde6fff1129c738d"
+  integrity sha512-Dpt1x3rhz6t8BMTS4vziTVos8VLkF4RngIxMBCSE6w0STmnVEEaoe3w+BG5xHyZXshye9lyZE99lpBDoLGY8eA==
+  dependencies:
+    "@types/minimatch" "*"
+
+"@types/form-data@*":
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.5.0.tgz#5025f7433016f923348434c40006d9a797c1b0e8"
+  integrity sha512-23/wYiuckYYtFpL+4RPWiWmRQH2BjFuqCUi2+N3amB1a1Drv+i/byTrGvlLwRVLFNAZbwpbQ7JvTK+VCAPMbcg==
+  dependencies:
+    form-data "*"
+
+"@types/freeport@^1.0.19":
+  version "1.0.21"
+  resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.21.tgz#73f6543ed67d3ca3fff97b985591598b7092066f"
+  integrity sha1-c/ZUPtZ9PKP/+XuYVZFZi3CSBm8=
+
+"@types/glob-stream@*":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc"
+  integrity sha512-RHv6ZQjcTncXo3thYZrsbAVwoy4vSKosSWhuhuQxLOTv74OJuFQxXkmUuZCr3q9uNBEVCvIzmZL/FeRNbHZGUg==
+  dependencies:
+    "@types/glob" "*"
+    "@types/node" "*"
+
+"@types/glob@*":
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"
+  integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==
+  dependencies:
+    "@types/events" "*"
+    "@types/minimatch" "*"
+    "@types/node" "*"
+
+"@types/globby@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/globby/-/globby-6.1.0.tgz#7c25b975512a89effea2a656ca8cf6db7fb29d11"
+  integrity sha512-j3XSDNoK4LO5T+ZviQD6PqfEjm07QFEacOTbJR3hnLWuWX0ZMLJl9oRPgj1PyrfGbXhfHFkksC9QZ9HFltJyrw==
+  dependencies:
+    "@types/glob" "*"
+
+"@types/gulp-if@0.0.33":
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/@types/gulp-if/-/gulp-if-0.0.33.tgz#edece22b7925d9a6db5f9c8c0d7882aa776fb678"
+  integrity sha512-J5lzff21X7r1x/4hSzn02GgIUEyjCqYIXZ9GgGBLhbsD3RiBdqwnkFWgF16/0jO5rcVZ52Zp+6MQMQdvIsWuKg==
+  dependencies:
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/html-minifier@^3.5.1":
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f"
+  integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==
+  dependencies:
+    "@types/clean-css" "*"
+    "@types/relateurl" "*"
+    "@types/uglify-js" "*"
+
+"@types/inquirer@*":
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-6.5.0.tgz#b83b0bf30b88b8be7246d40e51d32fe9d10e09be"
+  integrity sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==
+  dependencies:
+    "@types/through" "*"
+    rxjs "^6.4.0"
+
+"@types/inquirer@0.0.32":
+  version "0.0.32"
+  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-0.0.32.tgz#a4a08e83741c500a7c3c8e7776014f7f8a65870d"
+  integrity sha1-pKCOg3QcUAp8PI53dgFPf4plhw0=
+  dependencies:
+    "@types/rx" "*"
+    "@types/through" "*"
+
+"@types/is-windows@^0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
+  integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
+
+"@types/launchpad@^0.6.0":
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
+  integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
+
+"@types/long@^4.0.0":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
+  integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
+
+"@types/merge-stream@^1.0.28":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@types/merge-stream/-/merge-stream-1.1.2.tgz#a880ff66b1fbbb5eef4958d015c5947a9334dbb1"
+  integrity sha512-7faLmaE99g/yX0Y9pF1neh2IUqOf/fXMOWCVzsXjqI1EJ91lrgXmaBKf6bRWM164lLyiHxHt6t/ZO/cIzq61XA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/mime@*", "@types/mime@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
+  integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
+
+"@types/minimatch@*", "@types/minimatch@^3.0.1":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
+  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+
+"@types/mz@0.0.29":
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
+  integrity sha1-vCRyjGSZc/HHhR6QM/nOUlZowns=
+  dependencies:
+    "@types/bluebird" "*"
+    "@types/node" "*"
+
+"@types/mz@0.0.31", "@types/mz@^0.0.31":
+  version "0.0.31"
+  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52"
+  integrity sha1-pNgMCC/v5x5Ap8DwfR5lVbu8e1I=
+  dependencies:
+    "@types/node" "*"
+
+"@types/node@*", "@types/node@>= 8":
+  version "13.5.0"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-13.5.0.tgz#4e498dbf355795a611a87ae5ef811a8660d42662"
+  integrity sha512-Onhn+z72D2O2Pb2ql2xukJ55rglumsVo1H6Fmyi8mlU9SvKdBk/pUSUAiBY/d9bAOF7VVWajX3sths/+g6ZiAQ==
+
+"@types/node@6.0.*":
+  version "6.0.118"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.118.tgz#8014a9b1dee0b72b4d7cd142563f1af21241c3a2"
+  integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
+
+"@types/node@^10.1.0", "@types/node@^10.17.12":
+  version "10.17.13"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
+  integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
+
+"@types/node@^4.0.30":
+  version "4.9.4"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.4.tgz#75ef91733afaa856b01e12da6ecf48aa9d5e221f"
+  integrity sha512-nKoiCZ87x6+fs26bNHjy07AQt6f46nFEitGH0P9JmWbY6tEyum6LLfLf7SIsKFh4DnBWsyUM2gYhaQAt+aA0Sw==
+
+"@types/opn@^3.0.28":
+  version "3.0.28"
+  resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
+  integrity sha1-CX0NHJtXSVc6XZbfEyOHu20CEYo=
+  dependencies:
+    "@types/node" "*"
+
+"@types/parse5-html-rewriting-stream@^5.1.2":
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/@types/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.2.tgz#919d5bbf69ef61e11d873e7195891c3811491a03"
+  integrity sha512-7CHY6QlayurvYRST5xatE/ipIueph5V+EW2xU12P0CsNucuwygnuiE4foYsdQUEkhnKrTU62KmikANPnoxiGrg==
+  dependencies:
+    "@types/parse5-sax-parser" "*"
+
+"@types/parse5-sax-parser@*":
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/@types/parse5-sax-parser/-/parse5-sax-parser-5.0.1.tgz#f1e26e82bb09e48cb0c16ff6d1e88aea1e538fd5"
+  integrity sha512-wBEwg10aACLggnb44CwzAA27M1Jrc/8TR16zA61/rKO5XZoi7JSfLjdpXbshsm7wOlM6hpfvwygh40rzM2RsQQ==
+  dependencies:
+    "@types/node" "*"
+    "@types/parse5" "*"
+
+"@types/parse5@*":
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.2.tgz#a877a4658f8238c8266faef300ae41c84d72ec8a"
+  integrity sha512-BOl+6KDs4ItndUWUFchy3aEqGdHhw0BC4Uu+qoDonN/f0rbUnJbm71Ulj8Tt9jLFRaAxPLKvdS1bBLfx1qXR9g==
+
+"@types/parse5@^0.0.31":
+  version "0.0.31"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-0.0.31.tgz#e827a493a443b156e1b582a2e4c3bdc0040f2ee7"
+  integrity sha1-6Cekk6RDsVbhtYKi5MO9wAQPLuc=
+  dependencies:
+    "@types/node" "6.0.*"
+
+"@types/parse5@^2.2.34":
+  version "2.2.34"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
+  integrity sha1-44cKEOgnNacg9i1x3NGDunjvOp0=
+  dependencies:
+    "@types/node" "*"
+
+"@types/parse5@^4.0.0":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-4.0.0.tgz#26dd73df171a69be517395d294c7af2ae0cd2579"
+  integrity sha512-OaBwNFk6dO8gbdfWut41VYiD5Fmj3Yi24cr/oGCXFXCjT2fteSQx2l3kx/phuQvBte/F54ajN2uDQF5MRwupGw==
+  dependencies:
+    "@types/node" "*"
+
+"@types/path-is-inside@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
+  integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
+
+"@types/pem@^1.8.1":
+  version "1.9.5"
+  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.5.tgz#cd5548b5e0acb4b41a9e21067e9fcd8c57089c99"
+  integrity sha512-C0txxEw8B7DCoD85Ko7SEvzUogNd5VDJ5/YBG8XUcacsOGqxr5Oo4g3OUAfdEDUbhXanwUoVh/ZkMFw77FGPQQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/range-parser@*":
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
+  integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
+
+"@types/relateurl@*":
+  version "0.2.28"
+  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
+  integrity sha1-a9p9uGU/piZD9e5p6facEaOS46Y=
+
+"@types/request@2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/request/-/request-2.0.3.tgz#bdf0fba9488c822f77e97de3dd8fe357b2fb8c06"
+  integrity sha512-cIvnyFRARxwE4OHpCyYue7H+SxaKFPpeleRCHJicft8QhyTNbVYsMwjvEzEPqG06D2LGHZ+sN5lXc8+bTu6D8A==
+  dependencies:
+    "@types/form-data" "*"
+    "@types/node" "*"
+
+"@types/resolve@0.0.4":
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.4.tgz#9b586d65a947dea88c4bc24da0b905fe9520a0d5"
+  integrity sha1-m1htZalH3qiMS8JNoLkF/pUgoNU=
+  dependencies:
+    "@types/node" "*"
+
+"@types/resolve@0.0.6":
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
+  integrity sha512-g+Rg8uMWY76oYTyaL+m7ZcblqF/oj7pE6uEUyACluJx4zcop1Lk14qQiocdEkEVMDFm6DmKpxJhsER+ZuTwG3g==
+  dependencies:
+    "@types/node" "*"
+
+"@types/resolve@0.0.7":
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.7.tgz#b299c13be8d712b1b502fb14a084252acef84f4d"
+  integrity sha512-GPewdjkb0Q76o459qgp6pBLzJj/bD3oveS2kfLhIkZ9U3t3AFKtl5DlFB6lGTw0iZmcmxoGC8lpLW3NNJKrN9A==
+  dependencies:
+    "@types/node" "*"
+
+"@types/resolve@0.0.8":
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194"
+  integrity sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/rimraf@^0.0.28":
+  version "0.0.28"
+  resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-0.0.28.tgz#5562519bc7963caca8abf7f128cae3b594d41d06"
+  integrity sha1-VWJRm8eWPKyoq/fxKMrjtZTUHQY=
+
+"@types/rx-core-binding@*":
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/@types/rx-core-binding/-/rx-core-binding-4.0.4.tgz#d969d32f15a62b89e2862c17b3ee78fe329818d3"
+  integrity sha512-5pkfxnC4w810LqBPUwP5bg7SFR/USwhMSaAeZQQbEHeBp57pjKXRlXmqpMrLJB4y1oglR/c2502853uN0I+DAQ==
+  dependencies:
+    "@types/rx-core" "*"
+
+"@types/rx-core@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-core/-/rx-core-4.0.3.tgz#0b3354b1238cedbe2b74f6326f139dbc7a591d60"
+  integrity sha1-CzNUsSOM7b4rdPYybxOdvHpZHWA=
+
+"@types/rx-lite-aggregates@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-aggregates/-/rx-lite-aggregates-4.0.3.tgz#6efb2b7f3d5f07183a1cb2bd4b1371d7073384c2"
+  integrity sha512-MAGDAHy8cRatm94FDduhJF+iNS5//jrZ/PIfm+QYw9OCeDgbymFHChM8YVIvN2zArwsRftKgE33QfRWvQk4DPg==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-async@*":
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-async/-/rx-lite-async-4.0.2.tgz#27fbf0caeff029f41e2d2aae638b05e91ceb600c"
+  integrity sha512-vTEv5o8l6702ZwfAM5aOeVDfUwBSDOs+ARoGmWAKQ6LOInQ8J4/zjM7ov12fuTpktUKdMQjkeCp07Vd73mPkxw==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-backpressure@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-backpressure/-/rx-lite-backpressure-4.0.3.tgz#05abb19bdf87cc740196c355e5d0b37bb50b5d56"
+  integrity sha512-Y6aIeQCtNban5XSAF4B8dffhIKu6aAy/TXFlScHzSxh6ivfQBQw6UjxyEJxIOt3IT49YkS+siuayM2H/Q0cmgA==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-coincidence@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-coincidence/-/rx-lite-coincidence-4.0.3.tgz#80bd69acc4054a15cdc1638e2dc8843498cd85c0"
+  integrity sha512-1VNJqzE9gALUyMGypDXZZXzR0Tt7LC9DdAZQ3Ou/Q0MubNU35agVUNXKGHKpNTba+fr8GdIdkC26bRDqtCQBeQ==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-experimental@*":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-experimental/-/rx-lite-experimental-4.0.1.tgz#c532f5cbdf3f2c15da16ded8930d1b2984023cbd"
+  integrity sha1-xTL1y98/LBXaFt7Ykw0bKYQCPL0=
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-joinpatterns@*":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-joinpatterns/-/rx-lite-joinpatterns-4.0.1.tgz#f70fe370518a8432f29158cc92ffb56b4e4afc3e"
+  integrity sha1-9w/jcFGKhDLykVjMkv+1a05K/D4=
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-testing@*":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-testing/-/rx-lite-testing-4.0.1.tgz#21b19d11f4dfd6ffef5a9d1648e9c8879bfe21e9"
+  integrity sha1-IbGdEfTf1v/vWp0WSOnIh5v+Iek=
+  dependencies:
+    "@types/rx-lite-virtualtime" "*"
+
+"@types/rx-lite-time@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-time/-/rx-lite-time-4.0.3.tgz#0eda65474570237598f3448b845d2696f2dbb1c4"
+  integrity sha512-ukO5sPKDRwCGWRZRqPlaAU0SKVxmWwSjiOrLhoQDoWxZWg6vyB9XLEZViKOzIO6LnTIQBlk4UylYV0rnhJLxQw==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-virtualtime@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-virtualtime/-/rx-lite-virtualtime-4.0.3.tgz#4b30cacd0fe2e53af29f04f7438584c7d3959537"
+  integrity sha512-3uC6sGmjpOKatZSVHI2xB1+dedgml669ZRvqxy+WqmGJDVusOdyxcKfyzjW0P3/GrCiN4nmRkLVMhPwHCc5QLg==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite@*":
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite/-/rx-lite-4.0.6.tgz#3c02921c4244074234f26b772241bcc20c18c253"
+  integrity sha512-oYiDrFIcor9zDm0VDUca1UbROiMYBxMLMaM6qzz4ADAfOmA9r1dYEcAFH+2fsPI5BCCjPvV9pWC3X3flbrvs7w==
+  dependencies:
+    "@types/rx-core" "*"
+    "@types/rx-core-binding" "*"
+
+"@types/rx@*":
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/@types/rx/-/rx-4.1.1.tgz#598fc94a56baed975f194574e0f572fd8e627a48"
+  integrity sha1-WY/JSla67ZdfGUV04PVy/Y5iekg=
+  dependencies:
+    "@types/rx-core" "*"
+    "@types/rx-core-binding" "*"
+    "@types/rx-lite" "*"
+    "@types/rx-lite-aggregates" "*"
+    "@types/rx-lite-async" "*"
+    "@types/rx-lite-backpressure" "*"
+    "@types/rx-lite-coincidence" "*"
+    "@types/rx-lite-experimental" "*"
+    "@types/rx-lite-joinpatterns" "*"
+    "@types/rx-lite-testing" "*"
+    "@types/rx-lite-time" "*"
+    "@types/rx-lite-virtualtime" "*"
+
+"@types/semver@^5.3.30":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
+  integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
+
+"@types/serve-static@*", "@types/serve-static@^1.7.31":
+  version "1.13.3"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
+  integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==
+  dependencies:
+    "@types/express-serve-static-core" "*"
+    "@types/mime" "*"
+
+"@types/spdy@^3.4.1":
+  version "3.4.4"
+  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.4.tgz#3282fd4ad8c4603aa49f7017dd520a08a345b2bc"
+  integrity sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/temp@^0.8.28":
+  version "0.8.34"
+  resolved "https://registry.yarnpkg.com/@types/temp/-/temp-0.8.34.tgz#03e4b3cb67cbb48c425bbf54b12230fef85540ac"
+  integrity sha512-oLa9c5LHXgS6UimpEVp08De7QvZ+Dfu5bMQuWyMhf92Z26Q10ubEMOWy9OEfUdzW7Y/sDWVHmUaLFtmnX/2j0w==
+  dependencies:
+    "@types/node" "*"
+
+"@types/through@*":
+  version "0.0.30"
+  resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"
+  integrity sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg==
+  dependencies:
+    "@types/node" "*"
+
+"@types/ua-parser-js@^0.7.31":
+  version "0.7.33"
+  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.33.tgz#4a92089511574e12928a7cb6b99a01831acd1dd7"
+  integrity sha512-ngUKcHnytUodUCL7C6EZ+lVXUjTMQb+9p/e1JjV5tN9TVzS98lHozWEFRPY1QcCdwFeMsmVWfZ3DPPT/udCyIw==
+
+"@types/uglify-js@*":
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
+  integrity sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==
+  dependencies:
+    source-map "^0.6.1"
+
+"@types/update-notifier@^1.0.0":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/update-notifier/-/update-notifier-1.0.3.tgz#3c7ee1921af6f16149cdcaef356baf57d7a0b806"
+  integrity sha512-BLStNhP2DFF7funARwTcoD6tetRte8NK3Sc59mn7GNALCN975jOlKX3dGvsFxXr/HwQMxxCuRn9IWB3WQ7odHQ==
+
+"@types/uuid@^3.4.3":
+  version "3.4.6"
+  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.6.tgz#d2c4c48eb85a757bf2927f75f939942d521e3016"
+  integrity sha512-cCdlC/1kGEZdEglzOieLDYBxHsvEOIg7kp/2FYyVR9Pxakq+Qf/inL3RKQ+PA8gOlI/NnL+fXmQH12nwcGzsHw==
+  dependencies:
+    "@types/node" "*"
+
+"@types/vinyl-fs@0.0.28":
+  version "0.0.28"
+  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-0.0.28.tgz#4663017bc802c6570eae4f3409fd5cabf97cbfde"
+  integrity sha1-RmMBe8gCxlcOrk80Cf1cq/l8v94=
+  dependencies:
+    "@types/glob-stream" "*"
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/vinyl-fs@^2.4.8":
+  version "2.4.11"
+  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.11.tgz#b98119b8bb2494141eaf649b09fbfeb311161206"
+  integrity sha512-2OzQSfIr9CqqWMGqmcERE6Hnd2KY3eBVtFaulVo3sJghplUcaeMdL9ZjEiljcQQeHjheWY9RlNmumjIAvsBNaA==
+  dependencies:
+    "@types/glob-stream" "*"
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/vinyl@*", "@types/vinyl@^2.0.0":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.4.tgz#9a7a8071c8d14d3a95d41ebe7135babe4ad5995a"
+  integrity sha512-2o6a2ixaVI2EbwBPg1QYLGQoHK56p/8X/sGfKbFC8N6sY9lfjsMf/GprtkQkSya0D4uRiutRZ2BWj7k3JvLsAQ==
+  dependencies:
+    "@types/expect" "^1.20.4"
+    "@types/node" "*"
+
+"@types/whatwg-url@^6.4.0":
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
+  integrity sha512-tonhlcbQ2eho09am6RHnHOgvtDfDYINd5rgxD+2YSkKENooVCFsWizJz139MQW/PV8FfClyKrNe9ZbdHrSCxGg==
+  dependencies:
+    "@types/node" "*"
+
+"@types/which@^1.3.1":
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
+  integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
+
+"@types/yeoman-generator@^2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/yeoman-generator/-/yeoman-generator-2.0.3.tgz#f4b161ee354078b526e0901a5a5f87d4f8e085f6"
+  integrity sha512-vch2UFd6k7DdfWEv/alRwZIRXQoxZNUDpfLOK24+005dzE1HVnwSWfETF3WxJnWlsOcH87wU4uzldAE/7F/6Lw==
+  dependencies:
+    "@types/events" "*"
+    "@types/inquirer" "*"
+
+"@webcomponents/webcomponentsjs@^1.0.7":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
+  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
+
+accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
+  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
+  dependencies:
+    mime-types "~2.1.24"
+    negotiator "0.6.2"
+
+accessibility-developer-tools@^2.12.0:
+  version "2.12.0"
+  resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
+  integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
+
+acorn-jsx@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
+  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
+  dependencies:
+    acorn "^3.0.4"
+
+acorn@^3.0.4:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
+  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
+
+acorn@^5.5.0:
+  version "5.7.3"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
+  integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==
+
+acorn@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
+  integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
+
+adm-zip@~0.4.3:
+  version "0.4.13"
+  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
+  integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
+
+after@0.8.2:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
+  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
+
+agent-base@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
+ajv@^6.5.5:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.11.0.tgz#c3607cbc8ae392d8a5a536f25b21f8e5f3f87fe9"
+  integrity sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ansi-align@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-1.1.0.tgz#2f0c1658829739add5ebb15e6b0c6e3423f016ba"
+  integrity sha1-LwwWWIKXOa3V67FeawxuNCPwFro=
+  dependencies:
+    string-width "^1.0.1"
+
+ansi-align@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
+  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
+  dependencies:
+    string-width "^2.0.0"
+
+ansi-escape-sequences@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-escape-sequences/-/ansi-escape-sequences-3.0.0.tgz#1c18394b6af9b76ff9a63509fa497669fd2ce53e"
+  integrity sha1-HBg5S2r5t2/5pjUJ+kl2af0s5T4=
+  dependencies:
+    array-back "^1.0.3"
+
+ansi-escapes@^1.1.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
+  integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=
+
+ansi-escapes@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b"
+  integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==
+
+ansi-regex@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+
+ansi-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+
+ansi-regex@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
+  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
+
+ansi-styles@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
+  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
+
+ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+ansi-styles@^4.1.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359"
+  integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==
+  dependencies:
+    "@types/color-name" "^1.1.1"
+    color-convert "^2.0.1"
+
+ansi-styles@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
+  integrity sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=
+
+any-promise@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
+  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
+
+anymatch@^1.3.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
+  integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==
+  dependencies:
+    micromatch "^2.1.5"
+    normalize-path "^2.0.0"
+
+append-field@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
+  integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
+
+archiver-utils@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
+  integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==
+  dependencies:
+    glob "^7.1.4"
+    graceful-fs "^4.2.0"
+    lazystream "^1.0.0"
+    lodash.defaults "^4.2.0"
+    lodash.difference "^4.5.0"
+    lodash.flatten "^4.4.0"
+    lodash.isplainobject "^4.0.6"
+    lodash.union "^4.6.0"
+    normalize-path "^3.0.0"
+    readable-stream "^2.0.0"
+
+archiver@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
+  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
+  dependencies:
+    archiver-utils "^2.1.0"
+    async "^2.6.3"
+    buffer-crc32 "^0.2.1"
+    glob "^7.1.4"
+    readable-stream "^3.4.0"
+    tar-stream "^2.1.0"
+    zip-stream "^2.1.2"
+
+arr-diff@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
+  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
+  dependencies:
+    arr-flatten "^1.0.1"
+
+arr-diff@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
+  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
+
+arr-flatten@^1.0.1, arr-flatten@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
+
+arr-union@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
+  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
+
+array-back@^1.0.3, array-back@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-1.0.4.tgz#644ba7f095f7ffcf7c43b5f0dc39d3c1f03c063b"
+  integrity sha1-ZEun8JX3/898Q7Xw3DnTwfA8Bjs=
+  dependencies:
+    typical "^2.6.0"
+
+array-back@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022"
+  integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==
+  dependencies:
+    typical "^2.6.1"
+
+array-back@^3.0.1:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-differ@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
+  integrity sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=
+
+array-find-index@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
+  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
+
+array-flatten@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
+
+array-union@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
+  integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=
+  dependencies:
+    array-uniq "^1.0.1"
+
+array-uniq@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
+  integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=
+
+array-unique@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
+  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
+
+array-unique@^0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
+  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
+
+arraybuffer.slice@~0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
+  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
+
+arrify@^1.0.0, arrify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
+  integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
+
+asn1@~0.2.3:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
+  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
+  dependencies:
+    safer-buffer "~2.1.0"
+
+assert-plus@1.0.0, assert-plus@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
+  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
+
+assign-symbols@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
+  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
+
+async-each@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
+  integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
+
+async-limiter@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
+  integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
+
+async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.0, async@^2.6.1, async@^2.6.2, async@^2.6.3:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
+  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+  dependencies:
+    lodash "^4.17.14"
+
+async@~0.2.9:
+  version "0.2.10"
+  resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
+  integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
+
+asynckit@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+
+atob-lite@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
+  integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=
+
+atob@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
+  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
+
+aws-sign2@~0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+
+aws4@^1.8.0:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"
+  integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==
+
+babel-code-frame@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
+  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
+  dependencies:
+    chalk "^1.1.3"
+    esutils "^2.0.2"
+    js-tokens "^3.0.2"
+
+babel-generator@^6.26.1:
+  version "6.26.1"
+  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
+  integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==
+  dependencies:
+    babel-messages "^6.23.0"
+    babel-runtime "^6.26.0"
+    babel-types "^6.26.0"
+    detect-indent "^4.0.0"
+    jsesc "^1.3.0"
+    lodash "^4.17.4"
+    source-map "^0.5.7"
+    trim-right "^1.0.1"
+
+babel-helper-evaluate-path@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.5.0.tgz#a62fa9c4e64ff7ea5cea9353174ef023a900a67c"
+  integrity sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==
+
+babel-helper-flip-expressions@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.4.3.tgz#3696736a128ac18bc25254b5f40a22ceb3c1d3fd"
+  integrity sha1-NpZzahKKwYvCUlS19AoizrPB0/0=
+
+babel-helper-is-nodes-equiv@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
+  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
+
+babel-helper-is-void-0@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.4.3.tgz#7d9c01b4561e7b95dbda0f6eee48f5b60e67313e"
+  integrity sha1-fZwBtFYee5Xb2g9u7kj1tg5nMT4=
+
+babel-helper-mark-eval-scopes@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.4.3.tgz#d244a3bef9844872603ffb46e22ce8acdf551562"
+  integrity sha1-0kSjvvmESHJgP/tG4izorN9VFWI=
+
+babel-helper-remove-or-void@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.4.3.tgz#a4f03b40077a0ffe88e45d07010dee241ff5ae60"
+  integrity sha1-pPA7QAd6D/6I5F0HAQ3uJB/1rmA=
+
+babel-helper-to-multiple-sequence-expressions@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d"
+  integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==
+
+babel-messages@^6.23.0:
+  version "6.23.0"
+  resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
+  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
+  dependencies:
+    babel-runtime "^6.22.0"
+
+babel-plugin-dynamic-import-node@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f"
+  integrity sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==
+  dependencies:
+    object.assign "^4.1.0"
+
+babel-plugin-minify-builtins@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.5.0.tgz#31eb82ed1a0d0efdc31312f93b6e4741ce82c36b"
+  integrity sha512-wpqbN7Ov5hsNwGdzuzvFcjgRlzbIeVv1gMIlICbPj0xkexnfoIDe7q+AZHMkQmAE/F9R5jkrB6TLfTegImlXag==
+
+babel-plugin-minify-constant-folding@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.5.0.tgz#f84bc8dbf6a561e5e350ff95ae216b0ad5515b6e"
+  integrity sha512-Vj97CTn/lE9hR1D+jKUeHfNy+m1baNiJ1wJvoGyOBUx7F7kJqDZxr9nCHjO/Ad+irbR3HzR6jABpSSA29QsrXQ==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+
+babel-plugin-minify-dead-code-elimination@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.1.tgz#1a0c68e44be30de4976ca69ffc535e08be13683f"
+  integrity sha512-x8OJOZIrRmQBcSqxBcLbMIK8uPmTvNWPXH2bh5MDCW1latEqYiRMuUkPImKcfpo59pTUB2FT7HfcgtG8ZlR5Qg==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+    babel-helper-mark-eval-scopes "^0.4.3"
+    babel-helper-remove-or-void "^0.4.3"
+    lodash "^4.17.11"
+
+babel-plugin-minify-flip-comparisons@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.4.3.tgz#00ca870cb8f13b45c038b3c1ebc0f227293c965a"
+  integrity sha1-AMqHDLjxO0XAOLPB68DyJyk8llo=
+  dependencies:
+    babel-helper-is-void-0 "^0.4.3"
+
+babel-plugin-minify-guarded-expressions@^0.4.3, babel-plugin-minify-guarded-expressions@^0.4.4:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.4.tgz#818960f64cc08aee9d6c75bec6da974c4d621135"
+  integrity sha512-RMv0tM72YuPPfLT9QLr3ix9nwUIq+sHT6z8Iu3sLbqldzC1Dls8DPCywzUIzkTx9Zh1hWX4q/m9BPoPed9GOfA==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+    babel-helper-flip-expressions "^0.4.3"
+
+babel-plugin-minify-infinity@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.4.3.tgz#dfb876a1b08a06576384ef3f92e653ba607b39ca"
+  integrity sha1-37h2obCKBldjhO8/kuZTumB7Oco=
+
+babel-plugin-minify-mangle-names@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.5.0.tgz#bcddb507c91d2c99e138bd6b17a19c3c271e3fd3"
+  integrity sha512-3jdNv6hCAw6fsX1p2wBGPfWuK69sfOjfd3zjUXkbq8McbohWy23tpXfy5RnToYWggvqzuMOwlId1PhyHOfgnGw==
+  dependencies:
+    babel-helper-mark-eval-scopes "^0.4.3"
+
+babel-plugin-minify-numeric-literals@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.4.3.tgz#8e4fd561c79f7801286ff60e8c5fd9deee93c0bc"
+  integrity sha1-jk/VYcefeAEob/YOjF/Z3u6TwLw=
+
+babel-plugin-minify-replace@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.5.0.tgz#d3e2c9946c9096c070efc96761ce288ec5c3f71c"
+  integrity sha512-aXZiaqWDNUbyNNNpWs/8NyST+oU7QTpK7J9zFEFSA0eOmtUNMU3fczlTTTlnCxHmq/jYNFEmkkSG3DDBtW3Y4Q==
+
+babel-plugin-minify-simplify@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.1.tgz#f21613c8b95af3450a2ca71502fdbd91793c8d6a"
+  integrity sha512-OSYDSnoCxP2cYDMk9gxNAed6uJDiDz65zgL6h8d3tm8qXIagWGMLWhqysT6DY3Vs7Fgq7YUDcjOomhVUb+xX6A==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+    babel-helper-flip-expressions "^0.4.3"
+    babel-helper-is-nodes-equiv "^0.0.1"
+    babel-helper-to-multiple-sequence-expressions "^0.5.0"
+
+babel-plugin-minify-type-constructors@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.4.3.tgz#1bc6f15b87f7ab1085d42b330b717657a2156500"
+  integrity sha1-G8bxW4f3qxCF1CszC3F2V6IVZQA=
+  dependencies:
+    babel-helper-is-void-0 "^0.4.3"
+
+babel-plugin-transform-inline-consecutive-adds@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.4.3.tgz#323d47a3ea63a83a7ac3c811ae8e6941faf2b0d1"
+  integrity sha1-Mj1Ho+pjqDp6w8gRro5pQfrysNE=
+
+babel-plugin-transform-member-expression-literals@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
+  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
+
+babel-plugin-transform-merge-sibling-variables@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
+  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
+
+babel-plugin-transform-minify-booleans@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
+  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
+
+babel-plugin-transform-property-literals@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
+  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
+  dependencies:
+    esutils "^2.0.2"
+
+babel-plugin-transform-regexp-constructors@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.4.3.tgz#58b7775b63afcf33328fae9a5f88fbd4fb0b4965"
+  integrity sha1-WLd3W2OvzzMyj66aX4j71PsLSWU=
+
+babel-plugin-transform-remove-console@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
+  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
+
+babel-plugin-transform-remove-debugger@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
+  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
+
+babel-plugin-transform-remove-undefined@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.5.0.tgz#80208b31225766c630c97fa2d288952056ea22dd"
+  integrity sha512-+M7fJYFaEE/M9CXa0/IRkDbiV3wRELzA1kKQFCJ4ifhrzLKn/9VCCgj9OFmYWwBd8IB48YdgPkHYtbYq+4vtHQ==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+
+babel-plugin-transform-simplify-comparison-operators@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
+  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
+
+babel-plugin-transform-undefined-to-void@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
+  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
+
+babel-preset-minify@^0.5.0:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.1.tgz#25f5d0bce36ec818be80338d0e594106e21eaa9f"
+  integrity sha512-1IajDumYOAPYImkHbrKeiN5AKKP9iOmRoO2IPbIuVp0j2iuCcj0n7P260z38siKMZZ+85d3mJZdtW8IgOv+Tzg==
+  dependencies:
+    babel-plugin-minify-builtins "^0.5.0"
+    babel-plugin-minify-constant-folding "^0.5.0"
+    babel-plugin-minify-dead-code-elimination "^0.5.1"
+    babel-plugin-minify-flip-comparisons "^0.4.3"
+    babel-plugin-minify-guarded-expressions "^0.4.4"
+    babel-plugin-minify-infinity "^0.4.3"
+    babel-plugin-minify-mangle-names "^0.5.0"
+    babel-plugin-minify-numeric-literals "^0.4.3"
+    babel-plugin-minify-replace "^0.5.0"
+    babel-plugin-minify-simplify "^0.5.1"
+    babel-plugin-minify-type-constructors "^0.4.3"
+    babel-plugin-transform-inline-consecutive-adds "^0.4.3"
+    babel-plugin-transform-member-expression-literals "^6.9.4"
+    babel-plugin-transform-merge-sibling-variables "^6.9.4"
+    babel-plugin-transform-minify-booleans "^6.9.4"
+    babel-plugin-transform-property-literals "^6.9.4"
+    babel-plugin-transform-regexp-constructors "^0.4.3"
+    babel-plugin-transform-remove-console "^6.9.4"
+    babel-plugin-transform-remove-debugger "^6.9.4"
+    babel-plugin-transform-remove-undefined "^0.5.0"
+    babel-plugin-transform-simplify-comparison-operators "^6.9.4"
+    babel-plugin-transform-undefined-to-void "^6.9.4"
+    lodash "^4.17.11"
+
+babel-runtime@^6.22.0, babel-runtime@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
+  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
+  dependencies:
+    core-js "^2.4.0"
+    regenerator-runtime "^0.11.0"
+
+babel-traverse@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
+  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
+  dependencies:
+    babel-code-frame "^6.26.0"
+    babel-messages "^6.23.0"
+    babel-runtime "^6.26.0"
+    babel-types "^6.26.0"
+    babylon "^6.18.0"
+    debug "^2.6.8"
+    globals "^9.18.0"
+    invariant "^2.2.2"
+    lodash "^4.17.4"
+
+babel-types@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
+  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
+  dependencies:
+    babel-runtime "^6.26.0"
+    esutils "^2.0.2"
+    lodash "^4.17.4"
+    to-fast-properties "^1.0.3"
+
+babylon@^6.18.0:
+  version "6.18.0"
+  resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
+  integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
+
+babylon@^7.0.0-beta.42:
+  version "7.0.0-beta.47"
+  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
+  integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
+
+backo2@1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
+  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
+
+balanced-match@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+
+base64-arraybuffer@0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
+  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+
+base64-js@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
+  integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
+
+base64-js@^1.0.2:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
+  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
+
+base64id@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
+  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
+
+base@^0.11.1:
+  version "0.11.2"
+  resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
+  integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==
+  dependencies:
+    cache-base "^1.0.1"
+    class-utils "^0.3.5"
+    component-emitter "^1.2.1"
+    define-property "^1.0.0"
+    isobject "^3.0.1"
+    mixin-deep "^1.2.0"
+    pascalcase "^0.1.1"
+
+bcrypt-pbkdf@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
+  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
+  dependencies:
+    tweetnacl "^0.14.3"
+
+before-after-hook@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635"
+  integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==
+
+better-assert@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
+  integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
+  dependencies:
+    callsite "1.0.0"
+
+binary-extensions@^1.0.0:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
+  integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
+
+binaryextensions@^2.1.2:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.2.0.tgz#e7c6ba82d4f5f5758c26078fe8eea28881233311"
+  integrity sha512-bHhs98rj/7i/RZpCSJ3uk55pLXOItjIrh2sRQZSM6OoktScX+LxJzvlU+FELp9j3TdcddTmmYArLSGptCTwjuw==
+
+bindings@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
+  integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
+  dependencies:
+    file-uri-to-path "1.0.0"
+
+bl@^1.0.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c"
+  integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==
+  dependencies:
+    readable-stream "^2.3.5"
+    safe-buffer "^5.1.1"
+
+bl@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.0.tgz#e1a574cdf528e4053019bb800b041c0ac88da493"
+  integrity sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==
+  dependencies:
+    readable-stream "^2.3.5"
+    safe-buffer "^5.1.1"
+
+bl@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
+  integrity sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==
+  dependencies:
+    readable-stream "^3.0.1"
+
+blob@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
+  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
+
+body-parser@1.19.0, body-parser@^1.17.2:
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
+  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+  dependencies:
+    bytes "3.1.0"
+    content-type "~1.0.4"
+    debug "2.6.9"
+    depd "~1.1.2"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    on-finished "~2.3.0"
+    qs "6.7.0"
+    raw-body "2.4.0"
+    type-is "~1.6.17"
+
+bower-config@^1.4.0, bower-config@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.1.tgz#85fd9df367c2b8dbbd0caa4c5f2bad40cd84c2cc"
+  integrity sha1-hf2d82fCuNu9DKpMXyutQM2Ewsw=
+  dependencies:
+    graceful-fs "^4.1.3"
+    mout "^1.0.0"
+    optimist "^0.6.1"
+    osenv "^0.1.3"
+    untildify "^2.1.0"
+
+bower-json@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/bower-json/-/bower-json-0.8.1.tgz#96c14723241ae6466a9c52e16caa32623a883843"
+  integrity sha1-lsFHIyQa5kZqnFLhbKoyYjqIOEM=
+  dependencies:
+    deep-extend "^0.4.0"
+    ext-name "^3.0.0"
+    graceful-fs "^4.1.3"
+    intersect "^1.0.1"
+
+bower-logger@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/bower-logger/-/bower-logger-0.2.2.tgz#39be07e979b2fc8e03a94634205ed9422373d381"
+  integrity sha1-Ob4H6Xmy/I4DqUY0IF7ZQiNz04E=
+
+bower@^1.8.8:
+  version "1.8.8"
+  resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.8.tgz#82544be34a33aeae7efb8bdf9905247b2cffa985"
+  integrity sha512-1SrJnXnkP9soITHptSO+ahx3QKp3cVzn8poI6ujqc5SeOkg5iqM1pK9H+DSc2OQ8SnO0jC/NG4Ur/UIwy7574A==
+
+boxen@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-0.6.0.tgz#8364d4248ac34ff0ef1b2f2bf49a6c60ce0d81b6"
+  integrity sha1-g2TUJIrDT/DvGy8r9JpsYM4NgbY=
+  dependencies:
+    ansi-align "^1.1.0"
+    camelcase "^2.1.0"
+    chalk "^1.1.1"
+    cli-boxes "^1.0.0"
+    filled-array "^1.0.0"
+    object-assign "^4.0.1"
+    repeating "^2.0.0"
+    string-width "^1.0.1"
+    widest-line "^1.0.0"
+
+boxen@^1.2.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
+  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
+  dependencies:
+    ansi-align "^2.0.0"
+    camelcase "^4.0.0"
+    chalk "^2.0.1"
+    cli-boxes "^1.0.0"
+    string-width "^2.0.0"
+    term-size "^1.2.0"
+    widest-line "^2.0.0"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+braces@^1.8.2:
+  version "1.8.5"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
+  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
+  dependencies:
+    expand-range "^1.8.1"
+    preserve "^0.2.0"
+    repeat-element "^1.1.2"
+
+braces@^2.3.1:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
+  integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==
+  dependencies:
+    arr-flatten "^1.1.0"
+    array-unique "^0.3.2"
+    extend-shallow "^2.0.1"
+    fill-range "^4.0.0"
+    isobject "^3.0.1"
+    repeat-element "^1.1.2"
+    snapdragon "^0.8.1"
+    snapdragon-node "^2.0.1"
+    split-string "^3.0.2"
+    to-regex "^3.0.1"
+
+browser-capabilities@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/browser-capabilities/-/browser-capabilities-1.1.4.tgz#a6bd657a07a134532ad66c722b8949904478b973"
+  integrity sha512-BezMQhbQklxjRQpZZQ8tnbzEo6AldUwMh8/PeWt5/CTBSwByQRXZEAK2fbnEahQ4poeeaI0suAYRq25A1YGOmw==
+  dependencies:
+    "@types/ua-parser-js" "^0.7.31"
+    ua-parser-js "^0.7.15"
+
+browserify-zlib@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d"
+  integrity sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=
+  dependencies:
+    pako "~0.2.0"
+
+browserstack@^1.2.0:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.5.3.tgz#93ab48799a12ef99dbd074dd595410ddb196a7ac"
+  integrity sha512-AO+mECXsW4QcqC9bxwM29O7qWa7bJT94uBFzeb5brylIQwawuEziwq20dPYbins95GlWzOawgyDNdjYAo32EKg==
+  dependencies:
+    https-proxy-agent "^2.2.1"
+
+btoa-lite@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
+  integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
+
+buffer-alloc-unsafe@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
+  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
+
+buffer-alloc@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
+  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
+  dependencies:
+    buffer-alloc-unsafe "^1.1.0"
+    buffer-fill "^1.0.0"
+
+buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
+
+buffer-fill@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
+  integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
+
+buffer-from@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+
+buffer@^5.1.0:
+  version "5.4.3"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
+  integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
+  dependencies:
+    base64-js "^1.0.2"
+    ieee754 "^1.1.4"
+
+builtin-modules@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484"
+  integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==
+
+busboy@^0.2.11:
+  version "0.2.14"
+  resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
+  integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
+  dependencies:
+    dicer "0.2.5"
+    readable-stream "1.1.x"
+
+bytes@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
+  integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
+
+bytes@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
+  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
+
+cache-base@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
+  integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==
+  dependencies:
+    collection-visit "^1.0.0"
+    component-emitter "^1.2.1"
+    get-value "^2.0.6"
+    has-value "^1.0.0"
+    isobject "^3.0.1"
+    set-value "^2.0.0"
+    to-object-path "^0.3.0"
+    union-value "^1.0.0"
+    unset-value "^1.0.0"
+
+call-me-maybe@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
+  integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
+
+callsite@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
+  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
+
+camel-case@3.0.x:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
+  integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
+  dependencies:
+    no-case "^2.2.0"
+    upper-case "^1.1.1"
+
+camelcase-keys@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
+  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
+  dependencies:
+    camelcase "^2.0.0"
+    map-obj "^1.0.0"
+
+camelcase@^2.0.0, camelcase@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
+  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
+
+camelcase@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
+  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
+
+cancel-token@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
+  integrity sha1-wYGXZ0uxyEwdaTPr8V2NWlznm08=
+  dependencies:
+    "@types/node" "^4.0.30"
+
+capture-stack-trace@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
+  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
+
+caseless@~0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
+  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
+
+chalk@*:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
+  integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
+chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
+  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
+  dependencies:
+    ansi-styles "^2.2.1"
+    escape-string-regexp "^1.0.2"
+    has-ansi "^2.0.0"
+    strip-ansi "^3.0.0"
+    supports-color "^2.0.0"
+
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+chalk@~0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
+  integrity sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=
+  dependencies:
+    ansi-styles "~1.0.0"
+    has-color "~0.1.0"
+    strip-ansi "~0.1.0"
+
+chardet@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
+  integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
+
+charenc@~0.0.1:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
+  integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
+
+chokidar@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
+  integrity sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=
+  dependencies:
+    anymatch "^1.3.0"
+    async-each "^1.0.0"
+    glob-parent "^2.0.0"
+    inherits "^2.0.1"
+    is-binary-path "^1.0.0"
+    is-glob "^2.0.0"
+    path-is-absolute "^1.0.0"
+    readdirp "^2.0.0"
+  optionalDependencies:
+    fsevents "^1.0.0"
+
+chownr@^1.0.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142"
+  integrity sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==
+
+ci-info@^1.5.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
+  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
+
+class-utils@^0.3.5:
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
+  integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==
+  dependencies:
+    arr-union "^3.1.0"
+    define-property "^0.2.5"
+    isobject "^3.0.0"
+    static-extend "^0.1.1"
+
+clean-css@4.2.x:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.2.tgz#8519abda724b3e759bc79d196369906925d81a3f"
+  integrity sha512-yKycArwReQXbOD/3pmsPmt6p7oUBww8MisDabL2pCUWkbVONvCJoBdCjgY4ZVQmKX5juz/JB9oDcP6XzGUpjwQ==
+  dependencies:
+    source-map "~0.6.0"
+
+cleankill@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/cleankill/-/cleankill-2.0.0.tgz#59830dfc8b411d53dc72ad09d45a78ea33161a91"
+  integrity sha1-WYMN/ItBHVPccq0J1Fp46jMWGpE=
+
+cli-boxes@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
+  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
+
+cli-cursor@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
+  integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=
+  dependencies:
+    restore-cursor "^1.0.1"
+
+cli-cursor@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
+  integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
+  dependencies:
+    restore-cursor "^2.0.0"
+
+cli-table@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23"
+  integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM=
+  dependencies:
+    colors "1.0.3"
+
+cli-width@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
+  integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=
+
+clone-buffer@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
+  integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
+
+clone-stats@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
+  integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=
+
+clone-stats@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
+  integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
+
+clone@^1.0.0, clone@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
+  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
+
+clone@^2.0.0, clone@^2.1.0, clone@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
+
+cloneable-readable@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec"
+  integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==
+  dependencies:
+    inherits "^2.0.1"
+    process-nextick-args "^2.0.0"
+    readable-stream "^2.3.5"
+
+code-point-at@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+
+collection-visit@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
+  integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=
+  dependencies:
+    map-visit "^1.0.0"
+    object-visit "^1.0.0"
+
+color-convert@^1.9.0, color-convert@^1.9.1:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
+color-name@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
+
+color-name@^1.0.0, color-name@~1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+color-string@^1.5.2:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
+  integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
+  dependencies:
+    color-name "^1.0.0"
+    simple-swizzle "^0.2.2"
+
+color@3.0.x:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
+  integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==
+  dependencies:
+    color-convert "^1.9.1"
+    color-string "^1.5.2"
+
+colornames@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96"
+  integrity sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=
+
+colors@1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
+  integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
+
+colors@^1.2.1:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
+  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
+
+colorspace@1.1.x:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5"
+  integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==
+  dependencies:
+    color "3.0.x"
+    text-hex "1.0.x"
+
+combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+  dependencies:
+    delayed-stream "~1.0.0"
+
+command-line-args@^3.0.1:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-3.0.5.tgz#5bd4ad45e7983e5c1344918e40280ee2693c5ac0"
+  integrity sha1-W9StReeYPlwTRJGOQCgO4mk8WsA=
+  dependencies:
+    array-back "^1.0.4"
+    feature-detect-es6 "^1.3.1"
+    find-replace "^1.0.2"
+    typical "^2.6.0"
+
+command-line-args@^5.0.2:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.1.1.tgz#88e793e5bb3ceb30754a86863f0401ac92fd369a"
+  integrity sha512-hL/eG8lrll1Qy1ezvkant+trihbGnaKaeEjj6Scyr3DN+RC7iQ5Rz84IeLERfAWDGo0HBSNAakczwgCilDXnWg==
+  dependencies:
+    array-back "^3.0.1"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-commands@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/command-line-commands/-/command-line-commands-2.0.1.tgz#c58aa13dc78c06038ed67077e57ad09a6f858f46"
+  integrity sha512-m8c2p1DrNd2ruIAggxd/y6DgygQayf6r8RHwchhXryaLF8I6koYjoYroVP+emeROE9DXN5b9sP1Gh+WtvTTdtQ==
+  dependencies:
+    array-back "^2.0.0"
+
+command-line-usage@^3.0.8:
+  version "3.0.8"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-3.0.8.tgz#b6a20978c1b383477f5c11a529428b880bfe0f4d"
+  integrity sha1-tqIJeMGzg0d/XBGlKUKLiAv+D00=
+  dependencies:
+    ansi-escape-sequences "^3.0.0"
+    array-back "^1.0.3"
+    feature-detect-es6 "^1.3.1"
+    table-layout "^0.3.0"
+    typical "^2.6.0"
+
+command-line-usage@^5.0.5:
+  version "5.0.5"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-5.0.5.tgz#5f25933ffe6dedd983c635d38a21d7e623fda357"
+  integrity sha512-d8NrGylA5oCXSbGoKz05FkehDAzSmIm4K03S5VDh4d5lZAtTWfc3D1RuETtuQCn8129nYfJfDdF7P/lwcz1BlA==
+  dependencies:
+    array-back "^2.0.0"
+    chalk "^2.4.1"
+    table-layout "^0.4.3"
+    typical "^2.6.1"
+
+commander@2.17.x:
+  version "2.17.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
+  integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
+
+commander@^2.19.0, commander@^2.20.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+commander@~2.19.0:
+  version "2.19.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
+  integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
+
+commondir@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
+  integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
+
+component-bind@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
+  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
+
+component-emitter@1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
+  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
+
+component-emitter@^1.2.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
+  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
+
+component-inherit@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
+  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
+
+compress-commons@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
+  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
+  dependencies:
+    buffer-crc32 "^0.2.13"
+    crc32-stream "^3.0.1"
+    normalize-path "^3.0.0"
+    readable-stream "^2.3.6"
+
+compressible@~2.0.16:
+  version "2.0.18"
+  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
+  integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
+  dependencies:
+    mime-db ">= 1.43.0 < 2"
+
+compression@^1.6.2:
+  version "1.7.4"
+  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
+  integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
+  dependencies:
+    accepts "~1.3.5"
+    bytes "3.0.0"
+    compressible "~2.0.16"
+    debug "2.6.9"
+    on-headers "~1.0.2"
+    safe-buffer "5.1.2"
+    vary "~1.1.2"
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+concat-stream@^1.4.7, concat-stream@^1.5.2:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
+  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
+  dependencies:
+    buffer-from "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^2.2.2"
+    typedarray "^0.0.6"
+
+configstore@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-2.1.0.tgz#737a3a7036e9886102aa6099e47bb33ab1aba1a1"
+  integrity sha1-c3o6cDbpiGECqmCZ5HuzOrGroaE=
+  dependencies:
+    dot-prop "^3.0.0"
+    graceful-fs "^4.1.2"
+    mkdirp "^0.5.0"
+    object-assign "^4.0.1"
+    os-tmpdir "^1.0.0"
+    osenv "^0.1.0"
+    uuid "^2.0.1"
+    write-file-atomic "^1.1.2"
+    xdg-basedir "^2.0.0"
+
+configstore@^3.0.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
+  integrity sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==
+  dependencies:
+    dot-prop "^4.1.0"
+    graceful-fs "^4.1.2"
+    make-dir "^1.0.0"
+    unique-string "^1.0.0"
+    write-file-atomic "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+content-disposition@0.5.3:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
+  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
+  dependencies:
+    safe-buffer "5.1.2"
+
+content-type@^1.0.2, content-type@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+convert-source-map@^1.1.1, convert-source-map@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
+  integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
+  dependencies:
+    safe-buffer "~5.1.1"
+
+cookie-signature@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
+  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
+
+cookie@0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
+  integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
+
+cookie@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
+  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
+
+copy-descriptor@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
+  integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
+
+core-js@^2.4.0, core-js@^2.4.1:
+  version "2.6.11"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
+  integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
+
+core-util-is@1.0.2, core-util-is@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+
+cors@^2.8.4:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
+  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
+  dependencies:
+    object-assign "^4"
+    vary "^1"
+
+crc32-stream@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
+  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
+  dependencies:
+    crc "^3.4.4"
+    readable-stream "^3.4.0"
+
+crc@^3.4.4:
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
+  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
+  dependencies:
+    buffer "^5.1.0"
+
+create-error-class@^3.0.0, create-error-class@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
+  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
+  dependencies:
+    capture-stack-trace "^1.0.0"
+
+crisper@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/crisper/-/crisper-2.1.1.tgz#4cc7321c3e90f3c5cbdc3503217f118fd7d5c51c"
+  integrity sha512-yxfj9nTbFunDASztAxVF8hCPwaZBvTjayNzG3YL/VVQfQaKBXX2+TM3p1xB1Pxd8RYeDQJkJIQRwM3FQSIa+pw==
+  dependencies:
+    command-line-args "^3.0.1"
+    command-line-usage "^3.0.8"
+    dom5 "^1.0.1"
+
+cross-spawn@^5.0.1:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
+  dependencies:
+    lru-cache "^4.0.1"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+cross-spawn@^6.0.0, cross-spawn@^6.0.5:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
+  dependencies:
+    nice-try "^1.0.4"
+    path-key "^2.0.1"
+    semver "^5.5.0"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+crypt@~0.0.1:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
+  integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
+
+crypto-random-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
+  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
+
+css-slam@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
+  integrity sha512-cObrY+mhFEmepWpua6EpMrgRNTQ0eeym+kvR0lukI6hDEzK7F8himEDS4cJ9+fPHCoArTzVrrR0d+oAUbTR1NA==
+  dependencies:
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    dom5 "^3.0.0"
+    parse5 "^4.0.0"
+    shady-css-parser "^0.1.0"
+
+css-what@^2.1.0:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
+  integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
+
+cssbeautify@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
+  integrity sha1-Et0fc0A1wub6ymfcvc73TkKBE5c=
+
+currently-unhandled@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
+  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
+  dependencies:
+    array-find-index "^1.0.1"
+
+dargs@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/dargs/-/dargs-6.1.0.tgz#1f3b9b56393ecf8caa7cbfd6c31496ffcfb9b272"
+  integrity sha512-5dVBvpBLBnPwSsYXqfybFyehMmC/EenKEcf23AhCTgTf48JFBbmJKqoZBsERDnjL0FyiVTYWdFsRfTLHxLyKdQ==
+
+dashdash@^1.12.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
+  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
+  dependencies:
+    assert-plus "^1.0.0"
+
+dateformat@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
+  integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
+
+debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@^3.0.0, debug@^3.1.0:
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+  dependencies:
+    ms "^2.1.1"
+
+debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+  dependencies:
+    ms "^2.1.1"
+
+debug@~3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+  dependencies:
+    ms "2.0.0"
+
+decamelize@^1.1.2:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+  integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
+
+decode-uri-component@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
+  integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
+
+decompress-response@^3.2.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
+  integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
+  dependencies:
+    mimic-response "^1.0.0"
+
+deep-extend@^0.4.0, deep-extend@~0.4.1:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
+  integrity sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=
+
+deep-extend@^0.6.0, deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+define-properties@^1.1.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
+  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
+  dependencies:
+    object-keys "^1.0.12"
+
+define-property@^0.2.5:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
+  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
+  dependencies:
+    is-descriptor "^0.1.0"
+
+define-property@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
+  integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY=
+  dependencies:
+    is-descriptor "^1.0.0"
+
+define-property@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
+  integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==
+  dependencies:
+    is-descriptor "^1.0.2"
+    isobject "^3.0.1"
+
+del@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5"
+  integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=
+  dependencies:
+    globby "^6.1.0"
+    is-path-cwd "^1.0.0"
+    is-path-in-cwd "^1.0.0"
+    p-map "^1.1.1"
+    pify "^3.0.0"
+    rimraf "^2.2.8"
+
+delayed-stream@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
+
+deprecation@^2.0.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
+  integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
+
+destroy@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
+  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
+
+detect-conflict@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/detect-conflict/-/detect-conflict-1.0.1.tgz#088657a66a961c05019db7c4230883b1c6b4176e"
+  integrity sha1-CIZXpmqWHAUBnbfEIwiDsca0F24=
+
+detect-file@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63"
+  integrity sha1-STXe39lIhkjgBrASlWbpOGcR6mM=
+  dependencies:
+    fs-exists-sync "^0.1.0"
+
+detect-file@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
+  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
+
+detect-indent@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
+  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
+  dependencies:
+    repeating "^2.0.0"
+
+detect-node@^2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
+  integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
+
+diagnostics@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a"
+  integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==
+  dependencies:
+    colorspace "1.1.x"
+    enabled "1.0.x"
+    kuler "1.0.x"
+
+dicer@0.2.5:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
+  integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
+  dependencies:
+    readable-stream "1.1.x"
+    streamsearch "0.1.2"
+
+diff@^2.1.2:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99"
+  integrity sha1-YOr9DSjukG5Oj/ClLBIpUhAzv5k=
+
+diff@^3.1.0, diff@^3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
+  integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
+
+dir-glob@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
+  integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==
+  dependencies:
+    arrify "^1.0.1"
+    path-type "^3.0.0"
+
+doctrine@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
+  integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
+  dependencies:
+    esutils "^2.0.2"
+
+dom-urls@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
+  integrity sha1-AB3fgWKM0ecGElxxdvU8zsVdkY4=
+  dependencies:
+    urijs "^1.16.1"
+
+dom5@^1.0.1:
+  version "1.3.6"
+  resolved "https://registry.yarnpkg.com/dom5/-/dom5-1.3.6.tgz#a7088a9fc5f3b08dc9f6eda4c7abaeb241945e0d"
+  integrity sha1-pwiKn8XzsI3J9u2kx6uuskGUXg0=
+  dependencies:
+    "@types/clone" "^0.1.29"
+    "@types/node" "^4.0.30"
+    "@types/parse5" "^0.0.31"
+    clone "^1.0.2"
+    parse5 "^1.4.1"
+
+dom5@^3.0.0, dom5@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dom5/-/dom5-3.0.1.tgz#cdfc7331f376e284bf379e6ea054afc136702944"
+  integrity sha512-JPFiouQIr16VQ4dX6i0+Hpbg3H2bMKPmZ+WZgBOSSvOPx9QHwwY8sPzeM2baUtViESYto6wC2nuZOMC/6gulcA==
+  dependencies:
+    "@types/parse5" "^2.2.34"
+    clone "^2.1.0"
+    parse5 "^4.0.0"
+
+dot-prop@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177"
+  integrity sha1-G3CK8JSknJoOfbyteQq6U52sEXc=
+  dependencies:
+    is-obj "^1.0.0"
+
+dot-prop@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
+  integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==
+  dependencies:
+    is-obj "^1.0.0"
+
+duplexer2@^0.1.2, duplexer2@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
+  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
+  dependencies:
+    readable-stream "^2.0.2"
+
+duplexer3@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
+
+duplexify@^3.2.0, duplexify@^3.5.0, duplexify@^3.6.0:
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
+  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
+  dependencies:
+    end-of-stream "^1.0.0"
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
+    stream-shift "^1.0.0"
+
+ecc-jsbn@~0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
+  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+  dependencies:
+    jsbn "~0.1.0"
+    safer-buffer "^2.1.0"
+
+editions@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/editions/-/editions-2.3.0.tgz#47f2d5309340bce93ab5eb6ad755b9e90ff825e4"
+  integrity sha512-jeXYwHPKbitU1l14dWlsl5Nm+b1Hsm7VX73BsrQ4RVwEcAQQIPFHTZAbVtuIGxZBrpdT2FXd8lbtrNBrzZxIsA==
+  dependencies:
+    errlop "^2.0.0"
+    semver "^6.3.0"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
+
+ejs@^2.5.9:
+  version "2.7.4"
+  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
+  integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
+
+emitter-component@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
+  integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
+
+enabled@1.0.x:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93"
+  integrity sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=
+  dependencies:
+    env-variable "0.0.x"
+
+encodeurl@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
+
+end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+  dependencies:
+    once "^1.4.0"
+
+ends-with@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
+  integrity sha1-L52pjVelDP2kVxzkM5AAUA9Oa4o=
+
+engine.io-client@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700"
+  integrity sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==
+  dependencies:
+    component-emitter "1.2.1"
+    component-inherit "0.0.3"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    ws "~6.1.0"
+    xmlhttprequest-ssl "~1.5.4"
+    yeast "0.1.2"
+
+engine.io-parser@~2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.0.tgz#312c4894f57d52a02b420868da7b5c1c84af80ed"
+  integrity sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==
+  dependencies:
+    after "0.8.2"
+    arraybuffer.slice "~0.0.7"
+    base64-arraybuffer "0.1.5"
+    blob "0.0.5"
+    has-binary2 "~1.0.2"
+
+engine.io@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.0.tgz#3a962cc4535928c252759a00f98519cb46c53ff3"
+  integrity sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==
+  dependencies:
+    accepts "~1.3.4"
+    base64id "2.0.0"
+    cookie "0.3.1"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
+    ws "^7.1.2"
+
+env-variable@0.0.x:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
+  integrity sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==
+
+errlop@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/errlop/-/errlop-2.0.0.tgz#52b97d35da1b0795e2647b5d2d3a46d17776f55a"
+  integrity sha512-z00WIrQhtOMUnjdTG0O4f6hMG64EVccVDBy2WwgjcF8S4UB1exGYuc2OFwmdQmsJwLQVEIHWHPCz/omXXgAZHw==
+
+error-ex@^1.2.0, error-ex@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
+  dependencies:
+    is-arrayish "^0.2.1"
+
+error@^7.0.2:
+  version "7.2.1"
+  resolved "https://registry.yarnpkg.com/error/-/error-7.2.1.tgz#eab21a4689b5f684fc83da84a0e390de82d94894"
+  integrity sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA==
+  dependencies:
+    string-template "~0.2.1"
+
+es6-promise@^4.0.3, es6-promise@^4.0.5:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  dependencies:
+    es6-promise "^4.0.3"
+
+es6-promisify@^6.0.0:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.0.2.tgz#525c23725b8510f5f1f2feb5a1fbad93a93e29b4"
+  integrity sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==
+
+escape-html@^1.0.3, escape-html@~1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
+
+escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
+
+espree@^3.5.2:
+  version "3.5.4"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
+  integrity sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==
+  dependencies:
+    acorn "^5.5.0"
+    acorn-jsx "^3.0.0"
+
+estree-walker@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
+  integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
+
+esutils@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
+  integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
+
+etag@~1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
+
+eventemitter3@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
+  integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
+
+execa@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
+  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
+  dependencies:
+    cross-spawn "^5.0.1"
+    get-stream "^3.0.0"
+    is-stream "^1.1.0"
+    npm-run-path "^2.0.0"
+    p-finally "^1.0.0"
+    signal-exit "^3.0.0"
+    strip-eof "^1.0.0"
+
+execa@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
+  integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
+  dependencies:
+    cross-spawn "^6.0.0"
+    get-stream "^4.0.0"
+    is-stream "^1.1.0"
+    npm-run-path "^2.0.0"
+    p-finally "^1.0.0"
+    signal-exit "^3.0.0"
+    strip-eof "^1.0.0"
+
+exit-hook@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
+  integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=
+
+expand-brackets@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
+  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
+  dependencies:
+    is-posix-bracket "^0.1.0"
+
+expand-brackets@^2.1.4:
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
+  integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI=
+  dependencies:
+    debug "^2.3.3"
+    define-property "^0.2.5"
+    extend-shallow "^2.0.1"
+    posix-character-classes "^0.1.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+expand-range@^1.8.1:
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
+  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
+  dependencies:
+    fill-range "^2.1.0"
+
+expand-tilde@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449"
+  integrity sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=
+  dependencies:
+    os-homedir "^1.0.1"
+
+expand-tilde@^2.0.0, expand-tilde@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
+  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
+  dependencies:
+    homedir-polyfill "^1.0.1"
+
+express@^4.15.3, express@^4.8.5:
+  version "4.17.1"
+  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
+  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
+  dependencies:
+    accepts "~1.3.7"
+    array-flatten "1.1.1"
+    body-parser "1.19.0"
+    content-disposition "0.5.3"
+    content-type "~1.0.4"
+    cookie "0.4.0"
+    cookie-signature "1.0.6"
+    debug "2.6.9"
+    depd "~1.1.2"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    finalhandler "~1.1.2"
+    fresh "0.5.2"
+    merge-descriptors "1.0.1"
+    methods "~1.1.2"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    path-to-regexp "0.1.7"
+    proxy-addr "~2.0.5"
+    qs "6.7.0"
+    range-parser "~1.2.1"
+    safe-buffer "5.1.2"
+    send "0.17.1"
+    serve-static "1.14.1"
+    setprototypeof "1.1.1"
+    statuses "~1.5.0"
+    type-is "~1.6.18"
+    utils-merge "1.0.1"
+    vary "~1.1.2"
+
+ext-list@^2.0.0:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37"
+  integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==
+  dependencies:
+    mime-db "^1.28.0"
+
+ext-name@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ext-name/-/ext-name-3.0.0.tgz#07e4418737cb1f513c32c6ea48d8b8c8e0471abb"
+  integrity sha1-B+RBhzfLH1E8MsbqSNi4yOBHGrs=
+  dependencies:
+    ends-with "^0.2.0"
+    ext-list "^2.0.0"
+    meow "^3.1.0"
+    sort-keys-length "^1.0.0"
+
+extend-shallow@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
+  integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=
+  dependencies:
+    is-extendable "^0.1.0"
+
+extend-shallow@^3.0.0, extend-shallow@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
+  integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=
+  dependencies:
+    assign-symbols "^1.0.0"
+    is-extendable "^1.0.1"
+
+extend@^3.0.0, extend@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
+external-editor@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-1.1.1.tgz#12d7b0db850f7ff7e7081baf4005700060c4600b"
+  integrity sha1-Etew24UPf/fnCBuvQAVwAGDEYAs=
+  dependencies:
+    extend "^3.0.0"
+    spawn-sync "^1.0.15"
+    tmp "^0.0.29"
+
+external-editor@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
+  integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==
+  dependencies:
+    chardet "^0.7.0"
+    iconv-lite "^0.4.24"
+    tmp "^0.0.33"
+
+extglob@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
+  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
+  dependencies:
+    is-extglob "^1.0.0"
+
+extglob@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
+  integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==
+  dependencies:
+    array-unique "^0.3.2"
+    define-property "^1.0.0"
+    expand-brackets "^2.1.4"
+    extend-shallow "^2.0.1"
+    fragment-cache "^0.2.1"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+extsprintf@1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
+
+extsprintf@^1.2.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
+  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
+
+fast-deep-equal@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4"
+  integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==
+
+fast-glob@^2.0.2:
+  version "2.2.7"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
+  integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==
+  dependencies:
+    "@mrmlnc/readdir-enhanced" "^2.2.1"
+    "@nodelib/fs.stat" "^1.1.2"
+    glob-parent "^3.1.0"
+    is-glob "^4.0.0"
+    merge2 "^1.2.3"
+    micromatch "^3.1.10"
+
+fast-json-stable-stringify@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fast-levenshtein@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
+
+fast-safe-stringify@^2.0.4:
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
+  integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==
+
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+  dependencies:
+    pend "~1.2.0"
+
+feature-detect-es6@^1.3.1:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/feature-detect-es6/-/feature-detect-es6-1.5.0.tgz#a69bb7662c65f64f89f07eac5a461b649a1e0a00"
+  integrity sha512-DzWPIGzTnfp3/KK1d/YPfmgLqeDju9F2DQYBL35VusgSApcA7XGqVtXfR4ETOOFEzdFJ3J7zh0Gkk011TiA4uQ==
+  dependencies:
+    array-back "^1.0.4"
+
+fecha@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
+  integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
+
+figures@^1.3.5:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
+  integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=
+  dependencies:
+    escape-string-regexp "^1.0.5"
+    object-assign "^4.1.0"
+
+figures@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
+  integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=
+  dependencies:
+    escape-string-regexp "^1.0.5"
+
+file-uri-to-path@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
+  integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
+
+filename-regex@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
+  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
+
+fill-range@^2.1.0:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
+  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
+  dependencies:
+    is-number "^2.1.0"
+    isobject "^2.0.0"
+    randomatic "^3.0.0"
+    repeat-element "^1.1.2"
+    repeat-string "^1.5.2"
+
+fill-range@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
+  integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=
+  dependencies:
+    extend-shallow "^2.0.1"
+    is-number "^3.0.0"
+    repeat-string "^1.6.1"
+    to-regex-range "^2.1.0"
+
+filled-array@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/filled-array/-/filled-array-1.1.0.tgz#c3c4f6c663b923459a9aa29912d2d031f1507f84"
+  integrity sha1-w8T2xmO5I0WamqKZEtLQMfFQf4Q=
+
+finalhandler@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
+  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
+  dependencies:
+    debug "2.6.9"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    statuses "~1.5.0"
+    unpipe "~1.0.0"
+
+find-port@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/find-port/-/find-port-1.0.1.tgz#db084a6cbf99564d99869ae79fbdecf66e8a185c"
+  integrity sha1-2whKbL+ZVk2Zhprnn73s9m6KGFw=
+  dependencies:
+    async "~0.2.9"
+
+find-replace@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-1.0.3.tgz#b88e7364d2d9c959559f388c66670d6130441fa0"
+  integrity sha1-uI5zZNLZyVlVnziMZmcNYTBEH6A=
+  dependencies:
+    array-back "^1.0.4"
+    test-value "^2.1.0"
+
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
+  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
+  dependencies:
+    path-exists "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+find-up@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
+  integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
+  dependencies:
+    locate-path "^3.0.0"
+
+findup-sync@^0.4.2:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12"
+  integrity sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=
+  dependencies:
+    detect-file "^0.1.0"
+    is-glob "^2.0.1"
+    micromatch "^2.3.7"
+    resolve-dir "^0.1.0"
+
+findup-sync@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
+  integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=
+  dependencies:
+    detect-file "^1.0.0"
+    is-glob "^3.1.0"
+    micromatch "^3.0.4"
+    resolve-dir "^1.0.1"
+
+first-chunk-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
+  integrity sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=
+
+first-chunk-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70"
+  integrity sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA=
+  dependencies:
+    readable-stream "^2.0.2"
+
+follow-redirects@^1.0.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb"
+  integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ==
+  dependencies:
+    debug "^3.0.0"
+
+for-in@^1.0.1, for-in@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
+  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
+
+for-own@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
+  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
+  dependencies:
+    for-in "^1.0.1"
+
+forever-agent@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+
+fork-stream@^0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70"
+  integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
+
+form-data@*:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
+  integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.8"
+    mime-types "^2.1.12"
+
+form-data@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
+  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.6"
+    mime-types "^2.1.12"
+
+formatio@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
+  integrity sha1-87IWfZBoxGmKjVH092CjmlTYGOs=
+  dependencies:
+    samsam "1.x"
+
+forwarded@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
+
+fragment-cache@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
+  integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=
+  dependencies:
+    map-cache "^0.2.2"
+
+freeport@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
+  integrity sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=
+
+fresh@0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
+
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
+fs-exists-sync@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
+  integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
+fsevents@^1.0.0:
+  version "1.2.11"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.11.tgz#67bf57f4758f02ede88fb2a1712fef4d15358be3"
+  integrity sha512-+ux3lx6peh0BpvY0JebGyZoiR4D+oYzdPZMKJwkZ+sFkNJzpL7tXc/wehS49gUAxg3tmMHPHZkA8JU2rhhgDHw==
+  dependencies:
+    bindings "^1.5.0"
+    nan "^2.12.1"
+
+function-bind@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+gensync@^1.0.0-beta.1:
+  version "1.0.0-beta.1"
+  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
+  integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==
+
+get-stdin@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
+  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
+
+get-stream@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
+
+get-stream@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
+  integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
+  dependencies:
+    pump "^3.0.0"
+
+get-value@^2.0.3, get-value@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
+  integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
+
+getpass@^0.1.1:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
+  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
+  dependencies:
+    assert-plus "^1.0.0"
+
+gh-got@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-6.0.0.tgz#d74353004c6ec466647520a10bd46f7299d268d0"
+  integrity sha512-F/mS+fsWQMo1zfgG9MD8KWvTWPPzzhuVwY++fhQ5Ggd+0P+CAMHtzMZhNxG+TqGfHDChJKsbh6otfMGqO2AKBw==
+  dependencies:
+    got "^7.0.0"
+    is-plain-obj "^1.1.0"
+
+github-username@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/github-username/-/github-username-4.1.0.tgz#cbe280041883206da4212ae9e4b5f169c30bf417"
+  integrity sha1-y+KABBiDIG2kISrp5LXxacML9Bc=
+  dependencies:
+    gh-got "^6.0.0"
+
+glob-base@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
+  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
+  dependencies:
+    glob-parent "^2.0.0"
+    is-glob "^2.0.0"
+
+glob-parent@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
+  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
+  dependencies:
+    is-glob "^2.0.0"
+
+glob-parent@^3.0.0, glob-parent@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
+  integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=
+  dependencies:
+    is-glob "^3.1.0"
+    path-dirname "^1.0.0"
+
+glob-stream@^5.3.2:
+  version "5.3.5"
+  resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-5.3.5.tgz#a55665a9a8ccdc41915a87c701e32d4e016fad22"
+  integrity sha1-pVZlqajM3EGRWofHAeMtTgFvrSI=
+  dependencies:
+    extend "^3.0.0"
+    glob "^5.0.3"
+    glob-parent "^3.0.0"
+    micromatch "^2.3.7"
+    ordered-read-streams "^0.3.0"
+    through2 "^0.6.0"
+    to-absolute-glob "^0.1.1"
+    unique-stream "^2.0.2"
+
+glob-to-regexp@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
+  integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
+
+glob@^5.0.3:
+  version "5.0.15"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
+  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^6.0.1:
+  version "6.0.4"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
+  integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
+  version "7.1.6"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
+  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+global-dirs@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
+  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
+  dependencies:
+    ini "^1.3.4"
+
+global-modules@^0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
+  integrity sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=
+  dependencies:
+    global-prefix "^0.1.4"
+    is-windows "^0.2.0"
+
+global-modules@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
+  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
+  dependencies:
+    global-prefix "^1.0.1"
+    is-windows "^1.0.1"
+    resolve-dir "^1.0.0"
+
+global-prefix@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f"
+  integrity sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=
+  dependencies:
+    homedir-polyfill "^1.0.0"
+    ini "^1.3.4"
+    is-windows "^0.2.0"
+    which "^1.2.12"
+
+global-prefix@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
+  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
+  dependencies:
+    expand-tilde "^2.0.2"
+    homedir-polyfill "^1.0.1"
+    ini "^1.3.4"
+    is-windows "^1.0.1"
+    which "^1.2.14"
+
+globals@^11.1.0:
+  version "11.12.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
+  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
+
+globals@^9.18.0:
+  version "9.18.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
+  integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
+
+globby@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-4.1.0.tgz#080f54549ec1b82a6c60e631fc82e1211dbe95f8"
+  integrity sha1-CA9UVJ7BuCpsYOYx/ILhIR2+lfg=
+  dependencies:
+    array-union "^1.0.1"
+    arrify "^1.0.0"
+    glob "^6.0.1"
+    object-assign "^4.0.1"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+globby@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"
+  integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=
+  dependencies:
+    array-union "^1.0.1"
+    glob "^7.0.3"
+    object-assign "^4.0.1"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+globby@^8.0.1:
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d"
+  integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w==
+  dependencies:
+    array-union "^1.0.1"
+    dir-glob "2.0.0"
+    fast-glob "^2.0.2"
+    glob "^7.1.2"
+    ignore "^3.3.5"
+    pify "^3.0.0"
+    slash "^1.0.0"
+
+got@^5.0.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
+  integrity sha1-X4FjWmHkplifGAVp6k44FoClHzU=
+  dependencies:
+    create-error-class "^3.0.1"
+    duplexer2 "^0.1.4"
+    is-redirect "^1.0.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    lowercase-keys "^1.0.0"
+    node-status-codes "^1.0.0"
+    object-assign "^4.0.1"
+    parse-json "^2.1.0"
+    pinkie-promise "^2.0.0"
+    read-all-stream "^3.0.0"
+    readable-stream "^2.0.5"
+    timed-out "^3.0.0"
+    unzip-response "^1.0.2"
+    url-parse-lax "^1.0.0"
+
+got@^6.7.1:
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
+  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
+  dependencies:
+    create-error-class "^3.0.0"
+    duplexer3 "^0.1.4"
+    get-stream "^3.0.0"
+    is-redirect "^1.0.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    lowercase-keys "^1.0.0"
+    safe-buffer "^5.0.1"
+    timed-out "^4.0.0"
+    unzip-response "^2.0.1"
+    url-parse-lax "^1.0.0"
+
+got@^7.0.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a"
+  integrity sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==
+  dependencies:
+    decompress-response "^3.2.0"
+    duplexer3 "^0.1.4"
+    get-stream "^3.0.0"
+    is-plain-obj "^1.1.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    isurl "^1.0.0-alpha5"
+    lowercase-keys "^1.0.0"
+    p-cancelable "^0.3.0"
+    p-timeout "^1.1.1"
+    safe-buffer "^5.0.1"
+    timed-out "^4.0.0"
+    url-parse-lax "^1.0.0"
+    url-to-options "^1.0.1"
+
+graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.2.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
+  integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==
+
+grouped-queue@^0.3.0, grouped-queue@^0.3.3:
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-0.3.3.tgz#c167d2a5319c5a0e0964ef6a25b7c2df8996c85c"
+  integrity sha1-wWfSpTGcWg4JZO9qJbfC34mWyFw=
+  dependencies:
+    lodash "^4.17.2"
+
+gulp-if@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
+  integrity sha1-pJe351cwBQQcqivIt92jyARE1ik=
+  dependencies:
+    gulp-match "^1.0.3"
+    ternary-stream "^2.0.1"
+    through2 "^2.0.1"
+
+gulp-match@^1.0.3:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.1.0.tgz#552b7080fc006ee752c90563f9fec9d61aafdf4f"
+  integrity sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==
+  dependencies:
+    minimatch "^3.0.3"
+
+gulp-sourcemaps@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz#b86ff349d801ceb56e1d9e7dc7bbcb4b7dee600c"
+  integrity sha1-uG/zSdgBzrVuHZ59x7vLS33uYAw=
+  dependencies:
+    convert-source-map "^1.1.1"
+    graceful-fs "^4.1.2"
+    strip-bom "^2.0.0"
+    through2 "^2.0.0"
+    vinyl "^1.0.0"
+
+gunzip-maybe@^1.3.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.1.tgz#39c72ed89d1b49ba708e18776500488902a52027"
+  integrity sha512-qtutIKMthNJJgeHQS7kZ9FqDq59/Wn0G2HYCRNjpup7yKfVI6/eqwpmroyZGFoCYaG+sW6psNVb4zoLADHpp2g==
+  dependencies:
+    browserify-zlib "^0.1.4"
+    is-deflate "^1.0.0"
+    is-gzip "^1.0.0"
+    peek-stream "^1.1.0"
+    pumpify "^1.3.3"
+    through2 "^2.0.3"
+
+handle-thing@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
+  integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=
+
+har-schema@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+
+har-validator@~5.1.0:
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
+  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+  dependencies:
+    ajv "^6.5.5"
+    har-schema "^2.0.0"
+
+has-ansi@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
+  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+has-binary2@~1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
+  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
+  dependencies:
+    isarray "2.0.1"
+
+has-color@~0.1.0:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f"
+  integrity sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=
+
+has-cors@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
+  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+has-symbol-support-x@^1.4.1:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455"
+  integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==
+
+has-symbols@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
+  integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
+
+has-to-string-tag-x@^1.2.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d"
+  integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==
+  dependencies:
+    has-symbol-support-x "^1.4.1"
+
+has-value@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
+  integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=
+  dependencies:
+    get-value "^2.0.3"
+    has-values "^0.1.4"
+    isobject "^2.0.0"
+
+has-value@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
+  integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=
+  dependencies:
+    get-value "^2.0.6"
+    has-values "^1.0.0"
+    isobject "^3.0.0"
+
+has-values@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
+  integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E=
+
+has-values@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
+  integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=
+  dependencies:
+    is-number "^3.0.0"
+    kind-of "^4.0.0"
+
+he@1.2.x:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
+homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
+  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
+  dependencies:
+    parse-passwd "^1.0.0"
+
+hosted-git-info@^2.1.4:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c"
+  integrity sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==
+
+hpack.js@^2.1.6:
+  version "2.1.6"
+  resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
+  integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
+  dependencies:
+    inherits "^2.0.1"
+    obuf "^1.0.0"
+    readable-stream "^2.0.1"
+    wbuf "^1.1.0"
+
+html-minifier@^3.5.10:
+  version "3.5.21"
+  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c"
+  integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==
+  dependencies:
+    camel-case "3.0.x"
+    clean-css "4.2.x"
+    commander "2.17.x"
+    he "1.2.x"
+    param-case "2.1.x"
+    relateurl "0.2.x"
+    uglify-js "3.4.x"
+
+http-deceiver@^1.2.7:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
+  integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
+
+http-errors@1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
+  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
+http-errors@~1.7.2:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
+  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
+http-proxy-middleware@^0.17.2:
+  version "0.17.4"
+  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
+  integrity sha1-ZC6ISIUdZvCdTxJJEoRtuutBuDM=
+  dependencies:
+    http-proxy "^1.16.2"
+    is-glob "^3.1.0"
+    lodash "^4.17.2"
+    micromatch "^2.3.11"
+
+http-proxy@^1.16.2:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
+  integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
+  dependencies:
+    eventemitter3 "^4.0.0"
+    follow-redirects "^1.0.0"
+    requires-port "^1.0.0"
+
+http-signature@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
+  dependencies:
+    assert-plus "^1.0.0"
+    jsprim "^1.2.2"
+    sshpk "^1.7.0"
+
+https-proxy-agent@^2.2.1:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
+  integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+https-proxy-agent@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
+  integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+iconv-lite@0.4.24, iconv-lite@^0.4.24:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+ieee754@^1.1.4:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
+  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
+
+ignore@^3.3.5:
+  version "3.3.10"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
+  integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
+
+import-lazy@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
+  integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
+
+imurmurhash@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+  integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
+
+indent-string@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
+  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
+  dependencies:
+    repeating "^2.0.0"
+
+indent@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
+  integrity sha1-jHnwgBkFWbaHA0uEx676l9WpEdk=
+
+indexof@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
+  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+
+ini@^1.3.4, ini@~1.3.0:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+
+inquirer@^1.0.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-1.2.3.tgz#4dec6f32f37ef7bb0b2ed3f1d1a5c3f545074918"
+  integrity sha1-TexvMvN+97sLLtPx0aXD9UUHSRg=
+  dependencies:
+    ansi-escapes "^1.1.0"
+    chalk "^1.0.0"
+    cli-cursor "^1.0.1"
+    cli-width "^2.0.0"
+    external-editor "^1.1.0"
+    figures "^1.3.5"
+    lodash "^4.3.0"
+    mute-stream "0.0.6"
+    pinkie-promise "^2.0.0"
+    run-async "^2.2.0"
+    rx "^4.1.0"
+    string-width "^1.0.1"
+    strip-ansi "^3.0.0"
+    through "^2.3.6"
+
+inquirer@^6.0.0:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca"
+  integrity sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==
+  dependencies:
+    ansi-escapes "^3.2.0"
+    chalk "^2.4.2"
+    cli-cursor "^2.1.0"
+    cli-width "^2.0.0"
+    external-editor "^3.0.3"
+    figures "^2.0.0"
+    lodash "^4.17.12"
+    mute-stream "0.0.7"
+    run-async "^2.2.0"
+    rxjs "^6.4.0"
+    string-width "^2.1.0"
+    strip-ansi "^5.1.0"
+    through "^2.3.6"
+
+interpret@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
+  integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
+
+intersect@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/intersect/-/intersect-1.0.1.tgz#332650e10854d8c0ac58c192bdc27a8bf7e7a30c"
+  integrity sha1-MyZQ4QhU2MCsWMGSvcJ6i/fnoww=
+
+invariant@^2.2.2:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
+  dependencies:
+    loose-envify "^1.0.0"
+
+ipaddr.js@1.9.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
+  integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
+
+is-accessor-descriptor@^0.1.6:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
+  integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-accessor-descriptor@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
+  integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==
+  dependencies:
+    kind-of "^6.0.0"
+
+is-arrayish@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+  integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
+
+is-arrayish@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
+  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
+
+is-binary-path@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
+  integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=
+  dependencies:
+    binary-extensions "^1.0.0"
+
+is-buffer@^1.1.5, is-buffer@~1.1.1:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+  integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
+
+is-ci@^1.0.10:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
+  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
+  dependencies:
+    ci-info "^1.5.0"
+
+is-data-descriptor@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
+  integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-data-descriptor@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
+  integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==
+  dependencies:
+    kind-of "^6.0.0"
+
+is-deflate@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14"
+  integrity sha1-yGKQHDwWH7CdrHzcfnhPgOmPLxQ=
+
+is-descriptor@^0.1.0:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
+  integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==
+  dependencies:
+    is-accessor-descriptor "^0.1.6"
+    is-data-descriptor "^0.1.4"
+    kind-of "^5.0.0"
+
+is-descriptor@^1.0.0, is-descriptor@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
+  integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==
+  dependencies:
+    is-accessor-descriptor "^1.0.0"
+    is-data-descriptor "^1.0.0"
+    kind-of "^6.0.2"
+
+is-dotfile@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
+  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
+
+is-equal-shallow@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
+  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
+  dependencies:
+    is-primitive "^2.0.0"
+
+is-extendable@^0.1.0, is-extendable@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
+  integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=
+
+is-extendable@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
+  integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==
+  dependencies:
+    is-plain-object "^2.0.4"
+
+is-extglob@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
+  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
+
+is-extglob@^2.1.0, is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
+
+is-finite@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
+  integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
+
+is-glob@^2.0.0, is-glob@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
+  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
+  dependencies:
+    is-extglob "^1.0.0"
+
+is-glob@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
+  integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=
+  dependencies:
+    is-extglob "^2.1.0"
+
+is-glob@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
+  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-gzip@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-gzip/-/is-gzip-1.0.0.tgz#6ca8b07b99c77998025900e555ced8ed80879a83"
+  integrity sha1-bKiwe5nHeZgCWQDlVc7Y7YCHmoM=
+
+is-installed-globally@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
+  dependencies:
+    global-dirs "^0.1.0"
+    is-path-inside "^1.0.0"
+
+is-module@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
+
+is-npm@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
+  integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
+
+is-number@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
+  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-number@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
+  integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-number@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+  integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
+
+is-obj@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
+  integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
+
+is-object@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
+  integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA=
+
+is-path-cwd@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
+  integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=
+
+is-path-in-cwd@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52"
+  integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==
+  dependencies:
+    is-path-inside "^1.0.0"
+
+is-path-inside@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
+  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
+  dependencies:
+    path-is-inside "^1.0.1"
+
+is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
+  integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
+
+is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
+  dependencies:
+    isobject "^3.0.1"
+
+is-plain-object@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.0.tgz#47bfc5da1b5d50d64110806c199359482e75a928"
+  integrity sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==
+  dependencies:
+    isobject "^4.0.0"
+
+is-posix-bracket@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
+  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
+
+is-potential-custom-element-name@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
+  integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c=
+
+is-primitive@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
+
+is-promise@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
+  integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=
+
+is-redirect@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
+  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
+
+is-retry-allowed@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
+  integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
+
+is-scoped@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-scoped/-/is-scoped-1.0.0.tgz#449ca98299e713038256289ecb2b540dc437cb30"
+  integrity sha1-RJypgpnnEwOCViieyytUDcQ3yzA=
+  dependencies:
+    scoped-regex "^1.0.0"
+
+is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
+  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+
+is-typedarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
+
+is-utf8@^0.2.0:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
+  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
+
+is-valid-glob@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-0.3.0.tgz#d4b55c69f51886f9b65c70d6c2622d37e29f48fe"
+  integrity sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=
+
+is-windows@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c"
+  integrity sha1-3hqm1j6indJIc3tp8f+LgALSEIw=
+
+is-windows@^1.0.1, is-windows@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
+  integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
+
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
+
+isarray@1.0.0, isarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+
+isarray@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
+  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
+
+isbinaryfile@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80"
+  integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==
+  dependencies:
+    buffer-alloc "^1.2.0"
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
+
+isobject@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
+  integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=
+  dependencies:
+    isarray "1.0.0"
+
+isobject@^3.0.0, isobject@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
+
+isobject@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0"
+  integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==
+
+isstream@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
+  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
+
+istextorbinary@^2.2.1:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.6.0.tgz#60776315fb0fa3999add276c02c69557b9ca28ab"
+  integrity sha512-+XRlFseT8B3L9KyjxxLjfXSLMuErKDsd8DBNrsaxoViABMEZlOSCstwmw0qpoFX3+U6yWU1yhLudAe6/lETGGA==
+  dependencies:
+    binaryextensions "^2.1.2"
+    editions "^2.2.0"
+    textextensions "^2.5.0"
+
+isurl@^1.0.0-alpha5:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
+  integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==
+  dependencies:
+    has-to-string-tag-x "^1.2.0"
+    is-object "^1.0.1"
+
+jest-worker@^24.9.0:
+  version "24.9.0"
+  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5"
+  integrity sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw==
+  dependencies:
+    merge-stream "^2.0.0"
+    supports-color "^6.1.0"
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+js-tokens@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
+  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
+
+jsbn@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
+  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+
+jsesc@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
+  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
+
+jsesc@^2.5.1:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
+  integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
+
+jsesc@~0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
+  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
+
+json-parse-better-errors@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
+
+json-schema-traverse@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+json-schema@0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
+  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+
+json-stable-stringify-without-jsonify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+  integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
+
+json-stringify-safe@~5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+
+json5@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.1.tgz#81b6cb04e9ba496f1c7005d07b4368a2638f90b6"
+  integrity sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==
+  dependencies:
+    minimist "^1.2.0"
+
+jsonschema@^1.1.0, jsonschema@^1.1.1:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.5.tgz#bab69d97fa28946aec0a56a9cc266d23fe80ae61"
+  integrity sha512-kVTF+08x25PQ0CjuVc0gRM9EUPb0Fe9Ln/utFOgcdxEIOHuU7ooBk/UPTd7t1M91pP35m0MU1T8M5P7vP1bRRw==
+
+jsprim@^1.2.2:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
+  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+  dependencies:
+    assert-plus "1.0.0"
+    extsprintf "1.3.0"
+    json-schema "0.2.3"
+    verror "1.10.0"
+
+kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
+  integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=
+  dependencies:
+    is-buffer "^1.1.5"
+
+kind-of@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
+  integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc=
+  dependencies:
+    is-buffer "^1.1.5"
+
+kind-of@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
+  integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
+
+kind-of@^6.0.0, kind-of@^6.0.2:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
+  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
+
+kuler@1.0.x:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6"
+  integrity sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==
+  dependencies:
+    colornames "^1.1.1"
+
+latest-version@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-2.0.0.tgz#56f8d6139620847b8017f8f1f4d78e211324168b"
+  integrity sha1-VvjWE5YghHuAF/jx9NeOIRMkFos=
+  dependencies:
+    package-json "^2.0.0"
+
+latest-version@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
+  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
+  dependencies:
+    package-json "^4.0.0"
+
+launchpad@^0.7.0:
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.5.tgz#a16950c937572f10ef01c9be945a96f7aef8e427"
+  integrity sha512-gsYFgT8XKL3X2XZHPPPrgwM0JqeQwGpSWnzg7EYadBY3MirbQrTVq6L4fm6l7UE2T+7gnfuhiGkKr/xxuU/fdw==
+  dependencies:
+    async "^2.0.1"
+    browserstack "^1.2.0"
+    debug "^2.2.0"
+    mkdirp "^0.5.1"
+    plist "^2.0.1"
+    q "^1.4.1"
+    rimraf "^3.0.0"
+    underscore "^1.8.3"
+
+lazy-req@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/lazy-req/-/lazy-req-1.1.0.tgz#bdaebead30f8d824039ce0ce149d4daa07ba1fac"
+  integrity sha1-va6+rTD42CQDnODOFJ1Nqge6H6w=
+
+lazystream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
+  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
+  dependencies:
+    readable-stream "^2.0.5"
+
+load-json-file@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
+  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
+  dependencies:
+    graceful-fs "^4.1.2"
+    parse-json "^2.2.0"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+    strip-bom "^2.0.0"
+
+load-json-file@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
+  integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs=
+  dependencies:
+    graceful-fs "^4.1.2"
+    parse-json "^4.0.0"
+    pify "^3.0.0"
+    strip-bom "^3.0.0"
+
+locate-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
+  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
+  dependencies:
+    p-locate "^3.0.0"
+    path-exists "^3.0.0"
+
+lodash._reinterpolate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
+  integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
+
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
+
+lodash.defaults@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
+  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
+
+lodash.difference@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
+  integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
+
+lodash.flatten@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
+  integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
+
+lodash.get@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+  integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
+
+lodash.isequal@^4.0.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
+
+lodash.isplainobject@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+
+lodash.padend@^4.6.1:
+  version "4.6.1"
+  resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
+  integrity sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=
+
+lodash.set@^4.3.2:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
+  integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
+
+lodash.sortby@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
+  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
+
+lodash.template@^4.4.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
+  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
+  dependencies:
+    lodash._reinterpolate "^3.0.0"
+    lodash.templatesettings "^4.0.0"
+
+lodash.templatesettings@^4.0.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
+  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
+  dependencies:
+    lodash._reinterpolate "^3.0.0"
+
+lodash.union@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
+  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
+
+lodash.uniq@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
+
+lodash@^3.0.0, lodash@^3.10.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
+  integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
+
+lodash@^4.0.0, lodash@^4.11.1, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.3.0:
+  version "4.17.15"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
+log-symbols@^1.0.0, log-symbols@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
+  integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=
+  dependencies:
+    chalk "^1.0.0"
+
+log-symbols@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
+  integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
+  dependencies:
+    chalk "^2.0.1"
+
+logform@^1.9.1:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/logform/-/logform-1.10.0.tgz#c9d5598714c92b546e23f4e78147c40f1e02012e"
+  integrity sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==
+  dependencies:
+    colors "^1.2.1"
+    fast-safe-stringify "^2.0.4"
+    fecha "^2.3.3"
+    ms "^2.1.1"
+    triple-beam "^1.2.0"
+
+logform@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360"
+  integrity sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==
+  dependencies:
+    colors "^1.2.1"
+    fast-safe-stringify "^2.0.4"
+    fecha "^2.3.3"
+    ms "^2.1.1"
+    triple-beam "^1.3.0"
+
+lolex@^1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
+  integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=
+
+long@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
+  integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
+
+loose-envify@^1.0.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+  integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+  dependencies:
+    js-tokens "^3.0.0 || ^4.0.0"
+
+loud-rejection@^1.0.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
+  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
+  dependencies:
+    currently-unhandled "^0.4.1"
+    signal-exit "^3.0.0"
+
+lower-case@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
+  integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
+
+lowercase-keys@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
+  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
+
+lru-cache@^4.0.1, lru-cache@^4.0.2:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
+  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
+  dependencies:
+    pseudomap "^1.0.2"
+    yallist "^2.1.2"
+
+macos-release@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f"
+  integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA==
+
+magic-string@^0.22.4:
+  version "0.22.5"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
+  integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==
+  dependencies:
+    vlq "^0.2.2"
+
+make-dir@^1.0.0, make-dir@^1.1.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
+  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
+  dependencies:
+    pify "^3.0.0"
+
+map-cache@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
+  integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
+
+map-obj@^1.0.0, map-obj@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
+  integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
+
+map-visit@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
+  integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=
+  dependencies:
+    object-visit "^1.0.0"
+
+matcher@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
+  integrity sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==
+  dependencies:
+    escape-string-regexp "^1.0.4"
+
+math-random@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
+  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
+
+md5@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
+  integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=
+  dependencies:
+    charenc "~0.0.1"
+    crypt "~0.0.1"
+    is-buffer "~1.1.1"
+
+media-typer@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+  integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
+
+mem-fs-editor@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-5.1.0.tgz#51972241640be8567680a04f7adaffe5fc603667"
+  integrity sha512-2Yt2GCYEbcotYbIJagmow4gEtHDqzpq5XN94+yAx/NT5+bGqIjkXnm3KCUQfE6kRfScGp9IZknScoGRKu8L78w==
+  dependencies:
+    commondir "^1.0.1"
+    deep-extend "^0.6.0"
+    ejs "^2.5.9"
+    glob "^7.0.3"
+    globby "^8.0.1"
+    isbinaryfile "^3.0.2"
+    mkdirp "^0.5.0"
+    multimatch "^2.0.0"
+    rimraf "^2.2.8"
+    through2 "^2.0.0"
+    vinyl "^2.0.1"
+
+mem-fs@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/mem-fs/-/mem-fs-1.1.3.tgz#b8ae8d2e3fcb6f5d3f9165c12d4551a065d989cc"
+  integrity sha1-uK6NLj/Lb10/kWXBLUVRoGXZicw=
+  dependencies:
+    through2 "^2.0.0"
+    vinyl "^1.1.0"
+    vinyl-file "^2.0.0"
+
+meow@^3.1.0, meow@^3.7.0:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
+  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
+  dependencies:
+    camelcase-keys "^2.0.0"
+    decamelize "^1.1.2"
+    loud-rejection "^1.0.0"
+    map-obj "^1.0.1"
+    minimist "^1.1.3"
+    normalize-package-data "^2.3.4"
+    object-assign "^4.0.1"
+    read-pkg-up "^1.0.1"
+    redent "^1.0.0"
+    trim-newlines "^1.0.0"
+
+merge-descriptors@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
+  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
+
+merge-stream@^1.0.0, merge-stream@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
+  integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=
+  dependencies:
+    readable-stream "^2.0.1"
+
+merge-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
+  integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
+
+merge2@^1.2.3:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.3.0.tgz#5b366ee83b2f1582c48f87e47cf1a9352103ca81"
+  integrity sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw==
+
+methods@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
+  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
+
+micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7:
+  version "2.3.11"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
+  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
+  dependencies:
+    arr-diff "^2.0.0"
+    array-unique "^0.2.1"
+    braces "^1.8.2"
+    expand-brackets "^0.1.4"
+    extglob "^0.3.1"
+    filename-regex "^2.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.1"
+    kind-of "^3.0.2"
+    normalize-path "^2.0.1"
+    object.omit "^2.0.0"
+    parse-glob "^3.0.4"
+    regex-cache "^0.4.2"
+
+micromatch@^3.0.4, micromatch@^3.1.10:
+  version "3.1.10"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
+  integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
+  dependencies:
+    arr-diff "^4.0.0"
+    array-unique "^0.3.2"
+    braces "^2.3.1"
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    extglob "^2.0.4"
+    fragment-cache "^0.2.1"
+    kind-of "^6.0.2"
+    nanomatch "^1.2.9"
+    object.pick "^1.3.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.2"
+
+mime-db@1.43.0, "mime-db@>= 1.43.0 < 2", mime-db@^1.28.0:
+  version "1.43.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
+  integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
+
+mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
+  version "2.1.26"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06"
+  integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==
+  dependencies:
+    mime-db "1.43.0"
+
+mime@1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
+  integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==
+
+mime@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
+
+mime@^2.3.1:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
+  integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
+
+mimic-fn@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
+  integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==
+
+mimic-response@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
+  integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
+
+minimalistic-assert@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
+  integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
+
+minimatch-all@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/minimatch-all/-/minimatch-all-1.1.0.tgz#40c496a27a2e128d19bf758e76bb01a0c7145787"
+  integrity sha1-QMSWonouEo0Zv3WOdrsBoMcUV4c=
+  dependencies:
+    minimatch "^3.0.2"
+
+"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@0.0.8:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
+
+minimist@^1.1.3, minimist@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
+  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
+
+minimist@~0.0.1:
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
+  integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
+
+mixin-deep@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
+  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
+  dependencies:
+    for-in "^1.0.2"
+    is-extendable "^1.0.1"
+
+mkdirp@^0.5.0, mkdirp@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
+  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
+  dependencies:
+    minimist "0.0.8"
+
+mout@^1.0.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/mout/-/mout-1.2.2.tgz#c9b718a499806a0632cede178e80f436259e777d"
+  integrity sha512-w0OUxFEla6z3d7sVpMZGBCpQvYh8PHS1wZ6Wu9GNKHMpAHWJ0if0LsQZh3DlOqw55HlhJEOMLpFnwtxp99Y5GA==
+
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
+
+ms@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
+  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
+
+ms@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+multer@^1.3.0:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
+  integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==
+  dependencies:
+    append-field "^1.0.0"
+    busboy "^0.2.11"
+    concat-stream "^1.5.2"
+    mkdirp "^0.5.1"
+    object-assign "^4.1.1"
+    on-finished "^2.3.0"
+    type-is "^1.6.4"
+    xtend "^4.0.0"
+
+multimatch@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-2.1.0.tgz#9c7906a22fb4c02919e2f5f75161b4cdbd4b2a2b"
+  integrity sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=
+  dependencies:
+    array-differ "^1.0.0"
+    array-union "^1.0.1"
+    arrify "^1.0.0"
+    minimatch "^3.0.0"
+
+multipipe@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
+  integrity sha1-zBPv2DPJzamfIk+GhGG44aP9k50=
+  dependencies:
+    duplexer2 "^0.1.2"
+    object-assign "^4.1.0"
+
+mute-stream@0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.6.tgz#48962b19e169fd1dfc240b3f1e7317627bbc47db"
+  integrity sha1-SJYrGeFp/R38JAs/HnMXYnu8R9s=
+
+mute-stream@0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
+  integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
+
+mz@^2.4.0, mz@^2.6.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
+  integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
+  dependencies:
+    any-promise "^1.0.0"
+    object-assign "^4.0.1"
+    thenify-all "^1.0.0"
+
+nan@^2.12.1:
+  version "2.14.0"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
+  integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
+
+nanomatch@^1.2.9:
+  version "1.2.13"
+  resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
+  integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==
+  dependencies:
+    arr-diff "^4.0.0"
+    array-unique "^0.3.2"
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    fragment-cache "^0.2.1"
+    is-windows "^1.0.2"
+    kind-of "^6.0.2"
+    object.pick "^1.3.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+native-promise-only@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
+  integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=
+
+negotiator@0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
+  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
+
+nice-try@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+
+no-case@^2.2.0:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
+  integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==
+  dependencies:
+    lower-case "^1.1.1"
+
+node-fetch@^2.3.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
+  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
+
+node-status-codes@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f"
+  integrity sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=
+
+nomnom@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"
+  integrity sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=
+  dependencies:
+    chalk "~0.4.0"
+    underscore "~1.6.0"
+
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
+  integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
+  dependencies:
+    hosted-git-info "^2.1.4"
+    resolve "^1.10.0"
+    semver "2 || 3 || 4 || 5"
+    validate-npm-package-license "^3.0.1"
+
+normalize-path@^2.0.0, normalize-path@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
+  integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
+  dependencies:
+    remove-trailing-separator "^1.0.1"
+
+normalize-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+npm-run-path@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
+  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
+  dependencies:
+    path-key "^2.0.0"
+
+number-is-nan@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
+
+oauth-sign@~0.9.0:
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
+  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
+
+object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+
+object-component@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
+  integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
+
+object-copy@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
+  integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw=
+  dependencies:
+    copy-descriptor "^0.1.0"
+    define-property "^0.2.5"
+    kind-of "^3.0.3"
+
+object-keys@^1.0.11, object-keys@^1.0.12:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
+  integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
+
+object-visit@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
+  integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=
+  dependencies:
+    isobject "^3.0.0"
+
+object.assign@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
+  integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
+  dependencies:
+    define-properties "^1.1.2"
+    function-bind "^1.1.1"
+    has-symbols "^1.0.0"
+    object-keys "^1.0.11"
+
+object.omit@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
+  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
+  dependencies:
+    for-own "^0.1.4"
+    is-extendable "^0.1.1"
+
+object.pick@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
+  integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=
+  dependencies:
+    isobject "^3.0.1"
+
+obuf@^1.0.0, obuf@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
+  integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
+
+octokit-pagination-methods@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
+  integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
+
+on-finished@^2.3.0, on-finished@~2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
+  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
+  dependencies:
+    ee-first "1.1.1"
+
+on-headers@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
+  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
+
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  dependencies:
+    wrappy "1"
+
+one-time@0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e"
+  integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=
+
+onetime@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
+  integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
+
+onetime@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
+  integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=
+  dependencies:
+    mimic-fn "^1.0.0"
+
+opn@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
+  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
+  dependencies:
+    object-assign "^4.0.1"
+
+optimist@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
+  integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY=
+  dependencies:
+    minimist "~0.0.1"
+    wordwrap "~0.0.2"
+
+ordered-read-streams@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
+  integrity sha1-cTfmmzKYuzQiR6G77jiByA4v14s=
+  dependencies:
+    is-stream "^1.0.1"
+    readable-stream "^2.0.1"
+
+os-homedir@^1.0.0, os-homedir@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
+
+os-name@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801"
+  integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==
+  dependencies:
+    macos-release "^2.2.0"
+    windows-release "^3.1.0"
+
+os-shim@^0.1.2:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917"
+  integrity sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=
+
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
+
+osenv@^0.1.0, osenv@^0.1.3:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
+  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
+  dependencies:
+    os-homedir "^1.0.0"
+    os-tmpdir "^1.0.0"
+
+p-cancelable@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
+  integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
+
+p-finally@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
+
+p-limit@^2.0.0:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e"
+  integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==
+  dependencies:
+    p-try "^2.0.0"
+
+p-locate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
+  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
+  dependencies:
+    p-limit "^2.0.0"
+
+p-map@^1.1.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
+  integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==
+
+p-timeout@^1.1.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386"
+  integrity sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=
+  dependencies:
+    p-finally "^1.0.0"
+
+p-try@^2.0.0, p-try@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
+package-json@^2.0.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-2.4.0.tgz#0d15bd67d1cbbddbb2ca222ff2edb86bcb31a8bb"
+  integrity sha1-DRW9Z9HLvduyyiIv8u24a8sxqLs=
+  dependencies:
+    got "^5.0.0"
+    registry-auth-token "^3.0.1"
+    registry-url "^3.0.3"
+    semver "^5.1.0"
+
+package-json@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
+  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
+  dependencies:
+    got "^6.7.1"
+    registry-auth-token "^3.0.1"
+    registry-url "^3.0.3"
+    semver "^5.1.0"
+
+pako@~0.2.0:
+  version "0.2.9"
+  resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
+  integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=
+
+param-case@2.1.x:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
+  integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
+  dependencies:
+    no-case "^2.2.0"
+
+parse-glob@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
+  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
+  dependencies:
+    glob-base "^0.3.0"
+    is-dotfile "^1.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.0"
+
+parse-json@^2.1.0, parse-json@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
+  integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
+  dependencies:
+    error-ex "^1.2.0"
+
+parse-json@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
+  integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
+  dependencies:
+    error-ex "^1.3.1"
+    json-parse-better-errors "^1.0.1"
+
+parse-passwd@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
+  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
+
+parse5-html-rewriting-stream@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#fc18570ba0d09b5091250956d1c3f716ef0a07b7"
+  integrity sha512-rbXBeMlJ3pk3tKxLKAUaqvQTZM5KTohXmZvYEv2gU9sQC70w65BxPsh3PVVnwiVNCnNYDtNZRqCKmiMlfdG07Q==
+  dependencies:
+    parse5 "^5.1.1"
+    parse5-sax-parser "^5.1.1"
+
+parse5-sax-parser@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-5.1.1.tgz#02834a9d08b23ea2d99584841c38be09d5247a15"
+  integrity sha512-9HIh6zd7bF1NJe95LPCUC311CekdOi55R+HWXNCsGY6053DWaMijVKOv1oPvdvPTvFicifZyimBVJ6/qvG039Q==
+  dependencies:
+    parse5 "^5.1.1"
+
+parse5@^1.4.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
+  integrity sha1-m387DeMr543CQBsXVzzK8Pb1nZQ=
+
+parse5@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
+  integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
+
+parse5@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
+  integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
+
+parseqs@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
+  integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseuri@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
+  integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseurl@~1.3.3:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+pascalcase@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
+  integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
+
+path-dirname@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
+  integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
+
+path-exists@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
+  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
+  dependencies:
+    pinkie-promise "^2.0.0"
+
+path-exists@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
+  integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
+
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
+path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
+  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+
+path-key@^2.0.0, path-key@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
+
+path-parse@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
+  integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
+
+path-to-regexp@0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
+  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
+
+path-to-regexp@^1.0.1, path-to-regexp@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+  dependencies:
+    isarray "0.0.1"
+
+path-type@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
+  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
+  dependencies:
+    graceful-fs "^4.1.2"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+path-type@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
+  integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==
+  dependencies:
+    pify "^3.0.0"
+
+peek-stream@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67"
+  integrity sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==
+  dependencies:
+    buffer-from "^1.0.0"
+    duplexify "^3.5.0"
+    through2 "^2.0.3"
+
+pem@^1.8.3:
+  version "1.14.4"
+  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.4.tgz#a68c70c6e751ccc5b3b5bcd7af78b0aec1177ff9"
+  integrity sha512-v8lH3NpirgiEmbOqhx0vwQTxwi0ExsiWBGYh0jYNq7K6mQuO4gI6UEFlr6fLAdv9TPXRt6GqiwE37puQdIDS8g==
+  dependencies:
+    es6-promisify "^6.0.0"
+    md5 "^2.2.1"
+    os-tmpdir "^1.0.1"
+    which "^2.0.2"
+
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
+
+performance-now@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
+
+pify@^2.0.0, pify@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+  integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
+
+pify@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
+  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
+
+pify@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
+  integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
+
+pinkie-promise@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
+  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
+  dependencies:
+    pinkie "^2.0.0"
+
+pinkie@^2.0.0:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
+  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
+
+plist@^2.0.1:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
+  integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
+  dependencies:
+    base64-js "1.2.0"
+    xmlbuilder "8.2.2"
+    xmldom "0.1.x"
+
+plylog@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/plylog/-/plylog-1.1.0.tgz#f6f354e2ae0b01f6db4ed111f4b3855da9c37417"
+  integrity sha512-/QnY5aSVaP54va6hruzNtAj02HpsLlAt7V5EndMrtq6ZUTZJKUja43rgiUtGXqm95yrSJjbZoPW0yQQQwLpoJA==
+  dependencies:
+    logform "^1.9.1"
+    winston "^3.0.0"
+    winston-transport "^4.2.0"
+
+polymer-analyzer@^3.0.0, polymer-analyzer@^3.1.3, polymer-analyzer@^3.2.2:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
+  integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
+  dependencies:
+    "@babel/generator" "^7.0.0-beta.42"
+    "@babel/traverse" "^7.0.0-beta.42"
+    "@babel/types" "^7.0.0-beta.42"
+    "@types/babel-generator" "^6.25.1"
+    "@types/babel-traverse" "^6.25.2"
+    "@types/babel-types" "^6.25.1"
+    "@types/babylon" "^6.16.2"
+    "@types/chai-subset" "^1.3.0"
+    "@types/chalk" "^0.4.30"
+    "@types/clone" "^0.1.30"
+    "@types/cssbeautify" "^0.3.1"
+    "@types/doctrine" "^0.0.1"
+    "@types/is-windows" "^0.2.0"
+    "@types/minimatch" "^3.0.1"
+    "@types/parse5" "^2.2.34"
+    "@types/path-is-inside" "^1.0.0"
+    "@types/resolve" "0.0.6"
+    "@types/whatwg-url" "^6.4.0"
+    babylon "^7.0.0-beta.42"
+    cancel-token "^0.1.1"
+    chalk "^1.1.3"
+    clone "^2.0.0"
+    cssbeautify "^0.3.1"
+    doctrine "^2.0.2"
+    dom5 "^3.0.0"
+    indent "0.0.2"
+    is-windows "^1.0.2"
+    jsonschema "^1.1.0"
+    minimatch "^3.0.4"
+    parse5 "^4.0.0"
+    path-is-inside "^1.0.2"
+    resolve "^1.5.0"
+    shady-css-parser "^0.1.0"
+    stable "^0.1.6"
+    strip-indent "^2.0.0"
+    vscode-uri "=1.0.6"
+    whatwg-url "^6.4.0"
+
+polymer-build@^3.1.0, polymer-build@^3.1.4:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/polymer-build/-/polymer-build-3.1.4.tgz#ab539f1a13d803518b13b73ffd09198431d98142"
+  integrity sha512-OhTOPG5Y/tK2HqGZ5XA/CVDh+TuOaDv7wTZWXDCg6hxeMgNKuljDMn2coyGU5NLM0pLbS+gwFAc2ZJ5cWHCHNg==
+  dependencies:
+    "@babel/core" "^7.0.0"
+    "@babel/plugin-external-helpers" "^7.0.0"
+    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
+    "@babel/plugin-proposal-object-rest-spread" "^7.0.0"
+    "@babel/plugin-syntax-async-generators" "^7.0.0"
+    "@babel/plugin-syntax-dynamic-import" "^7.0.0"
+    "@babel/plugin-syntax-import-meta" "^7.0.0"
+    "@babel/plugin-syntax-object-rest-spread" "^7.0.0"
+    "@babel/plugin-transform-arrow-functions" "^7.0.0"
+    "@babel/plugin-transform-async-to-generator" "^7.0.0"
+    "@babel/plugin-transform-block-scoped-functions" "^7.0.0"
+    "@babel/plugin-transform-block-scoping" "^7.0.0"
+    "@babel/plugin-transform-classes" "^7.0.0"
+    "@babel/plugin-transform-computed-properties" "^7.0.0"
+    "@babel/plugin-transform-destructuring" "^7.0.0"
+    "@babel/plugin-transform-duplicate-keys" "^7.0.0"
+    "@babel/plugin-transform-exponentiation-operator" "^7.0.0"
+    "@babel/plugin-transform-for-of" "^7.0.0"
+    "@babel/plugin-transform-function-name" "^7.0.0"
+    "@babel/plugin-transform-instanceof" "^7.0.0"
+    "@babel/plugin-transform-literals" "^7.0.0"
+    "@babel/plugin-transform-modules-amd" "^7.0.0"
+    "@babel/plugin-transform-object-super" "^7.0.0"
+    "@babel/plugin-transform-parameters" "^7.0.0"
+    "@babel/plugin-transform-regenerator" "^7.0.0"
+    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
+    "@babel/plugin-transform-spread" "^7.0.0"
+    "@babel/plugin-transform-sticky-regex" "^7.0.0"
+    "@babel/plugin-transform-template-literals" "^7.0.0"
+    "@babel/plugin-transform-typeof-symbol" "^7.0.0"
+    "@babel/plugin-transform-unicode-regex" "^7.0.0"
+    "@babel/traverse" "^7.0.0"
+    "@polymer/esm-amd-loader" "^1.0.0"
+    "@types/babel-types" "^6.25.1"
+    "@types/babylon" "^6.16.2"
+    "@types/gulp-if" "0.0.33"
+    "@types/html-minifier" "^3.5.1"
+    "@types/is-windows" "^0.2.0"
+    "@types/mz" "0.0.31"
+    "@types/parse5" "^2.2.34"
+    "@types/resolve" "0.0.7"
+    "@types/uuid" "^3.4.3"
+    "@types/vinyl" "^2.0.0"
+    "@types/vinyl-fs" "^2.4.8"
+    babel-plugin-minify-guarded-expressions "^0.4.3"
+    babel-preset-minify "^0.5.0"
+    babylon "^7.0.0-beta.42"
+    css-slam "^2.1.2"
+    dom5 "^3.0.0"
+    gulp-if "^2.0.2"
+    html-minifier "^3.5.10"
+    matcher "^1.1.0"
+    multipipe "^1.0.2"
+    mz "^2.6.0"
+    parse5 "^4.0.0"
+    plylog "^1.0.0"
+    polymer-analyzer "^3.1.3"
+    polymer-bundler "^4.0.9"
+    polymer-project-config "^4.0.3"
+    regenerator-runtime "^0.11.1"
+    stream "0.0.2"
+    sw-precache "^5.1.1"
+    uuid "^3.2.1"
+    vinyl "^1.2.0"
+    vinyl-fs "^2.4.4"
+
+polymer-bundler@^4.0.10, polymer-bundler@^4.0.9:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/polymer-bundler/-/polymer-bundler-4.0.10.tgz#abc8d33977652f031068d034c8104841e80d4cbb"
+  integrity sha512-nwlN3LQlQDqbZ2sUH3394C/dHZUDHq8tpdS5HARvPDb0Q9IXWD+znOR1cr7wSjF0EZN4LiUH5hWyUoV4QSjhpQ==
+  dependencies:
+    "@types/babel-generator" "^6.25.1"
+    "@types/babel-traverse" "^6.25.3"
+    babel-generator "^6.26.1"
+    babel-traverse "^6.26.0"
+    babel-types "^6.26.0"
+    clone "^2.1.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    dom5 "^3.0.0"
+    espree "^3.5.2"
+    magic-string "^0.22.4"
+    mkdirp "^0.5.1"
+    parse5 "^4.0.0"
+    polymer-analyzer "^3.2.2"
+    rollup "^1.3.0"
+    source-map "^0.5.6"
+    vscode-uri "=1.0.6"
+
+polymer-cli@^1.9.11:
+  version "1.9.11"
+  resolved "https://registry.yarnpkg.com/polymer-cli/-/polymer-cli-1.9.11.tgz#0b5310732b787e07b811af96627ef0fd1263f5da"
+  integrity sha512-tiURjHDCOUUtDVPuVYvrfFI9PXe4OOUmBbn6Sg5GJNQ2POtP7r7hv+I5yI8P9qsxmalHTa19chVtf5/t9IBXDg==
+  dependencies:
+    "@octokit/rest" "^16.2.0"
+    "@types/chalk" "^2.2.0"
+    "@types/del" "^3.0.0"
+    "@types/findup-sync" "^0.3.29"
+    "@types/globby" "^6.1.0"
+    "@types/inquirer" "0.0.32"
+    "@types/merge-stream" "^1.0.28"
+    "@types/mz" "^0.0.31"
+    "@types/request" "2.0.3"
+    "@types/resolve" "0.0.4"
+    "@types/rimraf" "^0.0.28"
+    "@types/semver" "^5.3.30"
+    "@types/temp" "^0.8.28"
+    "@types/update-notifier" "^1.0.0"
+    "@types/vinyl" "^2.0.0"
+    "@types/vinyl-fs" "0.0.28"
+    "@types/yeoman-generator" "^2.0.3"
+    bower "^1.8.8"
+    bower-json "^0.8.1"
+    bower-logger "^0.2.2"
+    chalk "^2.4.2"
+    chokidar "^1.7.0"
+    command-line-args "^5.0.2"
+    command-line-commands "^2.0.1"
+    command-line-usage "^5.0.5"
+    del "^3.0.0"
+    findup-sync "^0.4.2"
+    globby "^8.0.1"
+    gunzip-maybe "^1.3.1"
+    inquirer "^1.0.2"
+    merge-stream "^1.0.1"
+    mz "^2.6.0"
+    plylog "^1.0.0"
+    polymer-analyzer "^3.2.2"
+    polymer-build "^3.1.4"
+    polymer-bundler "^4.0.9"
+    polymer-linter "^3.0.0"
+    polymer-project-config "^4.0.3"
+    polyserve "^0.27.15"
+    request "^2.72.0"
+    rimraf "^2.6.1"
+    semver "^5.3.0"
+    tar-fs "^1.12.0"
+    temp "^0.8.3"
+    update-notifier "^1.0.0"
+    validate-element-name "^2.1.1"
+    vinyl "^1.1.1"
+    vinyl-fs "^2.4.3"
+    web-component-tester "^6.9.0"
+    yeoman-environment "^1.5.2"
+    yeoman-generator "^3.1.1"
+
+polymer-linter@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/polymer-linter/-/polymer-linter-3.0.1.tgz#8804e1705fa2a7c263467b8a22da11bb764ee26b"
+  integrity sha512-eDh2CeswZz4Rwf8gfYXpMN66pieq4qJvP9bH3m39LLGm81hRePo4N5OHoQzR5unen1PUdmtjDv0Iicz3dTYEZQ==
+  dependencies:
+    "@types/fast-levenshtein" "0.0.1"
+    "@types/parse5" "^2.2.34"
+    babel-traverse "^6.26.0"
+    babel-types "^6.26.0"
+    cancel-token "^0.1.1"
+    css-what "^2.1.0"
+    dom5 "^3.0.0"
+    fast-levenshtein "^2.0.6"
+    parse5 "^4.0.0"
+    polymer-analyzer "^3.0.0"
+    shady-css-parser "^0.1.0"
+    stable "^0.1.6"
+    strip-indent "^2.0.0"
+    validate-element-name "^2.1.1"
+
+polymer-project-config@^4.0.0, polymer-project-config@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/polymer-project-config/-/polymer-project-config-4.0.3.tgz#ef0c1a676ce4809907986c8e910745660de8024f"
+  integrity sha512-Drr+Imq+znhBC8XSt9pMlmPixoGnIOmleV5SD6mto1zOGC5oCDbSNsQL2v89DWOk+9aSUO79vnWwOmEPDSvYfw==
+  dependencies:
+    "@types/parse5" "^2.2.34"
+    browser-capabilities "^1.0.0"
+    jsonschema "^1.1.1"
+    minimatch-all "^1.1.0"
+    plylog "^1.0.0"
+    winston "^3.0.0"
+
+polyserve@^0.27.13, polyserve@^0.27.15:
+  version "0.27.15"
+  resolved "https://registry.yarnpkg.com/polyserve/-/polyserve-0.27.15.tgz#261fa5a0873c8d95fd7068598f44c9dac20cf9c4"
+  integrity sha512-AaFgANt+tUUVgHLw+BnaVYcn649JiwL1ru0TOZUKj1gGGn/Bq2S16gxql+1muGpRaAsgFu13Zu7k5XkwatwwSg==
+  dependencies:
+    "@types/compression" "^0.0.33"
+    "@types/content-type" "^1.1.0"
+    "@types/escape-html" "0.0.20"
+    "@types/express" "^4.0.36"
+    "@types/mime" "^2.0.0"
+    "@types/mz" "0.0.29"
+    "@types/opn" "^3.0.28"
+    "@types/parse5" "^2.2.34"
+    "@types/pem" "^1.8.1"
+    "@types/resolve" "0.0.6"
+    "@types/serve-static" "^1.7.31"
+    "@types/spdy" "^3.4.1"
+    bower-config "^1.4.1"
+    browser-capabilities "^1.0.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    compression "^1.6.2"
+    content-type "^1.0.2"
+    cors "^2.8.4"
+    escape-html "^1.0.3"
+    express "^4.8.5"
+    find-port "^1.0.1"
+    http-proxy-middleware "^0.17.2"
+    lru-cache "^4.0.2"
+    mime "^2.3.1"
+    mz "^2.4.0"
+    opn "^3.0.2"
+    pem "^1.8.3"
+    polymer-build "^3.1.0"
+    polymer-project-config "^4.0.0"
+    requirejs "^2.3.4"
+    resolve "^1.5.0"
+    send "^0.16.2"
+    spdy "^3.3.3"
+
+posix-character-classes@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
+  integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
+
+prepend-http@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
+  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
+
+preserve@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
+
+pretty-bytes@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
+  integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=
+
+pretty-bytes@^5.1.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2"
+  integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg==
+
+private@^0.1.6:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
+  integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
+
+process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
+
+progress@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+protobufjs@6.8.8:
+  version "6.8.8"
+  resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"
+  integrity sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==
+  dependencies:
+    "@protobufjs/aspromise" "^1.1.2"
+    "@protobufjs/base64" "^1.1.2"
+    "@protobufjs/codegen" "^2.0.4"
+    "@protobufjs/eventemitter" "^1.1.0"
+    "@protobufjs/fetch" "^1.1.0"
+    "@protobufjs/float" "^1.0.2"
+    "@protobufjs/inquire" "^1.1.0"
+    "@protobufjs/path" "^1.1.2"
+    "@protobufjs/pool" "^1.1.0"
+    "@protobufjs/utf8" "^1.1.0"
+    "@types/long" "^4.0.0"
+    "@types/node" "^10.1.0"
+    long "^4.0.0"
+
+proxy-addr@~2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
+  integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
+  dependencies:
+    forwarded "~0.1.2"
+    ipaddr.js "1.9.0"
+
+pseudomap@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
+
+psl@^1.1.24:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c"
+  integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==
+
+pump@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
+  integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pump@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
+  integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pumpify@^1.3.3:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
+  integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
+  dependencies:
+    duplexify "^3.6.0"
+    inherits "^2.0.3"
+    pump "^2.0.0"
+
+punycode@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
+  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
+
+punycode@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+q@^1.4.1, q@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
+  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
+
+qs@6.7.0:
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
+  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
+
+qs@~6.5.2:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+
+randomatic@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
+  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
+  dependencies:
+    is-number "^4.0.0"
+    kind-of "^6.0.0"
+    math-random "^1.0.1"
+
+range-parser@~1.2.0, range-parser@~1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
+  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+
+raw-body@2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
+  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+  dependencies:
+    bytes "3.1.0"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+rc@^1.0.1, rc@^1.1.6:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+  dependencies:
+    deep-extend "^0.6.0"
+    ini "~1.3.0"
+    minimist "^1.2.0"
+    strip-json-comments "~2.0.1"
+
+read-all-stream@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"
+  integrity sha1-NcPhd/IHjveJ7kv6+kNzB06u9Po=
+  dependencies:
+    pinkie-promise "^2.0.0"
+    readable-stream "^2.0.0"
+
+read-chunk@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/read-chunk/-/read-chunk-3.2.0.tgz#2984afe78ca9bfbbdb74b19387bf9e86289c16ca"
+  integrity sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==
+  dependencies:
+    pify "^4.0.1"
+    with-open-file "^0.1.6"
+
+read-pkg-up@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
+  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
+  dependencies:
+    find-up "^1.0.0"
+    read-pkg "^1.0.0"
+
+read-pkg-up@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978"
+  integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==
+  dependencies:
+    find-up "^3.0.0"
+    read-pkg "^3.0.0"
+
+read-pkg@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
+  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
+  dependencies:
+    load-json-file "^1.0.0"
+    normalize-package-data "^2.3.2"
+    path-type "^1.0.0"
+
+read-pkg@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
+  integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=
+  dependencies:
+    load-json-file "^4.0.0"
+    normalize-package-data "^2.3.2"
+    path-type "^3.0.0"
+
+readable-stream@1.1.x:
+  version "1.1.14"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
+  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+"readable-stream@2 || 3", readable-stream@^3.0.1, readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.5.0.tgz#465d70e6d1087f6162d079cd0b5db7fbebfd1606"
+  integrity sha512-gSz026xs2LfxBPudDuI41V1lka8cxg64E66SGe78zJlsUofOg/yqwezdIcdfwik6B4h8LFmWPA9ef9X3FiNFLA==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
+"readable-stream@>=1.0.33-1 <1.1.0-0":
+  version "1.0.34"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
+  integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+readdirp@^2.0.0:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
+  integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==
+  dependencies:
+    graceful-fs "^4.1.11"
+    micromatch "^3.1.10"
+    readable-stream "^2.0.2"
+
+rechoir@^0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
+  integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=
+  dependencies:
+    resolve "^1.1.6"
+
+redent@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
+  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
+  dependencies:
+    indent-string "^2.1.0"
+    strip-indent "^1.0.1"
+
+reduce-flatten@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
+  integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
+
+regenerate-unicode-properties@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e"
+  integrity sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==
+  dependencies:
+    regenerate "^1.4.0"
+
+regenerate@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
+  integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
+
+regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
+  version "0.11.1"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
+  integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
+
+regenerator-transform@^0.14.0:
+  version "0.14.1"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb"
+  integrity sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ==
+  dependencies:
+    private "^0.1.6"
+
+regex-cache@^0.4.2:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
+  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
+  dependencies:
+    is-equal-shallow "^0.1.3"
+
+regex-not@^1.0.0, regex-not@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
+  integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==
+  dependencies:
+    extend-shallow "^3.0.2"
+    safe-regex "^1.1.0"
+
+regexpu-core@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.6.0.tgz#2037c18b327cfce8a6fea2a4ec441f2432afb8b6"
+  integrity sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg==
+  dependencies:
+    regenerate "^1.4.0"
+    regenerate-unicode-properties "^8.1.0"
+    regjsgen "^0.5.0"
+    regjsparser "^0.6.0"
+    unicode-match-property-ecmascript "^1.0.4"
+    unicode-match-property-value-ecmascript "^1.1.0"
+
+registry-auth-token@^3.0.1:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
+  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
+  dependencies:
+    rc "^1.1.6"
+    safe-buffer "^5.0.1"
+
+registry-url@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
+  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
+  dependencies:
+    rc "^1.0.1"
+
+regjsgen@^0.5.0:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.1.tgz#48f0bf1a5ea205196929c0d9798b42d1ed98443c"
+  integrity sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==
+
+regjsparser@^0.6.0:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.2.tgz#fd62c753991467d9d1ffe0a9f67f27a529024b96"
+  integrity sha512-E9ghzUtoLwDekPT0DYCp+c4h+bvuUpe6rRHCTYn6eGoqj1LgKXxT6I0Il4WbjhQkOghzi/V+y03bPKvbllL93Q==
+  dependencies:
+    jsesc "~0.5.0"
+
+relateurl@0.2.x:
+  version "0.2.7"
+  resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
+  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
+
+remove-trailing-separator@^1.0.1:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
+  integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
+
+repeat-element@^1.1.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
+  integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==
+
+repeat-string@^1.5.2, repeat-string@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
+  integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
+
+repeating@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
+  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
+  dependencies:
+    is-finite "^1.0.0"
+
+replace-ext@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
+  integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
+
+replace-ext@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
+  integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=
+
+request@2.88.0, request@^2.72.0, request@^2.85.0:
+  version "2.88.0"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
+  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
+  dependencies:
+    aws-sign2 "~0.7.0"
+    aws4 "^1.8.0"
+    caseless "~0.12.0"
+    combined-stream "~1.0.6"
+    extend "~3.0.2"
+    forever-agent "~0.6.1"
+    form-data "~2.3.2"
+    har-validator "~5.1.0"
+    http-signature "~1.2.0"
+    is-typedarray "~1.0.0"
+    isstream "~0.1.2"
+    json-stringify-safe "~5.0.1"
+    mime-types "~2.1.19"
+    oauth-sign "~0.9.0"
+    performance-now "^2.1.0"
+    qs "~6.5.2"
+    safe-buffer "^5.1.2"
+    tough-cookie "~2.4.3"
+    tunnel-agent "^0.6.0"
+    uuid "^3.3.2"
+
+requirejs@^2.3.4:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
+  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
+
+requires-port@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
+  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
+
+resolve-dir@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e"
+  integrity sha1-shklmlYC+sXFxJatiUpujMQwJh4=
+  dependencies:
+    expand-tilde "^1.2.2"
+    global-modules "^0.2.3"
+
+resolve-dir@^1.0.0, resolve-dir@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
+  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
+  dependencies:
+    expand-tilde "^2.0.0"
+    global-modules "^1.0.0"
+
+resolve-url@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
+  integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
+
+resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.1, resolve@^1.3.2, resolve@^1.5.0:
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5"
+  integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==
+  dependencies:
+    path-parse "^1.0.6"
+
+restore-cursor@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
+  integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=
+  dependencies:
+    exit-hook "^1.0.0"
+    onetime "^1.0.0"
+
+restore-cursor@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
+  integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368=
+  dependencies:
+    onetime "^2.0.0"
+    signal-exit "^3.0.2"
+
+ret@~0.1.10:
+  version "0.1.15"
+  resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+  integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+
+rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
+  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b"
+  integrity sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@~2.6.2:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
+  integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
+  dependencies:
+    glob "^7.1.3"
+
+rollup-plugin-node-resolve@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz#730f93d10ed202473b1fb54a5997a7db8c6d8523"
+  integrity sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==
+  dependencies:
+    "@types/resolve" "0.0.8"
+    builtin-modules "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.11.1"
+    rollup-pluginutils "^2.8.1"
+
+rollup-plugin-terser@^5.1.3:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-5.2.0.tgz#ba758adf769347b7f1eaf9ef35978d2e207dccc7"
+  integrity sha512-jQI+nYhtDBc9HFRBz8iGttQg7li9klmzR62RG2W2nN6hJ/FI2K2ItYQ7kJ7/zn+vs+BP1AEccmVRjRN989I+Nw==
+  dependencies:
+    "@babel/code-frame" "^7.5.5"
+    jest-worker "^24.9.0"
+    rollup-pluginutils "^2.8.2"
+    serialize-javascript "^2.1.2"
+    terser "^4.6.2"
+
+rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2:
+  version "2.8.2"
+  resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"
+  integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==
+  dependencies:
+    estree-walker "^0.6.1"
+
+rollup@^1.27.5, rollup@^1.3.0:
+  version "1.30.0"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.30.0.tgz#ae9c893804e8eaa8f8f74b0aaf7e7fb4374a9d01"
+  integrity sha512-ANcmfaSQwpcJtZUTA0ZMNBtFcQ1B4A5FldlNqEK0WdWm9sHSKu93ffa2KV1ux8HA/yKIV/ZARV28m7rNdXJgEw==
+  dependencies:
+    "@types/estree" "*"
+    "@types/node" "*"
+    acorn "^7.1.0"
+
+run-async@^2.0.0, run-async@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
+  integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA=
+  dependencies:
+    is-promise "^2.1.0"
+
+rx@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
+  integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=
+
+rxjs@^6.4.0:
+  version "6.5.4"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c"
+  integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==
+  dependencies:
+    tslib "^1.9.0"
+
+safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
+  integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
+
+safe-regex@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
+  integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4=
+  dependencies:
+    ret "~0.1.10"
+
+"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+samsam@1.x, samsam@^1.1.3:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
+  integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
+
+sauce-connect-launcher@^1.0.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.1.tgz#31137f57b0f7176e1c0525b7fb09c6da746647cf"
+  integrity sha512-vIf9qDol3q2FlYzrKt0dr3kvec6LSjX2WS+/mVnAJIhqh1evSkPKCR2AzcJrnSmx9Xt9PtV0tLY7jYh0wsQi8A==
+  dependencies:
+    adm-zip "~0.4.3"
+    async "^2.1.2"
+    https-proxy-agent "^3.0.0"
+    lodash "^4.16.6"
+    rimraf "^2.5.4"
+
+scoped-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8"
+  integrity sha1-o0a7Gs1CB65wvXwMfKnlZra63bg=
+
+select-hose@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
+  integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
+
+selenium-standalone@^6.7.0:
+  version "6.17.0"
+  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.17.0.tgz#0f24b691836205ee9bc3d7a6f207ebcb28170cd9"
+  integrity sha512-5PSnDHwMiq+OCiAGlhwQ8BM9xuwFfvBOZ7Tfbw+ifkTnOy0PWbZmI1B9gPGuyGHpbQ/3J3CzIK7BYwrQ7EjtWQ==
+  dependencies:
+    async "^2.6.2"
+    commander "^2.19.0"
+    cross-spawn "^6.0.5"
+    debug "^4.1.1"
+    lodash "^4.17.11"
+    minimist "^1.2.0"
+    mkdirp "^0.5.1"
+    progress "2.0.3"
+    request "2.88.0"
+    tar-stream "2.0.0"
+    urijs "^1.19.1"
+    which "^1.3.1"
+    yauzl "^2.10.0"
+
+semver-diff@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
+  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
+  dependencies:
+    semver "^5.0.3"
+
+"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
+  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
+
+semver@5.6.0:
+  version "5.6.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
+  integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
+
+semver@^6.3.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+
+send@0.17.1:
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
+  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.2"
+    destroy "~1.0.4"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.7.2"
+    mime "1.6.0"
+    ms "2.1.1"
+    on-finished "~2.3.0"
+    range-parser "~1.2.1"
+    statuses "~1.5.0"
+
+send@^0.16.1, send@^0.16.2:
+  version "0.16.2"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
+  integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.2"
+    destroy "~1.0.4"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.6.2"
+    mime "1.4.1"
+    ms "2.0.0"
+    on-finished "~2.3.0"
+    range-parser "~1.2.0"
+    statuses "~1.4.0"
+
+serialize-javascript@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
+  integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
+
+serve-static@1.14.1:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
+  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
+  dependencies:
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    parseurl "~1.3.3"
+    send "0.17.1"
+
+server-destroy@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
+  integrity sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0=
+
+serviceworker-cache-polyfill@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
+  integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
+
+set-value@^2.0.0, set-value@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
+  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
+  dependencies:
+    extend-shallow "^2.0.1"
+    is-extendable "^0.1.1"
+    is-plain-object "^2.0.3"
+    split-string "^3.0.1"
+
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
+  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
+
+shady-css-parser@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
+  integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
+
+shebang-command@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
+  dependencies:
+    shebang-regex "^1.0.0"
+
+shebang-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
+
+shelljs@^0.8.0:
+  version "0.8.3"
+  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.3.tgz#a7f3319520ebf09ee81275b2368adb286659b097"
+  integrity sha512-fc0BKlAWiLpwZljmOvAOTE/gXawtCoNrP5oaY7KIaQbbyHeQVg01pSEuEGvGh3HEdBU4baCD7wQBwADmM/7f7A==
+  dependencies:
+    glob "^7.0.0"
+    interpret "^1.0.0"
+    rechoir "^0.6.2"
+
+signal-exit@^3.0.0, signal-exit@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
+
+simple-swizzle@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
+  integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
+  dependencies:
+    is-arrayish "^0.3.1"
+
+sinon-chai@^2.10.0:
+  version "2.14.0"
+  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.14.0.tgz#da7dd4cc83cd6a260b67cca0f7a9fdae26a1205d"
+  integrity sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==
+
+sinon@^2.3.5:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36"
+  integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==
+  dependencies:
+    diff "^3.1.0"
+    formatio "1.2.0"
+    lolex "^1.6.0"
+    native-promise-only "^0.8.1"
+    path-to-regexp "^1.7.0"
+    samsam "^1.1.3"
+    text-encoding "0.6.4"
+    type-detect "^4.0.0"
+
+slash@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
+  integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
+
+slide@^1.1.5:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
+  integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
+
+snapdragon-node@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
+  integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==
+  dependencies:
+    define-property "^1.0.0"
+    isobject "^3.0.0"
+    snapdragon-util "^3.0.1"
+
+snapdragon-util@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
+  integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==
+  dependencies:
+    kind-of "^3.2.0"
+
+snapdragon@^0.8.1:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
+  integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==
+  dependencies:
+    base "^0.11.1"
+    debug "^2.2.0"
+    define-property "^0.2.5"
+    extend-shallow "^2.0.1"
+    map-cache "^0.2.2"
+    source-map "^0.5.6"
+    source-map-resolve "^0.5.0"
+    use "^3.1.0"
+
+socket.io-adapter@~1.1.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
+  integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
+
+socket.io-client@2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4"
+  integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==
+  dependencies:
+    backo2 "1.0.2"
+    base64-arraybuffer "0.1.5"
+    component-bind "1.0.0"
+    component-emitter "1.2.1"
+    debug "~4.1.0"
+    engine.io-client "~3.4.0"
+    has-binary2 "~1.0.2"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    object-component "0.0.3"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    socket.io-parser "~3.3.0"
+    to-array "0.1.4"
+
+socket.io-parser@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
+  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~3.1.0"
+    isarray "2.0.1"
+
+socket.io-parser@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.0.tgz#370bb4a151df2f77ce3345ff55a7072cc6e9565a"
+  integrity sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~4.1.0"
+    isarray "2.0.1"
+
+socket.io@^2.0.3:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb"
+  integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==
+  dependencies:
+    debug "~4.1.0"
+    engine.io "~3.4.0"
+    has-binary2 "~1.0.2"
+    socket.io-adapter "~1.1.0"
+    socket.io-client "2.3.0"
+    socket.io-parser "~3.4.0"
+
+sort-keys-length@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188"
+  integrity sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=
+  dependencies:
+    sort-keys "^1.0.0"
+
+sort-keys@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
+  integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0=
+  dependencies:
+    is-plain-obj "^1.0.0"
+
+source-map-resolve@^0.5.0:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
+  integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==
+  dependencies:
+    atob "^2.1.2"
+    decode-uri-component "^0.2.0"
+    resolve-url "^0.2.1"
+    source-map-url "^0.4.0"
+    urix "^0.1.0"
+
+source-map-support@0.5.9:
+  version "0.5.9"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f"
+  integrity sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map-support@~0.5.12:
+  version "0.5.16"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042"
+  integrity sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map-url@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
+  integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=
+
+source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
+  version "0.5.7"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+  integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
+
+source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+spawn-sync@^1.0.15:
+  version "1.0.15"
+  resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-1.0.15.tgz#b00799557eb7fb0c8376c29d44e8a1ea67e57476"
+  integrity sha1-sAeZVX63+wyDdsKdROih6mfldHY=
+  dependencies:
+    concat-stream "^1.4.7"
+    os-shim "^0.1.2"
+
+spdx-correct@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4"
+  integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==
+  dependencies:
+    spdx-expression-parse "^3.0.0"
+    spdx-license-ids "^3.0.0"
+
+spdx-exceptions@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977"
+  integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==
+
+spdx-expression-parse@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
+  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  dependencies:
+    spdx-exceptions "^2.1.0"
+    spdx-license-ids "^3.0.0"
+
+spdx-license-ids@^3.0.0:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
+  integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
+
+spdy-transport@^2.0.18:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.1.tgz#c54815d73858aadd06ce63001e7d25fa6441623b"
+  integrity sha512-q7D8c148escoB3Z7ySCASadkegMmUZW8Wb/Q1u0/XBgDKMO880rLQDj8Twiew/tYi7ghemKUi/whSYOwE17f5Q==
+  dependencies:
+    debug "^2.6.8"
+    detect-node "^2.0.3"
+    hpack.js "^2.1.6"
+    obuf "^1.1.1"
+    readable-stream "^2.2.9"
+    safe-buffer "^5.0.1"
+    wbuf "^1.7.2"
+
+spdy@^3.3.3:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
+  integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=
+  dependencies:
+    debug "^2.6.8"
+    handle-thing "^1.2.5"
+    http-deceiver "^1.2.7"
+    safe-buffer "^5.0.1"
+    select-hose "^2.0.0"
+    spdy-transport "^2.0.18"
+
+split-string@^3.0.1, split-string@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
+  integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==
+  dependencies:
+    extend-shallow "^3.0.0"
+
+sshpk@^1.7.0:
+  version "1.16.1"
+  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
+  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
+  dependencies:
+    asn1 "~0.2.3"
+    assert-plus "^1.0.0"
+    bcrypt-pbkdf "^1.0.0"
+    dashdash "^1.12.0"
+    ecc-jsbn "~0.1.1"
+    getpass "^0.1.1"
+    jsbn "~0.1.0"
+    safer-buffer "^2.0.2"
+    tweetnacl "~0.14.0"
+
+stable@^0.1.6:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
+  integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
+
+stack-trace@0.0.x:
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
+  integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
+
+stacky@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/stacky/-/stacky-1.3.1.tgz#3f117e5187b9a73d23f876d69f05c85b11804a12"
+  integrity sha1-PxF+UYe5pz0j+HbWnwXIWxGAShI=
+  dependencies:
+    chalk "^1.1.1"
+    lodash "^3.0.0"
+
+static-extend@^0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
+  integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=
+  dependencies:
+    define-property "^0.2.5"
+    object-copy "^0.1.0"
+
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
+
+statuses@~1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
+  integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
+
+stream-shift@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
+  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
+
+stream@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef"
+  integrity sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=
+  dependencies:
+    emitter-component "^1.1.1"
+
+streamsearch@0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
+  integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
+
+string-template@~0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
+  integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=
+
+string-width@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+  dependencies:
+    code-point-at "^1.0.0"
+    is-fullwidth-code-point "^1.0.0"
+    strip-ansi "^3.0.0"
+
+string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
+  dependencies:
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^4.0.0"
+
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
+string_decoder@~0.10.x:
+  version "0.10.31"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
+  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
+
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+strip-ansi@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  dependencies:
+    ansi-regex "^3.0.0"
+
+strip-ansi@^5.1.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
+  integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
+  dependencies:
+    ansi-regex "^4.1.0"
+
+strip-ansi@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
+  integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
+
+strip-bom-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
+  integrity sha1-5xRDmFd9Uaa+0PoZlPoF9D/ZiO4=
+  dependencies:
+    first-chunk-stream "^1.0.0"
+    strip-bom "^2.0.0"
+
+strip-bom-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca"
+  integrity sha1-+H217yYT9paKpUWr/h7HKLaoKco=
+  dependencies:
+    first-chunk-stream "^2.0.0"
+    strip-bom "^2.0.0"
+
+strip-bom@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
+  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
+  dependencies:
+    is-utf8 "^0.2.0"
+
+strip-bom@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
+  integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
+
+strip-eof@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
+  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
+
+strip-indent@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
+  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
+  dependencies:
+    get-stdin "^4.0.1"
+
+strip-indent@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
+  integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
+
+strip-json-comments@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+
+supports-color@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
+  integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+supports-color@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
+  integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
+  dependencies:
+    has-flag "^3.0.0"
+
+supports-color@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
+  integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==
+  dependencies:
+    has-flag "^4.0.0"
+
+sw-precache@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
+  integrity sha512-8FAy+BP/FXE+ILfiVTt+GQJ6UEf4CVHD9OfhzH0JX+3zoy2uFk7Vn9EfXASOtVmmIVbL3jE/W8Z66VgPSZcMhw==
+  dependencies:
+    dom-urls "^1.1.0"
+    es6-promise "^4.0.5"
+    glob "^7.1.1"
+    lodash.defaults "^4.2.0"
+    lodash.template "^4.4.0"
+    meow "^3.7.0"
+    mkdirp "^0.5.1"
+    pretty-bytes "^4.0.2"
+    sw-toolbox "^3.4.0"
+    update-notifier "^2.3.0"
+
+sw-toolbox@^3.4.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/sw-toolbox/-/sw-toolbox-3.6.0.tgz#26df1d1c70348658e4dea2884319149b7b3183b5"
+  integrity sha1-Jt8dHHA0hljk3qKIQxkUm3sxg7U=
+  dependencies:
+    path-to-regexp "^1.0.1"
+    serviceworker-cache-polyfill "^4.0.0"
+
+table-layout@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.3.0.tgz#6ee20dc483db371b3e5c87f704ed2f7c799d2c9a"
+  integrity sha1-buINxIPbNxs+XIf3BO0vfHmdLJo=
+  dependencies:
+    array-back "^1.0.3"
+    core-js "^2.4.1"
+    deep-extend "~0.4.1"
+    feature-detect-es6 "^1.3.1"
+    typical "^2.6.0"
+    wordwrapjs "^2.0.0-0"
+
+table-layout@^0.4.3:
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.4.5.tgz#d906de6a25fa09c0c90d1d08ecd833ecedcb7378"
+  integrity sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==
+  dependencies:
+    array-back "^2.0.0"
+    deep-extend "~0.6.0"
+    lodash.padend "^4.6.1"
+    typical "^2.6.1"
+    wordwrapjs "^3.0.0"
+
+tar-fs@^1.12.0:
+  version "1.16.3"
+  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509"
+  integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw==
+  dependencies:
+    chownr "^1.0.1"
+    mkdirp "^0.5.1"
+    pump "^1.0.0"
+    tar-stream "^1.1.2"
+
+tar-stream@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.0.0.tgz#8829bbf83067bc0288a9089db49c56be395b6aea"
+  integrity sha512-n2vtsWshZOVr/SY4KtslPoUlyNh06I2SGgAOCZmquCEjlbV/LjY2CY80rDtdQRHFOYXNlgBDo6Fr3ww2CWPOtA==
+  dependencies:
+    bl "^2.2.0"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+tar-stream@^1.1.2:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555"
+  integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==
+  dependencies:
+    bl "^1.0.0"
+    buffer-alloc "^1.2.0"
+    end-of-stream "^1.0.0"
+    fs-constants "^1.0.0"
+    readable-stream "^2.3.0"
+    to-buffer "^1.1.1"
+    xtend "^4.0.0"
+
+tar-stream@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3"
+  integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==
+  dependencies:
+    bl "^3.0.0"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+temp@^0.8.1, temp@^0.8.3:
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
+  integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==
+  dependencies:
+    rimraf "~2.6.2"
+
+term-size@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
+  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
+  dependencies:
+    execa "^0.7.0"
+
+ternary-stream@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.1.1.tgz#4ad64b98668d796a085af2c493885a435a8a8bfc"
+  integrity sha512-j6ei9hxSoyGlqTmoMjOm+QNvUKDOIY6bNl4Uh1lhBvl6yjPW2iLqxDUYyfDPZknQ4KdRziFl+ec99iT4l7g0cw==
+  dependencies:
+    duplexify "^3.5.0"
+    fork-stream "^0.0.4"
+    merge-stream "^1.0.0"
+    through2 "^2.0.1"
+
+terser@^4.6.2:
+  version "4.6.3"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.3.tgz#e33aa42461ced5238d352d2df2a67f21921f8d87"
+  integrity sha512-Lw+ieAXmY69d09IIc/yqeBqXpEQIpDGZqT34ui1QWXIUpR2RjbqEkT8X7Lgex19hslSqcWM5iMN2kM11eMsESQ==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
+
+test-value@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/test-value/-/test-value-2.1.0.tgz#11da6ff670f3471a73b625ca4f3fdcf7bb748291"
+  integrity sha1-Edpv9nDzRxpztiXKTz/c97t0gpE=
+  dependencies:
+    array-back "^1.0.3"
+    typical "^2.6.0"
+
+text-encoding@0.6.4:
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
+  integrity sha1-45mpgiV6J22uQou5KEXLcb3CbRk=
+
+text-hex@1.0.x:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
+  integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
+
+text-table@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+  integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
+
+textextensions@^2.5.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.6.0.tgz#d7e4ab13fe54e32e08873be40d51b74229b00fc4"
+  integrity sha512-49WtAWS+tcsy93dRt6P0P3AMD2m5PvXRhuEA0kaXos5ZLlujtYmpmFsB+QvWUSxE1ZsstmYXfQ7L40+EcQgpAQ==
+
+thenify-all@^1.0.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
+  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
+  dependencies:
+    thenify ">= 3.1.0 < 4"
+
+"thenify@>= 3.1.0 < 4":
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839"
+  integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=
+  dependencies:
+    any-promise "^1.0.0"
+
+through2-filter@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec"
+  integrity sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=
+  dependencies:
+    through2 "~2.0.0"
+    xtend "~4.0.0"
+
+through2-filter@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
+  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
+  dependencies:
+    through2 "~2.0.0"
+    xtend "~4.0.0"
+
+through2@^0.6.0:
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
+  integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=
+  dependencies:
+    readable-stream ">=1.0.33-1 <1.1.0-0"
+    xtend ">=4.0.0 <4.1.0-0"
+
+through2@^2.0.0, through2@^2.0.1, through2@^2.0.3, through2@~2.0.0:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
+  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
+  dependencies:
+    readable-stream "~2.3.6"
+    xtend "~4.0.1"
+
+through2@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a"
+  integrity sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==
+  dependencies:
+    readable-stream "2 || 3"
+
+through@^2.3.6:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
+
+timed-out@^3.0.0:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-3.1.3.tgz#95860bfcc5c76c277f8f8326fd0f5b2e20eba217"
+  integrity sha1-lYYL/MXHbCd/j4Mm/Q9bLiDrohc=
+
+timed-out@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
+  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
+
+tmp@^0.0.29:
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.29.tgz#f25125ff0dd9da3ccb0c2dd371ee1288bb9128c0"
+  integrity sha1-8lEl/w3Z2jzLDC3Tce4SiLuRKMA=
+  dependencies:
+    os-tmpdir "~1.0.1"
+
+tmp@^0.0.33:
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
+  dependencies:
+    os-tmpdir "~1.0.2"
+
+to-absolute-glob@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f"
+  integrity sha1-HN+kcqnvUMI57maZm2YsoOs5k38=
+  dependencies:
+    extend-shallow "^2.0.1"
+
+to-array@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
+  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
+
+to-buffer@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
+  integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==
+
+to-fast-properties@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
+  integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=
+
+to-fast-properties@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
+  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
+
+to-object-path@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
+  integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=
+  dependencies:
+    kind-of "^3.0.2"
+
+to-regex-range@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
+  integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=
+  dependencies:
+    is-number "^3.0.0"
+    repeat-string "^1.6.1"
+
+to-regex@^3.0.1, to-regex@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
+  integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==
+  dependencies:
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    regex-not "^1.0.2"
+    safe-regex "^1.1.0"
+
+toidentifier@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
+  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
+
+tough-cookie@~2.4.3:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
+  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
+  dependencies:
+    psl "^1.1.24"
+    punycode "^1.4.1"
+
+tr46@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
+  integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
+  dependencies:
+    punycode "^2.1.0"
+
+trim-newlines@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
+  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
+
+trim-right@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
+  integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
+
+triple-beam@^1.2.0, triple-beam@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
+  integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
+
+tslib@^1.8.1, tslib@^1.9.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
+  integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
+
+tsutils@2.27.2:
+  version "2.27.2"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.27.2.tgz#60ba88a23d6f785ec4b89c6e8179cac9b431f1c7"
+  integrity sha512-qf6rmT84TFMuxAKez2pIfR8UCai49iQsfB7YWVjV1bKpy/d0PWT5rEOSM6La9PiHZ0k1RRZQiwVdVJfQ3BPHgg==
+  dependencies:
+    tslib "^1.8.1"
+
+tunnel-agent@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+  dependencies:
+    safe-buffer "^5.0.1"
+
+tweetnacl@^0.14.3, tweetnacl@~0.14.0:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
+  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+
+type-detect@^4.0.0:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+  integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
+
+type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+typedarray@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
+  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+
+typescript@^3.7.4:
+  version "3.7.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
+  integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
+
+typical@^2.6.0, typical@^2.6.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d"
+  integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0=
+
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+ua-parser-js@^0.7.15:
+  version "0.7.21"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"
+  integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==
+
+uglify-js@3.4.x:
+  version "3.4.10"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
+  integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==
+  dependencies:
+    commander "~2.19.0"
+    source-map "~0.6.1"
+
+underscore@^1.8.3:
+  version "1.9.2"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.2.tgz#0c8d6f536d6f378a5af264a72f7bec50feb7cf2f"
+  integrity sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==
+
+underscore@~1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"
+  integrity sha1-izixDKze9jM3uLJOT/htRa6lKag=
+
+unicode-canonical-property-names-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
+  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
+
+unicode-match-property-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
+  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
+  dependencies:
+    unicode-canonical-property-names-ecmascript "^1.0.4"
+    unicode-property-aliases-ecmascript "^1.0.4"
+
+unicode-match-property-value-ecmascript@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277"
+  integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==
+
+unicode-property-aliases-ecmascript@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57"
+  integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==
+
+union-value@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
+  integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==
+  dependencies:
+    arr-union "^3.1.0"
+    get-value "^2.0.6"
+    is-extendable "^0.1.1"
+    set-value "^2.0.1"
+
+unique-stream@^2.0.2:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
+  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
+  dependencies:
+    json-stable-stringify-without-jsonify "^1.0.1"
+    through2-filter "^3.0.0"
+
+unique-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
+  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
+  dependencies:
+    crypto-random-string "^1.0.0"
+
+universal-user-agent@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.0.tgz#27da2ec87e32769619f68a14996465ea1cb9df16"
+  integrity sha512-eM8knLpev67iBDizr/YtqkJsF3GK8gzDc6st/WKzrTuPtcsOKW/0IdL4cnMBsU69pOx0otavLWBDGTwg+dB0aA==
+  dependencies:
+    os-name "^3.1.0"
+
+unpipe@1.0.0, unpipe@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
+
+unset-value@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
+  integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=
+  dependencies:
+    has-value "^0.3.1"
+    isobject "^3.0.0"
+
+untildify@^2.0.0, untildify@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0"
+  integrity sha1-F+soB5h/dpUunASF/DEdBqgmouA=
+  dependencies:
+    os-homedir "^1.0.0"
+
+untildify@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9"
+  integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==
+
+unzip-response@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe"
+  integrity sha1-uYTwh3/AqJwsdzzB73tbIytbBv4=
+
+unzip-response@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
+  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
+
+update-notifier@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-1.0.3.tgz#8f92c515482bd6831b7c93013e70f87552c7cf5a"
+  integrity sha1-j5LFFUgr1oMbfJMBPnD4dVLHz1o=
+  dependencies:
+    boxen "^0.6.0"
+    chalk "^1.0.0"
+    configstore "^2.0.0"
+    is-npm "^1.0.0"
+    latest-version "^2.0.0"
+    lazy-req "^1.1.0"
+    semver-diff "^2.0.0"
+    xdg-basedir "^2.0.0"
+
+update-notifier@^2.2.0, update-notifier@^2.3.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
+  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
+  dependencies:
+    boxen "^1.2.1"
+    chalk "^2.0.1"
+    configstore "^3.0.0"
+    import-lazy "^2.1.0"
+    is-ci "^1.0.10"
+    is-installed-globally "^0.1.0"
+    is-npm "^1.0.0"
+    latest-version "^3.0.0"
+    semver-diff "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+upper-case@^1.1.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
+  integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
+
+uri-js@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
+  integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
+  dependencies:
+    punycode "^2.1.0"
+
+urijs@^1.16.1, urijs@^1.19.1:
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.2.tgz#f9be09f00c4c5134b7cb3cf475c1dd394526265a"
+  integrity sha512-s/UIq9ap4JPZ7H1EB5ULo/aOUbWqfDi7FKzMC2Nz+0Si8GiT1rIEaprt8hy3Vy2Ex2aJPpOQv4P4DuOZ+K1c6w==
+
+urix@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
+  integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
+
+url-parse-lax@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
+  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
+  dependencies:
+    prepend-http "^1.0.1"
+
+url-to-options@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
+  integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
+
+use@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
+  integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
+
+util-deprecate@^1.0.1, util-deprecate@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+utils-merge@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+
+uuid@^2.0.1:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
+  integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=
+
+uuid@^3.2.1, uuid@^3.3.2:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
+  integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
+
+vali-date@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/vali-date/-/vali-date-1.0.0.tgz#1b904a59609fb328ef078138420934f6b86709a6"
+  integrity sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=
+
+validate-element-name@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/validate-element-name/-/validate-element-name-2.1.1.tgz#8ff75f7da69f73e7c510588362130508b7ac644e"
+  integrity sha1-j/dffaafc+fFEFiDYhMFCLesZE4=
+  dependencies:
+    is-potential-custom-element-name "^1.0.0"
+    log-symbols "^1.0.0"
+    meow "^3.7.0"
+
+validate-npm-package-license@^3.0.1:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
+  integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
+  dependencies:
+    spdx-correct "^3.0.0"
+    spdx-expression-parse "^3.0.0"
+
+vargs@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
+  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
+
+vary@^1, vary@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
+
+verror@1.10.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
+  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+  dependencies:
+    assert-plus "^1.0.0"
+    core-util-is "1.0.2"
+    extsprintf "^1.2.0"
+
+vinyl-file@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a"
+  integrity sha1-p+v1/779obfRjRQPyweyI++2dRo=
+  dependencies:
+    graceful-fs "^4.1.2"
+    pify "^2.3.0"
+    pinkie-promise "^2.0.0"
+    strip-bom "^2.0.0"
+    strip-bom-stream "^2.0.0"
+    vinyl "^1.1.0"
+
+vinyl-fs@^2.4.3, vinyl-fs@^2.4.4:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-2.4.4.tgz#be6ff3270cb55dfd7d3063640de81f25d7532239"
+  integrity sha1-vm/zJwy1Xf19MGNkDegfJddTIjk=
+  dependencies:
+    duplexify "^3.2.0"
+    glob-stream "^5.3.2"
+    graceful-fs "^4.0.0"
+    gulp-sourcemaps "1.6.0"
+    is-valid-glob "^0.3.0"
+    lazystream "^1.0.0"
+    lodash.isequal "^4.0.0"
+    merge-stream "^1.0.0"
+    mkdirp "^0.5.0"
+    object-assign "^4.0.0"
+    readable-stream "^2.0.4"
+    strip-bom "^2.0.0"
+    strip-bom-stream "^1.0.0"
+    through2 "^2.0.0"
+    through2-filter "^2.0.0"
+    vali-date "^1.0.0"
+    vinyl "^1.0.0"
+
+vinyl@^1.0.0, vinyl@^1.1.0, vinyl@^1.1.1, vinyl@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
+  integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
+  dependencies:
+    clone "^1.0.0"
+    clone-stats "^0.0.1"
+    replace-ext "0.0.1"
+
+vinyl@^2.0.1:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86"
+  integrity sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg==
+  dependencies:
+    clone "^2.1.1"
+    clone-buffer "^1.0.0"
+    clone-stats "^1.0.0"
+    cloneable-readable "^1.0.0"
+    remove-trailing-separator "^1.0.1"
+    replace-ext "^1.0.0"
+
+vlq@^0.2.2:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
+  integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
+
+vscode-uri@=1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
+  integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
+
+wbuf@^1.1.0, wbuf@^1.7.2:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
+  integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
+  dependencies:
+    minimalistic-assert "^1.0.0"
+
+wct-local@^2.1.1:
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.5.tgz#f7986753e3ad9a35d39178a9989350523561fff1"
+  integrity sha512-eqoZhjGy4Xq2tY0uB46Grkw/ztq+/rC0ImbYKl62unFHXtOgal+kkvnxR3SLRFNM8ty9+ItgycPeH0IpTqVL+w==
+  dependencies:
+    "@types/express" "^4.0.30"
+    "@types/freeport" "^1.0.19"
+    "@types/launchpad" "^0.6.0"
+    "@types/which" "^1.3.1"
+    chalk "^2.3.0"
+    cleankill "^2.0.0"
+    freeport "^1.0.4"
+    launchpad "^0.7.0"
+    selenium-standalone "^6.7.0"
+    which "^1.0.8"
+
+wct-sauce@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/wct-sauce/-/wct-sauce-2.1.0.tgz#67d0be346aabbbc28384e8d143b8d3ca7ba774c0"
+  integrity sha512-c3R4PJcbpS7Gxv2vZ4HDAqpXV6cT9peslAWMU7hHH9PMhKDPbn8RNa6E4DVL0tOmZznB+3cRmtZ6+vJ/aDwu1A==
+  dependencies:
+    chalk "^2.4.1"
+    cleankill "^2.0.0"
+    lodash "^4.17.10"
+    request "^2.85.0"
+    sauce-connect-launcher "^1.0.0"
+    temp "^0.8.1"
+    uuid "^3.2.1"
+
+wd@^1.2.0:
+  version "1.12.1"
+  resolved "https://registry.yarnpkg.com/wd/-/wd-1.12.1.tgz#067eb3674db00eeb9e506701f9314657c44d5a89"
+  integrity sha512-O99X8OnOgkqfmsPyLIRzG9LmZ+rjmdGFBCyhGpnsSL4MB4xzHoeWmSVcumDiQ5QqPZcwGkszTgeJvjk2VjtiNw==
+  dependencies:
+    archiver "^3.0.0"
+    async "^2.0.0"
+    lodash "^4.0.0"
+    mkdirp "^0.5.1"
+    q "^1.5.1"
+    request "2.88.0"
+    vargs "^0.1.0"
+
+web-component-tester@^6.9.0:
+  version "6.9.2"
+  resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
+  integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
+  dependencies:
+    "@polymer/sinonjs" "^1.14.1"
+    "@polymer/test-fixture" "^0.0.3"
+    "@webcomponents/webcomponentsjs" "^1.0.7"
+    accessibility-developer-tools "^2.12.0"
+    async "^2.4.1"
+    body-parser "^1.17.2"
+    bower-config "^1.4.0"
+    chalk "^1.1.3"
+    cleankill "^2.0.0"
+    express "^4.15.3"
+    findup-sync "^2.0.0"
+    glob "^7.1.2"
+    lodash "^3.10.1"
+    multer "^1.3.0"
+    nomnom "^1.8.1"
+    polyserve "^0.27.13"
+    resolve "^1.5.0"
+    semver "^5.3.0"
+    send "^0.16.1"
+    server-destroy "^1.0.1"
+    sinon "^2.3.5"
+    sinon-chai "^2.10.0"
+    socket.io "^2.0.3"
+    stacky "^1.3.1"
+    wd "^1.2.0"
+  optionalDependencies:
+    update-notifier "^2.2.0"
+    wct-local "^2.1.1"
+    wct-sauce "^2.0.2"
+
+webidl-conversions@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
+  integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
+
+whatwg-url@^6.4.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
+  integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==
+  dependencies:
+    lodash.sortby "^4.7.0"
+    tr46 "^1.0.1"
+    webidl-conversions "^4.0.2"
+
+which@^1.0.8, which@^1.2.12, which@^1.2.14, which@^1.2.9, which@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
+  dependencies:
+    isexe "^2.0.0"
+
+which@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+  dependencies:
+    isexe "^2.0.0"
+
+widest-line@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c"
+  integrity sha1-DAnIXCqUaD0Nfq+O4JfVZL8OEFw=
+  dependencies:
+    string-width "^1.0.1"
+
+widest-line@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
+  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
+  dependencies:
+    string-width "^2.1.1"
+
+windows-release@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f"
+  integrity sha512-QTlz2hKLrdqukrsapKsINzqMgOUpQW268eJ0OaOpJN32h272waxR9fkB9VoWRtK7uKHG5EHJcTXQBD8XZVJkFA==
+  dependencies:
+    execa "^1.0.0"
+
+winston-transport@^4.2.0, winston-transport@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.3.0.tgz#df68c0c202482c448d9b47313c07304c2d7c2c66"
+  integrity sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==
+  dependencies:
+    readable-stream "^2.3.6"
+    triple-beam "^1.2.0"
+
+winston@^3.0.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/winston/-/winston-3.2.1.tgz#63061377976c73584028be2490a1846055f77f07"
+  integrity sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==
+  dependencies:
+    async "^2.6.1"
+    diagnostics "^1.1.1"
+    is-stream "^1.1.0"
+    logform "^2.1.1"
+    one-time "0.0.4"
+    readable-stream "^3.1.1"
+    stack-trace "0.0.x"
+    triple-beam "^1.3.0"
+    winston-transport "^4.3.0"
+
+with-open-file@^0.1.6:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/with-open-file/-/with-open-file-0.1.7.tgz#e2de8d974e8a8ae6e58886be4fe8e7465b58a729"
+  integrity sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==
+  dependencies:
+    p-finally "^1.0.0"
+    p-try "^2.1.0"
+    pify "^4.0.1"
+
+wordwrap@~0.0.2:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
+  integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
+
+wordwrapjs@^2.0.0-0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-2.0.0.tgz#ab55f695e6118da93858fdd70c053d1c5e01ac20"
+  integrity sha1-q1X2leYRjak4WP3XDAU9HF4BrCA=
+  dependencies:
+    array-back "^1.0.3"
+    feature-detect-es6 "^1.3.1"
+    reduce-flatten "^1.0.1"
+    typical "^2.6.0"
+
+wordwrapjs@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-3.0.0.tgz#c94c372894cadc6feb1a66bff64e1d9af92c5d1e"
+  integrity sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==
+  dependencies:
+    reduce-flatten "^1.0.1"
+    typical "^2.6.1"
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+
+write-file-atomic@^1.1.2:
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.4.tgz#f807a4f0b1d9e913ae7a48112e6cc3af1991b45f"
+  integrity sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=
+  dependencies:
+    graceful-fs "^4.1.11"
+    imurmurhash "^0.1.4"
+    slide "^1.1.5"
+
+write-file-atomic@^2.0.0:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
+  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
+  dependencies:
+    graceful-fs "^4.1.11"
+    imurmurhash "^0.1.4"
+    signal-exit "^3.0.2"
+
+ws@^7.1.2:
+  version "7.2.1"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
+  integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==
+
+ws@~6.1.0:
+  version "6.1.4"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
+  integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
+  dependencies:
+    async-limiter "~1.0.0"
+
+xdg-basedir@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
+  integrity sha1-7byQPMOF/ARSPZZqM1UEtVBNG9I=
+  dependencies:
+    os-homedir "^1.0.0"
+
+xdg-basedir@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
+  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
+
+xmlbuilder@8.2.2:
+  version "8.2.2"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
+  integrity sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=
+
+xmldom@0.1.x:
+  version "0.1.31"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
+  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
+
+xmlhttprequest-ssl@~1.5.4:
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
+  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+
+"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
+  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
+
+yallist@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
+  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
+yeast@0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
+  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
+
+yeoman-environment@^1.5.2:
+  version "1.6.6"
+  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-1.6.6.tgz#cd85fa67d156060e440d7807d7ef7cf0d2d1d671"
+  integrity sha1-zYX6Z9FWBg5EDXgH1+988NLR1nE=
+  dependencies:
+    chalk "^1.0.0"
+    debug "^2.0.0"
+    diff "^2.1.2"
+    escape-string-regexp "^1.0.2"
+    globby "^4.0.0"
+    grouped-queue "^0.3.0"
+    inquirer "^1.0.2"
+    lodash "^4.11.1"
+    log-symbols "^1.0.1"
+    mem-fs "^1.1.0"
+    text-table "^0.2.0"
+    untildify "^2.0.0"
+
+yeoman-environment@^2.0.5:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.7.0.tgz#d1b6679de883ce14a68b869c4b19d55a0d66f477"
+  integrity sha512-YNzSUWgJVSgnm0qgLON4Gb2nTm+kywBiWjK4MbvosjUP2YJJ30lNhEx7ukyzKRPUlsavd5IsuALtF6QaVrq81A==
+  dependencies:
+    chalk "^2.4.1"
+    cross-spawn "^6.0.5"
+    debug "^3.1.0"
+    diff "^3.5.0"
+    escape-string-regexp "^1.0.2"
+    globby "^8.0.1"
+    grouped-queue "^0.3.3"
+    inquirer "^6.0.0"
+    is-scoped "^1.0.0"
+    lodash "^4.17.10"
+    log-symbols "^2.2.0"
+    mem-fs "^1.1.0"
+    strip-ansi "^4.0.0"
+    text-table "^0.2.0"
+    untildify "^3.0.3"
+
+yeoman-generator@^3.1.1:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-3.2.0.tgz#02077d2d7ff28fedc1ed7dad7f9967fd7c3604cc"
+  integrity sha512-iR/qb2je3GdXtSfxgvOXxUW0Cp8+C6LaZaNlK2BAICzFNzwHtM10t/QBwz5Ea9nk6xVDQNj4Q889TjCXGuIv8w==
+  dependencies:
+    async "^2.6.0"
+    chalk "^2.3.0"
+    cli-table "^0.3.1"
+    cross-spawn "^6.0.5"
+    dargs "^6.0.0"
+    dateformat "^3.0.3"
+    debug "^4.1.0"
+    detect-conflict "^1.0.0"
+    error "^7.0.2"
+    find-up "^3.0.0"
+    github-username "^4.0.0"
+    istextorbinary "^2.2.1"
+    lodash "^4.17.10"
+    make-dir "^1.1.0"
+    mem-fs-editor "^5.0.0"
+    minimist "^1.2.0"
+    pretty-bytes "^5.1.0"
+    read-chunk "^3.0.0"
+    read-pkg-up "^4.0.0"
+    rimraf "^2.6.2"
+    run-async "^2.0.0"
+    shelljs "^0.8.0"
+    text-table "^0.2.0"
+    through2 "^3.0.0"
+    yeoman-environment "^2.0.5"
+
+zip-stream@^2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
+  integrity sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==
+  dependencies:
+    archiver-utils "^2.1.0"
+    compress-commons "^2.1.1"
+    readable-stream "^3.4.0"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 5165e09..d3f5543 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -23,22 +23,16 @@
 
     maven_jar(
         name = "dropwizard-core",
-        artifact = "io.dropwizard.metrics:metrics-core:4.0.7",
-        sha1 = "673899f605f52ca35836673ccfee97154a496a61",
+        artifact = "io.dropwizard.metrics:metrics-core:4.1.12.1",
+        sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.3.0"
+    SSHD_VERS = "2.4.0"
 
     maven_jar(
-        name = "sshd",
-        artifact = "org.apache.sshd:sshd-core:" + SSHD_VERS,
-        sha1 = "21aeea9deba96c9b81ea0935fa4fac61aa3cf646",
-    )
-
-    maven_jar(
-        name = "sshd-common",
-        artifact = "org.apache.sshd:sshd-common:" + SSHD_VERS,
-        sha1 = "8b6e3baaa0d35b547696965eef3e62477f5e74c9",
+        name = "sshd-osgi",
+        artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
+        sha1 = "fc4551c1eeda35e4671b263297d37d2bca81c4d4",
     )
 
     maven_jar(
@@ -56,7 +50,7 @@
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "55dc0830dfcbceba01f9460812ee454978a15fe8",
+        sha1 = "8aa8715d07bd61ad8315df66d43c0c04b1b755c8",
     )
 
     # elasticsearch-rest-client explicitly depends on this version
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/tools/polygerrit-updater/src/utils/unexpectedValue.ts b/tools/polygerrit-updater/src/utils/unexpectedValue.ts
new file mode 100644
index 0000000..690c283
--- /dev/null
+++ b/tools/polygerrit-updater/src/utils/unexpectedValue.ts
@@ -0,0 +1,17 @@
+// 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.
+
+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/tools/remote-bazelrc b/tools/remote-bazelrc
index 826be1d..13c498e 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -57,7 +57,7 @@
 # Set various strategies so that all actions execute remotely. Mixing remote
 # and local execution will lead to errors unless the toolchain and remote
 # machine exactly match the host machine.
-build:remote --spawn_strategy=remote
+build:remote --spawn_strategy=remote,sandboxed
 build:remote --strategy=Javac=remote
 build:remote --strategy=Closure=remote
 build:remote --strategy=Genrule=remote
diff --git a/version.bzl b/version.bzl
index 95ebd2c..4f80099 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.13-SNAPSHOT"
+GERRIT_VERSION = "3.2.8-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 0000000..820cca3
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,9222 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
+  integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==
+  dependencies:
+    "@babel/highlight" "^7.0.0"
+
+"@babel/core@^7.0.0":
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.5.4.tgz#4c32df7ad5a58e9ea27ad025c11276324e0b4ddd"
+  integrity sha512-+DaeBEpYq6b2+ZmHx3tHspC+ZRflrvLqwfv8E3hNr5LVQoyBnL8RPKSBCg+rK2W2My9PWlujBiqd0ZPsR9Q6zQ==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    "@babel/generator" "^7.5.0"
+    "@babel/helpers" "^7.5.4"
+    "@babel/parser" "^7.5.0"
+    "@babel/template" "^7.4.4"
+    "@babel/traverse" "^7.5.0"
+    "@babel/types" "^7.5.0"
+    convert-source-map "^1.1.0"
+    debug "^4.1.0"
+    json5 "^2.1.0"
+    lodash "^4.17.11"
+    resolve "^1.3.2"
+    semver "^5.4.1"
+    source-map "^0.5.0"
+
+"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.5.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.5.0.tgz#f20e4b7a91750ee8b63656073d843d2a736dca4a"
+  integrity sha512-1TTVrt7J9rcG5PMjvO7VEG3FrEoEJNHxumRq66GemPmzboLWtIjjcJgk8rokuAS7IiRSpgVSu5Vb9lc99iJkOA==
+  dependencies:
+    "@babel/types" "^7.5.0"
+    jsesc "^2.5.1"
+    lodash "^4.17.11"
+    source-map "^0.5.0"
+    trim-right "^1.0.1"
+
+"@babel/helper-annotate-as-pure@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32"
+  integrity sha512-3UYcJUj9kvSLbLbUIfQTqzcy5VX7GRZ/CCDrnOaZorFFM01aXp1+GJwuFGV4NDDoAS+mOUyHcO6UD/RfqOks3Q==
+  dependencies:
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.1.0.tgz#6b69628dfe4087798e0c4ed98e3d4a6b2fbd2f5f"
+  integrity sha512-qNSR4jrmJ8M1VMM9tibvyRAHXQs2PmaksQF7c1CGJNipfe3D8p+wgNwgso/P2A2r2mdgBWAXljNWR0QRZAMW8w==
+  dependencies:
+    "@babel/helper-explode-assignable-expression" "^7.1.0"
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-call-delegate@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz#87c1f8ca19ad552a736a7a27b1c1fcf8b1ff1f43"
+  integrity sha512-l79boDFJ8S1c5hvQvG+rc+wHw6IuH7YldmRKsYtpbawsxURu/paVy57FZMomGK22/JckepaikOkY0MoAmdyOlQ==
+  dependencies:
+    "@babel/helper-hoist-variables" "^7.4.4"
+    "@babel/traverse" "^7.4.4"
+    "@babel/types" "^7.4.4"
+
+"@babel/helper-define-map@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.4.4.tgz#6969d1f570b46bdc900d1eba8e5d59c48ba2c12a"
+  integrity sha512-IX3Ln8gLhZpSuqHJSnTNBWGDE9kdkTEWl21A/K7PQ00tseBwbqCHTvNLHSBd9M0R5rER4h5Rsvj9vw0R5SieBg==
+  dependencies:
+    "@babel/helper-function-name" "^7.1.0"
+    "@babel/types" "^7.4.4"
+    lodash "^4.17.11"
+
+"@babel/helper-explode-assignable-expression@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.1.0.tgz#537fa13f6f1674df745b0c00ec8fe4e99681c8f6"
+  integrity sha512-NRQpfHrJ1msCHtKjbzs9YcMmJZOg6mQMmGRB+hbamEdG5PNpaSm95275VD92DvJKuyl0s2sFiDmMZ+EnnvufqA==
+  dependencies:
+    "@babel/traverse" "^7.1.0"
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-function-name@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz#a0ceb01685f73355d4360c1247f582bfafc8ff53"
+  integrity sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==
+  dependencies:
+    "@babel/helper-get-function-arity" "^7.0.0"
+    "@babel/template" "^7.1.0"
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-get-function-arity@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3"
+  integrity sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==
+  dependencies:
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-hoist-variables@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.4.4.tgz#0298b5f25c8c09c53102d52ac4a98f773eb2850a"
+  integrity sha512-VYk2/H/BnYbZDDg39hr3t2kKyifAm1W6zHRfhx8jGjIHpQEBv9dry7oQ2f3+J703TLu69nYdxsovl0XYfcnK4w==
+  dependencies:
+    "@babel/types" "^7.4.4"
+
+"@babel/helper-member-expression-to-functions@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz#8cd14b0a0df7ff00f009e7d7a436945f47c7a16f"
+  integrity sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg==
+  dependencies:
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-module-imports@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz#96081b7111e486da4d2cd971ad1a4fe216cc2e3d"
+  integrity sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A==
+  dependencies:
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-module-transforms@^7.1.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.4.4.tgz#96115ea42a2f139e619e98ed46df6019b94414b8"
+  integrity sha512-3Z1yp8TVQf+B4ynN7WoHPKS8EkdTbgAEy0nU0rs/1Kw4pDgmvYH3rz3aI11KgxKCba2cn7N+tqzV1mY2HMN96w==
+  dependencies:
+    "@babel/helper-module-imports" "^7.0.0"
+    "@babel/helper-simple-access" "^7.1.0"
+    "@babel/helper-split-export-declaration" "^7.4.4"
+    "@babel/template" "^7.4.4"
+    "@babel/types" "^7.4.4"
+    lodash "^4.17.11"
+
+"@babel/helper-optimise-call-expression@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.0.0.tgz#a2920c5702b073c15de51106200aa8cad20497d5"
+  integrity sha512-u8nd9NQePYNQV8iPWu/pLLYBqZBa4ZaY1YWRFMuxrid94wKI1QNt67NEZ7GAe5Kc/0LLScbim05xZFWkAdrj9g==
+  dependencies:
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-plugin-utils@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250"
+  integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==
+
+"@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.4.4.tgz#a47e02bc91fb259d2e6727c2a30013e3ac13c4a2"
+  integrity sha512-Y5nuB/kESmR3tKjU8Nkn1wMGEx1tjJX076HBMeL3XLQCu6vA/YRzuTW0bbb+qRnXvQGn+d6Rx953yffl8vEy7Q==
+  dependencies:
+    lodash "^4.17.11"
+
+"@babel/helper-remap-async-to-generator@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.1.0.tgz#361d80821b6f38da75bd3f0785ece20a88c5fe7f"
+  integrity sha512-3fOK0L+Fdlg8S5al8u/hWE6vhufGSn0bN09xm2LXMy//REAF8kDCrYoOBKYmA8m5Nom+sV9LyLCwrFynA8/slg==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.0.0"
+    "@babel/helper-wrap-function" "^7.1.0"
+    "@babel/template" "^7.1.0"
+    "@babel/traverse" "^7.1.0"
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-replace-supers@^7.1.0", "@babel/helper-replace-supers@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz#aee41783ebe4f2d3ab3ae775e1cc6f1a90cefa27"
+  integrity sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg==
+  dependencies:
+    "@babel/helper-member-expression-to-functions" "^7.0.0"
+    "@babel/helper-optimise-call-expression" "^7.0.0"
+    "@babel/traverse" "^7.4.4"
+    "@babel/types" "^7.4.4"
+
+"@babel/helper-simple-access@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz#65eeb954c8c245beaa4e859da6188f39d71e585c"
+  integrity sha512-Vk+78hNjRbsiu49zAPALxTb+JUQCz1aolpd8osOF16BGnLtseD21nbHgLPGUwrXEurZgiCOUmvs3ExTu4F5x6w==
+  dependencies:
+    "@babel/template" "^7.1.0"
+    "@babel/types" "^7.0.0"
+
+"@babel/helper-split-export-declaration@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz#ff94894a340be78f53f06af038b205c49d993677"
+  integrity sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==
+  dependencies:
+    "@babel/types" "^7.4.4"
+
+"@babel/helper-wrap-function@^7.1.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa"
+  integrity sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ==
+  dependencies:
+    "@babel/helper-function-name" "^7.1.0"
+    "@babel/template" "^7.1.0"
+    "@babel/traverse" "^7.1.0"
+    "@babel/types" "^7.2.0"
+
+"@babel/helpers@^7.5.4":
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.5.4.tgz#2f00608aa10d460bde0ccf665d6dcf8477357cf0"
+  integrity sha512-6LJ6xwUEJP51w0sIgKyfvFMJvIb9mWAfohJp0+m6eHJigkFdcH8duZ1sfhn0ltJRzwUIT/yqqhdSfRpCpL7oow==
+  dependencies:
+    "@babel/template" "^7.4.4"
+    "@babel/traverse" "^7.5.0"
+    "@babel/types" "^7.5.0"
+
+"@babel/highlight@^7.0.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.5.0.tgz#56d11312bd9248fa619591d02472be6e8cb32540"
+  integrity sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==
+  dependencies:
+    chalk "^2.0.0"
+    esutils "^2.0.2"
+    js-tokens "^4.0.0"
+
+"@babel/parser@^7.4.4", "@babel/parser@^7.5.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.0.tgz#3e0713dff89ad6ae37faec3b29dcfc5c979770b7"
+  integrity sha512-I5nW8AhGpOXGCCNYGc+p7ExQIBxRFnS2fd/d862bNOKvmoEPjYPcfIjsfdy0ujagYOIYPczKgD9l3FsgTkAzKA==
+
+"@babel/plugin-external-helpers@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.2.0.tgz#7f4cb7dee651cd380d2034847d914288467a6be4"
+  integrity sha512-QFmtcCShFkyAsNtdCM3lJPmRe1iB+vPZymlB4LnDIKEBj2yKQLQKtoxXxJ8ePT5fwMl4QGg303p4mB0UsSI2/g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-proposal-async-generator-functions@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz#b289b306669dce4ad20b0252889a15768c9d417e"
+  integrity sha512-+Dfo/SCQqrwx48ptLVGLdE39YtWRuKc/Y9I5Fy0P1DDBB9lsAHpjcEJQt+4IifuSOSTLBKJObJqMvaO1pIE8LQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-remap-async-to-generator" "^7.1.0"
+    "@babel/plugin-syntax-async-generators" "^7.2.0"
+
+"@babel/plugin-proposal-object-rest-spread@^7.0.0":
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.5.4.tgz#250de35d867ce8260a31b1fdac6c4fc1baa99331"
+  integrity sha512-KCx0z3y7y8ipZUMAEEJOyNi11lMb/FOPUjjB113tfowgw0c16EGYos7worCKBcUAh2oG+OBnoUhsnTSoLpV9uA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/plugin-syntax-object-rest-spread" "^7.2.0"
+
+"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.2.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.2.0.tgz#69e1f0db34c6f5a0cf7e2b3323bf159a76c8cb7f"
+  integrity sha512-1ZrIRBv2t0GSlcwVoQ6VgSLpLgiN/FVQUzt9znxo7v2Ov4jJrs8RY8tv0wvDmFN3qIdMKWrmMMW6yZ0G19MfGg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-syntax-dynamic-import@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz#69c159ffaf4998122161ad8ebc5e6d1f55df8612"
+  integrity sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-syntax-import-meta@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.2.0.tgz#2333ef4b875553a3bcd1e93f8ebc09f5b9213a40"
+  integrity sha512-Hq6kFSZD7+PHkmBN8bCpHR6J8QEoCuEV/B38AIQscYjgMZkGlXB7cHNFzP5jR4RCh5545yP1ujHdmO7hAgKtBA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.2.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz#3b7a3e733510c57e820b9142a6579ac8b0dfad2e"
+  integrity sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-arrow-functions@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550"
+  integrity sha512-ER77Cax1+8/8jCB9fo4Ud161OZzWN5qawi4GusDuRLcDbDG+bIGYY20zb2dfAFdTRGzrfq2xZPvF0R64EHnimg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-async-to-generator@^7.0.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.5.0.tgz#89a3848a0166623b5bc481164b5936ab947e887e"
+  integrity sha512-mqvkzwIGkq0bEF1zLRRiTdjfomZJDV33AH3oQzHVGkI2VzEmXLpKKOBvEVaFZBJdN0XTyH38s9j/Kiqr68dggg==
+  dependencies:
+    "@babel/helper-module-imports" "^7.0.0"
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-remap-async-to-generator" "^7.1.0"
+
+"@babel/plugin-transform-block-scoped-functions@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.2.0.tgz#5d3cc11e8d5ddd752aa64c9148d0db6cb79fd190"
+  integrity sha512-ntQPR6q1/NKuphly49+QiQiTN0O63uOwjdD6dhIjSWBI5xlrbUFh720TIpzBhpnrLfv2tNH/BXvLIab1+BAI0w==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-block-scoping@^7.0.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.4.4.tgz#c13279fabf6b916661531841a23c4b7dae29646d"
+  integrity sha512-jkTUyWZcTrwxu5DD4rWz6rDB5Cjdmgz6z7M7RLXOJyCUkFBawssDGcGh8M/0FTSB87avyJI1HsTwUXp9nKA1PA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    lodash "^4.17.11"
+
+"@babel/plugin-transform-classes@^7.0.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.4.4.tgz#0ce4094cdafd709721076d3b9c38ad31ca715eb6"
+  integrity sha512-/e44eFLImEGIpL9qPxSRat13I5QNRgBLu2hOQJCF7VLy/otSM/sypV1+XaIw5+502RX/+6YaSAPmldk+nhHDPw==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.0.0"
+    "@babel/helper-define-map" "^7.4.4"
+    "@babel/helper-function-name" "^7.1.0"
+    "@babel/helper-optimise-call-expression" "^7.0.0"
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-replace-supers" "^7.4.4"
+    "@babel/helper-split-export-declaration" "^7.4.4"
+    globals "^11.1.0"
+
+"@babel/plugin-transform-computed-properties@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.2.0.tgz#83a7df6a658865b1c8f641d510c6f3af220216da"
+  integrity sha512-kP/drqTxY6Xt3NNpKiMomfgkNn4o7+vKxK2DDKcBG9sHj51vHqMBGy8wbDS/J4lMxnqs153/T3+DmCEAkC5cpA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-destructuring@^7.0.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.5.0.tgz#f6c09fdfe3f94516ff074fe877db7bc9ef05855a"
+  integrity sha512-YbYgbd3TryYYLGyC7ZR+Tq8H/+bCmwoaxHfJHupom5ECstzbRLTch6gOQbhEY9Z4hiCNHEURgq06ykFv9JZ/QQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-duplicate-keys@^7.0.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.5.0.tgz#c5dbf5106bf84cdf691222c0974c12b1df931853"
+  integrity sha512-igcziksHizyQPlX9gfSjHkE2wmoCH3evvD2qR5w29/Dk0SMKE/eOI7f1HhBdNhR/zxJDqrgpoDTq5YSLH/XMsQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-exponentiation-operator@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.2.0.tgz#a63868289e5b4007f7054d46491af51435766008"
+  integrity sha512-umh4hR6N7mu4Elq9GG8TOu9M0bakvlsREEC+ialrQN6ABS4oDQ69qJv1VtR3uxlKMCQMCvzk7vr17RHKcjx68A==
+  dependencies:
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.1.0"
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-for-of@^7.0.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.4.tgz#0267fc735e24c808ba173866c6c4d1440fc3c556"
+  integrity sha512-9T/5Dlr14Z9TIEXLXkt8T1DU7F24cbhwhMNUziN3hB1AXoZcdzPcTiKGRn/6iOymDqtTKWnr/BtRKN9JwbKtdQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-function-name@^7.0.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.4.4.tgz#e1436116abb0610c2259094848754ac5230922ad"
+  integrity sha512-iU9pv7U+2jC9ANQkKeNF6DrPy4GBa4NWQtl6dHB4Pb3izX2JOEvDTFarlNsBj/63ZEzNNIAMs3Qw4fNCcSOXJA==
+  dependencies:
+    "@babel/helper-function-name" "^7.1.0"
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-instanceof@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.2.0.tgz#2ee9dc9b389fa13cf325be10ff8414be0d10aecf"
+  integrity sha512-tw2fb96tpcd5XaJXns19tGKo/SeIUS0exAteHJ/7EP27Bke7RmV/gAftHCf1WKZgKeZOUfzOL7nrXH2HIH9auA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-literals@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.2.0.tgz#690353e81f9267dad4fd8cfd77eafa86aba53ea1"
+  integrity sha512-2ThDhm4lI4oV7fVQ6pNNK+sx+c/GM5/SaML0w/r4ZB7sAneD/piDJtwdKlNckXeyGK7wlwg2E2w33C/Hh+VFCg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-modules-amd@^7.0.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.5.0.tgz#ef00435d46da0a5961aa728a1d2ecff063e4fb91"
+  integrity sha512-n20UsQMKnWrltocZZm24cRURxQnWIvsABPJlw/fvoy9c6AgHZzoelAIzajDHAQrDpuKFFPPcFGd7ChsYuIUMpg==
+  dependencies:
+    "@babel/helper-module-transforms" "^7.1.0"
+    "@babel/helper-plugin-utils" "^7.0.0"
+    babel-plugin-dynamic-import-node "^2.3.0"
+
+"@babel/plugin-transform-object-super@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.2.0.tgz#b35d4c10f56bab5d650047dad0f1d8e8814b6598"
+  integrity sha512-VMyhPYZISFZAqAPVkiYb7dUe2AsVi2/wCT5+wZdsNO31FojQJa9ns40hzZ6U9f50Jlq4w6qwzdBB2uwqZ00ebg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-replace-supers" "^7.1.0"
+
+"@babel/plugin-transform-parameters@^7.0.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.4.4.tgz#7556cf03f318bd2719fe4c922d2d808be5571e16"
+  integrity sha512-oMh5DUO1V63nZcu/ZVLQFqiihBGo4OpxJxR1otF50GMeCLiRx5nUdtokd+u9SuVJrvvuIh9OosRFPP4pIPnwmw==
+  dependencies:
+    "@babel/helper-call-delegate" "^7.4.4"
+    "@babel/helper-get-function-arity" "^7.0.0"
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-regenerator@^7.0.0":
+  version "7.4.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz#629dc82512c55cee01341fb27bdfcb210354680f"
+  integrity sha512-gBKRh5qAaCWntnd09S8QC7r3auLCqq5DI6O0DlfoyDjslSBVqBibrMdsqO+Uhmx3+BlOmE/Kw1HFxmGbv0N9dA==
+  dependencies:
+    regenerator-transform "^0.14.0"
+
+"@babel/plugin-transform-shorthand-properties@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz#6333aee2f8d6ee7e28615457298934a3b46198f0"
+  integrity sha512-QP4eUM83ha9zmYtpbnyjTLAGKQritA5XW/iG9cjtuOI8s1RuL/3V6a3DeSHfKutJQ+ayUfeZJPcnCYEQzaPQqg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-spread@^7.0.0":
+  version "7.2.2"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.2.2.tgz#3103a9abe22f742b6d406ecd3cd49b774919b406"
+  integrity sha512-KWfky/58vubwtS0hLqEnrWJjsMGaOeSBn90Ezn5Jeg9Z8KKHmELbP1yGylMlm5N6TPKeY9A2+UaSYLdxahg01w==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-sticky-regex@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.2.0.tgz#a1e454b5995560a9c1e0d537dfc15061fd2687e1"
+  integrity sha512-KKYCoGaRAf+ckH8gEL3JHUaFVyNHKe3ASNsZ+AlktgHevvxGigoIttrEJb8iKN03Q7Eazlv1s6cx2B2cQ3Jabw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-regex" "^7.0.0"
+
+"@babel/plugin-transform-template-literals@^7.0.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.4.4.tgz#9d28fea7bbce637fb7612a0750989d8321d4bcb0"
+  integrity sha512-mQrEC4TWkhLN0z8ygIvEL9ZEToPhG5K7KDW3pzGqOfIGZ28Jb0POUkeWcoz8HnHvhFy6dwAT1j8OzqN8s804+g==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.0.0"
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-typeof-symbol@^7.0.0":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.2.0.tgz#117d2bcec2fbf64b4b59d1f9819894682d29f2b2"
+  integrity sha512-2LNhETWYxiYysBtrBTqL8+La0jIoQQnIScUJc74OYvUGRmkskNY4EzLCnjHBzdmb38wqtTaixpo1NctEcvMDZw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+
+"@babel/plugin-transform-unicode-regex@^7.0.0":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.4.tgz#ab4634bb4f14d36728bf5978322b35587787970f"
+  integrity sha512-il+/XdNw01i93+M9J9u4T7/e/Ue/vWfNZE4IRUQjplu2Mqb/AFTDimkw2tdEdSH50wuQXZAbXSql0UphQke+vA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-regex" "^7.4.4"
+    regexpu-core "^4.5.4"
+
+"@babel/template@^7.1.0", "@babel/template@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.4.tgz#f4b88d1225689a08f5bc3a17483545be9e4ed237"
+  integrity sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    "@babel/parser" "^7.4.4"
+    "@babel/types" "^7.4.4"
+
+"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.5.0.tgz#4216d6586854ef5c3c4592dab56ec7eb78485485"
+  integrity sha512-SnA9aLbyOCcnnbQEGwdfBggnc142h/rbqqsXcaATj2hZcegCl903pUD/lfpsNBlBSuWow/YDfRyJuWi2EPR5cg==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    "@babel/generator" "^7.5.0"
+    "@babel/helper-function-name" "^7.1.0"
+    "@babel/helper-split-export-declaration" "^7.4.4"
+    "@babel/parser" "^7.5.0"
+    "@babel/types" "^7.5.0"
+    debug "^4.1.0"
+    globals "^11.1.0"
+    lodash "^4.17.11"
+
+"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.42", "@babel/types@^7.2.0", "@babel/types@^7.4.4", "@babel/types@^7.5.0":
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.5.0.tgz#e47d43840c2e7f9105bc4d3a2c371b4d0c7832ab"
+  integrity sha512-UFpDVqRABKsW01bvw7/wSUe56uy6RXM5+VJibVVAybDGxEW25jdwiFJEf7ASvSaC7sN7rbE/l3cLp2izav+CtQ==
+  dependencies:
+    esutils "^2.0.2"
+    lodash "^4.17.11"
+    to-fast-properties "^2.0.0"
+
+"@bazel/rollup@^1.1.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-1.2.0.tgz#8b9569ed6f1c00d2a833567901f8ee4600a389fb"
+  integrity sha512-yrXW+AAUoqc9qN/CweD5p8OEN9bNKFjXnXPBRE4w84LxpkmaJFx+yQJ++c1F57zWMoq2o9EV4CM7y+mK8zxwUg==
+
+"@bazel/typescript@^1.0.1":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.2.0.tgz#ab2016e1d6eb7a86b44536e887f51eaf3d75f1a7"
+  integrity sha512-hPEG8K0psyEcs6HFRiqZNQwXL/dQ8sXKdrNFWv87+rh+YUNfd58uktoynhllympOPThcbUZcZicLWBEFQOc8nA==
+  dependencies:
+    protobufjs "6.8.8"
+    semver "5.6.0"
+    source-map-support "0.5.9"
+    tsutils "2.27.2"
+
+"@mrmlnc/readdir-enhanced@^2.2.1":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
+  integrity sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==
+  dependencies:
+    call-me-maybe "^1.0.1"
+    glob-to-regexp "^0.3.0"
+
+"@nodelib/fs.stat@^1.1.2":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
+  integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
+
+"@octokit/endpoint@^5.1.0":
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-5.2.1.tgz#e5ef98bc4a41fad62b17e71af1a1710f6076b8df"
+  integrity sha512-GoUsRSRhtbCQugRY8eDWg5BnsczUZNq00qArrP7tKPHFmvz2KzJ8DoEq6IAQhLGwAOBHbZQ/Zml3DiaEKAWwkA==
+  dependencies:
+    deepmerge "4.0.0"
+    is-plain-object "^3.0.0"
+    universal-user-agent "^2.1.0"
+    url-template "^2.0.8"
+
+"@octokit/request-error@^1.0.1", "@octokit/request-error@^1.0.2":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.0.4.tgz#15e1dc22123ba4a9a4391914d80ec1e5303a23be"
+  integrity sha512-L4JaJDXn8SGT+5G0uX79rZLv0MNJmfGa4vb4vy1NnpjSnWDLJRy6m90udGwvMmavwsStgbv2QNkPzzTCMmL+ig==
+  dependencies:
+    deprecation "^2.0.0"
+    once "^1.4.0"
+
+"@octokit/request@^4.0.1":
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-4.1.1.tgz#614262214f48417b4d3b14e047d09a9c8e2f7a09"
+  integrity sha512-LOyL0i3oxRo418EXRSJNk/3Q4I0/NKawTn6H/CQp+wnrG1UFLGu080gSsgnWobhPo5BpUNgSQ5BRk5FOOJhD1Q==
+  dependencies:
+    "@octokit/endpoint" "^5.1.0"
+    "@octokit/request-error" "^1.0.1"
+    deprecation "^2.0.0"
+    is-plain-object "^3.0.0"
+    node-fetch "^2.3.0"
+    once "^1.4.0"
+    universal-user-agent "^2.1.0"
+
+"@octokit/rest@^16.2.0":
+  version "16.28.2"
+  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.28.2.tgz#3fc3b8700046ab29ab1e2a4bdf49f89e94f7ba27"
+  integrity sha512-csuYiHvJ1P/GFDadVn0QhwO83R1+YREjcwCY7ZIezB6aJTRIEidJZj+R7gAkUhT687cqYb4cXTZsDVu9F+Fmug==
+  dependencies:
+    "@octokit/request" "^4.0.1"
+    "@octokit/request-error" "^1.0.2"
+    atob-lite "^2.0.0"
+    before-after-hook "^1.4.0"
+    btoa-lite "^1.0.0"
+    deprecation "^2.0.0"
+    lodash.get "^4.4.2"
+    lodash.set "^4.3.2"
+    lodash.uniq "^4.5.0"
+    octokit-pagination-methods "^1.1.0"
+    once "^1.4.0"
+    universal-user-agent "^2.0.0"
+    url-template "^2.0.8"
+
+"@polymer/esm-amd-loader@^1.0.0":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
+  integrity sha512-h+hqYkL+tQV/y2ESD5gFXMl5z4cC+XY1jTlBeGSBaTcj3VbB5OBEScbvRXm63NcEbBneQQYbHfBAXAkF9i9wIA==
+
+"@polymer/sinonjs@^1.14.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@polymer/sinonjs/-/sinonjs-1.17.1.tgz#e47d3785b7d0e8c29feb97f7e924b0fc597e2e9b"
+  integrity sha512-/U8F/cOTrbF2iVVYgINYmvKbtbexs+89Q3v8AaHADRYabTg7aOZGOb0RyWpOI+sUJt04kj63U4FwMhzW5r4wZA==
+
+"@polymer/test-fixture@^0.0.3":
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-0.0.3.tgz#4443752697d4d9293bbc412ea0b5e4d341f149d9"
+  integrity sha1-REN1JpfU2Sk7vEEuoLXk00HxSdk=
+
+"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
+  integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78=
+
+"@protobufjs/base64@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
+  integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
+
+"@protobufjs/codegen@^2.0.4":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
+  integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==
+
+"@protobufjs/eventemitter@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
+  integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A=
+
+"@protobufjs/fetch@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
+  integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=
+  dependencies:
+    "@protobufjs/aspromise" "^1.1.1"
+    "@protobufjs/inquire" "^1.1.0"
+
+"@protobufjs/float@^1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
+  integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=
+
+"@protobufjs/inquire@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
+  integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=
+
+"@protobufjs/path@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
+  integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=
+
+"@protobufjs/pool@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
+  integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=
+
+"@protobufjs/utf8@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
+  integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
+
+"@types/babel-generator@^6.25.1":
+  version "6.25.3"
+  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.3.tgz#8f06caa12d0595a0538560abe771966d77d29286"
+  integrity sha512-pGgnuxVddKcYIc+VJkRDop7gxLhqclNKBdlsm/5Vp8d+37pQkkDK7fef8d9YYImRzw9xcojEPc18pUYnbxmjqA==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/babel-traverse@^6.25.2", "@types/babel-traverse@^6.25.3":
+  version "6.25.5"
+  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.5.tgz#6d293cf7523e48b524faa7b86dc3c488191484e5"
+  integrity sha512-WrMbwmu+MWf8FiUMbmVOGkc7bHPzndUafn1CivMaBHthBBoo0VNIcYk1KV71UovYguhsNOwf3UF5oRmkkGOU3w==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/babel-types@*", "@types/babel-types@^6.25.1":
+  version "6.25.2"
+  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-6.25.2.tgz#5c57f45973e4f13742dbc5273dd84cffe7373a9e"
+  integrity sha512-+3bMuktcY4a70a0KZc8aPJlEOArPuAKQYHU5ErjkOqGJdx8xuEEVK6nWogqigBOJ8nKPxRpyCUDTCPmZ3bUxGA==
+
+"@types/babylon@^6.16.2":
+  version "6.16.5"
+  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.5.tgz#1c5641db69eb8cdf378edd25b4be7754beeb48b4"
+  integrity sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==
+  dependencies:
+    "@types/babel-types" "*"
+
+"@types/bluebird@*":
+  version "3.5.29"
+  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.29.tgz#7cd933c902c4fc83046517a1bef973886d00bdb6"
+  integrity sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==
+
+"@types/body-parser@*":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897"
+  integrity sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@types/chai-subset@^1.3.0":
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.2.tgz#16e3267e0557aaec0d77651c0ce27b684f4602c1"
+  integrity sha512-VMA1aOXwPEJADlj5ykmYv77YKmbEuAxiLz/+lT6vFIWQ1EA06jF01TytVBAbVTNk0pjfW1Uhw5R5MaEq426N0A==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*":
+  version "4.1.7"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.7.tgz#1b8e33b61a8c09cbe1f85133071baa0dbf9fa71a"
+  integrity sha512-2Y8uPt0/jwjhQ6EiluT0XCri1Dbplr0ZxfFXUz+ye13gaqE8u5gL5ppao1JrUYr9cIip5S6MvQzBS7Kke7U9VA==
+
+"@types/chalk@^0.4.30":
+  version "0.4.31"
+  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-0.4.31.tgz#a31d74241a6b1edbb973cf36d97a2896834a51f9"
+  integrity sha1-ox10JBprHtu5c8822XooloNKUfk=
+
+"@types/chalk@^2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-2.2.0.tgz#b7f6e446f4511029ee8e3f43075fb5b73fbaa0ba"
+  integrity sha512-1zzPV9FDe1I/WHhRkf9SNgqtRJWZqrBWgu7JGveuHmmyR9CnAPCie2N/x+iHrgnpYBIcCJWHBoMRv2TRWktsvw==
+  dependencies:
+    chalk "*"
+
+"@types/clean-css@*":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.1.tgz#cb0134241ec5e6ede1b5344bc829668fd9871a8d"
+  integrity sha512-A1HQhQ0hkvqqByJMgg+Wiv9p9XdoYEzuwm11SVo1mX2/4PSdhjcrUlilJQoqLscIheC51t1D5g+EFWCXZ2VTQQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/clone@^0.1.30":
+  version "0.1.30"
+  resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
+  integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
+
+"@types/compression@^0.0.33":
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
+  integrity sha1-ldxzOiM5qoRjgdfxN3eS0lU9wn0=
+  dependencies:
+    "@types/express" "*"
+
+"@types/connect@*":
+  version "3.4.33"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546"
+  integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-type@^1.1.0":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.3.tgz#3688bd77fc12f935548eef102a4e34c512b03a07"
+  integrity sha512-pv8VcFrZ3fN93L4rTNIbbUzdkzjEyVMp5mPVjsFfOYTDOZMZiZ8P1dhu+kEv3faYyKzZgLlSvnyQNFg+p/v5ug==
+
+"@types/cssbeautify@^0.3.1":
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.1.tgz#8e0bee8f7decb952250da0caebe05e30591c17ef"
+  integrity sha1-jgvuj33suVIlDaDK6+BeMFkcF+8=
+
+"@types/del@^3.0.0":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/del/-/del-3.0.1.tgz#4712da8c119873cbbf533ad8dbf1baac5940ac5d"
+  integrity sha512-y6qRq6raBuu965clKgx6FHuiPu3oHdtmzMPXi8Uahsjdq1L6DL5fS/aY5/s71YwM7k6K1QIWvem5vNwlnNGIkQ==
+  dependencies:
+    "@types/glob" "*"
+
+"@types/doctrine@^0.0.1":
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
+  integrity sha1-uZny2fe0PKvgoaLzm8IDvH3K2p0=
+
+"@types/escape-html@0.0.20":
+  version "0.0.20"
+  resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
+  integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
+
+"@types/estree@0.0.39":
+  version "0.0.39"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
+  integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+
+"@types/events@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
+  integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
+
+"@types/express-serve-static-core@*":
+  version "4.17.1"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.1.tgz#82be64a77211b205641e0209096fd3afb62481d3"
+  integrity sha512-9e7jj549ZI+RxY21Cl0t8uBnWyb22HzILupyHZjYEVK//5TT/1bZodU+yUbLnPdoYViBBnNWbxp4zYjGV0zUGw==
+  dependencies:
+    "@types/node" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
+  version "4.17.2"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
+  integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "*"
+    "@types/serve-static" "*"
+
+"@types/fast-levenshtein@0.0.1":
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/@types/fast-levenshtein/-/fast-levenshtein-0.0.1.tgz#3a3615cf173645c8fca58d051e4e32824e4bd286"
+  integrity sha1-OjYVzxc2Rcj8pY0FHk4ygk5L0oY=
+
+"@types/findup-sync@^0.3.29":
+  version "0.3.30"
+  resolved "https://registry.yarnpkg.com/@types/findup-sync/-/findup-sync-0.3.30.tgz#8ab7bdbd6ba7cbf4f33b6596fde6fff1129c738d"
+  integrity sha512-Dpt1x3rhz6t8BMTS4vziTVos8VLkF4RngIxMBCSE6w0STmnVEEaoe3w+BG5xHyZXshye9lyZE99lpBDoLGY8eA==
+  dependencies:
+    "@types/minimatch" "*"
+
+"@types/form-data@*":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e"
+  integrity sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/freeport@^1.0.19":
+  version "1.0.21"
+  resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.21.tgz#73f6543ed67d3ca3fff97b985591598b7092066f"
+  integrity sha1-c/ZUPtZ9PKP/+XuYVZFZi3CSBm8=
+
+"@types/glob-stream@*":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc"
+  integrity sha512-RHv6ZQjcTncXo3thYZrsbAVwoy4vSKosSWhuhuQxLOTv74OJuFQxXkmUuZCr3q9uNBEVCvIzmZL/FeRNbHZGUg==
+  dependencies:
+    "@types/glob" "*"
+    "@types/node" "*"
+
+"@types/glob@*":
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"
+  integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==
+  dependencies:
+    "@types/events" "*"
+    "@types/minimatch" "*"
+    "@types/node" "*"
+
+"@types/globby@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/globby/-/globby-6.1.0.tgz#7c25b975512a89effea2a656ca8cf6db7fb29d11"
+  integrity sha512-j3XSDNoK4LO5T+ZviQD6PqfEjm07QFEacOTbJR3hnLWuWX0ZMLJl9oRPgj1PyrfGbXhfHFkksC9QZ9HFltJyrw==
+  dependencies:
+    "@types/glob" "*"
+
+"@types/gulp-if@0.0.33":
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/@types/gulp-if/-/gulp-if-0.0.33.tgz#edece22b7925d9a6db5f9c8c0d7882aa776fb678"
+  integrity sha512-J5lzff21X7r1x/4hSzn02GgIUEyjCqYIXZ9GgGBLhbsD3RiBdqwnkFWgF16/0jO5rcVZ52Zp+6MQMQdvIsWuKg==
+  dependencies:
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/html-minifier@^3.5.1":
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f"
+  integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==
+  dependencies:
+    "@types/clean-css" "*"
+    "@types/relateurl" "*"
+    "@types/uglify-js" "*"
+
+"@types/inquirer@*", "@types/inquirer@0.0.32":
+  version "0.0.32"
+  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-0.0.32.tgz#a4a08e83741c500a7c3c8e7776014f7f8a65870d"
+  integrity sha1-pKCOg3QcUAp8PI53dgFPf4plhw0=
+  dependencies:
+    "@types/rx" "*"
+    "@types/through" "*"
+
+"@types/is-windows@^0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
+  integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
+
+"@types/launchpad@^0.6.0":
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
+  integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
+
+"@types/long@^4.0.0":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
+  integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
+
+"@types/merge-stream@^1.0.28":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@types/merge-stream/-/merge-stream-1.1.2.tgz#a880ff66b1fbbb5eef4958d015c5947a9334dbb1"
+  integrity sha512-7faLmaE99g/yX0Y9pF1neh2IUqOf/fXMOWCVzsXjqI1EJ91lrgXmaBKf6bRWM164lLyiHxHt6t/ZO/cIzq61XA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/mime@*", "@types/mime@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
+  integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
+
+"@types/minimatch@*", "@types/minimatch@^3.0.1":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
+  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+
+"@types/mz@0.0.29":
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
+  integrity sha1-vCRyjGSZc/HHhR6QM/nOUlZowns=
+  dependencies:
+    "@types/bluebird" "*"
+    "@types/node" "*"
+
+"@types/mz@0.0.31", "@types/mz@^0.0.31":
+  version "0.0.31"
+  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52"
+  integrity sha1-pNgMCC/v5x5Ap8DwfR5lVbu8e1I=
+  dependencies:
+    "@types/node" "*"
+
+"@types/node@*", "@types/node@^12.0.10":
+  version "12.6.1"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.1.tgz#d5544f6de0aae03eefbb63d5120f6c8be0691946"
+  integrity sha512-rp7La3m845mSESCgsJePNL/JQyhkOJA6G4vcwvVgkDAwHhGdq5GCumxmPjEk1MZf+8p5ZQAUE7tqgQRQTXN7uQ==
+
+"@types/node@^10.1.0":
+  version "10.17.13"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
+  integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
+
+"@types/node@^4.0.30":
+  version "4.9.3"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.3.tgz#a24697a8157ab517996afe0c88fa716550ae419a"
+  integrity sha512-Q9eESThBvAbfEzznF1qTAKUoPbJEbK3lTSO0S3mICvmG/vUSZ+HnCtidpuB58Po7CJt5A2goKsDiYScN8d1V4A==
+
+"@types/opn@^3.0.28":
+  version "3.0.28"
+  resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
+  integrity sha1-CX0NHJtXSVc6XZbfEyOHu20CEYo=
+  dependencies:
+    "@types/node" "*"
+
+"@types/parse5@^2.2.34":
+  version "2.2.34"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
+  integrity sha1-44cKEOgnNacg9i1x3NGDunjvOp0=
+  dependencies:
+    "@types/node" "*"
+
+"@types/path-is-inside@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
+  integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
+
+"@types/pem@^1.8.1":
+  version "1.9.5"
+  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.5.tgz#cd5548b5e0acb4b41a9e21067e9fcd8c57089c99"
+  integrity sha512-C0txxEw8B7DCoD85Ko7SEvzUogNd5VDJ5/YBG8XUcacsOGqxr5Oo4g3OUAfdEDUbhXanwUoVh/ZkMFw77FGPQQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/range-parser@*":
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
+  integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
+
+"@types/relateurl@*":
+  version "0.2.28"
+  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
+  integrity sha1-a9p9uGU/piZD9e5p6facEaOS46Y=
+
+"@types/request@2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/request/-/request-2.0.3.tgz#bdf0fba9488c822f77e97de3dd8fe357b2fb8c06"
+  integrity sha512-cIvnyFRARxwE4OHpCyYue7H+SxaKFPpeleRCHJicft8QhyTNbVYsMwjvEzEPqG06D2LGHZ+sN5lXc8+bTu6D8A==
+  dependencies:
+    "@types/form-data" "*"
+    "@types/node" "*"
+
+"@types/resolve@0.0.4":
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.4.tgz#9b586d65a947dea88c4bc24da0b905fe9520a0d5"
+  integrity sha1-m1htZalH3qiMS8JNoLkF/pUgoNU=
+  dependencies:
+    "@types/node" "*"
+
+"@types/resolve@0.0.6":
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
+  integrity sha512-g+Rg8uMWY76oYTyaL+m7ZcblqF/oj7pE6uEUyACluJx4zcop1Lk14qQiocdEkEVMDFm6DmKpxJhsER+ZuTwG3g==
+  dependencies:
+    "@types/node" "*"
+
+"@types/resolve@0.0.7":
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.7.tgz#b299c13be8d712b1b502fb14a084252acef84f4d"
+  integrity sha512-GPewdjkb0Q76o459qgp6pBLzJj/bD3oveS2kfLhIkZ9U3t3AFKtl5DlFB6lGTw0iZmcmxoGC8lpLW3NNJKrN9A==
+  dependencies:
+    "@types/node" "*"
+
+"@types/rimraf@^0.0.28":
+  version "0.0.28"
+  resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-0.0.28.tgz#5562519bc7963caca8abf7f128cae3b594d41d06"
+  integrity sha1-VWJRm8eWPKyoq/fxKMrjtZTUHQY=
+
+"@types/rx-core-binding@*":
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/@types/rx-core-binding/-/rx-core-binding-4.0.4.tgz#d969d32f15a62b89e2862c17b3ee78fe329818d3"
+  integrity sha512-5pkfxnC4w810LqBPUwP5bg7SFR/USwhMSaAeZQQbEHeBp57pjKXRlXmqpMrLJB4y1oglR/c2502853uN0I+DAQ==
+  dependencies:
+    "@types/rx-core" "*"
+
+"@types/rx-core@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-core/-/rx-core-4.0.3.tgz#0b3354b1238cedbe2b74f6326f139dbc7a591d60"
+  integrity sha1-CzNUsSOM7b4rdPYybxOdvHpZHWA=
+
+"@types/rx-lite-aggregates@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-aggregates/-/rx-lite-aggregates-4.0.3.tgz#6efb2b7f3d5f07183a1cb2bd4b1371d7073384c2"
+  integrity sha512-MAGDAHy8cRatm94FDduhJF+iNS5//jrZ/PIfm+QYw9OCeDgbymFHChM8YVIvN2zArwsRftKgE33QfRWvQk4DPg==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-async@*":
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-async/-/rx-lite-async-4.0.2.tgz#27fbf0caeff029f41e2d2aae638b05e91ceb600c"
+  integrity sha512-vTEv5o8l6702ZwfAM5aOeVDfUwBSDOs+ARoGmWAKQ6LOInQ8J4/zjM7ov12fuTpktUKdMQjkeCp07Vd73mPkxw==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-backpressure@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-backpressure/-/rx-lite-backpressure-4.0.3.tgz#05abb19bdf87cc740196c355e5d0b37bb50b5d56"
+  integrity sha512-Y6aIeQCtNban5XSAF4B8dffhIKu6aAy/TXFlScHzSxh6ivfQBQw6UjxyEJxIOt3IT49YkS+siuayM2H/Q0cmgA==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-coincidence@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-coincidence/-/rx-lite-coincidence-4.0.3.tgz#80bd69acc4054a15cdc1638e2dc8843498cd85c0"
+  integrity sha512-1VNJqzE9gALUyMGypDXZZXzR0Tt7LC9DdAZQ3Ou/Q0MubNU35agVUNXKGHKpNTba+fr8GdIdkC26bRDqtCQBeQ==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-experimental@*":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-experimental/-/rx-lite-experimental-4.0.1.tgz#c532f5cbdf3f2c15da16ded8930d1b2984023cbd"
+  integrity sha1-xTL1y98/LBXaFt7Ykw0bKYQCPL0=
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-joinpatterns@*":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-joinpatterns/-/rx-lite-joinpatterns-4.0.1.tgz#f70fe370518a8432f29158cc92ffb56b4e4afc3e"
+  integrity sha1-9w/jcFGKhDLykVjMkv+1a05K/D4=
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-testing@*":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-testing/-/rx-lite-testing-4.0.1.tgz#21b19d11f4dfd6ffef5a9d1648e9c8879bfe21e9"
+  integrity sha1-IbGdEfTf1v/vWp0WSOnIh5v+Iek=
+  dependencies:
+    "@types/rx-lite-virtualtime" "*"
+
+"@types/rx-lite-time@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-time/-/rx-lite-time-4.0.3.tgz#0eda65474570237598f3448b845d2696f2dbb1c4"
+  integrity sha512-ukO5sPKDRwCGWRZRqPlaAU0SKVxmWwSjiOrLhoQDoWxZWg6vyB9XLEZViKOzIO6LnTIQBlk4UylYV0rnhJLxQw==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite-virtualtime@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite-virtualtime/-/rx-lite-virtualtime-4.0.3.tgz#4b30cacd0fe2e53af29f04f7438584c7d3959537"
+  integrity sha512-3uC6sGmjpOKatZSVHI2xB1+dedgml669ZRvqxy+WqmGJDVusOdyxcKfyzjW0P3/GrCiN4nmRkLVMhPwHCc5QLg==
+  dependencies:
+    "@types/rx-lite" "*"
+
+"@types/rx-lite@*":
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/@types/rx-lite/-/rx-lite-4.0.6.tgz#3c02921c4244074234f26b772241bcc20c18c253"
+  integrity sha512-oYiDrFIcor9zDm0VDUca1UbROiMYBxMLMaM6qzz4ADAfOmA9r1dYEcAFH+2fsPI5BCCjPvV9pWC3X3flbrvs7w==
+  dependencies:
+    "@types/rx-core" "*"
+    "@types/rx-core-binding" "*"
+
+"@types/rx@*":
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/@types/rx/-/rx-4.1.1.tgz#598fc94a56baed975f194574e0f572fd8e627a48"
+  integrity sha1-WY/JSla67ZdfGUV04PVy/Y5iekg=
+  dependencies:
+    "@types/rx-core" "*"
+    "@types/rx-core-binding" "*"
+    "@types/rx-lite" "*"
+    "@types/rx-lite-aggregates" "*"
+    "@types/rx-lite-async" "*"
+    "@types/rx-lite-backpressure" "*"
+    "@types/rx-lite-coincidence" "*"
+    "@types/rx-lite-experimental" "*"
+    "@types/rx-lite-joinpatterns" "*"
+    "@types/rx-lite-testing" "*"
+    "@types/rx-lite-time" "*"
+    "@types/rx-lite-virtualtime" "*"
+
+"@types/semver@^5.3.30":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
+  integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
+
+"@types/serve-static@*", "@types/serve-static@^1.7.31":
+  version "1.13.3"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
+  integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==
+  dependencies:
+    "@types/express-serve-static-core" "*"
+    "@types/mime" "*"
+
+"@types/spdy@^3.4.1":
+  version "3.4.4"
+  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.4.tgz#3282fd4ad8c4603aa49f7017dd520a08a345b2bc"
+  integrity sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/temp@^0.8.28":
+  version "0.8.34"
+  resolved "https://registry.yarnpkg.com/@types/temp/-/temp-0.8.34.tgz#03e4b3cb67cbb48c425bbf54b12230fef85540ac"
+  integrity sha512-oLa9c5LHXgS6UimpEVp08De7QvZ+Dfu5bMQuWyMhf92Z26Q10ubEMOWy9OEfUdzW7Y/sDWVHmUaLFtmnX/2j0w==
+  dependencies:
+    "@types/node" "*"
+
+"@types/through@*":
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.29.tgz#72943aac922e179339c651fa34a4428a4d722f93"
+  integrity sha512-9a7C5VHh+1BKblaYiq+7Tfc+EOmjMdZaD1MYtkQjSoxgB69tBjW98ry6SKsi4zEIWztLOMRuL87A3bdT/Fc/4w==
+  dependencies:
+    "@types/node" "*"
+
+"@types/ua-parser-js@^0.7.31":
+  version "0.7.33"
+  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.33.tgz#4a92089511574e12928a7cb6b99a01831acd1dd7"
+  integrity sha512-ngUKcHnytUodUCL7C6EZ+lVXUjTMQb+9p/e1JjV5tN9TVzS98lHozWEFRPY1QcCdwFeMsmVWfZ3DPPT/udCyIw==
+
+"@types/uglify-js@*":
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
+  integrity sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==
+  dependencies:
+    source-map "^0.6.1"
+
+"@types/update-notifier@^1.0.0":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/update-notifier/-/update-notifier-1.0.3.tgz#3c7ee1921af6f16149cdcaef356baf57d7a0b806"
+  integrity sha512-BLStNhP2DFF7funARwTcoD6tetRte8NK3Sc59mn7GNALCN975jOlKX3dGvsFxXr/HwQMxxCuRn9IWB3WQ7odHQ==
+
+"@types/uuid@^3.4.3":
+  version "3.4.5"
+  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.5.tgz#d4dc10785b497a1474eae0ba7f0cb09c0ddfd6eb"
+  integrity sha512-MNL15wC3EKyw1VLF+RoVO4hJJdk9t/Hlv3rt1OL65Qvuadm4BYo6g9ZJQqoq7X8NBFSsQXgAujWciovh2lpVjA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/vinyl-fs@0.0.28":
+  version "0.0.28"
+  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-0.0.28.tgz#4663017bc802c6570eae4f3409fd5cabf97cbfde"
+  integrity sha1-RmMBe8gCxlcOrk80Cf1cq/l8v94=
+  dependencies:
+    "@types/glob-stream" "*"
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/vinyl-fs@^2.4.8":
+  version "2.4.11"
+  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.11.tgz#b98119b8bb2494141eaf649b09fbfeb311161206"
+  integrity sha512-2OzQSfIr9CqqWMGqmcERE6Hnd2KY3eBVtFaulVo3sJghplUcaeMdL9ZjEiljcQQeHjheWY9RlNmumjIAvsBNaA==
+  dependencies:
+    "@types/glob-stream" "*"
+    "@types/node" "*"
+    "@types/vinyl" "*"
+
+"@types/vinyl@*", "@types/vinyl@^2.0.0":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.3.tgz#80a6ce362ab5b32a0c98e860748a31bce9bff0de"
+  integrity sha512-hrT6xg16CWSmndZqOTJ6BGIn2abKyTw0B58bI+7ioUoj3Sma6u8ftZ1DTI2yCaJamOVGLOnQWiPH3a74+EaqTA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/whatwg-url@^6.4.0":
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
+  integrity sha512-tonhlcbQ2eho09am6RHnHOgvtDfDYINd5rgxD+2YSkKENooVCFsWizJz139MQW/PV8FfClyKrNe9ZbdHrSCxGg==
+  dependencies:
+    "@types/node" "*"
+
+"@types/which@^1.3.1":
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
+  integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
+
+"@types/yeoman-generator@^2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/yeoman-generator/-/yeoman-generator-2.0.3.tgz#f4b161ee354078b526e0901a5a5f87d4f8e085f6"
+  integrity sha512-vch2UFd6k7DdfWEv/alRwZIRXQoxZNUDpfLOK24+005dzE1HVnwSWfETF3WxJnWlsOcH87wU4uzldAE/7F/6Lw==
+  dependencies:
+    "@types/events" "*"
+    "@types/inquirer" "*"
+
+"@webcomponents/webcomponentsjs@^1.0.7":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
+  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
+
+abbrev@1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+
+accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
+  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
+  dependencies:
+    mime-types "~2.1.24"
+    negotiator "0.6.2"
+
+accessibility-developer-tools@^2.12.0:
+  version "2.12.0"
+  resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
+  integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
+
+acorn-jsx@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
+  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
+  dependencies:
+    acorn "^3.0.4"
+
+acorn-jsx@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384"
+  integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==
+
+acorn@^3.0.4:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
+  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
+
+acorn@^5.5.0:
+  version "5.7.3"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
+  integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==
+
+acorn@^6.1.1:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.2.0.tgz#67f0da2fc339d6cfb5d6fb244fd449f33cd8bbe3"
+  integrity sha512-8oe72N3WPMjA+2zVG71Ia0nXZ8DpQH+QyyHO+p06jT8eg8FGG3FbcUIi8KziHlAfheJQZeoqbvq1mQSQHXKYLw==
+
+acorn@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
+  integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
+
+adm-zip@~0.4.3:
+  version "0.4.13"
+  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
+  integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
+
+after@0.8.2:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
+  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
+
+agent-base@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
+ajv@^6.10.0, ajv@^6.10.2:
+  version "6.10.2"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
+  integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==
+  dependencies:
+    fast-deep-equal "^2.0.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ajv@^6.5.5:
+  version "6.10.1"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.1.tgz#ebf8d3af22552df9dd049bfbe50cc2390e823593"
+  integrity sha512-w1YQaVGNC6t2UCPjEawK/vo/dG8OOrVtUmhBT1uJJYxbl5kU2Tj3v6LGqBcsysN1yhuCStJCCA3GqdvKY8sqXQ==
+  dependencies:
+    fast-deep-equal "^2.0.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ansi-align@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-1.1.0.tgz#2f0c1658829739add5ebb15e6b0c6e3423f016ba"
+  integrity sha1-LwwWWIKXOa3V67FeawxuNCPwFro=
+  dependencies:
+    string-width "^1.0.1"
+
+ansi-align@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
+  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
+  dependencies:
+    string-width "^2.0.0"
+
+ansi-escapes@^1.1.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
+  integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=
+
+ansi-escapes@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b"
+  integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==
+
+ansi-escapes@^4.2.1:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.0.tgz#a4ce2b33d6b214b7950d8595c212f12ac9cc569d"
+  integrity sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==
+  dependencies:
+    type-fest "^0.8.1"
+
+ansi-regex@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+
+ansi-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+
+ansi-regex@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
+  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
+
+ansi-regex@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
+  integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
+
+ansi-styles@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
+  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
+
+ansi-styles@^3.2.0, ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+ansi-styles@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
+  integrity sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=
+
+any-promise@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
+  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
+
+anymatch@^1.3.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
+  integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==
+  dependencies:
+    micromatch "^2.1.5"
+    normalize-path "^2.0.0"
+
+append-field@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
+  integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
+
+aproba@^1.0.3:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
+
+archiver-utils@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
+  integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==
+  dependencies:
+    glob "^7.1.4"
+    graceful-fs "^4.2.0"
+    lazystream "^1.0.0"
+    lodash.defaults "^4.2.0"
+    lodash.difference "^4.5.0"
+    lodash.flatten "^4.4.0"
+    lodash.isplainobject "^4.0.6"
+    lodash.union "^4.6.0"
+    normalize-path "^3.0.0"
+    readable-stream "^2.0.0"
+
+archiver@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
+  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
+  dependencies:
+    archiver-utils "^2.1.0"
+    async "^2.6.3"
+    buffer-crc32 "^0.2.1"
+    glob "^7.1.4"
+    readable-stream "^3.4.0"
+    tar-stream "^2.1.0"
+    zip-stream "^2.1.2"
+
+are-we-there-yet@~1.1.2:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
+  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
+  dependencies:
+    delegates "^1.0.0"
+    readable-stream "^2.0.6"
+
+argparse@^1.0.7:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
+  integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
+  dependencies:
+    sprintf-js "~1.0.2"
+
+arr-diff@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
+  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
+  dependencies:
+    arr-flatten "^1.0.1"
+
+arr-diff@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
+  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
+
+arr-flatten@^1.0.1, arr-flatten@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
+
+arr-union@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
+  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
+
+array-back@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022"
+  integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==
+  dependencies:
+    typical "^2.6.1"
+
+array-back@^3.0.1:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-differ@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
+  integrity sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=
+
+array-find-index@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
+  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
+
+array-flatten@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
+
+array-includes@^3.0.3:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348"
+  integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0"
+    is-string "^1.0.5"
+
+array-union@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
+  integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=
+  dependencies:
+    array-uniq "^1.0.1"
+
+array-uniq@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
+  integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=
+
+array-unique@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
+  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
+
+array-unique@^0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
+  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
+
+array.prototype.flat@^1.2.1:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b"
+  integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0-next.1"
+
+arraybuffer.slice@~0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
+  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
+
+arrify@^1.0.0, arrify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
+  integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
+
+asn1@~0.2.3:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
+  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
+  dependencies:
+    safer-buffer "~2.1.0"
+
+assert-plus@1.0.0, assert-plus@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
+  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
+
+assign-symbols@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
+  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
+
+astral-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
+  integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==
+
+async-each@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
+  integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
+
+async-limiter@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
+  integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
+
+async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.2, async@^2.6.3:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
+  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+  dependencies:
+    lodash "^4.17.14"
+
+async@^2.6.0, async@^2.6.1:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"
+  integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==
+  dependencies:
+    lodash "^4.17.11"
+
+async@~0.2.9:
+  version "0.2.10"
+  resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
+  integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
+
+asynckit@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+
+atob-lite@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
+  integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=
+
+atob@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
+  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
+
+aws-sign2@~0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+
+aws4@^1.8.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
+  integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
+
+babel-code-frame@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
+  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
+  dependencies:
+    chalk "^1.1.3"
+    esutils "^2.0.2"
+    js-tokens "^3.0.2"
+
+babel-generator@^6.26.1:
+  version "6.26.1"
+  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
+  integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==
+  dependencies:
+    babel-messages "^6.23.0"
+    babel-runtime "^6.26.0"
+    babel-types "^6.26.0"
+    detect-indent "^4.0.0"
+    jsesc "^1.3.0"
+    lodash "^4.17.4"
+    source-map "^0.5.7"
+    trim-right "^1.0.1"
+
+babel-helper-evaluate-path@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.5.0.tgz#a62fa9c4e64ff7ea5cea9353174ef023a900a67c"
+  integrity sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==
+
+babel-helper-flip-expressions@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.4.3.tgz#3696736a128ac18bc25254b5f40a22ceb3c1d3fd"
+  integrity sha1-NpZzahKKwYvCUlS19AoizrPB0/0=
+
+babel-helper-is-nodes-equiv@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
+  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
+
+babel-helper-is-void-0@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.4.3.tgz#7d9c01b4561e7b95dbda0f6eee48f5b60e67313e"
+  integrity sha1-fZwBtFYee5Xb2g9u7kj1tg5nMT4=
+
+babel-helper-mark-eval-scopes@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.4.3.tgz#d244a3bef9844872603ffb46e22ce8acdf551562"
+  integrity sha1-0kSjvvmESHJgP/tG4izorN9VFWI=
+
+babel-helper-remove-or-void@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.4.3.tgz#a4f03b40077a0ffe88e45d07010dee241ff5ae60"
+  integrity sha1-pPA7QAd6D/6I5F0HAQ3uJB/1rmA=
+
+babel-helper-to-multiple-sequence-expressions@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d"
+  integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==
+
+babel-messages@^6.23.0:
+  version "6.23.0"
+  resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
+  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
+  dependencies:
+    babel-runtime "^6.22.0"
+
+babel-plugin-dynamic-import-node@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f"
+  integrity sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==
+  dependencies:
+    object.assign "^4.1.0"
+
+babel-plugin-minify-builtins@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.5.0.tgz#31eb82ed1a0d0efdc31312f93b6e4741ce82c36b"
+  integrity sha512-wpqbN7Ov5hsNwGdzuzvFcjgRlzbIeVv1gMIlICbPj0xkexnfoIDe7q+AZHMkQmAE/F9R5jkrB6TLfTegImlXag==
+
+babel-plugin-minify-constant-folding@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.5.0.tgz#f84bc8dbf6a561e5e350ff95ae216b0ad5515b6e"
+  integrity sha512-Vj97CTn/lE9hR1D+jKUeHfNy+m1baNiJ1wJvoGyOBUx7F7kJqDZxr9nCHjO/Ad+irbR3HzR6jABpSSA29QsrXQ==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+
+babel-plugin-minify-dead-code-elimination@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.0.tgz#d23ef5445238ad06e8addf5c1cf6aec835bcda87"
+  integrity sha512-XQteBGXlgEoAKc/BhO6oafUdT4LBa7ARi55mxoyhLHNuA+RlzRmeMAfc31pb/UqU01wBzRc36YqHQzopnkd/6Q==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+    babel-helper-mark-eval-scopes "^0.4.3"
+    babel-helper-remove-or-void "^0.4.3"
+    lodash.some "^4.6.0"
+
+babel-plugin-minify-flip-comparisons@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.4.3.tgz#00ca870cb8f13b45c038b3c1ebc0f227293c965a"
+  integrity sha1-AMqHDLjxO0XAOLPB68DyJyk8llo=
+  dependencies:
+    babel-helper-is-void-0 "^0.4.3"
+
+babel-plugin-minify-guarded-expressions@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.3.tgz#cc709b4453fd21b1f302877444c89f88427ce397"
+  integrity sha1-zHCbRFP9IbHzAod0RMifiEJ845c=
+  dependencies:
+    babel-helper-flip-expressions "^0.4.3"
+
+babel-plugin-minify-infinity@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.4.3.tgz#dfb876a1b08a06576384ef3f92e653ba607b39ca"
+  integrity sha1-37h2obCKBldjhO8/kuZTumB7Oco=
+
+babel-plugin-minify-mangle-names@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.5.0.tgz#bcddb507c91d2c99e138bd6b17a19c3c271e3fd3"
+  integrity sha512-3jdNv6hCAw6fsX1p2wBGPfWuK69sfOjfd3zjUXkbq8McbohWy23tpXfy5RnToYWggvqzuMOwlId1PhyHOfgnGw==
+  dependencies:
+    babel-helper-mark-eval-scopes "^0.4.3"
+
+babel-plugin-minify-numeric-literals@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.4.3.tgz#8e4fd561c79f7801286ff60e8c5fd9deee93c0bc"
+  integrity sha1-jk/VYcefeAEob/YOjF/Z3u6TwLw=
+
+babel-plugin-minify-replace@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.5.0.tgz#d3e2c9946c9096c070efc96761ce288ec5c3f71c"
+  integrity sha512-aXZiaqWDNUbyNNNpWs/8NyST+oU7QTpK7J9zFEFSA0eOmtUNMU3fczlTTTlnCxHmq/jYNFEmkkSG3DDBtW3Y4Q==
+
+babel-plugin-minify-simplify@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.0.tgz#1f090018afb90d8b54d3d027fd8a4927f243da6f"
+  integrity sha512-TM01J/YcKZ8XIQd1Z3nF2AdWHoDsarjtZ5fWPDksYZNsoOjQ2UO2EWm824Ym6sp127m44gPlLFiO5KFxU8pA5Q==
+  dependencies:
+    babel-helper-flip-expressions "^0.4.3"
+    babel-helper-is-nodes-equiv "^0.0.1"
+    babel-helper-to-multiple-sequence-expressions "^0.5.0"
+
+babel-plugin-minify-type-constructors@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.4.3.tgz#1bc6f15b87f7ab1085d42b330b717657a2156500"
+  integrity sha1-G8bxW4f3qxCF1CszC3F2V6IVZQA=
+  dependencies:
+    babel-helper-is-void-0 "^0.4.3"
+
+babel-plugin-transform-inline-consecutive-adds@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.4.3.tgz#323d47a3ea63a83a7ac3c811ae8e6941faf2b0d1"
+  integrity sha1-Mj1Ho+pjqDp6w8gRro5pQfrysNE=
+
+babel-plugin-transform-member-expression-literals@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
+  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
+
+babel-plugin-transform-merge-sibling-variables@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
+  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
+
+babel-plugin-transform-minify-booleans@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
+  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
+
+babel-plugin-transform-property-literals@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
+  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
+  dependencies:
+    esutils "^2.0.2"
+
+babel-plugin-transform-regexp-constructors@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.4.3.tgz#58b7775b63afcf33328fae9a5f88fbd4fb0b4965"
+  integrity sha1-WLd3W2OvzzMyj66aX4j71PsLSWU=
+
+babel-plugin-transform-remove-console@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
+  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
+
+babel-plugin-transform-remove-debugger@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
+  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
+
+babel-plugin-transform-remove-undefined@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.5.0.tgz#80208b31225766c630c97fa2d288952056ea22dd"
+  integrity sha512-+M7fJYFaEE/M9CXa0/IRkDbiV3wRELzA1kKQFCJ4ifhrzLKn/9VCCgj9OFmYWwBd8IB48YdgPkHYtbYq+4vtHQ==
+  dependencies:
+    babel-helper-evaluate-path "^0.5.0"
+
+babel-plugin-transform-simplify-comparison-operators@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
+  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
+
+babel-plugin-transform-undefined-to-void@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
+  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
+
+babel-preset-minify@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.0.tgz#e25bb8d3590087af02b650967159a77c19bfb96b"
+  integrity sha512-xj1s9Mon+RFubH569vrGCayA9Fm2GMsCgDRm1Jb8SgctOB7KFcrVc2o8K3YHUyMz+SWP8aea75BoS8YfsXXuiA==
+  dependencies:
+    babel-plugin-minify-builtins "^0.5.0"
+    babel-plugin-minify-constant-folding "^0.5.0"
+    babel-plugin-minify-dead-code-elimination "^0.5.0"
+    babel-plugin-minify-flip-comparisons "^0.4.3"
+    babel-plugin-minify-guarded-expressions "^0.4.3"
+    babel-plugin-minify-infinity "^0.4.3"
+    babel-plugin-minify-mangle-names "^0.5.0"
+    babel-plugin-minify-numeric-literals "^0.4.3"
+    babel-plugin-minify-replace "^0.5.0"
+    babel-plugin-minify-simplify "^0.5.0"
+    babel-plugin-minify-type-constructors "^0.4.3"
+    babel-plugin-transform-inline-consecutive-adds "^0.4.3"
+    babel-plugin-transform-member-expression-literals "^6.9.4"
+    babel-plugin-transform-merge-sibling-variables "^6.9.4"
+    babel-plugin-transform-minify-booleans "^6.9.4"
+    babel-plugin-transform-property-literals "^6.9.4"
+    babel-plugin-transform-regexp-constructors "^0.4.3"
+    babel-plugin-transform-remove-console "^6.9.4"
+    babel-plugin-transform-remove-debugger "^6.9.4"
+    babel-plugin-transform-remove-undefined "^0.5.0"
+    babel-plugin-transform-simplify-comparison-operators "^6.9.4"
+    babel-plugin-transform-undefined-to-void "^6.9.4"
+    lodash.isplainobject "^4.0.6"
+
+babel-runtime@^6.22.0, babel-runtime@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
+  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
+  dependencies:
+    core-js "^2.4.0"
+    regenerator-runtime "^0.11.0"
+
+babel-traverse@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
+  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
+  dependencies:
+    babel-code-frame "^6.26.0"
+    babel-messages "^6.23.0"
+    babel-runtime "^6.26.0"
+    babel-types "^6.26.0"
+    babylon "^6.18.0"
+    debug "^2.6.8"
+    globals "^9.18.0"
+    invariant "^2.2.2"
+    lodash "^4.17.4"
+
+babel-types@^6.26.0:
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
+  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
+  dependencies:
+    babel-runtime "^6.26.0"
+    esutils "^2.0.2"
+    lodash "^4.17.4"
+    to-fast-properties "^1.0.3"
+
+babylon@^6.18.0:
+  version "6.18.0"
+  resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
+  integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
+
+babylon@^7.0.0-beta.42:
+  version "7.0.0-beta.47"
+  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
+  integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
+
+backo2@1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
+  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
+
+balanced-match@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+
+base64-arraybuffer@0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
+  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+
+base64-js@1.2.0, base64-js@^1.0.2:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
+  integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
+
+base64id@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
+  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
+
+base@^0.11.1:
+  version "0.11.2"
+  resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
+  integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==
+  dependencies:
+    cache-base "^1.0.1"
+    class-utils "^0.3.5"
+    component-emitter "^1.2.1"
+    define-property "^1.0.0"
+    isobject "^3.0.1"
+    mixin-deep "^1.2.0"
+    pascalcase "^0.1.1"
+
+bcrypt-pbkdf@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
+  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
+  dependencies:
+    tweetnacl "^0.14.3"
+
+before-after-hook@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.4.0.tgz#2b6bf23dca4f32e628fd2747c10a37c74a4b484d"
+  integrity sha512-l5r9ir56nda3qu14nAXIlyq1MmUSs0meCIaFAh8HwkFwP1F8eToOuS3ah2VAHHcY04jaYD7FpJC5JTXHYRbkzg==
+
+better-assert@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
+  integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
+  dependencies:
+    callsite "1.0.0"
+
+binary-extensions@^1.0.0:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
+  integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
+
+binaryextensions@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.2.tgz#c83c3d74233ba7674e4f313cb2a2b70f54e94b7c"
+  integrity sha512-xVNN69YGDghOqCCtA6FI7avYrr02mTJjOgB0/f1VPD3pJC8QEvjTKWc4epDx8AqxxA75NI0QpVM2gPJXUbE4Tg==
+
+bl@^1.0.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c"
+  integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==
+  dependencies:
+    readable-stream "^2.3.5"
+    safe-buffer "^5.1.1"
+
+bl@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.0.tgz#e1a574cdf528e4053019bb800b041c0ac88da493"
+  integrity sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==
+  dependencies:
+    readable-stream "^2.3.5"
+    safe-buffer "^5.1.1"
+
+bl@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
+  integrity sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==
+  dependencies:
+    readable-stream "^3.0.1"
+
+blob@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
+  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
+
+body-parser@1.19.0, body-parser@^1.17.2:
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
+  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+  dependencies:
+    bytes "3.1.0"
+    content-type "~1.0.4"
+    debug "2.6.9"
+    depd "~1.1.2"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    on-finished "~2.3.0"
+    qs "6.7.0"
+    raw-body "2.4.0"
+    type-is "~1.6.17"
+
+bower-config@^1.4.0, bower-config@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.1.tgz#85fd9df367c2b8dbbd0caa4c5f2bad40cd84c2cc"
+  integrity sha1-hf2d82fCuNu9DKpMXyutQM2Ewsw=
+  dependencies:
+    graceful-fs "^4.1.3"
+    mout "^1.0.0"
+    optimist "^0.6.1"
+    osenv "^0.1.3"
+    untildify "^2.1.0"
+
+bower-json@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/bower-json/-/bower-json-0.8.1.tgz#96c14723241ae6466a9c52e16caa32623a883843"
+  integrity sha1-lsFHIyQa5kZqnFLhbKoyYjqIOEM=
+  dependencies:
+    deep-extend "^0.4.0"
+    ext-name "^3.0.0"
+    graceful-fs "^4.1.3"
+    intersect "^1.0.1"
+
+bower-logger@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/bower-logger/-/bower-logger-0.2.2.tgz#39be07e979b2fc8e03a94634205ed9422373d381"
+  integrity sha1-Ob4H6Xmy/I4DqUY0IF7ZQiNz04E=
+
+bower@^1.8.8:
+  version "1.8.8"
+  resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.8.tgz#82544be34a33aeae7efb8bdf9905247b2cffa985"
+  integrity sha512-1SrJnXnkP9soITHptSO+ahx3QKp3cVzn8poI6ujqc5SeOkg5iqM1pK9H+DSc2OQ8SnO0jC/NG4Ur/UIwy7574A==
+
+boxen@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-0.6.0.tgz#8364d4248ac34ff0ef1b2f2bf49a6c60ce0d81b6"
+  integrity sha1-g2TUJIrDT/DvGy8r9JpsYM4NgbY=
+  dependencies:
+    ansi-align "^1.1.0"
+    camelcase "^2.1.0"
+    chalk "^1.1.1"
+    cli-boxes "^1.0.0"
+    filled-array "^1.0.0"
+    object-assign "^4.0.1"
+    repeating "^2.0.0"
+    string-width "^1.0.1"
+    widest-line "^1.0.0"
+
+boxen@^1.2.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
+  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
+  dependencies:
+    ansi-align "^2.0.0"
+    camelcase "^4.0.0"
+    chalk "^2.0.1"
+    cli-boxes "^1.0.0"
+    string-width "^2.0.0"
+    term-size "^1.2.0"
+    widest-line "^2.0.0"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+braces@^1.8.2:
+  version "1.8.5"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
+  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
+  dependencies:
+    expand-range "^1.8.1"
+    preserve "^0.2.0"
+    repeat-element "^1.1.2"
+
+braces@^2.3.1:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
+  integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==
+  dependencies:
+    arr-flatten "^1.1.0"
+    array-unique "^0.3.2"
+    extend-shallow "^2.0.1"
+    fill-range "^4.0.0"
+    isobject "^3.0.1"
+    repeat-element "^1.1.2"
+    snapdragon "^0.8.1"
+    snapdragon-node "^2.0.1"
+    split-string "^3.0.2"
+    to-regex "^3.0.1"
+
+browser-capabilities@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/browser-capabilities/-/browser-capabilities-1.1.4.tgz#a6bd657a07a134532ad66c722b8949904478b973"
+  integrity sha512-BezMQhbQklxjRQpZZQ8tnbzEo6AldUwMh8/PeWt5/CTBSwByQRXZEAK2fbnEahQ4poeeaI0suAYRq25A1YGOmw==
+  dependencies:
+    "@types/ua-parser-js" "^0.7.31"
+    ua-parser-js "^0.7.15"
+
+browserify-zlib@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d"
+  integrity sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=
+  dependencies:
+    pako "~0.2.0"
+
+browserstack@^1.2.0:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.5.3.tgz#93ab48799a12ef99dbd074dd595410ddb196a7ac"
+  integrity sha512-AO+mECXsW4QcqC9bxwM29O7qWa7bJT94uBFzeb5brylIQwawuEziwq20dPYbins95GlWzOawgyDNdjYAo32EKg==
+  dependencies:
+    https-proxy-agent "^2.2.1"
+
+btoa-lite@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
+  integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
+
+buffer-alloc-unsafe@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
+  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
+
+buffer-alloc@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
+  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
+  dependencies:
+    buffer-alloc-unsafe "^1.1.0"
+    buffer-fill "^1.0.0"
+
+buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
+
+buffer-fill@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
+  integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
+
+buffer-from@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+
+buffer@^5.1.0:
+  version "5.4.3"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
+  integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
+  dependencies:
+    base64-js "^1.0.2"
+    ieee754 "^1.1.4"
+
+busboy@^0.2.11:
+  version "0.2.14"
+  resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
+  integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
+  dependencies:
+    dicer "0.2.5"
+    readable-stream "1.1.x"
+
+bytes@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
+  integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
+
+bytes@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
+  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
+
+cache-base@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
+  integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==
+  dependencies:
+    collection-visit "^1.0.0"
+    component-emitter "^1.2.1"
+    get-value "^2.0.6"
+    has-value "^1.0.0"
+    isobject "^3.0.1"
+    set-value "^2.0.0"
+    to-object-path "^0.3.0"
+    union-value "^1.0.0"
+    unset-value "^1.0.0"
+
+call-me-maybe@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
+  integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
+
+callsite@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
+  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
+
+callsites@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
+  integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
+
+camel-case@3.0.x:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
+  integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
+  dependencies:
+    no-case "^2.2.0"
+    upper-case "^1.1.1"
+
+camelcase-keys@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
+  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
+  dependencies:
+    camelcase "^2.0.0"
+    map-obj "^1.0.0"
+
+camelcase@^2.0.0, camelcase@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
+  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
+
+camelcase@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
+  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
+
+cancel-token@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
+  integrity sha1-wYGXZ0uxyEwdaTPr8V2NWlznm08=
+  dependencies:
+    "@types/node" "^4.0.30"
+
+capture-stack-trace@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
+  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
+
+caseless@~0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
+  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
+
+chalk@*, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
+  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
+  dependencies:
+    ansi-styles "^2.2.1"
+    escape-string-regexp "^1.0.2"
+    has-ansi "^2.0.0"
+    strip-ansi "^3.0.0"
+    supports-color "^2.0.0"
+
+chalk@~0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
+  integrity sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=
+  dependencies:
+    ansi-styles "~1.0.0"
+    has-color "~0.1.0"
+    strip-ansi "~0.1.0"
+
+chardet@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
+  integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
+
+charenc@~0.0.1:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
+  integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
+
+chokidar@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
+  integrity sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=
+  dependencies:
+    anymatch "^1.3.0"
+    async-each "^1.0.0"
+    glob-parent "^2.0.0"
+    inherits "^2.0.1"
+    is-binary-path "^1.0.0"
+    is-glob "^2.0.0"
+    path-is-absolute "^1.0.0"
+    readdirp "^2.0.0"
+  optionalDependencies:
+    fsevents "^1.0.0"
+
+chownr@^1.0.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6"
+  integrity sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==
+
+chownr@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494"
+  integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==
+
+ci-info@^1.5.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
+  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
+
+class-utils@^0.3.5:
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
+  integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==
+  dependencies:
+    arr-union "^3.1.0"
+    define-property "^0.2.5"
+    isobject "^3.0.0"
+    static-extend "^0.1.1"
+
+clean-css@4.2.x:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17"
+  integrity sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==
+  dependencies:
+    source-map "~0.6.0"
+
+cleankill@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/cleankill/-/cleankill-2.0.0.tgz#59830dfc8b411d53dc72ad09d45a78ea33161a91"
+  integrity sha1-WYMN/ItBHVPccq0J1Fp46jMWGpE=
+
+cli-boxes@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
+  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
+
+cli-cursor@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
+  integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=
+  dependencies:
+    restore-cursor "^1.0.1"
+
+cli-cursor@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
+  integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
+  dependencies:
+    restore-cursor "^2.0.0"
+
+cli-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
+  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
+  dependencies:
+    restore-cursor "^3.1.0"
+
+cli-table@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23"
+  integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM=
+  dependencies:
+    colors "1.0.3"
+
+cli-width@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
+  integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=
+
+clone-buffer@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
+  integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
+
+clone-stats@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
+  integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=
+
+clone-stats@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
+  integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
+
+clone@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
+  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
+
+clone@^2.0.0, clone@^2.1.0, clone@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
+
+cloneable-readable@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec"
+  integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==
+  dependencies:
+    inherits "^2.0.1"
+    process-nextick-args "^2.0.0"
+    readable-stream "^2.3.5"
+
+code-point-at@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+
+collection-visit@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
+  integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=
+  dependencies:
+    map-visit "^1.0.0"
+    object-visit "^1.0.0"
+
+color-convert@^1.9.0, color-convert@^1.9.1:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-name@1.1.3, color-name@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
+
+color-string@^1.5.2:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
+  integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
+  dependencies:
+    color-name "^1.0.0"
+    simple-swizzle "^0.2.2"
+
+color@3.0.x:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
+  integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==
+  dependencies:
+    color-convert "^1.9.1"
+    color-string "^1.5.2"
+
+colornames@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96"
+  integrity sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=
+
+colors@1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
+  integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
+
+colors@^1.2.1:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.3.tgz#39e005d546afe01e01f9c4ca8fa50f686a01205d"
+  integrity sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg==
+
+colorspace@1.1.x:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5"
+  integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==
+  dependencies:
+    color "3.0.x"
+    text-hex "1.0.x"
+
+combined-stream@^1.0.6, combined-stream@~1.0.6:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+  dependencies:
+    delayed-stream "~1.0.0"
+
+command-line-args@^5.0.2:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.1.1.tgz#88e793e5bb3ceb30754a86863f0401ac92fd369a"
+  integrity sha512-hL/eG8lrll1Qy1ezvkant+trihbGnaKaeEjj6Scyr3DN+RC7iQ5Rz84IeLERfAWDGo0HBSNAakczwgCilDXnWg==
+  dependencies:
+    array-back "^3.0.1"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-commands@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/command-line-commands/-/command-line-commands-2.0.1.tgz#c58aa13dc78c06038ed67077e57ad09a6f858f46"
+  integrity sha512-m8c2p1DrNd2ruIAggxd/y6DgygQayf6r8RHwchhXryaLF8I6koYjoYroVP+emeROE9DXN5b9sP1Gh+WtvTTdtQ==
+  dependencies:
+    array-back "^2.0.0"
+
+command-line-usage@^5.0.5:
+  version "5.0.5"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-5.0.5.tgz#5f25933ffe6dedd983c635d38a21d7e623fda357"
+  integrity sha512-d8NrGylA5oCXSbGoKz05FkehDAzSmIm4K03S5VDh4d5lZAtTWfc3D1RuETtuQCn8129nYfJfDdF7P/lwcz1BlA==
+  dependencies:
+    array-back "^2.0.0"
+    chalk "^2.4.1"
+    table-layout "^0.4.3"
+    typical "^2.6.1"
+
+commander@2.17.x:
+  version "2.17.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
+  integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
+
+commander@^2.19.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+commander@~2.19.0:
+  version "2.19.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
+  integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
+
+comment-parser@^0.7.2:
+  version "0.7.2"
+  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-0.7.2.tgz#baf6d99b42038678b81096f15b630d18142f4b8a"
+  integrity sha512-4Rjb1FnxtOcv9qsfuaNuVsmmVn4ooVoBHzYfyKteiXwIU84PClyGA5jASoFMwPV93+FPh9spwueXauxFJZkGAg==
+
+commondir@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
+  integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
+
+component-bind@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
+  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
+
+component-emitter@1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
+  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
+
+component-emitter@^1.2.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
+  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
+
+component-inherit@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
+  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
+
+compress-commons@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
+  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
+  dependencies:
+    buffer-crc32 "^0.2.13"
+    crc32-stream "^3.0.1"
+    normalize-path "^3.0.0"
+    readable-stream "^2.3.6"
+
+compressible@~2.0.16:
+  version "2.0.18"
+  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
+  integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
+  dependencies:
+    mime-db ">= 1.43.0 < 2"
+
+compression@^1.6.2:
+  version "1.7.4"
+  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
+  integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
+  dependencies:
+    accepts "~1.3.5"
+    bytes "3.0.0"
+    compressible "~2.0.16"
+    debug "2.6.9"
+    on-headers "~1.0.2"
+    safe-buffer "5.1.2"
+    vary "~1.1.2"
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+concat-stream@^1.4.7, concat-stream@^1.5.2:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
+  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
+  dependencies:
+    buffer-from "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^2.2.2"
+    typedarray "^0.0.6"
+
+configstore@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-2.1.0.tgz#737a3a7036e9886102aa6099e47bb33ab1aba1a1"
+  integrity sha1-c3o6cDbpiGECqmCZ5HuzOrGroaE=
+  dependencies:
+    dot-prop "^3.0.0"
+    graceful-fs "^4.1.2"
+    mkdirp "^0.5.0"
+    object-assign "^4.0.1"
+    os-tmpdir "^1.0.0"
+    osenv "^0.1.0"
+    uuid "^2.0.1"
+    write-file-atomic "^1.1.2"
+    xdg-basedir "^2.0.0"
+
+configstore@^3.0.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
+  integrity sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==
+  dependencies:
+    dot-prop "^4.1.0"
+    graceful-fs "^4.1.2"
+    make-dir "^1.0.0"
+    unique-string "^1.0.0"
+    write-file-atomic "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
+
+contains-path@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
+  integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=
+
+content-disposition@0.5.3:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
+  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
+  dependencies:
+    safe-buffer "5.1.2"
+
+content-type@^1.0.2, content-type@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+convert-source-map@^1.1.0, convert-source-map@^1.1.1:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20"
+  integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==
+  dependencies:
+    safe-buffer "~5.1.1"
+
+cookie-signature@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
+  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
+
+cookie@0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
+  integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
+
+cookie@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
+  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
+
+copy-descriptor@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
+  integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
+
+core-js@^2.4.0:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
+  integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
+
+core-util-is@1.0.2, core-util-is@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+
+cors@^2.8.4:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
+  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
+  dependencies:
+    object-assign "^4"
+    vary "^1"
+
+crc32-stream@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
+  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
+  dependencies:
+    crc "^3.4.4"
+    readable-stream "^3.4.0"
+
+crc@^3.4.4:
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
+  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
+  dependencies:
+    buffer "^5.1.0"
+
+create-error-class@^3.0.0, create-error-class@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
+  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
+  dependencies:
+    capture-stack-trace "^1.0.0"
+
+cross-spawn@^5.0.1:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
+  dependencies:
+    lru-cache "^4.0.1"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+cross-spawn@^6.0.0, cross-spawn@^6.0.5:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
+  dependencies:
+    nice-try "^1.0.4"
+    path-key "^2.0.1"
+    semver "^5.5.0"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+crypt@~0.0.1:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
+  integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
+
+crypto-random-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
+  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
+
+css-slam@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
+  integrity sha512-cObrY+mhFEmepWpua6EpMrgRNTQ0eeym+kvR0lukI6hDEzK7F8himEDS4cJ9+fPHCoArTzVrrR0d+oAUbTR1NA==
+  dependencies:
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    dom5 "^3.0.0"
+    parse5 "^4.0.0"
+    shady-css-parser "^0.1.0"
+
+css-what@^2.1.0:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
+  integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
+
+cssbeautify@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
+  integrity sha1-Et0fc0A1wub6ymfcvc73TkKBE5c=
+
+currently-unhandled@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
+  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
+  dependencies:
+    array-find-index "^1.0.1"
+
+dargs@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/dargs/-/dargs-6.1.0.tgz#1f3b9b56393ecf8caa7cbfd6c31496ffcfb9b272"
+  integrity sha512-5dVBvpBLBnPwSsYXqfybFyehMmC/EenKEcf23AhCTgTf48JFBbmJKqoZBsERDnjL0FyiVTYWdFsRfTLHxLyKdQ==
+
+dashdash@^1.12.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
+  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
+  dependencies:
+    assert-plus "^1.0.0"
+
+dateformat@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
+  integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
+
+debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@^3.0.0, debug@^3.1.0:
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+  dependencies:
+    ms "^2.1.1"
+
+debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+  dependencies:
+    ms "^2.1.1"
+
+debug@~3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+  dependencies:
+    ms "2.0.0"
+
+decamelize@^1.1.2:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+  integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
+
+decode-uri-component@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
+  integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
+
+decompress-response@^3.2.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
+  integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
+  dependencies:
+    mimic-response "^1.0.0"
+
+deep-extend@^0.4.0:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
+  integrity sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=
+
+deep-extend@^0.6.0, deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+deep-is@~0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
+  integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
+
+deepmerge@4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.0.0.tgz#3e3110ca29205f120d7cb064960a39c3d2087c09"
+  integrity sha512-YZ1rOP5+kHor4hMAH+HRQnBQHg+wvS1un1hAOuIcxcBy0hzcUf6Jg2a1w65kpoOUnurOfZbERwjI1TfZxNjcww==
+
+define-properties@^1.1.2, define-properties@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
+  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
+  dependencies:
+    object-keys "^1.0.12"
+
+define-property@^0.2.5:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
+  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
+  dependencies:
+    is-descriptor "^0.1.0"
+
+define-property@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
+  integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY=
+  dependencies:
+    is-descriptor "^1.0.0"
+
+define-property@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
+  integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==
+  dependencies:
+    is-descriptor "^1.0.2"
+    isobject "^3.0.1"
+
+del@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5"
+  integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=
+  dependencies:
+    globby "^6.1.0"
+    is-path-cwd "^1.0.0"
+    is-path-in-cwd "^1.0.0"
+    p-map "^1.1.1"
+    pify "^3.0.0"
+    rimraf "^2.2.8"
+
+delayed-stream@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
+
+deprecation@^2.0.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
+  integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
+
+destroy@~1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
+  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
+
+detect-conflict@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/detect-conflict/-/detect-conflict-1.0.1.tgz#088657a66a961c05019db7c4230883b1c6b4176e"
+  integrity sha1-CIZXpmqWHAUBnbfEIwiDsca0F24=
+
+detect-file@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63"
+  integrity sha1-STXe39lIhkjgBrASlWbpOGcR6mM=
+  dependencies:
+    fs-exists-sync "^0.1.0"
+
+detect-file@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
+  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
+
+detect-indent@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
+  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
+  dependencies:
+    repeating "^2.0.0"
+
+detect-libc@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+
+detect-node@^2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
+  integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
+
+diagnostics@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a"
+  integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==
+  dependencies:
+    colorspace "1.1.x"
+    enabled "1.0.x"
+    kuler "1.0.x"
+
+dicer@0.2.5:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
+  integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
+  dependencies:
+    readable-stream "1.1.x"
+    streamsearch "0.1.2"
+
+diff@^2.1.2:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99"
+  integrity sha1-YOr9DSjukG5Oj/ClLBIpUhAzv5k=
+
+diff@^3.1.0, diff@^3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
+  integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
+
+dir-glob@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
+  integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==
+  dependencies:
+    arrify "^1.0.1"
+    path-type "^3.0.0"
+
+doctrine@1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
+  integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=
+  dependencies:
+    esutils "^2.0.2"
+    isarray "^1.0.0"
+
+doctrine@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
+  integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
+  dependencies:
+    esutils "^2.0.2"
+
+doctrine@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
+  integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
+  dependencies:
+    esutils "^2.0.2"
+
+dom-serializer@0:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
+  integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
+  dependencies:
+    domelementtype "^2.0.1"
+    entities "^2.0.0"
+
+dom-urls@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
+  integrity sha1-AB3fgWKM0ecGElxxdvU8zsVdkY4=
+  dependencies:
+    urijs "^1.16.1"
+
+dom5@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dom5/-/dom5-3.0.1.tgz#cdfc7331f376e284bf379e6ea054afc136702944"
+  integrity sha512-JPFiouQIr16VQ4dX6i0+Hpbg3H2bMKPmZ+WZgBOSSvOPx9QHwwY8sPzeM2baUtViESYto6wC2nuZOMC/6gulcA==
+  dependencies:
+    "@types/parse5" "^2.2.34"
+    clone "^2.1.0"
+    parse5 "^4.0.0"
+
+domelementtype@1, domelementtype@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
+  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
+
+domelementtype@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d"
+  integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==
+
+domhandler@^2.3.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
+  integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==
+  dependencies:
+    domelementtype "1"
+
+domutils@^1.5.1:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
+  integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==
+  dependencies:
+    dom-serializer "0"
+    domelementtype "1"
+
+dot-prop@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177"
+  integrity sha1-G3CK8JSknJoOfbyteQq6U52sEXc=
+  dependencies:
+    is-obj "^1.0.0"
+
+dot-prop@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
+  integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==
+  dependencies:
+    is-obj "^1.0.0"
+
+duplexer2@^0.1.2, duplexer2@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
+  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
+  dependencies:
+    readable-stream "^2.0.2"
+
+duplexer3@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
+
+duplexify@^3.2.0, duplexify@^3.5.0, duplexify@^3.6.0:
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
+  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
+  dependencies:
+    end-of-stream "^1.0.0"
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
+    stream-shift "^1.0.0"
+
+ecc-jsbn@~0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
+  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+  dependencies:
+    jsbn "~0.1.0"
+    safer-buffer "^2.1.0"
+
+editions@^2.1.2, editions@^2.1.3:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/editions/-/editions-2.1.3.tgz#727ccf3ec2c7b12dcc652c71000f16c4824d6f7d"
+  integrity sha512-xDZyVm0A4nLgMNWVVLJvcwMjI80ShiH/27RyLiCnW1L273TcJIA25C4pwJ33AWV01OX6UriP35Xu+lH4S7HWQw==
+  dependencies:
+    errlop "^1.1.1"
+    semver "^5.6.0"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
+
+ejs@^2.5.9:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.2.tgz#3a32c63d1cd16d11266cd4703b14fec4e74ab4f6"
+  integrity sha512-PcW2a0tyTuPHz3tWyYqtK6r1fZ3gp+3Sop8Ph+ZYN81Ob5rwmbHEzaqs10N3BEsaGTkh/ooniXK+WwszGlc2+Q==
+
+emitter-component@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
+  integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
+
+emoji-regex@^7.0.1:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
+  integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
+
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+enabled@1.0.x:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93"
+  integrity sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=
+  dependencies:
+    env-variable "0.0.x"
+
+encodeurl@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
+
+end-of-stream@^1.0.0, end-of-stream@^1.1.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
+  integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==
+  dependencies:
+    once "^1.4.0"
+
+end-of-stream@^1.4.1:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+  dependencies:
+    once "^1.4.0"
+
+ends-with@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
+  integrity sha1-L52pjVelDP2kVxzkM5AAUA9Oa4o=
+
+engine.io-client@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700"
+  integrity sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==
+  dependencies:
+    component-emitter "1.2.1"
+    component-inherit "0.0.3"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    ws "~6.1.0"
+    xmlhttprequest-ssl "~1.5.4"
+    yeast "0.1.2"
+
+engine.io-parser@~2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.0.tgz#312c4894f57d52a02b420868da7b5c1c84af80ed"
+  integrity sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==
+  dependencies:
+    after "0.8.2"
+    arraybuffer.slice "~0.0.7"
+    base64-arraybuffer "0.1.5"
+    blob "0.0.5"
+    has-binary2 "~1.0.2"
+
+engine.io@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.0.tgz#3a962cc4535928c252759a00f98519cb46c53ff3"
+  integrity sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==
+  dependencies:
+    accepts "~1.3.4"
+    base64id "2.0.0"
+    cookie "0.3.1"
+    debug "~4.1.0"
+    engine.io-parser "~2.2.0"
+    ws "^7.1.2"
+
+entities@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
+  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
+
+entities@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
+  integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==
+
+env-variable@0.0.x:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
+  integrity sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==
+
+errlop@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/errlop/-/errlop-1.1.1.tgz#d9ae4c76c3e64956c5d79e6e035d6343bfd62250"
+  integrity sha512-WX7QjiPHhsny7/PQvrhS5VMizXXKoKCS3udaBp8gjlARdbn+XmK300eKBAAN0hGyRaTCtRpOaxK+xFVPUJ3zkw==
+  dependencies:
+    editions "^2.1.2"
+
+error-ex@^1.2.0, error-ex@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
+  dependencies:
+    is-arrayish "^0.2.1"
+
+error@^7.0.2:
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02"
+  integrity sha1-pfdf/02ZJhJt2sDqXcOOaJFTywI=
+  dependencies:
+    string-template "~0.2.1"
+    xtend "~4.0.0"
+
+es-abstract@^1.17.0, es-abstract@^1.17.0-next.1:
+  version "1.17.5"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.5.tgz#d8c9d1d66c8981fb9200e2251d799eee92774ae9"
+  integrity sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==
+  dependencies:
+    es-to-primitive "^1.2.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.1"
+    is-callable "^1.1.5"
+    is-regex "^1.0.5"
+    object-inspect "^1.7.0"
+    object-keys "^1.1.1"
+    object.assign "^4.1.0"
+    string.prototype.trimleft "^2.1.1"
+    string.prototype.trimright "^2.1.1"
+
+es-to-primitive@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
+  integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
+  dependencies:
+    is-callable "^1.1.4"
+    is-date-object "^1.0.1"
+    is-symbol "^1.0.2"
+
+es6-promise@^4.0.3, es6-promise@^4.0.5:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  dependencies:
+    es6-promise "^4.0.3"
+
+es6-promisify@^6.0.0:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.0.2.tgz#525c23725b8510f5f1f2feb5a1fbad93a93e29b4"
+  integrity sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==
+
+escape-html@^1.0.3, escape-html@~1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
+
+escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
+
+eslint-config-google@^0.13.0:
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.13.0.tgz#e277d16d2cb25c1ffd3fd13fb0035ad7421382fe"
+  integrity sha512-ELgMdOIpn0CFdsQS+FuxO+Ttu4p+aLaXHv9wA9yVnzqlUGV7oN/eRRnJekk7TCur6Cu2FXX0fqfIXRBaM14lpQ==
+
+eslint-import-resolver-node@^0.3.2:
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404"
+  integrity sha512-b8crLDo0M5RSe5YG8Pu2DYBj71tSB6OvXkfzwbJU2w7y8P4/yo0MyF8jU26IEuEuHF2K5/gcAJE3LhQGqBBbVg==
+  dependencies:
+    debug "^2.6.9"
+    resolve "^1.13.1"
+
+eslint-module-utils@^2.4.1:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.5.2.tgz#7878f7504824e1b857dd2505b59a8e5eda26a708"
+  integrity sha512-LGScZ/JSlqGKiT8OC+cYRxseMjyqt6QO54nl281CK93unD89ijSeRV6An8Ci/2nvWVKe8K/Tqdm75RQoIOCr+Q==
+  dependencies:
+    debug "^2.6.9"
+    pkg-dir "^2.0.0"
+
+eslint-plugin-html@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.0.0.tgz#28e5c3e71e6f612e07e73d7c215e469766628c13"
+  integrity sha512-PQcGippOHS+HTbQCStmH5MY1BF2MaU8qW/+Mvo/8xTa/ioeMXdSP+IiaBw2+nh0KEMfYQKuTz1Zo+vHynjwhbg==
+  dependencies:
+    htmlparser2 "^3.10.1"
+
+eslint-plugin-import@^2.20.1:
+  version "2.20.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.20.1.tgz#802423196dcb11d9ce8435a5fc02a6d3b46939b3"
+  integrity sha512-qQHgFOTjguR+LnYRoToeZWT62XM55MBVXObHM6SKFd1VzDcX/vqT1kAz8ssqigh5eMj8qXcRoXXGZpPP6RfdCw==
+  dependencies:
+    array-includes "^3.0.3"
+    array.prototype.flat "^1.2.1"
+    contains-path "^0.1.0"
+    debug "^2.6.9"
+    doctrine "1.5.0"
+    eslint-import-resolver-node "^0.3.2"
+    eslint-module-utils "^2.4.1"
+    has "^1.0.3"
+    minimatch "^3.0.4"
+    object.values "^1.1.0"
+    read-pkg-up "^2.0.0"
+    resolve "^1.12.0"
+
+eslint-plugin-jsdoc@^19.2.0:
+  version "19.2.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-19.2.0.tgz#f522b970878ae402b28ce62187305b33dfe2c834"
+  integrity sha512-QdNifBFLXCDGdy+26RXxcrqzEZarFWNybCZQVqJQYEYPlxd6lm+LPkrs6mCOhaGc2wqC6zqpedBQFX8nQJuKSw==
+  dependencies:
+    comment-parser "^0.7.2"
+    debug "^4.1.1"
+    jsdoctypeparser "^6.1.0"
+    lodash "^4.17.15"
+    object.entries-ponyfill "^1.0.1"
+    regextras "^0.7.0"
+    semver "^6.3.0"
+    spdx-expression-parse "^3.0.0"
+
+eslint-plugin-prettier@^3.1.3:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.3.tgz#ae116a0fc0e598fdae48743a4430903de5b4e6ca"
+  integrity sha512-+HG5jmu/dN3ZV3T6eCD7a4BlAySdN7mLIbJYo0z1cFQuI+r2DiTJEFeF68ots93PsnrMxbzIZ2S/ieX+mkrBeQ==
+  dependencies:
+    prettier-linter-helpers "^1.0.0"
+
+eslint-scope@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9"
+  integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==
+  dependencies:
+    esrecurse "^4.1.0"
+    estraverse "^4.1.1"
+
+eslint-utils@^1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f"
+  integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==
+  dependencies:
+    eslint-visitor-keys "^1.1.0"
+
+eslint-visitor-keys@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
+  integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
+
+eslint@^6.6.0:
+  version "6.8.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb"
+  integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    ajv "^6.10.0"
+    chalk "^2.1.0"
+    cross-spawn "^6.0.5"
+    debug "^4.0.1"
+    doctrine "^3.0.0"
+    eslint-scope "^5.0.0"
+    eslint-utils "^1.4.3"
+    eslint-visitor-keys "^1.1.0"
+    espree "^6.1.2"
+    esquery "^1.0.1"
+    esutils "^2.0.2"
+    file-entry-cache "^5.0.1"
+    functional-red-black-tree "^1.0.1"
+    glob-parent "^5.0.0"
+    globals "^12.1.0"
+    ignore "^4.0.6"
+    import-fresh "^3.0.0"
+    imurmurhash "^0.1.4"
+    inquirer "^7.0.0"
+    is-glob "^4.0.0"
+    js-yaml "^3.13.1"
+    json-stable-stringify-without-jsonify "^1.0.1"
+    levn "^0.3.0"
+    lodash "^4.17.14"
+    minimatch "^3.0.4"
+    mkdirp "^0.5.1"
+    natural-compare "^1.4.0"
+    optionator "^0.8.3"
+    progress "^2.0.0"
+    regexpp "^2.0.1"
+    semver "^6.1.2"
+    strip-ansi "^5.2.0"
+    strip-json-comments "^3.0.1"
+    table "^5.2.3"
+    text-table "^0.2.0"
+    v8-compile-cache "^2.0.3"
+
+espree@^3.5.2:
+  version "3.5.4"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
+  integrity sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==
+  dependencies:
+    acorn "^5.5.0"
+    acorn-jsx "^3.0.0"
+
+espree@^6.1.2:
+  version "6.1.2"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.2.tgz#6c272650932b4f91c3714e5e7b5f5e2ecf47262d"
+  integrity sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA==
+  dependencies:
+    acorn "^7.1.0"
+    acorn-jsx "^5.1.0"
+    eslint-visitor-keys "^1.1.0"
+
+esprima@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
+  integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
+
+esquery@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
+  integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==
+  dependencies:
+    estraverse "^4.0.0"
+
+esrecurse@^4.1.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf"
+  integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==
+  dependencies:
+    estraverse "^4.1.0"
+
+estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
+
+esutils@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
+  integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=
+
+etag@~1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
+
+eventemitter3@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
+  integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
+
+execa@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
+  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
+  dependencies:
+    cross-spawn "^5.0.1"
+    get-stream "^3.0.0"
+    is-stream "^1.1.0"
+    npm-run-path "^2.0.0"
+    p-finally "^1.0.0"
+    signal-exit "^3.0.0"
+    strip-eof "^1.0.0"
+
+execa@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
+  integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
+  dependencies:
+    cross-spawn "^6.0.0"
+    get-stream "^4.0.0"
+    is-stream "^1.1.0"
+    npm-run-path "^2.0.0"
+    p-finally "^1.0.0"
+    signal-exit "^3.0.0"
+    strip-eof "^1.0.0"
+
+exit-hook@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
+  integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=
+
+expand-brackets@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
+  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
+  dependencies:
+    is-posix-bracket "^0.1.0"
+
+expand-brackets@^2.1.4:
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
+  integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI=
+  dependencies:
+    debug "^2.3.3"
+    define-property "^0.2.5"
+    extend-shallow "^2.0.1"
+    posix-character-classes "^0.1.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+expand-range@^1.8.1:
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
+  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
+  dependencies:
+    fill-range "^2.1.0"
+
+expand-tilde@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449"
+  integrity sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=
+  dependencies:
+    os-homedir "^1.0.1"
+
+expand-tilde@^2.0.0, expand-tilde@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
+  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
+  dependencies:
+    homedir-polyfill "^1.0.1"
+
+express@^4.15.3, express@^4.8.5:
+  version "4.17.1"
+  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
+  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
+  dependencies:
+    accepts "~1.3.7"
+    array-flatten "1.1.1"
+    body-parser "1.19.0"
+    content-disposition "0.5.3"
+    content-type "~1.0.4"
+    cookie "0.4.0"
+    cookie-signature "1.0.6"
+    debug "2.6.9"
+    depd "~1.1.2"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    finalhandler "~1.1.2"
+    fresh "0.5.2"
+    merge-descriptors "1.0.1"
+    methods "~1.1.2"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    path-to-regexp "0.1.7"
+    proxy-addr "~2.0.5"
+    qs "6.7.0"
+    range-parser "~1.2.1"
+    safe-buffer "5.1.2"
+    send "0.17.1"
+    serve-static "1.14.1"
+    setprototypeof "1.1.1"
+    statuses "~1.5.0"
+    type-is "~1.6.18"
+    utils-merge "1.0.1"
+    vary "~1.1.2"
+
+ext-list@^2.0.0:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37"
+  integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==
+  dependencies:
+    mime-db "^1.28.0"
+
+ext-name@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ext-name/-/ext-name-3.0.0.tgz#07e4418737cb1f513c32c6ea48d8b8c8e0471abb"
+  integrity sha1-B+RBhzfLH1E8MsbqSNi4yOBHGrs=
+  dependencies:
+    ends-with "^0.2.0"
+    ext-list "^2.0.0"
+    meow "^3.1.0"
+    sort-keys-length "^1.0.0"
+
+extend-shallow@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
+  integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=
+  dependencies:
+    is-extendable "^0.1.0"
+
+extend-shallow@^3.0.0, extend-shallow@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
+  integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=
+  dependencies:
+    assign-symbols "^1.0.0"
+    is-extendable "^1.0.1"
+
+extend@^3.0.0, extend@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
+external-editor@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-1.1.1.tgz#12d7b0db850f7ff7e7081baf4005700060c4600b"
+  integrity sha1-Etew24UPf/fnCBuvQAVwAGDEYAs=
+  dependencies:
+    extend "^3.0.0"
+    spawn-sync "^1.0.15"
+    tmp "^0.0.29"
+
+external-editor@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
+  integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==
+  dependencies:
+    chardet "^0.7.0"
+    iconv-lite "^0.4.24"
+    tmp "^0.0.33"
+
+extglob@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
+  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
+  dependencies:
+    is-extglob "^1.0.0"
+
+extglob@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
+  integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==
+  dependencies:
+    array-unique "^0.3.2"
+    define-property "^1.0.0"
+    expand-brackets "^2.1.4"
+    extend-shallow "^2.0.1"
+    fragment-cache "^0.2.1"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+extsprintf@1.3.0, extsprintf@^1.2.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
+
+fast-deep-equal@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
+  integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
+
+fast-diff@^1.1.2:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
+  integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
+
+fast-glob@^2.0.2:
+  version "2.2.7"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
+  integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==
+  dependencies:
+    "@mrmlnc/readdir-enhanced" "^2.2.1"
+    "@nodelib/fs.stat" "^1.1.2"
+    glob-parent "^3.1.0"
+    is-glob "^4.0.0"
+    merge2 "^1.2.3"
+    micromatch "^3.1.10"
+
+fast-json-stable-stringify@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
+  integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
+
+fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
+
+fast-safe-stringify@^2.0.4:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2"
+  integrity sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==
+
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+  dependencies:
+    pend "~1.2.0"
+
+fecha@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
+  integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
+
+figures@^1.3.5:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
+  integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=
+  dependencies:
+    escape-string-regexp "^1.0.5"
+    object-assign "^4.1.0"
+
+figures@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
+  integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=
+  dependencies:
+    escape-string-regexp "^1.0.5"
+
+figures@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-3.1.0.tgz#4b198dd07d8d71530642864af2d45dd9e459c4ec"
+  integrity sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==
+  dependencies:
+    escape-string-regexp "^1.0.5"
+
+file-entry-cache@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c"
+  integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==
+  dependencies:
+    flat-cache "^2.0.1"
+
+filename-regex@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
+  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
+
+fill-range@^2.1.0:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
+  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
+  dependencies:
+    is-number "^2.1.0"
+    isobject "^2.0.0"
+    randomatic "^3.0.0"
+    repeat-element "^1.1.2"
+    repeat-string "^1.5.2"
+
+fill-range@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
+  integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=
+  dependencies:
+    extend-shallow "^2.0.1"
+    is-number "^3.0.0"
+    repeat-string "^1.6.1"
+    to-regex-range "^2.1.0"
+
+filled-array@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/filled-array/-/filled-array-1.1.0.tgz#c3c4f6c663b923459a9aa29912d2d031f1507f84"
+  integrity sha1-w8T2xmO5I0WamqKZEtLQMfFQf4Q=
+
+finalhandler@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
+  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
+  dependencies:
+    debug "2.6.9"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    statuses "~1.5.0"
+    unpipe "~1.0.0"
+
+find-port@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/find-port/-/find-port-1.0.1.tgz#db084a6cbf99564d99869ae79fbdecf66e8a185c"
+  integrity sha1-2whKbL+ZVk2Zhprnn73s9m6KGFw=
+  dependencies:
+    async "~0.2.9"
+
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
+  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
+  dependencies:
+    path-exists "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+find-up@^2.0.0, find-up@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
+  integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c=
+  dependencies:
+    locate-path "^2.0.0"
+
+find-up@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
+  integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
+  dependencies:
+    locate-path "^3.0.0"
+
+findup-sync@^0.4.2:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12"
+  integrity sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=
+  dependencies:
+    detect-file "^0.1.0"
+    is-glob "^2.0.1"
+    micromatch "^2.3.7"
+    resolve-dir "^0.1.0"
+
+findup-sync@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
+  integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=
+  dependencies:
+    detect-file "^1.0.0"
+    is-glob "^3.1.0"
+    micromatch "^3.0.4"
+    resolve-dir "^1.0.1"
+
+first-chunk-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
+  integrity sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=
+
+first-chunk-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70"
+  integrity sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA=
+  dependencies:
+    readable-stream "^2.0.2"
+
+flat-cache@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0"
+  integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==
+  dependencies:
+    flatted "^2.0.0"
+    rimraf "2.6.3"
+    write "1.0.3"
+
+flatted@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08"
+  integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==
+
+follow-redirects@^1.0.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
+  integrity sha512-CRcPzsSIbXyVDl0QI01muNDu69S8trU4jArW9LpOt2WtC6LyUJetcIrmfHsRBx7/Jb6GHJUiuqyYxPooFfNt6A==
+  dependencies:
+    debug "^3.0.0"
+
+for-in@^1.0.1, for-in@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
+  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
+
+for-own@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
+  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
+  dependencies:
+    for-in "^1.0.1"
+
+forever-agent@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+
+fork-stream@^0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70"
+  integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
+
+form-data@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
+  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.6"
+    mime-types "^2.1.12"
+
+formatio@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
+  integrity sha1-87IWfZBoxGmKjVH092CjmlTYGOs=
+  dependencies:
+    samsam "1.x"
+
+forwarded@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
+
+fragment-cache@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
+  integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=
+  dependencies:
+    map-cache "^0.2.2"
+
+freeport@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
+  integrity sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=
+
+fresh@0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
+
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
+fs-exists-sync@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
+  integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=
+
+fs-minipass@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
+  integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==
+  dependencies:
+    minipass "^2.2.1"
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
+fsevents@^1.0.0:
+  version "1.2.9"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.9.tgz#3f5ed66583ccd6f400b5a00db6f7e861363e388f"
+  integrity sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==
+  dependencies:
+    nan "^2.12.1"
+    node-pre-gyp "^0.12.0"
+
+function-bind@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+functional-red-black-tree@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
+  integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
+
+gauge@~2.7.3:
+  version "2.7.4"
+  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
+  dependencies:
+    aproba "^1.0.3"
+    console-control-strings "^1.0.0"
+    has-unicode "^2.0.0"
+    object-assign "^4.1.0"
+    signal-exit "^3.0.0"
+    string-width "^1.0.1"
+    strip-ansi "^3.0.1"
+    wide-align "^1.1.0"
+
+get-stdin@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
+  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
+
+get-stream@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
+
+get-stream@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
+  integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
+  dependencies:
+    pump "^3.0.0"
+
+get-value@^2.0.3, get-value@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
+  integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
+
+getpass@^0.1.1:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
+  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
+  dependencies:
+    assert-plus "^1.0.0"
+
+gh-got@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-6.0.0.tgz#d74353004c6ec466647520a10bd46f7299d268d0"
+  integrity sha512-F/mS+fsWQMo1zfgG9MD8KWvTWPPzzhuVwY++fhQ5Ggd+0P+CAMHtzMZhNxG+TqGfHDChJKsbh6otfMGqO2AKBw==
+  dependencies:
+    got "^7.0.0"
+    is-plain-obj "^1.1.0"
+
+github-username@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/github-username/-/github-username-4.1.0.tgz#cbe280041883206da4212ae9e4b5f169c30bf417"
+  integrity sha1-y+KABBiDIG2kISrp5LXxacML9Bc=
+  dependencies:
+    gh-got "^6.0.0"
+
+glob-base@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
+  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
+  dependencies:
+    glob-parent "^2.0.0"
+    is-glob "^2.0.0"
+
+glob-parent@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
+  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
+  dependencies:
+    is-glob "^2.0.0"
+
+glob-parent@^3.0.0, glob-parent@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
+  integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=
+  dependencies:
+    is-glob "^3.1.0"
+    path-dirname "^1.0.0"
+
+glob-parent@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2"
+  integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==
+  dependencies:
+    is-glob "^4.0.1"
+
+glob-stream@^5.3.2:
+  version "5.3.5"
+  resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-5.3.5.tgz#a55665a9a8ccdc41915a87c701e32d4e016fad22"
+  integrity sha1-pVZlqajM3EGRWofHAeMtTgFvrSI=
+  dependencies:
+    extend "^3.0.0"
+    glob "^5.0.3"
+    glob-parent "^3.0.0"
+    micromatch "^2.3.7"
+    ordered-read-streams "^0.3.0"
+    through2 "^0.6.0"
+    to-absolute-glob "^0.1.1"
+    unique-stream "^2.0.2"
+
+glob-to-regexp@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
+  integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
+
+glob@^5.0.3:
+  version "5.0.15"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
+  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^6.0.1:
+  version "6.0.4"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
+  integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2:
+  version "7.1.4"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
+  integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^7.1.3, glob@^7.1.4:
+  version "7.1.6"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
+  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+global-dirs@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
+  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
+  dependencies:
+    ini "^1.3.4"
+
+global-modules@^0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
+  integrity sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=
+  dependencies:
+    global-prefix "^0.1.4"
+    is-windows "^0.2.0"
+
+global-modules@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
+  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
+  dependencies:
+    global-prefix "^1.0.1"
+    is-windows "^1.0.1"
+    resolve-dir "^1.0.0"
+
+global-prefix@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f"
+  integrity sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=
+  dependencies:
+    homedir-polyfill "^1.0.0"
+    ini "^1.3.4"
+    is-windows "^0.2.0"
+    which "^1.2.12"
+
+global-prefix@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
+  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
+  dependencies:
+    expand-tilde "^2.0.2"
+    homedir-polyfill "^1.0.1"
+    ini "^1.3.4"
+    is-windows "^1.0.1"
+    which "^1.2.14"
+
+globals@^11.1.0:
+  version "11.12.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
+  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
+
+globals@^12.1.0:
+  version "12.3.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-12.3.0.tgz#1e564ee5c4dded2ab098b0f88f24702a3c56be13"
+  integrity sha512-wAfjdLgFsPZsklLJvOBUBmzYE8/CwhEqSBEMRXA3qxIiNtyqvjYurAtIfDh6chlEPUfmTY3MnZh5Hfh4q0UlIw==
+  dependencies:
+    type-fest "^0.8.1"
+
+globals@^9.18.0:
+  version "9.18.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
+  integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
+
+globby@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-4.1.0.tgz#080f54549ec1b82a6c60e631fc82e1211dbe95f8"
+  integrity sha1-CA9UVJ7BuCpsYOYx/ILhIR2+lfg=
+  dependencies:
+    array-union "^1.0.1"
+    arrify "^1.0.0"
+    glob "^6.0.1"
+    object-assign "^4.0.1"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+globby@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"
+  integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=
+  dependencies:
+    array-union "^1.0.1"
+    glob "^7.0.3"
+    object-assign "^4.0.1"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+globby@^8.0.1:
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d"
+  integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w==
+  dependencies:
+    array-union "^1.0.1"
+    dir-glob "2.0.0"
+    fast-glob "^2.0.2"
+    glob "^7.1.2"
+    ignore "^3.3.5"
+    pify "^3.0.0"
+    slash "^1.0.0"
+
+got@^5.0.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
+  integrity sha1-X4FjWmHkplifGAVp6k44FoClHzU=
+  dependencies:
+    create-error-class "^3.0.1"
+    duplexer2 "^0.1.4"
+    is-redirect "^1.0.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    lowercase-keys "^1.0.0"
+    node-status-codes "^1.0.0"
+    object-assign "^4.0.1"
+    parse-json "^2.1.0"
+    pinkie-promise "^2.0.0"
+    read-all-stream "^3.0.0"
+    readable-stream "^2.0.5"
+    timed-out "^3.0.0"
+    unzip-response "^1.0.2"
+    url-parse-lax "^1.0.0"
+
+got@^6.7.1:
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
+  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
+  dependencies:
+    create-error-class "^3.0.0"
+    duplexer3 "^0.1.4"
+    get-stream "^3.0.0"
+    is-redirect "^1.0.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    lowercase-keys "^1.0.0"
+    safe-buffer "^5.0.1"
+    timed-out "^4.0.0"
+    unzip-response "^2.0.1"
+    url-parse-lax "^1.0.0"
+
+got@^7.0.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a"
+  integrity sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==
+  dependencies:
+    decompress-response "^3.2.0"
+    duplexer3 "^0.1.4"
+    get-stream "^3.0.0"
+    is-plain-obj "^1.1.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    isurl "^1.0.0-alpha5"
+    lowercase-keys "^1.0.0"
+    p-cancelable "^0.3.0"
+    p-timeout "^1.1.1"
+    safe-buffer "^5.0.1"
+    timed-out "^4.0.0"
+    url-parse-lax "^1.0.0"
+    url-to-options "^1.0.1"
+
+graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b"
+  integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==
+
+graceful-fs@^4.2.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
+  integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==
+
+grouped-queue@^0.3.0, grouped-queue@^0.3.3:
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-0.3.3.tgz#c167d2a5319c5a0e0964ef6a25b7c2df8996c85c"
+  integrity sha1-wWfSpTGcWg4JZO9qJbfC34mWyFw=
+  dependencies:
+    lodash "^4.17.2"
+
+gulp-if@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
+  integrity sha1-pJe351cwBQQcqivIt92jyARE1ik=
+  dependencies:
+    gulp-match "^1.0.3"
+    ternary-stream "^2.0.1"
+    through2 "^2.0.1"
+
+gulp-match@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.0.3.tgz#91c7c0d7f29becd6606d57d80a7f8776a87aba8e"
+  integrity sha1-kcfA1/Kb7NZgbVfYCn+Hdqh6uo4=
+  dependencies:
+    minimatch "^3.0.3"
+
+gulp-sourcemaps@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz#b86ff349d801ceb56e1d9e7dc7bbcb4b7dee600c"
+  integrity sha1-uG/zSdgBzrVuHZ59x7vLS33uYAw=
+  dependencies:
+    convert-source-map "^1.1.1"
+    graceful-fs "^4.1.2"
+    strip-bom "^2.0.0"
+    through2 "^2.0.0"
+    vinyl "^1.0.0"
+
+gunzip-maybe@^1.3.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.1.tgz#39c72ed89d1b49ba708e18776500488902a52027"
+  integrity sha512-qtutIKMthNJJgeHQS7kZ9FqDq59/Wn0G2HYCRNjpup7yKfVI6/eqwpmroyZGFoCYaG+sW6psNVb4zoLADHpp2g==
+  dependencies:
+    browserify-zlib "^0.1.4"
+    is-deflate "^1.0.0"
+    is-gzip "^1.0.0"
+    peek-stream "^1.1.0"
+    pumpify "^1.3.3"
+    through2 "^2.0.3"
+
+handle-thing@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
+  integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=
+
+har-schema@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+
+har-validator@~5.1.0:
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
+  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+  dependencies:
+    ajv "^6.5.5"
+    har-schema "^2.0.0"
+
+has-ansi@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
+  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+has-binary2@~1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
+  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
+  dependencies:
+    isarray "2.0.1"
+
+has-color@~0.1.0:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f"
+  integrity sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=
+
+has-cors@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
+  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
+
+has-symbol-support-x@^1.4.1:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455"
+  integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==
+
+has-symbols@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44"
+  integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=
+
+has-symbols@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
+  integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
+
+has-to-string-tag-x@^1.2.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d"
+  integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==
+  dependencies:
+    has-symbol-support-x "^1.4.1"
+
+has-unicode@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+
+has-value@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
+  integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=
+  dependencies:
+    get-value "^2.0.3"
+    has-values "^0.1.4"
+    isobject "^2.0.0"
+
+has-value@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
+  integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=
+  dependencies:
+    get-value "^2.0.6"
+    has-values "^1.0.0"
+    isobject "^3.0.0"
+
+has-values@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
+  integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E=
+
+has-values@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
+  integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=
+  dependencies:
+    is-number "^3.0.0"
+    kind-of "^4.0.0"
+
+has@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+  dependencies:
+    function-bind "^1.1.1"
+
+he@1.2.x:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
+homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
+  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
+  dependencies:
+    parse-passwd "^1.0.0"
+
+hosted-git-info@^2.1.4:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
+  integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==
+
+hpack.js@^2.1.6:
+  version "2.1.6"
+  resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
+  integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
+  dependencies:
+    inherits "^2.0.1"
+    obuf "^1.0.0"
+    readable-stream "^2.0.1"
+    wbuf "^1.1.0"
+
+html-minifier@^3.5.10:
+  version "3.5.21"
+  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c"
+  integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==
+  dependencies:
+    camel-case "3.0.x"
+    clean-css "4.2.x"
+    commander "2.17.x"
+    he "1.2.x"
+    param-case "2.1.x"
+    relateurl "0.2.x"
+    uglify-js "3.4.x"
+
+htmlparser2@^3.10.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
+  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
+  dependencies:
+    domelementtype "^1.3.1"
+    domhandler "^2.3.0"
+    domutils "^1.5.1"
+    entities "^1.1.1"
+    inherits "^2.0.1"
+    readable-stream "^3.1.1"
+
+http-deceiver@^1.2.7:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
+  integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
+
+http-errors@1.7.2, http-errors@~1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
+  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
+http-proxy-middleware@^0.17.2:
+  version "0.17.4"
+  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
+  integrity sha1-ZC6ISIUdZvCdTxJJEoRtuutBuDM=
+  dependencies:
+    http-proxy "^1.16.2"
+    is-glob "^3.1.0"
+    lodash "^4.17.2"
+    micromatch "^2.3.11"
+
+http-proxy@^1.16.2:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
+  integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
+  dependencies:
+    eventemitter3 "^4.0.0"
+    follow-redirects "^1.0.0"
+    requires-port "^1.0.0"
+
+http-signature@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
+  dependencies:
+    assert-plus "^1.0.0"
+    jsprim "^1.2.2"
+    sshpk "^1.7.0"
+
+https-proxy-agent@^2.2.1:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
+  integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+https-proxy-agent@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
+  integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+ieee754@^1.1.4:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
+  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
+
+ignore-walk@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
+  integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==
+  dependencies:
+    minimatch "^3.0.4"
+
+ignore@^3.3.5:
+  version "3.3.10"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
+  integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
+
+ignore@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
+  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
+
+import-fresh@^3.0.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
+  integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==
+  dependencies:
+    parent-module "^1.0.0"
+    resolve-from "^4.0.0"
+
+import-lazy@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
+  integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
+
+imurmurhash@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+  integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
+
+indent-string@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
+  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
+  dependencies:
+    repeating "^2.0.0"
+
+indent@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
+  integrity sha1-jHnwgBkFWbaHA0uEx676l9WpEdk=
+
+indexof@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
+  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+
+ini@^1.3.4, ini@~1.3.0:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+
+inquirer@^1.0.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-1.2.3.tgz#4dec6f32f37ef7bb0b2ed3f1d1a5c3f545074918"
+  integrity sha1-TexvMvN+97sLLtPx0aXD9UUHSRg=
+  dependencies:
+    ansi-escapes "^1.1.0"
+    chalk "^1.0.0"
+    cli-cursor "^1.0.1"
+    cli-width "^2.0.0"
+    external-editor "^1.1.0"
+    figures "^1.3.5"
+    lodash "^4.3.0"
+    mute-stream "0.0.6"
+    pinkie-promise "^2.0.0"
+    run-async "^2.2.0"
+    rx "^4.1.0"
+    string-width "^1.0.1"
+    strip-ansi "^3.0.0"
+    through "^2.3.6"
+
+inquirer@^6.0.0:
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.4.1.tgz#7bd9e5ab0567cd23b41b0180b68e0cfa82fc3c0b"
+  integrity sha512-/Jw+qPZx4EDYsaT6uz7F4GJRNFMRdKNeUZw3ZnKV8lyuUgz/YWRCSUAJMZSVhSq4Ec0R2oYnyi6b3d4JXcL5Nw==
+  dependencies:
+    ansi-escapes "^3.2.0"
+    chalk "^2.4.2"
+    cli-cursor "^2.1.0"
+    cli-width "^2.0.0"
+    external-editor "^3.0.3"
+    figures "^2.0.0"
+    lodash "^4.17.11"
+    mute-stream "0.0.7"
+    run-async "^2.2.0"
+    rxjs "^6.4.0"
+    string-width "^2.1.0"
+    strip-ansi "^5.1.0"
+    through "^2.3.6"
+
+inquirer@^7.0.0:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.3.tgz#f9b4cd2dff58b9f73e8d43759436ace15bed4567"
+  integrity sha512-+OiOVeVydu4hnCGLCSX+wedovR/Yzskv9BFqUNNKq9uU2qg7LCcCo3R86S2E7WLo0y/x2pnEZfZe1CoYnORUAw==
+  dependencies:
+    ansi-escapes "^4.2.1"
+    chalk "^2.4.2"
+    cli-cursor "^3.1.0"
+    cli-width "^2.0.0"
+    external-editor "^3.0.3"
+    figures "^3.0.0"
+    lodash "^4.17.15"
+    mute-stream "0.0.8"
+    run-async "^2.2.0"
+    rxjs "^6.5.3"
+    string-width "^4.1.0"
+    strip-ansi "^5.1.0"
+    through "^2.3.6"
+
+interpret@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
+  integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
+
+intersect@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/intersect/-/intersect-1.0.1.tgz#332650e10854d8c0ac58c192bdc27a8bf7e7a30c"
+  integrity sha1-MyZQ4QhU2MCsWMGSvcJ6i/fnoww=
+
+invariant@^2.2.2:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
+  dependencies:
+    loose-envify "^1.0.0"
+
+ipaddr.js@1.9.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
+  integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
+
+is-accessor-descriptor@^0.1.6:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
+  integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-accessor-descriptor@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
+  integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==
+  dependencies:
+    kind-of "^6.0.0"
+
+is-arrayish@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+  integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
+
+is-arrayish@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
+  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
+
+is-binary-path@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
+  integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=
+  dependencies:
+    binary-extensions "^1.0.0"
+
+is-buffer@^1.1.5, is-buffer@~1.1.1:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+  integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
+
+is-callable@^1.1.4, is-callable@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab"
+  integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==
+
+is-ci@^1.0.10:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
+  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
+  dependencies:
+    ci-info "^1.5.0"
+
+is-data-descriptor@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
+  integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-data-descriptor@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
+  integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==
+  dependencies:
+    kind-of "^6.0.0"
+
+is-date-object@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
+  integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
+
+is-deflate@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14"
+  integrity sha1-yGKQHDwWH7CdrHzcfnhPgOmPLxQ=
+
+is-descriptor@^0.1.0:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
+  integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==
+  dependencies:
+    is-accessor-descriptor "^0.1.6"
+    is-data-descriptor "^0.1.4"
+    kind-of "^5.0.0"
+
+is-descriptor@^1.0.0, is-descriptor@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
+  integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==
+  dependencies:
+    is-accessor-descriptor "^1.0.0"
+    is-data-descriptor "^1.0.0"
+    kind-of "^6.0.2"
+
+is-dotfile@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
+  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
+
+is-equal-shallow@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
+  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
+  dependencies:
+    is-primitive "^2.0.0"
+
+is-extendable@^0.1.0, is-extendable@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
+  integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=
+
+is-extendable@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
+  integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==
+  dependencies:
+    is-plain-object "^2.0.4"
+
+is-extglob@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
+  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
+
+is-extglob@^2.1.0, is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
+
+is-finite@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
+  integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
+
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-glob@^2.0.0, is-glob@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
+  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
+  dependencies:
+    is-extglob "^1.0.0"
+
+is-glob@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
+  integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=
+  dependencies:
+    is-extglob "^2.1.0"
+
+is-glob@^4.0.0, is-glob@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
+  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-gzip@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-gzip/-/is-gzip-1.0.0.tgz#6ca8b07b99c77998025900e555ced8ed80879a83"
+  integrity sha1-bKiwe5nHeZgCWQDlVc7Y7YCHmoM=
+
+is-installed-globally@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
+  dependencies:
+    global-dirs "^0.1.0"
+    is-path-inside "^1.0.0"
+
+is-npm@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
+  integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
+
+is-number@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
+  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-number@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
+  integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=
+  dependencies:
+    kind-of "^3.0.2"
+
+is-number@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+  integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
+
+is-obj@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
+  integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
+
+is-object@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
+  integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA=
+
+is-path-cwd@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
+  integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=
+
+is-path-in-cwd@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52"
+  integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==
+  dependencies:
+    is-path-inside "^1.0.0"
+
+is-path-inside@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
+  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
+  dependencies:
+    path-is-inside "^1.0.1"
+
+is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
+  integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
+
+is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
+  dependencies:
+    isobject "^3.0.1"
+
+is-plain-object@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.0.tgz#47bfc5da1b5d50d64110806c199359482e75a928"
+  integrity sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==
+  dependencies:
+    isobject "^4.0.0"
+
+is-posix-bracket@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
+  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
+
+is-potential-custom-element-name@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
+  integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c=
+
+is-primitive@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
+
+is-promise@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
+  integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=
+
+is-redirect@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
+  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
+
+is-regex@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
+  integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==
+  dependencies:
+    has "^1.0.3"
+
+is-retry-allowed@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34"
+  integrity sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=
+
+is-scoped@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-scoped/-/is-scoped-1.0.0.tgz#449ca98299e713038256289ecb2b540dc437cb30"
+  integrity sha1-RJypgpnnEwOCViieyytUDcQ3yzA=
+  dependencies:
+    scoped-regex "^1.0.0"
+
+is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
+  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+
+is-string@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
+  integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
+
+is-symbol@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
+  integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
+  dependencies:
+    has-symbols "^1.0.1"
+
+is-typedarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
+
+is-utf8@^0.2.0:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
+  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
+
+is-valid-glob@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-0.3.0.tgz#d4b55c69f51886f9b65c70d6c2622d37e29f48fe"
+  integrity sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=
+
+is-windows@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c"
+  integrity sha1-3hqm1j6indJIc3tp8f+LgALSEIw=
+
+is-windows@^1.0.1, is-windows@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
+  integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
+
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
+
+isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+
+isarray@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
+  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
+
+isbinaryfile@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80"
+  integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==
+  dependencies:
+    buffer-alloc "^1.2.0"
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
+
+isobject@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
+  integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=
+  dependencies:
+    isarray "1.0.0"
+
+isobject@^3.0.0, isobject@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
+
+isobject@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0"
+  integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==
+
+isstream@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
+  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
+
+istextorbinary@^2.2.1:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.5.1.tgz#14a33824cf6b9d5d7743eac1be2bd2c310d0ccbd"
+  integrity sha512-pv/JNPWnfpwGjPx7JrtWTwsWsxkrK3fNzcEVnt92YKEIErps4Fsk49+qzCe9iQF2hjqK8Naqf8P9kzoeCuQI1g==
+  dependencies:
+    binaryextensions "^2.1.2"
+    editions "^2.1.3"
+    textextensions "^2.4.0"
+
+isurl@^1.0.0-alpha5:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
+  integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==
+  dependencies:
+    has-to-string-tag-x "^1.2.0"
+    is-object "^1.0.1"
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+js-tokens@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
+  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
+
+js-yaml@^3.13.1:
+  version "3.13.1"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
+  integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
+  dependencies:
+    argparse "^1.0.7"
+    esprima "^4.0.0"
+
+jsbn@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
+  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+
+jsdoctypeparser@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/jsdoctypeparser/-/jsdoctypeparser-6.1.0.tgz#acfb936c26300d98f1405cb03e20b06748e512a8"
+  integrity sha512-UCQBZ3xCUBv/PLfwKAJhp6jmGOSLFNKzrotXGNgbKhWvz27wPsCsVeP7gIcHPElQw2agBmynAitXqhxR58XAmA==
+
+jsesc@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
+  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
+
+jsesc@^2.5.1:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
+  integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
+
+jsesc@~0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
+  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
+
+json-parse-better-errors@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
+
+json-schema-traverse@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+json-schema@0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
+  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+
+json-stable-stringify-without-jsonify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+  integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
+
+json-stringify-safe@~5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+
+json5@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850"
+  integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==
+  dependencies:
+    minimist "^1.2.0"
+
+jsonschema@^1.1.0, jsonschema@^1.1.1:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.4.tgz#a46bac5d3506a254465bc548876e267c6d0d6464"
+  integrity sha512-lz1nOH69GbsVHeVgEdvyavc/33oymY1AZwtePMiMj4HZPMbP5OIKK3zT9INMWjwua/V4Z4yq7wSlBbSG+g4AEw==
+
+jsprim@^1.2.2:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
+  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+  dependencies:
+    assert-plus "1.0.0"
+    extsprintf "1.3.0"
+    json-schema "0.2.3"
+    verror "1.10.0"
+
+kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
+  integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=
+  dependencies:
+    is-buffer "^1.1.5"
+
+kind-of@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
+  integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc=
+  dependencies:
+    is-buffer "^1.1.5"
+
+kind-of@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
+  integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
+
+kind-of@^6.0.0, kind-of@^6.0.2:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
+  integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==
+
+kuler@1.0.x:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6"
+  integrity sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==
+  dependencies:
+    colornames "^1.1.1"
+
+latest-version@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-2.0.0.tgz#56f8d6139620847b8017f8f1f4d78e211324168b"
+  integrity sha1-VvjWE5YghHuAF/jx9NeOIRMkFos=
+  dependencies:
+    package-json "^2.0.0"
+
+latest-version@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
+  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
+  dependencies:
+    package-json "^4.0.0"
+
+launchpad@^0.7.0:
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.4.tgz#08a7a38f48b963e73dc68be84f9f8f974c46c26b"
+  integrity sha512-3KDFHbvXm52CEA4vSQ9kz4vwAxH7SANj2k6SJcE6LCpmMJFOu6wCpZwXb+pq9kSDknl5XInBWeOl40i00P6wTQ==
+  dependencies:
+    async "^2.0.1"
+    browserstack "^1.2.0"
+    debug "^2.2.0"
+    mkdirp "^0.5.1"
+    plist "^2.0.1"
+    q "^1.4.1"
+    rimraf "^3.0.0"
+    underscore "^1.8.3"
+
+lazy-req@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/lazy-req/-/lazy-req-1.1.0.tgz#bdaebead30f8d824039ce0ce149d4daa07ba1fac"
+  integrity sha1-va6+rTD42CQDnODOFJ1Nqge6H6w=
+
+lazystream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
+  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
+  dependencies:
+    readable-stream "^2.0.5"
+
+levn@^0.3.0, levn@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
+  integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
+  dependencies:
+    prelude-ls "~1.1.2"
+    type-check "~0.3.2"
+
+load-json-file@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
+  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
+  dependencies:
+    graceful-fs "^4.1.2"
+    parse-json "^2.2.0"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+    strip-bom "^2.0.0"
+
+load-json-file@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8"
+  integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=
+  dependencies:
+    graceful-fs "^4.1.2"
+    parse-json "^2.2.0"
+    pify "^2.0.0"
+    strip-bom "^3.0.0"
+
+load-json-file@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
+  integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs=
+  dependencies:
+    graceful-fs "^4.1.2"
+    parse-json "^4.0.0"
+    pify "^3.0.0"
+    strip-bom "^3.0.0"
+
+locate-path@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
+  integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=
+  dependencies:
+    p-locate "^2.0.0"
+    path-exists "^3.0.0"
+
+locate-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
+  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
+  dependencies:
+    p-locate "^3.0.0"
+    path-exists "^3.0.0"
+
+lodash._reinterpolate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
+  integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
+
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
+
+lodash.defaults@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
+  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
+
+lodash.difference@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
+  integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
+
+lodash.flatten@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
+  integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
+
+lodash.get@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+  integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
+
+lodash.isequal@^4.0.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
+
+lodash.isplainobject@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+
+lodash.padend@^4.6.1:
+  version "4.6.1"
+  resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
+  integrity sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=
+
+lodash.set@^4.3.2:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
+  integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
+
+lodash.some@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d"
+  integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=
+
+lodash.sortby@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
+  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
+
+lodash.template@^4.4.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
+  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
+  dependencies:
+    lodash._reinterpolate "^3.0.0"
+    lodash.templatesettings "^4.0.0"
+
+lodash.templatesettings@^4.0.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
+  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
+  dependencies:
+    lodash._reinterpolate "^3.0.0"
+
+lodash.union@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
+  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
+
+lodash.uniq@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
+
+lodash@^3.0.0, lodash@^3.10.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
+  integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
+
+lodash@^4.0.0, lodash@^4.16.6, lodash@^4.17.14, lodash@^4.17.15:
+  version "4.17.15"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
+lodash@^4.11.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.3.0:
+  version "4.17.12"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.12.tgz#a712c74fdc31f7ecb20fe44f157d802d208097ef"
+  integrity sha512-+CiwtLnsJhX03p20mwXuvhoebatoh5B3tt+VvYlrPgZC1g36y+RRbkufX95Xa+X4I59aWEacDFYwnJZiyBh9gA==
+
+log-symbols@^1.0.0, log-symbols@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
+  integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=
+  dependencies:
+    chalk "^1.0.0"
+
+log-symbols@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
+  integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
+  dependencies:
+    chalk "^2.0.1"
+
+logform@^1.9.1:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/logform/-/logform-1.10.0.tgz#c9d5598714c92b546e23f4e78147c40f1e02012e"
+  integrity sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==
+  dependencies:
+    colors "^1.2.1"
+    fast-safe-stringify "^2.0.4"
+    fecha "^2.3.3"
+    ms "^2.1.1"
+    triple-beam "^1.2.0"
+
+logform@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360"
+  integrity sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==
+  dependencies:
+    colors "^1.2.1"
+    fast-safe-stringify "^2.0.4"
+    fecha "^2.3.3"
+    ms "^2.1.1"
+    triple-beam "^1.3.0"
+
+lolex@^1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
+  integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=
+
+long@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
+  integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
+
+loose-envify@^1.0.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+  integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+  dependencies:
+    js-tokens "^3.0.0 || ^4.0.0"
+
+loud-rejection@^1.0.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
+  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
+  dependencies:
+    currently-unhandled "^0.4.1"
+    signal-exit "^3.0.0"
+
+lower-case@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
+  integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
+
+lowercase-keys@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
+  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
+
+lru-cache@^4.0.1, lru-cache@^4.0.2:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
+  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
+  dependencies:
+    pseudomap "^1.0.2"
+    yallist "^2.1.2"
+
+macos-release@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f"
+  integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA==
+
+magic-string@^0.22.4:
+  version "0.22.5"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
+  integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==
+  dependencies:
+    vlq "^0.2.2"
+
+make-dir@^1.0.0, make-dir@^1.1.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
+  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
+  dependencies:
+    pify "^3.0.0"
+
+map-cache@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
+  integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
+
+map-obj@^1.0.0, map-obj@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
+  integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
+
+map-visit@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
+  integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=
+  dependencies:
+    object-visit "^1.0.0"
+
+matcher@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
+  integrity sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==
+  dependencies:
+    escape-string-regexp "^1.0.4"
+
+math-random@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
+  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
+
+md5@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
+  integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=
+  dependencies:
+    charenc "~0.0.1"
+    crypt "~0.0.1"
+    is-buffer "~1.1.1"
+
+media-typer@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+  integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
+
+mem-fs-editor@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-5.1.0.tgz#51972241640be8567680a04f7adaffe5fc603667"
+  integrity sha512-2Yt2GCYEbcotYbIJagmow4gEtHDqzpq5XN94+yAx/NT5+bGqIjkXnm3KCUQfE6kRfScGp9IZknScoGRKu8L78w==
+  dependencies:
+    commondir "^1.0.1"
+    deep-extend "^0.6.0"
+    ejs "^2.5.9"
+    glob "^7.0.3"
+    globby "^8.0.1"
+    isbinaryfile "^3.0.2"
+    mkdirp "^0.5.0"
+    multimatch "^2.0.0"
+    rimraf "^2.2.8"
+    through2 "^2.0.0"
+    vinyl "^2.0.1"
+
+mem-fs@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/mem-fs/-/mem-fs-1.1.3.tgz#b8ae8d2e3fcb6f5d3f9165c12d4551a065d989cc"
+  integrity sha1-uK6NLj/Lb10/kWXBLUVRoGXZicw=
+  dependencies:
+    through2 "^2.0.0"
+    vinyl "^1.1.0"
+    vinyl-file "^2.0.0"
+
+meow@^3.1.0, meow@^3.7.0:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
+  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
+  dependencies:
+    camelcase-keys "^2.0.0"
+    decamelize "^1.1.2"
+    loud-rejection "^1.0.0"
+    map-obj "^1.0.1"
+    minimist "^1.1.3"
+    normalize-package-data "^2.3.4"
+    object-assign "^4.0.1"
+    read-pkg-up "^1.0.1"
+    redent "^1.0.0"
+    trim-newlines "^1.0.0"
+
+merge-descriptors@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
+  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
+
+merge-stream@^1.0.0, merge-stream@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
+  integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=
+  dependencies:
+    readable-stream "^2.0.1"
+
+merge2@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5"
+  integrity sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA==
+
+methods@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
+  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
+
+micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7:
+  version "2.3.11"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
+  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
+  dependencies:
+    arr-diff "^2.0.0"
+    array-unique "^0.2.1"
+    braces "^1.8.2"
+    expand-brackets "^0.1.4"
+    extglob "^0.3.1"
+    filename-regex "^2.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.1"
+    kind-of "^3.0.2"
+    normalize-path "^2.0.1"
+    object.omit "^2.0.0"
+    parse-glob "^3.0.4"
+    regex-cache "^0.4.2"
+
+micromatch@^3.0.4, micromatch@^3.1.10:
+  version "3.1.10"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
+  integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
+  dependencies:
+    arr-diff "^4.0.0"
+    array-unique "^0.3.2"
+    braces "^2.3.1"
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    extglob "^2.0.4"
+    fragment-cache "^0.2.1"
+    kind-of "^6.0.2"
+    nanomatch "^1.2.9"
+    object.pick "^1.3.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.2"
+
+mime-db@1.40.0, mime-db@^1.28.0:
+  version "1.40.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
+  integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
+
+mime-db@1.43.0, "mime-db@>= 1.43.0 < 2":
+  version "1.43.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
+  integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
+
+mime-types@^2.1.12, mime-types@~2.1.19:
+  version "2.1.24"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
+  integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
+  dependencies:
+    mime-db "1.40.0"
+
+mime-types@~2.1.24:
+  version "2.1.26"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06"
+  integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==
+  dependencies:
+    mime-db "1.43.0"
+
+mime@1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
+  integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==
+
+mime@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
+
+mime@^2.3.1:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
+  integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
+
+mimic-fn@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
+  integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==
+
+mimic-fn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+
+mimic-response@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
+  integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
+
+minimalistic-assert@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
+  integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
+
+minimatch-all@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/minimatch-all/-/minimatch-all-1.1.0.tgz#40c496a27a2e128d19bf758e76bb01a0c7145787"
+  integrity sha1-QMSWonouEo0Zv3WOdrsBoMcUV4c=
+  dependencies:
+    minimatch "^3.0.2"
+
+"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@0.0.8, minimist@~0.0.1:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
+
+minimist@^1.1.3, minimist@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
+  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
+
+minipass@^2.2.1, minipass@^2.3.4:
+  version "2.3.5"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848"
+  integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==
+  dependencies:
+    safe-buffer "^5.1.2"
+    yallist "^3.0.0"
+
+minizlib@^1.1.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614"
+  integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==
+  dependencies:
+    minipass "^2.2.1"
+
+mixin-deep@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
+  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
+  dependencies:
+    for-in "^1.0.2"
+    is-extendable "^1.0.1"
+
+mkdirp@^0.5.0, mkdirp@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
+  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
+  dependencies:
+    minimist "0.0.8"
+
+mout@^1.0.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/mout/-/mout-1.2.2.tgz#c9b718a499806a0632cede178e80f436259e777d"
+  integrity sha512-w0OUxFEla6z3d7sVpMZGBCpQvYh8PHS1wZ6Wu9GNKHMpAHWJ0if0LsQZh3DlOqw55HlhJEOMLpFnwtxp99Y5GA==
+
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
+
+ms@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
+  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
+
+ms@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+multer@^1.3.0:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
+  integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==
+  dependencies:
+    append-field "^1.0.0"
+    busboy "^0.2.11"
+    concat-stream "^1.5.2"
+    mkdirp "^0.5.1"
+    object-assign "^4.1.1"
+    on-finished "^2.3.0"
+    type-is "^1.6.4"
+    xtend "^4.0.0"
+
+multimatch@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-2.1.0.tgz#9c7906a22fb4c02919e2f5f75161b4cdbd4b2a2b"
+  integrity sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=
+  dependencies:
+    array-differ "^1.0.0"
+    array-union "^1.0.1"
+    arrify "^1.0.0"
+    minimatch "^3.0.0"
+
+multipipe@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
+  integrity sha1-zBPv2DPJzamfIk+GhGG44aP9k50=
+  dependencies:
+    duplexer2 "^0.1.2"
+    object-assign "^4.1.0"
+
+mute-stream@0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.6.tgz#48962b19e169fd1dfc240b3f1e7317627bbc47db"
+  integrity sha1-SJYrGeFp/R38JAs/HnMXYnu8R9s=
+
+mute-stream@0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
+  integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
+
+mute-stream@0.0.8:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
+  integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
+
+mz@^2.4.0, mz@^2.6.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
+  integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
+  dependencies:
+    any-promise "^1.0.0"
+    object-assign "^4.0.1"
+    thenify-all "^1.0.0"
+
+nan@^2.12.1:
+  version "2.14.0"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
+  integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
+
+nanomatch@^1.2.9:
+  version "1.2.13"
+  resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
+  integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==
+  dependencies:
+    arr-diff "^4.0.0"
+    array-unique "^0.3.2"
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    fragment-cache "^0.2.1"
+    is-windows "^1.0.2"
+    kind-of "^6.0.2"
+    object.pick "^1.3.0"
+    regex-not "^1.0.0"
+    snapdragon "^0.8.1"
+    to-regex "^3.0.1"
+
+native-promise-only@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
+  integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=
+
+natural-compare@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
+  integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
+
+needle@^2.2.1:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.0.tgz#ce3fea21197267bacb310705a7bbe24f2a3a3492"
+  integrity sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==
+  dependencies:
+    debug "^4.1.0"
+    iconv-lite "^0.4.4"
+    sax "^1.2.4"
+
+negotiator@0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
+  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
+
+nice-try@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+
+no-case@^2.2.0:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
+  integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==
+  dependencies:
+    lower-case "^1.1.1"
+
+node-fetch@^2.3.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
+  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
+
+node-pre-gyp@^0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149"
+  integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==
+  dependencies:
+    detect-libc "^1.0.2"
+    mkdirp "^0.5.1"
+    needle "^2.2.1"
+    nopt "^4.0.1"
+    npm-packlist "^1.1.6"
+    npmlog "^4.0.2"
+    rc "^1.2.7"
+    rimraf "^2.6.1"
+    semver "^5.3.0"
+    tar "^4"
+
+node-status-codes@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f"
+  integrity sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=
+
+nomnom@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"
+  integrity sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=
+  dependencies:
+    chalk "~0.4.0"
+    underscore "~1.6.0"
+
+nopt@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
+  integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=
+  dependencies:
+    abbrev "1"
+    osenv "^0.1.4"
+
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
+  integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
+  dependencies:
+    hosted-git-info "^2.1.4"
+    resolve "^1.10.0"
+    semver "2 || 3 || 4 || 5"
+    validate-npm-package-license "^3.0.1"
+
+normalize-path@^2.0.0, normalize-path@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
+  integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
+  dependencies:
+    remove-trailing-separator "^1.0.1"
+
+normalize-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+npm-bundled@^1.0.1:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
+  integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==
+
+npm-packlist@^1.1.6:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc"
+  integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==
+  dependencies:
+    ignore-walk "^3.0.1"
+    npm-bundled "^1.0.1"
+
+npm-run-path@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
+  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
+  dependencies:
+    path-key "^2.0.0"
+
+npmlog@^4.0.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
+  dependencies:
+    are-we-there-yet "~1.1.2"
+    console-control-strings "~1.1.0"
+    gauge "~2.7.3"
+    set-blocking "~2.0.0"
+
+number-is-nan@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
+
+oauth-sign@~0.9.0:
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
+  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
+
+object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+
+object-component@0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
+  integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
+
+object-copy@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
+  integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw=
+  dependencies:
+    copy-descriptor "^0.1.0"
+    define-property "^0.2.5"
+    kind-of "^3.0.3"
+
+object-inspect@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
+  integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
+
+object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
+  integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
+
+object-visit@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
+  integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=
+  dependencies:
+    isobject "^3.0.0"
+
+object.assign@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
+  integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
+  dependencies:
+    define-properties "^1.1.2"
+    function-bind "^1.1.1"
+    has-symbols "^1.0.0"
+    object-keys "^1.0.11"
+
+object.entries-ponyfill@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/object.entries-ponyfill/-/object.entries-ponyfill-1.0.1.tgz#29abdf77cbfbd26566dd1aa24e9d88f65433d256"
+  integrity sha1-Kavfd8v70mVm3RqiTp2I9lQz0lY=
+
+object.omit@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
+  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
+  dependencies:
+    for-own "^0.1.4"
+    is-extendable "^0.1.1"
+
+object.pick@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
+  integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=
+  dependencies:
+    isobject "^3.0.1"
+
+object.values@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e"
+  integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0-next.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+
+obuf@^1.0.0, obuf@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
+  integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
+
+octokit-pagination-methods@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
+  integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
+
+on-finished@^2.3.0, on-finished@~2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
+  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
+  dependencies:
+    ee-first "1.1.1"
+
+on-headers@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
+  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
+
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  dependencies:
+    wrappy "1"
+
+one-time@0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e"
+  integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=
+
+onetime@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
+  integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
+
+onetime@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
+  integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=
+  dependencies:
+    mimic-fn "^1.0.0"
+
+onetime@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5"
+  integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==
+  dependencies:
+    mimic-fn "^2.1.0"
+
+opn@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
+  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
+  dependencies:
+    object-assign "^4.0.1"
+
+optimist@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
+  integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY=
+  dependencies:
+    minimist "~0.0.1"
+    wordwrap "~0.0.2"
+
+optionator@^0.8.3:
+  version "0.8.3"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
+  integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
+  dependencies:
+    deep-is "~0.1.3"
+    fast-levenshtein "~2.0.6"
+    levn "~0.3.0"
+    prelude-ls "~1.1.2"
+    type-check "~0.3.2"
+    word-wrap "~1.2.3"
+
+ordered-read-streams@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
+  integrity sha1-cTfmmzKYuzQiR6G77jiByA4v14s=
+  dependencies:
+    is-stream "^1.0.1"
+    readable-stream "^2.0.1"
+
+os-homedir@^1.0.0, os-homedir@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
+
+os-name@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801"
+  integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==
+  dependencies:
+    macos-release "^2.2.0"
+    windows-release "^3.1.0"
+
+os-shim@^0.1.2:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917"
+  integrity sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=
+
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
+
+osenv@^0.1.0, osenv@^0.1.3, osenv@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
+  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
+  dependencies:
+    os-homedir "^1.0.0"
+    os-tmpdir "^1.0.0"
+
+p-cancelable@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
+  integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
+
+p-finally@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
+
+p-limit@^1.1.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
+  integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==
+  dependencies:
+    p-try "^1.0.0"
+
+p-limit@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.0.tgz#417c9941e6027a9abcba5092dd2904e255b5fbc2"
+  integrity sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==
+  dependencies:
+    p-try "^2.0.0"
+
+p-locate@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
+  integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=
+  dependencies:
+    p-limit "^1.1.0"
+
+p-locate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
+  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
+  dependencies:
+    p-limit "^2.0.0"
+
+p-map@^1.1.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
+  integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==
+
+p-timeout@^1.1.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386"
+  integrity sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=
+  dependencies:
+    p-finally "^1.0.0"
+
+p-try@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
+  integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=
+
+p-try@^2.0.0, p-try@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
+package-json@^2.0.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-2.4.0.tgz#0d15bd67d1cbbddbb2ca222ff2edb86bcb31a8bb"
+  integrity sha1-DRW9Z9HLvduyyiIv8u24a8sxqLs=
+  dependencies:
+    got "^5.0.0"
+    registry-auth-token "^3.0.1"
+    registry-url "^3.0.3"
+    semver "^5.1.0"
+
+package-json@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
+  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
+  dependencies:
+    got "^6.7.1"
+    registry-auth-token "^3.0.1"
+    registry-url "^3.0.3"
+    semver "^5.1.0"
+
+pako@~0.2.0:
+  version "0.2.9"
+  resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
+  integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=
+
+param-case@2.1.x:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
+  integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
+  dependencies:
+    no-case "^2.2.0"
+
+parent-module@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
+  integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==
+  dependencies:
+    callsites "^3.0.0"
+
+parse-glob@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
+  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
+  dependencies:
+    glob-base "^0.3.0"
+    is-dotfile "^1.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.0"
+
+parse-json@^2.1.0, parse-json@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
+  integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
+  dependencies:
+    error-ex "^1.2.0"
+
+parse-json@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
+  integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
+  dependencies:
+    error-ex "^1.3.1"
+    json-parse-better-errors "^1.0.1"
+
+parse-passwd@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
+  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
+
+parse5@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
+  integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
+
+parseqs@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
+  integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseuri@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
+  integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
+  dependencies:
+    better-assert "~1.0.0"
+
+parseurl@~1.3.3:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+pascalcase@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
+  integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
+
+path-dirname@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
+  integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
+
+path-exists@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
+  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
+  dependencies:
+    pinkie-promise "^2.0.0"
+
+path-exists@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
+  integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
+
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
+path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
+  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+
+path-key@^2.0.0, path-key@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
+
+path-parse@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
+  integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
+
+path-to-regexp@0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
+  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
+
+path-to-regexp@^1.0.1:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d"
+  integrity sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=
+  dependencies:
+    isarray "0.0.1"
+
+path-to-regexp@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+  dependencies:
+    isarray "0.0.1"
+
+path-type@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
+  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
+  dependencies:
+    graceful-fs "^4.1.2"
+    pify "^2.0.0"
+    pinkie-promise "^2.0.0"
+
+path-type@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
+  integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=
+  dependencies:
+    pify "^2.0.0"
+
+path-type@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
+  integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==
+  dependencies:
+    pify "^3.0.0"
+
+peek-stream@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67"
+  integrity sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==
+  dependencies:
+    buffer-from "^1.0.0"
+    duplexify "^3.5.0"
+    through2 "^2.0.3"
+
+pem@^1.8.3:
+  version "1.14.3"
+  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.3.tgz#347e5a5c194a5f7612b88083e45042fcc4fb4901"
+  integrity sha512-Q+AMVMD3fzeVvZs5PHeI+pVt0hgZY2fjhkliBW43qyONLgCXPVk1ryim43F9eupHlNGLJNT5T/NNrzhUdiC5Zg==
+  dependencies:
+    es6-promisify "^6.0.0"
+    md5 "^2.2.1"
+    os-tmpdir "^1.0.1"
+    which "^1.3.1"
+
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
+
+performance-now@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
+
+pify@^2.0.0, pify@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+  integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
+
+pify@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
+  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
+
+pify@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
+  integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
+
+pinkie-promise@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
+  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
+  dependencies:
+    pinkie "^2.0.0"
+
+pinkie@^2.0.0:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
+  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
+
+pkg-dir@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
+  integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=
+  dependencies:
+    find-up "^2.1.0"
+
+plist@^2.0.1:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
+  integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
+  dependencies:
+    base64-js "1.2.0"
+    xmlbuilder "8.2.2"
+    xmldom "0.1.x"
+
+plylog@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/plylog/-/plylog-1.1.0.tgz#f6f354e2ae0b01f6db4ed111f4b3855da9c37417"
+  integrity sha512-/QnY5aSVaP54va6hruzNtAj02HpsLlAt7V5EndMrtq6ZUTZJKUja43rgiUtGXqm95yrSJjbZoPW0yQQQwLpoJA==
+  dependencies:
+    logform "^1.9.1"
+    winston "^3.0.0"
+    winston-transport "^4.2.0"
+
+polymer-analyzer@^3.0.0, polymer-analyzer@^3.1.3, polymer-analyzer@^3.2.2:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
+  integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
+  dependencies:
+    "@babel/generator" "^7.0.0-beta.42"
+    "@babel/traverse" "^7.0.0-beta.42"
+    "@babel/types" "^7.0.0-beta.42"
+    "@types/babel-generator" "^6.25.1"
+    "@types/babel-traverse" "^6.25.2"
+    "@types/babel-types" "^6.25.1"
+    "@types/babylon" "^6.16.2"
+    "@types/chai-subset" "^1.3.0"
+    "@types/chalk" "^0.4.30"
+    "@types/clone" "^0.1.30"
+    "@types/cssbeautify" "^0.3.1"
+    "@types/doctrine" "^0.0.1"
+    "@types/is-windows" "^0.2.0"
+    "@types/minimatch" "^3.0.1"
+    "@types/parse5" "^2.2.34"
+    "@types/path-is-inside" "^1.0.0"
+    "@types/resolve" "0.0.6"
+    "@types/whatwg-url" "^6.4.0"
+    babylon "^7.0.0-beta.42"
+    cancel-token "^0.1.1"
+    chalk "^1.1.3"
+    clone "^2.0.0"
+    cssbeautify "^0.3.1"
+    doctrine "^2.0.2"
+    dom5 "^3.0.0"
+    indent "0.0.2"
+    is-windows "^1.0.2"
+    jsonschema "^1.1.0"
+    minimatch "^3.0.4"
+    parse5 "^4.0.0"
+    path-is-inside "^1.0.2"
+    resolve "^1.5.0"
+    shady-css-parser "^0.1.0"
+    stable "^0.1.6"
+    strip-indent "^2.0.0"
+    vscode-uri "=1.0.6"
+    whatwg-url "^6.4.0"
+
+polymer-build@^3.1.0, polymer-build@^3.1.4:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/polymer-build/-/polymer-build-3.1.4.tgz#ab539f1a13d803518b13b73ffd09198431d98142"
+  integrity sha512-OhTOPG5Y/tK2HqGZ5XA/CVDh+TuOaDv7wTZWXDCg6hxeMgNKuljDMn2coyGU5NLM0pLbS+gwFAc2ZJ5cWHCHNg==
+  dependencies:
+    "@babel/core" "^7.0.0"
+    "@babel/plugin-external-helpers" "^7.0.0"
+    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
+    "@babel/plugin-proposal-object-rest-spread" "^7.0.0"
+    "@babel/plugin-syntax-async-generators" "^7.0.0"
+    "@babel/plugin-syntax-dynamic-import" "^7.0.0"
+    "@babel/plugin-syntax-import-meta" "^7.0.0"
+    "@babel/plugin-syntax-object-rest-spread" "^7.0.0"
+    "@babel/plugin-transform-arrow-functions" "^7.0.0"
+    "@babel/plugin-transform-async-to-generator" "^7.0.0"
+    "@babel/plugin-transform-block-scoped-functions" "^7.0.0"
+    "@babel/plugin-transform-block-scoping" "^7.0.0"
+    "@babel/plugin-transform-classes" "^7.0.0"
+    "@babel/plugin-transform-computed-properties" "^7.0.0"
+    "@babel/plugin-transform-destructuring" "^7.0.0"
+    "@babel/plugin-transform-duplicate-keys" "^7.0.0"
+    "@babel/plugin-transform-exponentiation-operator" "^7.0.0"
+    "@babel/plugin-transform-for-of" "^7.0.0"
+    "@babel/plugin-transform-function-name" "^7.0.0"
+    "@babel/plugin-transform-instanceof" "^7.0.0"
+    "@babel/plugin-transform-literals" "^7.0.0"
+    "@babel/plugin-transform-modules-amd" "^7.0.0"
+    "@babel/plugin-transform-object-super" "^7.0.0"
+    "@babel/plugin-transform-parameters" "^7.0.0"
+    "@babel/plugin-transform-regenerator" "^7.0.0"
+    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
+    "@babel/plugin-transform-spread" "^7.0.0"
+    "@babel/plugin-transform-sticky-regex" "^7.0.0"
+    "@babel/plugin-transform-template-literals" "^7.0.0"
+    "@babel/plugin-transform-typeof-symbol" "^7.0.0"
+    "@babel/plugin-transform-unicode-regex" "^7.0.0"
+    "@babel/traverse" "^7.0.0"
+    "@polymer/esm-amd-loader" "^1.0.0"
+    "@types/babel-types" "^6.25.1"
+    "@types/babylon" "^6.16.2"
+    "@types/gulp-if" "0.0.33"
+    "@types/html-minifier" "^3.5.1"
+    "@types/is-windows" "^0.2.0"
+    "@types/mz" "0.0.31"
+    "@types/parse5" "^2.2.34"
+    "@types/resolve" "0.0.7"
+    "@types/uuid" "^3.4.3"
+    "@types/vinyl" "^2.0.0"
+    "@types/vinyl-fs" "^2.4.8"
+    babel-plugin-minify-guarded-expressions "^0.4.3"
+    babel-preset-minify "^0.5.0"
+    babylon "^7.0.0-beta.42"
+    css-slam "^2.1.2"
+    dom5 "^3.0.0"
+    gulp-if "^2.0.2"
+    html-minifier "^3.5.10"
+    matcher "^1.1.0"
+    multipipe "^1.0.2"
+    mz "^2.6.0"
+    parse5 "^4.0.0"
+    plylog "^1.0.0"
+    polymer-analyzer "^3.1.3"
+    polymer-bundler "^4.0.9"
+    polymer-project-config "^4.0.3"
+    regenerator-runtime "^0.11.1"
+    stream "0.0.2"
+    sw-precache "^5.1.1"
+    uuid "^3.2.1"
+    vinyl "^1.2.0"
+    vinyl-fs "^2.4.4"
+
+polymer-bundler@^4.0.9:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/polymer-bundler/-/polymer-bundler-4.0.10.tgz#abc8d33977652f031068d034c8104841e80d4cbb"
+  integrity sha512-nwlN3LQlQDqbZ2sUH3394C/dHZUDHq8tpdS5HARvPDb0Q9IXWD+znOR1cr7wSjF0EZN4LiUH5hWyUoV4QSjhpQ==
+  dependencies:
+    "@types/babel-generator" "^6.25.1"
+    "@types/babel-traverse" "^6.25.3"
+    babel-generator "^6.26.1"
+    babel-traverse "^6.26.0"
+    babel-types "^6.26.0"
+    clone "^2.1.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    dom5 "^3.0.0"
+    espree "^3.5.2"
+    magic-string "^0.22.4"
+    mkdirp "^0.5.1"
+    parse5 "^4.0.0"
+    polymer-analyzer "^3.2.2"
+    rollup "^1.3.0"
+    source-map "^0.5.6"
+    vscode-uri "=1.0.6"
+
+polymer-cli@^1.9.11:
+  version "1.9.11"
+  resolved "https://registry.yarnpkg.com/polymer-cli/-/polymer-cli-1.9.11.tgz#0b5310732b787e07b811af96627ef0fd1263f5da"
+  integrity sha512-tiURjHDCOUUtDVPuVYvrfFI9PXe4OOUmBbn6Sg5GJNQ2POtP7r7hv+I5yI8P9qsxmalHTa19chVtf5/t9IBXDg==
+  dependencies:
+    "@octokit/rest" "^16.2.0"
+    "@types/chalk" "^2.2.0"
+    "@types/del" "^3.0.0"
+    "@types/findup-sync" "^0.3.29"
+    "@types/globby" "^6.1.0"
+    "@types/inquirer" "0.0.32"
+    "@types/merge-stream" "^1.0.28"
+    "@types/mz" "^0.0.31"
+    "@types/request" "2.0.3"
+    "@types/resolve" "0.0.4"
+    "@types/rimraf" "^0.0.28"
+    "@types/semver" "^5.3.30"
+    "@types/temp" "^0.8.28"
+    "@types/update-notifier" "^1.0.0"
+    "@types/vinyl" "^2.0.0"
+    "@types/vinyl-fs" "0.0.28"
+    "@types/yeoman-generator" "^2.0.3"
+    bower "^1.8.8"
+    bower-json "^0.8.1"
+    bower-logger "^0.2.2"
+    chalk "^2.4.2"
+    chokidar "^1.7.0"
+    command-line-args "^5.0.2"
+    command-line-commands "^2.0.1"
+    command-line-usage "^5.0.5"
+    del "^3.0.0"
+    findup-sync "^0.4.2"
+    globby "^8.0.1"
+    gunzip-maybe "^1.3.1"
+    inquirer "^1.0.2"
+    merge-stream "^1.0.1"
+    mz "^2.6.0"
+    plylog "^1.0.0"
+    polymer-analyzer "^3.2.2"
+    polymer-build "^3.1.4"
+    polymer-bundler "^4.0.9"
+    polymer-linter "^3.0.0"
+    polymer-project-config "^4.0.3"
+    polyserve "^0.27.15"
+    request "^2.72.0"
+    rimraf "^2.6.1"
+    semver "^5.3.0"
+    tar-fs "^1.12.0"
+    temp "^0.8.3"
+    update-notifier "^1.0.0"
+    validate-element-name "^2.1.1"
+    vinyl "^1.1.1"
+    vinyl-fs "^2.4.3"
+    web-component-tester "^6.9.0"
+    yeoman-environment "^1.5.2"
+    yeoman-generator "^3.1.1"
+
+polymer-linter@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/polymer-linter/-/polymer-linter-3.0.1.tgz#8804e1705fa2a7c263467b8a22da11bb764ee26b"
+  integrity sha512-eDh2CeswZz4Rwf8gfYXpMN66pieq4qJvP9bH3m39LLGm81hRePo4N5OHoQzR5unen1PUdmtjDv0Iicz3dTYEZQ==
+  dependencies:
+    "@types/fast-levenshtein" "0.0.1"
+    "@types/parse5" "^2.2.34"
+    babel-traverse "^6.26.0"
+    babel-types "^6.26.0"
+    cancel-token "^0.1.1"
+    css-what "^2.1.0"
+    dom5 "^3.0.0"
+    fast-levenshtein "^2.0.6"
+    parse5 "^4.0.0"
+    polymer-analyzer "^3.0.0"
+    shady-css-parser "^0.1.0"
+    stable "^0.1.6"
+    strip-indent "^2.0.0"
+    validate-element-name "^2.1.1"
+
+polymer-project-config@^4.0.0, polymer-project-config@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/polymer-project-config/-/polymer-project-config-4.0.3.tgz#ef0c1a676ce4809907986c8e910745660de8024f"
+  integrity sha512-Drr+Imq+znhBC8XSt9pMlmPixoGnIOmleV5SD6mto1zOGC5oCDbSNsQL2v89DWOk+9aSUO79vnWwOmEPDSvYfw==
+  dependencies:
+    "@types/parse5" "^2.2.34"
+    browser-capabilities "^1.0.0"
+    jsonschema "^1.1.1"
+    minimatch-all "^1.1.0"
+    plylog "^1.0.0"
+    winston "^3.0.0"
+
+polyserve@^0.27.13, polyserve@^0.27.15:
+  version "0.27.15"
+  resolved "https://registry.yarnpkg.com/polyserve/-/polyserve-0.27.15.tgz#261fa5a0873c8d95fd7068598f44c9dac20cf9c4"
+  integrity sha512-AaFgANt+tUUVgHLw+BnaVYcn649JiwL1ru0TOZUKj1gGGn/Bq2S16gxql+1muGpRaAsgFu13Zu7k5XkwatwwSg==
+  dependencies:
+    "@types/compression" "^0.0.33"
+    "@types/content-type" "^1.1.0"
+    "@types/escape-html" "0.0.20"
+    "@types/express" "^4.0.36"
+    "@types/mime" "^2.0.0"
+    "@types/mz" "0.0.29"
+    "@types/opn" "^3.0.28"
+    "@types/parse5" "^2.2.34"
+    "@types/pem" "^1.8.1"
+    "@types/resolve" "0.0.6"
+    "@types/serve-static" "^1.7.31"
+    "@types/spdy" "^3.4.1"
+    bower-config "^1.4.1"
+    browser-capabilities "^1.0.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^5.0.5"
+    compression "^1.6.2"
+    content-type "^1.0.2"
+    cors "^2.8.4"
+    escape-html "^1.0.3"
+    express "^4.8.5"
+    find-port "^1.0.1"
+    http-proxy-middleware "^0.17.2"
+    lru-cache "^4.0.2"
+    mime "^2.3.1"
+    mz "^2.4.0"
+    opn "^3.0.2"
+    pem "^1.8.3"
+    polymer-build "^3.1.0"
+    polymer-project-config "^4.0.0"
+    requirejs "^2.3.4"
+    resolve "^1.5.0"
+    send "^0.16.2"
+    spdy "^3.3.3"
+
+posix-character-classes@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
+  integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
+
+prelude-ls@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
+  integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
+
+prepend-http@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
+  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
+
+preserve@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
+
+prettier-linter-helpers@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
+  integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
+  dependencies:
+    fast-diff "^1.1.2"
+
+prettier@2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4"
+  integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==
+
+pretty-bytes@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
+  integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=
+
+pretty-bytes@^5.1.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.2.0.tgz#96c92c6e95a0b35059253fb33c03e260d40f5a1f"
+  integrity sha512-ujANBhiUsl9AhREUDUEY1GPOharMGm8x8juS7qOHybcLi7XsKfrYQ88hSly1l2i0klXHTDYrlL8ihMCG55Dc3w==
+
+private@^0.1.6:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
+  integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
+
+process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
+
+progress@2.0.3, progress@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+protobufjs@6.8.8:
+  version "6.8.8"
+  resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"
+  integrity sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==
+  dependencies:
+    "@protobufjs/aspromise" "^1.1.2"
+    "@protobufjs/base64" "^1.1.2"
+    "@protobufjs/codegen" "^2.0.4"
+    "@protobufjs/eventemitter" "^1.1.0"
+    "@protobufjs/fetch" "^1.1.0"
+    "@protobufjs/float" "^1.0.2"
+    "@protobufjs/inquire" "^1.1.0"
+    "@protobufjs/path" "^1.1.2"
+    "@protobufjs/pool" "^1.1.0"
+    "@protobufjs/utf8" "^1.1.0"
+    "@types/long" "^4.0.0"
+    "@types/node" "^10.1.0"
+    long "^4.0.0"
+
+proxy-addr@~2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
+  integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
+  dependencies:
+    forwarded "~0.1.2"
+    ipaddr.js "1.9.0"
+
+pseudomap@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
+
+psl@^1.1.24:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.2.0.tgz#df12b5b1b3a30f51c329eacbdef98f3a6e136dc6"
+  integrity sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==
+
+pump@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
+  integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pump@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
+  integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pumpify@^1.3.3:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
+  integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
+  dependencies:
+    duplexify "^3.6.0"
+    inherits "^2.0.3"
+    pump "^2.0.0"
+
+punycode@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
+  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
+
+punycode@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+q@^1.4.1, q@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
+  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
+
+qs@6.7.0:
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
+  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
+
+qs@~6.5.2:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+
+randomatic@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
+  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
+  dependencies:
+    is-number "^4.0.0"
+    kind-of "^6.0.0"
+    math-random "^1.0.1"
+
+range-parser@~1.2.0, range-parser@~1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
+  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+
+raw-body@2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
+  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+  dependencies:
+    bytes "3.1.0"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+  dependencies:
+    deep-extend "^0.6.0"
+    ini "~1.3.0"
+    minimist "^1.2.0"
+    strip-json-comments "~2.0.1"
+
+read-all-stream@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"
+  integrity sha1-NcPhd/IHjveJ7kv6+kNzB06u9Po=
+  dependencies:
+    pinkie-promise "^2.0.0"
+    readable-stream "^2.0.0"
+
+read-chunk@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/read-chunk/-/read-chunk-3.2.0.tgz#2984afe78ca9bfbbdb74b19387bf9e86289c16ca"
+  integrity sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==
+  dependencies:
+    pify "^4.0.1"
+    with-open-file "^0.1.6"
+
+read-pkg-up@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
+  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
+  dependencies:
+    find-up "^1.0.0"
+    read-pkg "^1.0.0"
+
+read-pkg-up@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be"
+  integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=
+  dependencies:
+    find-up "^2.0.0"
+    read-pkg "^2.0.0"
+
+read-pkg-up@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978"
+  integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==
+  dependencies:
+    find-up "^3.0.0"
+    read-pkg "^3.0.0"
+
+read-pkg@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
+  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
+  dependencies:
+    load-json-file "^1.0.0"
+    normalize-package-data "^2.3.2"
+    path-type "^1.0.0"
+
+read-pkg@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8"
+  integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=
+  dependencies:
+    load-json-file "^2.0.0"
+    normalize-package-data "^2.3.2"
+    path-type "^2.0.0"
+
+read-pkg@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
+  integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=
+  dependencies:
+    load-json-file "^4.0.0"
+    normalize-package-data "^2.3.2"
+    path-type "^3.0.0"
+
+readable-stream@1.1.x:
+  version "1.1.14"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
+  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+"readable-stream@2 || 3", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
+  integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+"readable-stream@>=1.0.33-1 <1.1.0-0":
+  version "1.0.34"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
+  integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+readable-stream@^2.2.2, readable-stream@^2.2.9:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+readable-stream@^3.0.1, readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc"
+  integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
+readdirp@^2.0.0:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
+  integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==
+  dependencies:
+    graceful-fs "^4.1.11"
+    micromatch "^3.1.10"
+    readable-stream "^2.0.2"
+
+rechoir@^0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
+  integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=
+  dependencies:
+    resolve "^1.1.6"
+
+redent@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
+  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
+  dependencies:
+    indent-string "^2.1.0"
+    strip-indent "^1.0.1"
+
+reduce-flatten@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
+  integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
+
+regenerate-unicode-properties@^8.0.2:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e"
+  integrity sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==
+  dependencies:
+    regenerate "^1.4.0"
+
+regenerate@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
+  integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
+
+regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
+  version "0.11.1"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
+  integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
+
+regenerator-transform@^0.14.0:
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.0.tgz#2ca9aaf7a2c239dd32e4761218425b8c7a86ecaf"
+  integrity sha512-rtOelq4Cawlbmq9xuMR5gdFmv7ku/sFoB7sRiywx7aq53bc52b4j6zvH7Te1Vt/X2YveDKnCGUbioieU7FEL3w==
+  dependencies:
+    private "^0.1.6"
+
+regex-cache@^0.4.2:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
+  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
+  dependencies:
+    is-equal-shallow "^0.1.3"
+
+regex-not@^1.0.0, regex-not@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
+  integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==
+  dependencies:
+    extend-shallow "^3.0.2"
+    safe-regex "^1.1.0"
+
+regexpp@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
+  integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==
+
+regexpu-core@^4.5.4:
+  version "4.5.4"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.5.4.tgz#080d9d02289aa87fe1667a4f5136bc98a6aebaae"
+  integrity sha512-BtizvGtFQKGPUcTy56o3nk1bGRp4SZOTYrDtGNlqCQufptV5IkkLN6Emw+yunAJjzf+C9FQFtvq7IoA3+oMYHQ==
+  dependencies:
+    regenerate "^1.4.0"
+    regenerate-unicode-properties "^8.0.2"
+    regjsgen "^0.5.0"
+    regjsparser "^0.6.0"
+    unicode-match-property-ecmascript "^1.0.4"
+    unicode-match-property-value-ecmascript "^1.1.0"
+
+regextras@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.0.tgz#2298bef8cfb92b1b7e3b9b12aa8f69547b7d71e4"
+  integrity sha512-ds+fL+Vhl918gbAUb0k2gVKbTZLsg84Re3DI6p85Et0U0tYME3hyW4nMK8Px4dtDaBA2qNjvG5uWyW7eK5gfmw==
+
+registry-auth-token@^3.0.1:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
+  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
+  dependencies:
+    rc "^1.1.6"
+    safe-buffer "^5.0.1"
+
+registry-url@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
+  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
+  dependencies:
+    rc "^1.0.1"
+
+regjsgen@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.0.tgz#a7634dc08f89209c2049adda3525711fb97265dd"
+  integrity sha512-RnIrLhrXCX5ow/E5/Mh2O4e/oa1/jW0eaBKTSy3LaCj+M3Bqvm97GWDp2yUtzIs4LEn65zR2yiYGFqb2ApnzDA==
+
+regjsparser@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.0.tgz#f1e6ae8b7da2bae96c99399b868cd6c933a2ba9c"
+  integrity sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ==
+  dependencies:
+    jsesc "~0.5.0"
+
+relateurl@0.2.x:
+  version "0.2.7"
+  resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
+  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
+
+remove-trailing-separator@^1.0.1:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
+  integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
+
+repeat-element@^1.1.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
+  integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==
+
+repeat-string@^1.5.2, repeat-string@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
+  integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
+
+repeating@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
+  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
+  dependencies:
+    is-finite "^1.0.0"
+
+replace-ext@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
+  integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
+
+replace-ext@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
+  integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=
+
+request@2.88.0, request@^2.72.0, request@^2.85.0:
+  version "2.88.0"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
+  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
+  dependencies:
+    aws-sign2 "~0.7.0"
+    aws4 "^1.8.0"
+    caseless "~0.12.0"
+    combined-stream "~1.0.6"
+    extend "~3.0.2"
+    forever-agent "~0.6.1"
+    form-data "~2.3.2"
+    har-validator "~5.1.0"
+    http-signature "~1.2.0"
+    is-typedarray "~1.0.0"
+    isstream "~0.1.2"
+    json-stringify-safe "~5.0.1"
+    mime-types "~2.1.19"
+    oauth-sign "~0.9.0"
+    performance-now "^2.1.0"
+    qs "~6.5.2"
+    safe-buffer "^5.1.2"
+    tough-cookie "~2.4.3"
+    tunnel-agent "^0.6.0"
+    uuid "^3.3.2"
+
+requirejs@^2.3.4:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
+  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
+
+requires-port@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
+  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
+
+resolve-dir@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e"
+  integrity sha1-shklmlYC+sXFxJatiUpujMQwJh4=
+  dependencies:
+    expand-tilde "^1.2.2"
+    global-modules "^0.2.3"
+
+resolve-dir@^1.0.0, resolve-dir@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
+  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
+  dependencies:
+    expand-tilde "^2.0.0"
+    global-modules "^1.0.0"
+
+resolve-from@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
+  integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
+
+resolve-url@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
+  integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
+
+resolve@^1.1.6, resolve@^1.10.0, resolve@^1.3.2:
+  version "1.11.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e"
+  integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==
+  dependencies:
+    path-parse "^1.0.6"
+
+resolve@^1.12.0, resolve@^1.13.1:
+  version "1.15.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8"
+  integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==
+  dependencies:
+    path-parse "^1.0.6"
+
+resolve@^1.5.0:
+  version "1.14.2"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.14.2.tgz#dbf31d0fa98b1f29aa5169783b9c290cb865fea2"
+  integrity sha512-EjlOBLBO1kxsUxsKjLt7TAECyKW6fOh1VRkykQkKGzcBbjjPIxBqGh0jf7GJ3k/f5mxMqW3htMD3WdTUVtW8HQ==
+  dependencies:
+    path-parse "^1.0.6"
+
+restore-cursor@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
+  integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=
+  dependencies:
+    exit-hook "^1.0.0"
+    onetime "^1.0.0"
+
+restore-cursor@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
+  integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368=
+  dependencies:
+    onetime "^2.0.0"
+    signal-exit "^3.0.2"
+
+restore-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
+  dependencies:
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+
+ret@~0.1.10:
+  version "0.1.15"
+  resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+  integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+
+rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@~2.6.2:
+  version "2.6.3"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
+  integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b"
+  integrity sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@~2.2.6:
+  version "2.2.8"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582"
+  integrity sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=
+
+rollup@^1.3.0:
+  version "1.16.7"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.16.7.tgz#4b539ca22465df39f6c963d2001d95f6527e97e1"
+  integrity sha512-P3GVcbVSLLjHWFLKGerYRe3Q/yggRXmTZFx/4WZf4wzGwO6hAg5jyMAFMQKc0dts8rFID4BQngfoz6yQbI7iMQ==
+  dependencies:
+    "@types/estree" "0.0.39"
+    "@types/node" "^12.0.10"
+    acorn "^6.1.1"
+
+run-async@^2.0.0, run-async@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
+  integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA=
+  dependencies:
+    is-promise "^2.1.0"
+
+rx@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
+  integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=
+
+rxjs@^6.4.0:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.2.tgz#2e35ce815cd46d84d02a209fb4e5921e051dbec7"
+  integrity sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==
+  dependencies:
+    tslib "^1.9.0"
+
+rxjs@^6.5.3:
+  version "6.5.4"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c"
+  integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==
+  dependencies:
+    tslib "^1.9.0"
+
+safe-buffer@5.1.2, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@~5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
+  integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
+
+safe-regex@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
+  integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4=
+  dependencies:
+    ret "~0.1.10"
+
+"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+samsam@1.x, samsam@^1.1.3:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
+  integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
+
+sauce-connect-launcher@^1.0.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.1.tgz#31137f57b0f7176e1c0525b7fb09c6da746647cf"
+  integrity sha512-vIf9qDol3q2FlYzrKt0dr3kvec6LSjX2WS+/mVnAJIhqh1evSkPKCR2AzcJrnSmx9Xt9PtV0tLY7jYh0wsQi8A==
+  dependencies:
+    adm-zip "~0.4.3"
+    async "^2.1.2"
+    https-proxy-agent "^3.0.0"
+    lodash "^4.16.6"
+    rimraf "^2.5.4"
+
+sax@^1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
+  integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
+
+scoped-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8"
+  integrity sha1-o0a7Gs1CB65wvXwMfKnlZra63bg=
+
+select-hose@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
+  integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
+
+selenium-standalone@^6.7.0:
+  version "6.17.0"
+  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.17.0.tgz#0f24b691836205ee9bc3d7a6f207ebcb28170cd9"
+  integrity sha512-5PSnDHwMiq+OCiAGlhwQ8BM9xuwFfvBOZ7Tfbw+ifkTnOy0PWbZmI1B9gPGuyGHpbQ/3J3CzIK7BYwrQ7EjtWQ==
+  dependencies:
+    async "^2.6.2"
+    commander "^2.19.0"
+    cross-spawn "^6.0.5"
+    debug "^4.1.1"
+    lodash "^4.17.11"
+    minimist "^1.2.0"
+    mkdirp "^0.5.1"
+    progress "2.0.3"
+    request "2.88.0"
+    tar-stream "2.0.0"
+    urijs "^1.19.1"
+    which "^1.3.1"
+    yauzl "^2.10.0"
+
+semver-diff@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
+  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
+  dependencies:
+    semver "^5.0.3"
+
+"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.6.0:
+  version "5.7.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b"
+  integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==
+
+semver@5.6.0, semver@^5.0.3, semver@^5.1.0, semver@^5.5.0:
+  version "5.6.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
+  integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
+
+semver@^6.1.2, semver@^6.3.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+
+send@0.17.1:
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
+  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.2"
+    destroy "~1.0.4"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.7.2"
+    mime "1.6.0"
+    ms "2.1.1"
+    on-finished "~2.3.0"
+    range-parser "~1.2.1"
+    statuses "~1.5.0"
+
+send@^0.16.1, send@^0.16.2:
+  version "0.16.2"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
+  integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.2"
+    destroy "~1.0.4"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.6.2"
+    mime "1.4.1"
+    ms "2.0.0"
+    on-finished "~2.3.0"
+    range-parser "~1.2.0"
+    statuses "~1.4.0"
+
+serve-static@1.14.1:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
+  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
+  dependencies:
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    parseurl "~1.3.3"
+    send "0.17.1"
+
+server-destroy@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
+  integrity sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0=
+
+serviceworker-cache-polyfill@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
+  integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
+
+set-blocking@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+
+set-value@^2.0.0, set-value@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
+  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
+  dependencies:
+    extend-shallow "^2.0.1"
+    is-extendable "^0.1.1"
+    is-plain-object "^2.0.3"
+    split-string "^3.0.1"
+
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
+  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
+
+shady-css-parser@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
+  integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
+
+shebang-command@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
+  dependencies:
+    shebang-regex "^1.0.0"
+
+shebang-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
+
+shelljs@^0.8.0:
+  version "0.8.3"
+  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.3.tgz#a7f3319520ebf09ee81275b2368adb286659b097"
+  integrity sha512-fc0BKlAWiLpwZljmOvAOTE/gXawtCoNrP5oaY7KIaQbbyHeQVg01pSEuEGvGh3HEdBU4baCD7wQBwADmM/7f7A==
+  dependencies:
+    glob "^7.0.0"
+    interpret "^1.0.0"
+    rechoir "^0.6.2"
+
+signal-exit@^3.0.0, signal-exit@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
+
+simple-swizzle@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
+  integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
+  dependencies:
+    is-arrayish "^0.3.1"
+
+sinon-chai@^2.10.0:
+  version "2.14.0"
+  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.14.0.tgz#da7dd4cc83cd6a260b67cca0f7a9fdae26a1205d"
+  integrity sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==
+
+sinon@^2.3.5:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36"
+  integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==
+  dependencies:
+    diff "^3.1.0"
+    formatio "1.2.0"
+    lolex "^1.6.0"
+    native-promise-only "^0.8.1"
+    path-to-regexp "^1.7.0"
+    samsam "^1.1.3"
+    text-encoding "0.6.4"
+    type-detect "^4.0.0"
+
+slash@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
+  integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
+
+slice-ansi@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636"
+  integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==
+  dependencies:
+    ansi-styles "^3.2.0"
+    astral-regex "^1.0.0"
+    is-fullwidth-code-point "^2.0.0"
+
+slide@^1.1.5:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
+  integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
+
+snapdragon-node@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
+  integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==
+  dependencies:
+    define-property "^1.0.0"
+    isobject "^3.0.0"
+    snapdragon-util "^3.0.1"
+
+snapdragon-util@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
+  integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==
+  dependencies:
+    kind-of "^3.2.0"
+
+snapdragon@^0.8.1:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
+  integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==
+  dependencies:
+    base "^0.11.1"
+    debug "^2.2.0"
+    define-property "^0.2.5"
+    extend-shallow "^2.0.1"
+    map-cache "^0.2.2"
+    source-map "^0.5.6"
+    source-map-resolve "^0.5.0"
+    use "^3.1.0"
+
+socket.io-adapter@~1.1.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
+  integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
+
+socket.io-client@2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4"
+  integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==
+  dependencies:
+    backo2 "1.0.2"
+    base64-arraybuffer "0.1.5"
+    component-bind "1.0.0"
+    component-emitter "1.2.1"
+    debug "~4.1.0"
+    engine.io-client "~3.4.0"
+    has-binary2 "~1.0.2"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    object-component "0.0.3"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    socket.io-parser "~3.3.0"
+    to-array "0.1.4"
+
+socket.io-parser@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
+  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~3.1.0"
+    isarray "2.0.1"
+
+socket.io-parser@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.0.tgz#370bb4a151df2f77ce3345ff55a7072cc6e9565a"
+  integrity sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~4.1.0"
+    isarray "2.0.1"
+
+socket.io@^2.0.3:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb"
+  integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==
+  dependencies:
+    debug "~4.1.0"
+    engine.io "~3.4.0"
+    has-binary2 "~1.0.2"
+    socket.io-adapter "~1.1.0"
+    socket.io-client "2.3.0"
+    socket.io-parser "~3.4.0"
+
+sort-keys-length@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188"
+  integrity sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=
+  dependencies:
+    sort-keys "^1.0.0"
+
+sort-keys@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
+  integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0=
+  dependencies:
+    is-plain-obj "^1.0.0"
+
+source-map-resolve@^0.5.0:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
+  integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==
+  dependencies:
+    atob "^2.1.2"
+    decode-uri-component "^0.2.0"
+    resolve-url "^0.2.1"
+    source-map-url "^0.4.0"
+    urix "^0.1.0"
+
+source-map-support@0.5.9:
+  version "0.5.9"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f"
+  integrity sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map-url@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
+  integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=
+
+source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
+  version "0.5.7"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+  integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
+
+source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+spawn-sync@^1.0.15:
+  version "1.0.15"
+  resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-1.0.15.tgz#b00799557eb7fb0c8376c29d44e8a1ea67e57476"
+  integrity sha1-sAeZVX63+wyDdsKdROih6mfldHY=
+  dependencies:
+    concat-stream "^1.4.7"
+    os-shim "^0.1.2"
+
+spdx-correct@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4"
+  integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==
+  dependencies:
+    spdx-expression-parse "^3.0.0"
+    spdx-license-ids "^3.0.0"
+
+spdx-exceptions@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977"
+  integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==
+
+spdx-expression-parse@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
+  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  dependencies:
+    spdx-exceptions "^2.1.0"
+    spdx-license-ids "^3.0.0"
+
+spdx-license-ids@^3.0.0:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz#75ecd1a88de8c184ef015eafb51b5b48bfd11bb1"
+  integrity sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA==
+
+spdy-transport@^2.0.18:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.1.tgz#c54815d73858aadd06ce63001e7d25fa6441623b"
+  integrity sha512-q7D8c148escoB3Z7ySCASadkegMmUZW8Wb/Q1u0/XBgDKMO880rLQDj8Twiew/tYi7ghemKUi/whSYOwE17f5Q==
+  dependencies:
+    debug "^2.6.8"
+    detect-node "^2.0.3"
+    hpack.js "^2.1.6"
+    obuf "^1.1.1"
+    readable-stream "^2.2.9"
+    safe-buffer "^5.0.1"
+    wbuf "^1.7.2"
+
+spdy@^3.3.3:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
+  integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=
+  dependencies:
+    debug "^2.6.8"
+    handle-thing "^1.2.5"
+    http-deceiver "^1.2.7"
+    safe-buffer "^5.0.1"
+    select-hose "^2.0.0"
+    spdy-transport "^2.0.18"
+
+split-string@^3.0.1, split-string@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
+  integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==
+  dependencies:
+    extend-shallow "^3.0.0"
+
+sprintf-js@~1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
+  integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
+
+sshpk@^1.7.0:
+  version "1.16.1"
+  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
+  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
+  dependencies:
+    asn1 "~0.2.3"
+    assert-plus "^1.0.0"
+    bcrypt-pbkdf "^1.0.0"
+    dashdash "^1.12.0"
+    ecc-jsbn "~0.1.1"
+    getpass "^0.1.1"
+    jsbn "~0.1.0"
+    safer-buffer "^2.0.2"
+    tweetnacl "~0.14.0"
+
+stable@^0.1.6:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
+  integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
+
+stack-trace@0.0.x:
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
+  integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
+
+stacky@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/stacky/-/stacky-1.3.1.tgz#3f117e5187b9a73d23f876d69f05c85b11804a12"
+  integrity sha1-PxF+UYe5pz0j+HbWnwXIWxGAShI=
+  dependencies:
+    chalk "^1.1.1"
+    lodash "^3.0.0"
+
+static-extend@^0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
+  integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=
+  dependencies:
+    define-property "^0.2.5"
+    object-copy "^0.1.0"
+
+"statuses@>= 1.4.0 < 2", statuses@~1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
+  integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
+
+"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
+
+stream-shift@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
+  integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=
+
+stream@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef"
+  integrity sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=
+  dependencies:
+    emitter-component "^1.1.1"
+
+streamsearch@0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
+  integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
+
+string-template@~0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
+  integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=
+
+string-width@^1.0.1, "string-width@^1.0.2 || 2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+  dependencies:
+    code-point-at "^1.0.0"
+    is-fullwidth-code-point "^1.0.0"
+    strip-ansi "^3.0.0"
+
+string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
+  dependencies:
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^4.0.0"
+
+string-width@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
+  integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
+  dependencies:
+    emoji-regex "^7.0.1"
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^5.1.0"
+
+string-width@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
+  integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.0"
+
+string.prototype.trimleft@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74"
+  integrity sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==
+  dependencies:
+    define-properties "^1.1.3"
+    function-bind "^1.1.1"
+
+string.prototype.trimright@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz#440314b15996c866ce8a0341894d45186200c5d9"
+  integrity sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==
+  dependencies:
+    define-properties "^1.1.3"
+    function-bind "^1.1.1"
+
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
+string_decoder@~0.10.x:
+  version "0.10.31"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
+  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
+
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  dependencies:
+    ansi-regex "^3.0.0"
+
+strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
+  integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
+  dependencies:
+    ansi-regex "^4.1.0"
+
+strip-ansi@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
+  integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
+  dependencies:
+    ansi-regex "^5.0.0"
+
+strip-ansi@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
+  integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
+
+strip-bom-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
+  integrity sha1-5xRDmFd9Uaa+0PoZlPoF9D/ZiO4=
+  dependencies:
+    first-chunk-stream "^1.0.0"
+    strip-bom "^2.0.0"
+
+strip-bom-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca"
+  integrity sha1-+H217yYT9paKpUWr/h7HKLaoKco=
+  dependencies:
+    first-chunk-stream "^2.0.0"
+    strip-bom "^2.0.0"
+
+strip-bom@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
+  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
+  dependencies:
+    is-utf8 "^0.2.0"
+
+strip-bom@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
+  integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
+
+strip-eof@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
+  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
+
+strip-indent@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
+  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
+  dependencies:
+    get-stdin "^4.0.1"
+
+strip-indent@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
+  integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
+
+strip-json-comments@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7"
+  integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==
+
+strip-json-comments@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+
+supports-color@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
+  integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+sw-precache@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
+  integrity sha512-8FAy+BP/FXE+ILfiVTt+GQJ6UEf4CVHD9OfhzH0JX+3zoy2uFk7Vn9EfXASOtVmmIVbL3jE/W8Z66VgPSZcMhw==
+  dependencies:
+    dom-urls "^1.1.0"
+    es6-promise "^4.0.5"
+    glob "^7.1.1"
+    lodash.defaults "^4.2.0"
+    lodash.template "^4.4.0"
+    meow "^3.7.0"
+    mkdirp "^0.5.1"
+    pretty-bytes "^4.0.2"
+    sw-toolbox "^3.4.0"
+    update-notifier "^2.3.0"
+
+sw-toolbox@^3.4.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/sw-toolbox/-/sw-toolbox-3.6.0.tgz#26df1d1c70348658e4dea2884319149b7b3183b5"
+  integrity sha1-Jt8dHHA0hljk3qKIQxkUm3sxg7U=
+  dependencies:
+    path-to-regexp "^1.0.1"
+    serviceworker-cache-polyfill "^4.0.0"
+
+table-layout@^0.4.3:
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.4.5.tgz#d906de6a25fa09c0c90d1d08ecd833ecedcb7378"
+  integrity sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==
+  dependencies:
+    array-back "^2.0.0"
+    deep-extend "~0.6.0"
+    lodash.padend "^4.6.1"
+    typical "^2.6.1"
+    wordwrapjs "^3.0.0"
+
+table@^5.2.3:
+  version "5.4.6"
+  resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
+  integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==
+  dependencies:
+    ajv "^6.10.2"
+    lodash "^4.17.14"
+    slice-ansi "^2.1.0"
+    string-width "^3.0.0"
+
+tar-fs@^1.12.0:
+  version "1.16.3"
+  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509"
+  integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw==
+  dependencies:
+    chownr "^1.0.1"
+    mkdirp "^0.5.1"
+    pump "^1.0.0"
+    tar-stream "^1.1.2"
+
+tar-stream@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.0.0.tgz#8829bbf83067bc0288a9089db49c56be395b6aea"
+  integrity sha512-n2vtsWshZOVr/SY4KtslPoUlyNh06I2SGgAOCZmquCEjlbV/LjY2CY80rDtdQRHFOYXNlgBDo6Fr3ww2CWPOtA==
+  dependencies:
+    bl "^2.2.0"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+tar-stream@^1.1.2:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555"
+  integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==
+  dependencies:
+    bl "^1.0.0"
+    buffer-alloc "^1.2.0"
+    end-of-stream "^1.0.0"
+    fs-constants "^1.0.0"
+    readable-stream "^2.3.0"
+    to-buffer "^1.1.1"
+    xtend "^4.0.0"
+
+tar-stream@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3"
+  integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==
+  dependencies:
+    bl "^3.0.0"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+tar@^4:
+  version "4.4.8"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d"
+  integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==
+  dependencies:
+    chownr "^1.1.1"
+    fs-minipass "^1.2.5"
+    minipass "^2.3.4"
+    minizlib "^1.1.1"
+    mkdirp "^0.5.0"
+    safe-buffer "^5.1.2"
+    yallist "^3.0.2"
+
+temp@^0.8.1:
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
+  integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==
+  dependencies:
+    rimraf "~2.6.2"
+
+temp@^0.8.3:
+  version "0.8.3"
+  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.3.tgz#e0c6bc4d26b903124410e4fed81103014dfc1f59"
+  integrity sha1-4Ma8TSa5AxJEEOT+2BEDAU38H1k=
+  dependencies:
+    os-tmpdir "^1.0.0"
+    rimraf "~2.2.6"
+
+term-size@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
+  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
+  dependencies:
+    execa "^0.7.0"
+
+ternary-stream@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.0.1.tgz#064e489b4b5bf60ba6a6b7bc7f2f5c274ecf8269"
+  integrity sha1-Bk5Im0tb9gumpre8fy9cJ07Pgmk=
+  dependencies:
+    duplexify "^3.5.0"
+    fork-stream "^0.0.4"
+    merge-stream "^1.0.0"
+    through2 "^2.0.1"
+
+text-encoding@0.6.4:
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
+  integrity sha1-45mpgiV6J22uQou5KEXLcb3CbRk=
+
+text-hex@1.0.x:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
+  integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
+
+text-table@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+  integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
+
+textextensions@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.4.0.tgz#6a143a985464384cc2cff11aea448cd5b018e72b"
+  integrity sha512-qftQXnX1DzpSV8EddtHIT0eDDEiBF8ywhFYR2lI9xrGtxqKN+CvLXhACeCIGbCpQfxxERbrkZEFb8cZcDKbVZA==
+
+thenify-all@^1.0.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
+  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
+  dependencies:
+    thenify ">= 3.1.0 < 4"
+
+"thenify@>= 3.1.0 < 4":
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839"
+  integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=
+  dependencies:
+    any-promise "^1.0.0"
+
+through2-filter@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec"
+  integrity sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=
+  dependencies:
+    through2 "~2.0.0"
+    xtend "~4.0.0"
+
+through2-filter@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
+  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
+  dependencies:
+    through2 "~2.0.0"
+    xtend "~4.0.0"
+
+through2@^0.6.0:
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
+  integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=
+  dependencies:
+    readable-stream ">=1.0.33-1 <1.1.0-0"
+    xtend ">=4.0.0 <4.1.0-0"
+
+through2@^2.0.0, through2@^2.0.1, through2@^2.0.3, through2@~2.0.0:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
+  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
+  dependencies:
+    readable-stream "~2.3.6"
+    xtend "~4.0.1"
+
+through2@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a"
+  integrity sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==
+  dependencies:
+    readable-stream "2 || 3"
+
+through@^2.3.6:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
+
+timed-out@^3.0.0:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-3.1.3.tgz#95860bfcc5c76c277f8f8326fd0f5b2e20eba217"
+  integrity sha1-lYYL/MXHbCd/j4Mm/Q9bLiDrohc=
+
+timed-out@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
+  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
+
+tmp@^0.0.29:
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.29.tgz#f25125ff0dd9da3ccb0c2dd371ee1288bb9128c0"
+  integrity sha1-8lEl/w3Z2jzLDC3Tce4SiLuRKMA=
+  dependencies:
+    os-tmpdir "~1.0.1"
+
+tmp@^0.0.33:
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
+  dependencies:
+    os-tmpdir "~1.0.2"
+
+to-absolute-glob@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f"
+  integrity sha1-HN+kcqnvUMI57maZm2YsoOs5k38=
+  dependencies:
+    extend-shallow "^2.0.1"
+
+to-array@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
+  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
+
+to-buffer@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
+  integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==
+
+to-fast-properties@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
+  integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=
+
+to-fast-properties@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
+  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
+
+to-object-path@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
+  integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=
+  dependencies:
+    kind-of "^3.0.2"
+
+to-regex-range@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
+  integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=
+  dependencies:
+    is-number "^3.0.0"
+    repeat-string "^1.6.1"
+
+to-regex@^3.0.1, to-regex@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
+  integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==
+  dependencies:
+    define-property "^2.0.2"
+    extend-shallow "^3.0.2"
+    regex-not "^1.0.2"
+    safe-regex "^1.1.0"
+
+toidentifier@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
+  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
+
+tough-cookie@~2.4.3:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
+  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
+  dependencies:
+    psl "^1.1.24"
+    punycode "^1.4.1"
+
+tr46@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
+  integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
+  dependencies:
+    punycode "^2.1.0"
+
+trim-newlines@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
+  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
+
+trim-right@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
+  integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
+
+triple-beam@^1.2.0, triple-beam@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
+  integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
+
+tslib@^1.8.1, tslib@^1.9.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
+  integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
+
+tsutils@2.27.2:
+  version "2.27.2"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.27.2.tgz#60ba88a23d6f785ec4b89c6e8179cac9b431f1c7"
+  integrity sha512-qf6rmT84TFMuxAKez2pIfR8UCai49iQsfB7YWVjV1bKpy/d0PWT5rEOSM6La9PiHZ0k1RRZQiwVdVJfQ3BPHgg==
+  dependencies:
+    tslib "^1.8.1"
+
+tunnel-agent@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+  dependencies:
+    safe-buffer "^5.0.1"
+
+tweetnacl@^0.14.3, tweetnacl@~0.14.0:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
+  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+
+type-check@~0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
+  integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
+  dependencies:
+    prelude-ls "~1.1.2"
+
+type-detect@^4.0.0:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+  integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
+
+type-fest@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
+  integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
+
+type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+typedarray@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
+  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+
+typescript@^3.7.4:
+  version "3.7.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
+  integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
+
+typical@^2.6.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d"
+  integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0=
+
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+ua-parser-js@^0.7.15:
+  version "0.7.21"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"
+  integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==
+
+uglify-js@3.4.x:
+  version "3.4.10"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
+  integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==
+  dependencies:
+    commander "~2.19.0"
+    source-map "~0.6.1"
+
+underscore@^1.8.3:
+  version "1.9.2"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.2.tgz#0c8d6f536d6f378a5af264a72f7bec50feb7cf2f"
+  integrity sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==
+
+underscore@~1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"
+  integrity sha1-izixDKze9jM3uLJOT/htRa6lKag=
+
+unicode-canonical-property-names-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
+  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
+
+unicode-match-property-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
+  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
+  dependencies:
+    unicode-canonical-property-names-ecmascript "^1.0.4"
+    unicode-property-aliases-ecmascript "^1.0.4"
+
+unicode-match-property-value-ecmascript@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277"
+  integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==
+
+unicode-property-aliases-ecmascript@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57"
+  integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==
+
+union-value@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
+  integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==
+  dependencies:
+    arr-union "^3.1.0"
+    get-value "^2.0.6"
+    is-extendable "^0.1.1"
+    set-value "^2.0.1"
+
+unique-stream@^2.0.2:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
+  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
+  dependencies:
+    json-stable-stringify-without-jsonify "^1.0.1"
+    through2-filter "^3.0.0"
+
+unique-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
+  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
+  dependencies:
+    crypto-random-string "^1.0.0"
+
+universal-user-agent@^2.0.0, universal-user-agent@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-2.1.0.tgz#5abfbcc036a1ba490cb941f8fd68c46d3669e8e4"
+  integrity sha512-8itiX7G05Tu3mGDTdNY2fB4KJ8MgZLS54RdG6PkkfwMAavrXu1mV/lls/GABx9O3Rw4PnTtasxrvbMQoBYY92Q==
+  dependencies:
+    os-name "^3.0.0"
+
+unpipe@1.0.0, unpipe@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
+
+unset-value@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
+  integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=
+  dependencies:
+    has-value "^0.3.1"
+    isobject "^3.0.0"
+
+untildify@^2.0.0, untildify@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0"
+  integrity sha1-F+soB5h/dpUunASF/DEdBqgmouA=
+  dependencies:
+    os-homedir "^1.0.0"
+
+untildify@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9"
+  integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==
+
+unzip-response@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe"
+  integrity sha1-uYTwh3/AqJwsdzzB73tbIytbBv4=
+
+unzip-response@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
+  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
+
+update-notifier@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-1.0.3.tgz#8f92c515482bd6831b7c93013e70f87552c7cf5a"
+  integrity sha1-j5LFFUgr1oMbfJMBPnD4dVLHz1o=
+  dependencies:
+    boxen "^0.6.0"
+    chalk "^1.0.0"
+    configstore "^2.0.0"
+    is-npm "^1.0.0"
+    latest-version "^2.0.0"
+    lazy-req "^1.1.0"
+    semver-diff "^2.0.0"
+    xdg-basedir "^2.0.0"
+
+update-notifier@^2.2.0, update-notifier@^2.3.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
+  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
+  dependencies:
+    boxen "^1.2.1"
+    chalk "^2.0.1"
+    configstore "^3.0.0"
+    import-lazy "^2.1.0"
+    is-ci "^1.0.10"
+    is-installed-globally "^0.1.0"
+    is-npm "^1.0.0"
+    latest-version "^3.0.0"
+    semver-diff "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+upper-case@^1.1.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
+  integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
+
+uri-js@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
+  integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
+  dependencies:
+    punycode "^2.1.0"
+
+urijs@^1.16.1:
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.1.tgz#5b0ff530c0cbde8386f6342235ba5ca6e995d25a"
+  integrity sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg==
+
+urijs@^1.19.1:
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.2.tgz#f9be09f00c4c5134b7cb3cf475c1dd394526265a"
+  integrity sha512-s/UIq9ap4JPZ7H1EB5ULo/aOUbWqfDi7FKzMC2Nz+0Si8GiT1rIEaprt8hy3Vy2Ex2aJPpOQv4P4DuOZ+K1c6w==
+
+urix@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
+  integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
+
+url-parse-lax@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
+  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
+  dependencies:
+    prepend-http "^1.0.1"
+
+url-template@^2.0.8:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
+  integrity sha1-/FZaPMy/93MMd19WQflVV5FDnyE=
+
+url-to-options@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
+  integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
+
+use@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
+  integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
+
+util-deprecate@^1.0.1, util-deprecate@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+utils-merge@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+
+uuid@^2.0.1:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
+  integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=
+
+uuid@^3.2.1, uuid@^3.3.2:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
+
+v8-compile-cache@^2.0.3:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"
+  integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==
+
+vali-date@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/vali-date/-/vali-date-1.0.0.tgz#1b904a59609fb328ef078138420934f6b86709a6"
+  integrity sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=
+
+validate-element-name@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/validate-element-name/-/validate-element-name-2.1.1.tgz#8ff75f7da69f73e7c510588362130508b7ac644e"
+  integrity sha1-j/dffaafc+fFEFiDYhMFCLesZE4=
+  dependencies:
+    is-potential-custom-element-name "^1.0.0"
+    log-symbols "^1.0.0"
+    meow "^3.7.0"
+
+validate-npm-package-license@^3.0.1:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
+  integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
+  dependencies:
+    spdx-correct "^3.0.0"
+    spdx-expression-parse "^3.0.0"
+
+vargs@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
+  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
+
+vary@^1, vary@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
+
+verror@1.10.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
+  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+  dependencies:
+    assert-plus "^1.0.0"
+    core-util-is "1.0.2"
+    extsprintf "^1.2.0"
+
+vinyl-file@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a"
+  integrity sha1-p+v1/779obfRjRQPyweyI++2dRo=
+  dependencies:
+    graceful-fs "^4.1.2"
+    pify "^2.3.0"
+    pinkie-promise "^2.0.0"
+    strip-bom "^2.0.0"
+    strip-bom-stream "^2.0.0"
+    vinyl "^1.1.0"
+
+vinyl-fs@^2.4.3, vinyl-fs@^2.4.4:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-2.4.4.tgz#be6ff3270cb55dfd7d3063640de81f25d7532239"
+  integrity sha1-vm/zJwy1Xf19MGNkDegfJddTIjk=
+  dependencies:
+    duplexify "^3.2.0"
+    glob-stream "^5.3.2"
+    graceful-fs "^4.0.0"
+    gulp-sourcemaps "1.6.0"
+    is-valid-glob "^0.3.0"
+    lazystream "^1.0.0"
+    lodash.isequal "^4.0.0"
+    merge-stream "^1.0.0"
+    mkdirp "^0.5.0"
+    object-assign "^4.0.0"
+    readable-stream "^2.0.4"
+    strip-bom "^2.0.0"
+    strip-bom-stream "^1.0.0"
+    through2 "^2.0.0"
+    through2-filter "^2.0.0"
+    vali-date "^1.0.0"
+    vinyl "^1.0.0"
+
+vinyl@^1.0.0, vinyl@^1.1.0, vinyl@^1.1.1, vinyl@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
+  integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
+  dependencies:
+    clone "^1.0.0"
+    clone-stats "^0.0.1"
+    replace-ext "0.0.1"
+
+vinyl@^2.0.1:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86"
+  integrity sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg==
+  dependencies:
+    clone "^2.1.1"
+    clone-buffer "^1.0.0"
+    clone-stats "^1.0.0"
+    cloneable-readable "^1.0.0"
+    remove-trailing-separator "^1.0.1"
+    replace-ext "^1.0.0"
+
+vlq@^0.2.2:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
+  integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
+
+vscode-uri@=1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
+  integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
+
+wbuf@^1.1.0, wbuf@^1.7.2:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
+  integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
+  dependencies:
+    minimalistic-assert "^1.0.0"
+
+wct-local@^2.1.1:
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.5.tgz#f7986753e3ad9a35d39178a9989350523561fff1"
+  integrity sha512-eqoZhjGy4Xq2tY0uB46Grkw/ztq+/rC0ImbYKl62unFHXtOgal+kkvnxR3SLRFNM8ty9+ItgycPeH0IpTqVL+w==
+  dependencies:
+    "@types/express" "^4.0.30"
+    "@types/freeport" "^1.0.19"
+    "@types/launchpad" "^0.6.0"
+    "@types/which" "^1.3.1"
+    chalk "^2.3.0"
+    cleankill "^2.0.0"
+    freeport "^1.0.4"
+    launchpad "^0.7.0"
+    selenium-standalone "^6.7.0"
+    which "^1.0.8"
+
+wct-sauce@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/wct-sauce/-/wct-sauce-2.1.0.tgz#67d0be346aabbbc28384e8d143b8d3ca7ba774c0"
+  integrity sha512-c3R4PJcbpS7Gxv2vZ4HDAqpXV6cT9peslAWMU7hHH9PMhKDPbn8RNa6E4DVL0tOmZznB+3cRmtZ6+vJ/aDwu1A==
+  dependencies:
+    chalk "^2.4.1"
+    cleankill "^2.0.0"
+    lodash "^4.17.10"
+    request "^2.85.0"
+    sauce-connect-launcher "^1.0.0"
+    temp "^0.8.1"
+    uuid "^3.2.1"
+
+wd@^1.2.0:
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/wd/-/wd-1.12.0.tgz#763ce9ebcaa854ab8e05d0a660f24da839674329"
+  integrity sha512-P8fOXV+FAxR7Pdto4sQ982ud6xUJHzxfY/JYH+NfBJ2EYJhGeO7fLXiVKosZK3YlZK7EQfPmUVqkbEzNlGTGgQ==
+  dependencies:
+    archiver "^3.0.0"
+    async "^2.0.0"
+    lodash "^4.0.0"
+    mkdirp "^0.5.1"
+    q "^1.5.1"
+    request "2.88.0"
+    vargs "^0.1.0"
+
+web-component-tester@^6.5.1, web-component-tester@^6.9.0:
+  version "6.9.2"
+  resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
+  integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
+  dependencies:
+    "@polymer/sinonjs" "^1.14.1"
+    "@polymer/test-fixture" "^0.0.3"
+    "@webcomponents/webcomponentsjs" "^1.0.7"
+    accessibility-developer-tools "^2.12.0"
+    async "^2.4.1"
+    body-parser "^1.17.2"
+    bower-config "^1.4.0"
+    chalk "^1.1.3"
+    cleankill "^2.0.0"
+    express "^4.15.3"
+    findup-sync "^2.0.0"
+    glob "^7.1.2"
+    lodash "^3.10.1"
+    multer "^1.3.0"
+    nomnom "^1.8.1"
+    polyserve "^0.27.13"
+    resolve "^1.5.0"
+    semver "^5.3.0"
+    send "^0.16.1"
+    server-destroy "^1.0.1"
+    sinon "^2.3.5"
+    sinon-chai "^2.10.0"
+    socket.io "^2.0.3"
+    stacky "^1.3.1"
+    wd "^1.2.0"
+  optionalDependencies:
+    update-notifier "^2.2.0"
+    wct-local "^2.1.1"
+    wct-sauce "^2.0.2"
+
+webidl-conversions@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
+  integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
+
+whatwg-url@^6.4.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
+  integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==
+  dependencies:
+    lodash.sortby "^4.7.0"
+    tr46 "^1.0.1"
+    webidl-conversions "^4.0.2"
+
+which@^1.0.8, which@^1.2.12, which@^1.2.14, which@^1.2.9, which@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
+  dependencies:
+    isexe "^2.0.0"
+
+wide-align@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+  dependencies:
+    string-width "^1.0.2 || 2"
+
+widest-line@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c"
+  integrity sha1-DAnIXCqUaD0Nfq+O4JfVZL8OEFw=
+  dependencies:
+    string-width "^1.0.1"
+
+widest-line@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
+  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
+  dependencies:
+    string-width "^2.1.1"
+
+windows-release@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f"
+  integrity sha512-QTlz2hKLrdqukrsapKsINzqMgOUpQW268eJ0OaOpJN32h272waxR9fkB9VoWRtK7uKHG5EHJcTXQBD8XZVJkFA==
+  dependencies:
+    execa "^1.0.0"
+
+winston-transport@^4.2.0, winston-transport@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.3.0.tgz#df68c0c202482c448d9b47313c07304c2d7c2c66"
+  integrity sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==
+  dependencies:
+    readable-stream "^2.3.6"
+    triple-beam "^1.2.0"
+
+winston@^3.0.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/winston/-/winston-3.2.1.tgz#63061377976c73584028be2490a1846055f77f07"
+  integrity sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==
+  dependencies:
+    async "^2.6.1"
+    diagnostics "^1.1.1"
+    is-stream "^1.1.0"
+    logform "^2.1.1"
+    one-time "0.0.4"
+    readable-stream "^3.1.1"
+    stack-trace "0.0.x"
+    triple-beam "^1.3.0"
+    winston-transport "^4.3.0"
+
+with-open-file@^0.1.6:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/with-open-file/-/with-open-file-0.1.6.tgz#0bc178ecab75f6baac8ae11c85e07445d690ea50"
+  integrity sha512-SQS05JekbtwQSgCYlBsZn/+m2gpn4zWsqpCYIrCHva0+ojXcnmUEPsBN6Ipoz3vmY/81k5PvYEWSxER2g4BTqA==
+  dependencies:
+    p-finally "^1.0.0"
+    p-try "^2.1.0"
+    pify "^4.0.1"
+
+word-wrap@~1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
+  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
+
+wordwrap@~0.0.2:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
+  integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
+
+wordwrapjs@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-3.0.0.tgz#c94c372894cadc6feb1a66bff64e1d9af92c5d1e"
+  integrity sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==
+  dependencies:
+    reduce-flatten "^1.0.1"
+    typical "^2.6.1"
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+
+write-file-atomic@^1.1.2:
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.4.tgz#f807a4f0b1d9e913ae7a48112e6cc3af1991b45f"
+  integrity sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=
+  dependencies:
+    graceful-fs "^4.1.11"
+    imurmurhash "^0.1.4"
+    slide "^1.1.5"
+
+write-file-atomic@^2.0.0:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
+  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
+  dependencies:
+    graceful-fs "^4.1.11"
+    imurmurhash "^0.1.4"
+    signal-exit "^3.0.2"
+
+write@1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
+  integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==
+  dependencies:
+    mkdirp "^0.5.1"
+
+ws@^7.1.2:
+  version "7.2.1"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
+  integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==
+
+ws@~6.1.0:
+  version "6.1.4"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
+  integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
+  dependencies:
+    async-limiter "~1.0.0"
+
+xdg-basedir@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
+  integrity sha1-7byQPMOF/ARSPZZqM1UEtVBNG9I=
+  dependencies:
+    os-homedir "^1.0.0"
+
+xdg-basedir@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
+  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
+
+xmlbuilder@8.2.2:
+  version "8.2.2"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
+  integrity sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=
+
+xmldom@0.1.x:
+  version "0.1.31"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
+  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
+
+xmlhttprequest-ssl@~1.5.4:
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
+  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+
+"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
+  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
+
+yallist@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
+  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+
+yallist@^3.0.0, yallist@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
+  integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
+
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
+yeast@0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
+  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
+
+yeoman-environment@^1.5.2:
+  version "1.6.6"
+  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-1.6.6.tgz#cd85fa67d156060e440d7807d7ef7cf0d2d1d671"
+  integrity sha1-zYX6Z9FWBg5EDXgH1+988NLR1nE=
+  dependencies:
+    chalk "^1.0.0"
+    debug "^2.0.0"
+    diff "^2.1.2"
+    escape-string-regexp "^1.0.2"
+    globby "^4.0.0"
+    grouped-queue "^0.3.0"
+    inquirer "^1.0.2"
+    lodash "^4.11.1"
+    log-symbols "^1.0.1"
+    mem-fs "^1.1.0"
+    text-table "^0.2.0"
+    untildify "^2.0.0"
+
+yeoman-environment@^2.0.5:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.4.0.tgz#4829445dc1306b02d9f5f7027cd224bf77a8224d"
+  integrity sha512-SsvoL0RNAFIX69eFxkUhwKUN2hG1UwUjxrcP+T2ytwdhqC/kHdnFOH2SXdtSN1Ju4aO4xuimmzfRoheYY88RuA==
+  dependencies:
+    chalk "^2.4.1"
+    cross-spawn "^6.0.5"
+    debug "^3.1.0"
+    diff "^3.5.0"
+    escape-string-regexp "^1.0.2"
+    globby "^8.0.1"
+    grouped-queue "^0.3.3"
+    inquirer "^6.0.0"
+    is-scoped "^1.0.0"
+    lodash "^4.17.10"
+    log-symbols "^2.2.0"
+    mem-fs "^1.1.0"
+    strip-ansi "^4.0.0"
+    text-table "^0.2.0"
+    untildify "^3.0.3"
+
+yeoman-generator@^3.1.1:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-3.2.0.tgz#02077d2d7ff28fedc1ed7dad7f9967fd7c3604cc"
+  integrity sha512-iR/qb2je3GdXtSfxgvOXxUW0Cp8+C6LaZaNlK2BAICzFNzwHtM10t/QBwz5Ea9nk6xVDQNj4Q889TjCXGuIv8w==
+  dependencies:
+    async "^2.6.0"
+    chalk "^2.3.0"
+    cli-table "^0.3.1"
+    cross-spawn "^6.0.5"
+    dargs "^6.0.0"
+    dateformat "^3.0.3"
+    debug "^4.1.0"
+    detect-conflict "^1.0.0"
+    error "^7.0.2"
+    find-up "^3.0.0"
+    github-username "^4.0.0"
+    istextorbinary "^2.2.1"
+    lodash "^4.17.10"
+    make-dir "^1.1.0"
+    mem-fs-editor "^5.0.0"
+    minimist "^1.2.0"
+    pretty-bytes "^5.1.0"
+    read-chunk "^3.0.0"
+    read-pkg-up "^4.0.0"
+    rimraf "^2.6.2"
+    run-async "^2.0.0"
+    shelljs "^0.8.0"
+    text-table "^0.2.0"
+    through2 "^3.0.0"
+    yeoman-environment "^2.0.5"
+
+zip-stream@^2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
+  integrity sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==
+  dependencies:
+    archiver-utils "^2.1.0"
+    compress-commons "^2.1.1"
+    readable-stream "^3.4.0"